From e79096f16b028eee011434efb227f1433b60bb0e Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Fri, 17 Apr 2026 13:19:39 +0300 Subject: [PATCH 01/48] feat(desktop): add App Drawer with categorized screen launcher Replace AddColumnDialog with full-screen App Drawer overlay (Cmd+K). Screens organized by category (Social, Long-Form, Discovery, Identity, Play) with instant search, keyboard navigation (arrows + Enter), and open-column indicators in Deck mode. Works in both Single Pane and Deck layout modes. - Add AppDrawer.kt with ScreenCategory, LAUNCHABLE_SCREENS registry - Add SinglePaneState for hoisted single-pane navigation - Wire Cmd+K shortcut, redirect Cmd+T to drawer - Add "More" button to SinglePaneLayout nav rail - Delete AddColumnDialog.kt Co-Authored-By: Claude Opus 4.6 (1M context) --- .../vitorpamplona/amethyst/desktop/Main.kt | 74 ++- .../desktop/ui/deck/AddColumnDialog.kt | 165 ------ .../amethyst/desktop/ui/deck/AppDrawer.kt | 472 ++++++++++++++++++ .../desktop/ui/deck/SinglePaneLayout.kt | 30 +- .../desktop/ui/deck/SinglePaneState.kt | 37 ++ 5 files changed, 589 insertions(+), 189 deletions(-) delete mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AddColumnDialog.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AppDrawer.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneState.kt diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 7d72e82e3..306105f95 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -88,12 +88,13 @@ import com.vitorpamplona.amethyst.desktop.ui.LoginScreen import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback import com.vitorpamplona.amethyst.desktop.ui.auth.ForceLogoutDialog import com.vitorpamplona.amethyst.desktop.ui.chats.DmSendTracker -import com.vitorpamplona.amethyst.desktop.ui.deck.AddColumnDialog +import com.vitorpamplona.amethyst.desktop.ui.deck.AppDrawer import com.vitorpamplona.amethyst.desktop.ui.deck.DeckColumnType 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.deck.SinglePaneState import com.vitorpamplona.amethyst.desktop.ui.media.LocalAwtWindow import com.vitorpamplona.amethyst.desktop.ui.media.LocalIsImmersiveFullscreen import com.vitorpamplona.amethyst.desktop.ui.media.LocalWindowState @@ -192,7 +193,7 @@ fun main() { val deckState = remember { DeckState(deckScope).also { it.load() } } val accountManager = remember { AccountManager.create() } val accountState by accountManager.accountState.collectAsState() - var showAddColumnDialog by remember { mutableStateOf(false) } + var showAppDrawer by remember { mutableStateOf(false) } // Tor state at Window level — survives key() app rebuild var torSettings by remember { @@ -299,6 +300,17 @@ fun main() { ) } Menu("View") { + Item( + "App Drawer", + shortcut = + if (isMacOS) { + KeyShortcut(Key.K, meta = true) + } else { + KeyShortcut(Key.K, ctrl = true) + }, + onClick = { showAppDrawer = !showAppDrawer }, + ) + Separator() Item( if (layoutMode == LayoutMode.DECK) "\u2713 Deck Layout" else "Deck Layout", shortcut = @@ -323,7 +335,7 @@ fun main() { } else { KeyShortcut(Key.T, ctrl = true) }, - onClick = { showAddColumnDialog = true }, + onClick = { showAppDrawer = true }, ) Item( "Close Column", @@ -435,7 +447,7 @@ fun main() { deckState = deckState, accountManager = accountManager, showComposeDialog = showComposeDialog, - showAddColumnDialog = showAddColumnDialog, + showAppDrawer = showAppDrawer, onShowComposeDialog = { showComposeDialog = true }, onShowReplyDialog = { event -> replyToNote = event @@ -445,8 +457,8 @@ fun main() { showComposeDialog = false replyToNote = null }, - onDismissAddColumnDialog = { showAddColumnDialog = false }, - onShowAddColumnDialog = { showAddColumnDialog = true }, + onDismissAppDrawer = { showAppDrawer = false }, + onShowAppDrawer = { showAppDrawer = true }, replyToNote = replyToNote, onRestartApp = { appRestartKey++ }, torManager = torManager, @@ -466,12 +478,12 @@ fun App( deckState: DeckState, accountManager: AccountManager, showComposeDialog: Boolean, - showAddColumnDialog: Boolean, + showAppDrawer: Boolean, onShowComposeDialog: () -> Unit, onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, onDismissComposeDialog: () -> Unit, - onDismissAddColumnDialog: () -> Unit, - onShowAddColumnDialog: () -> Unit, + onDismissAppDrawer: () -> Unit, + onShowAppDrawer: () -> Unit, replyToNote: com.vitorpamplona.quartz.nip01Core.core.Event?, onRestartApp: () -> Unit = {}, torManager: com.vitorpamplona.amethyst.desktop.tor.DesktopTorManager, @@ -479,6 +491,8 @@ fun App( externalPortFlow: kotlinx.coroutines.flow.MutableStateFlow, initialTorSettings: com.vitorpamplona.amethyst.commons.tor.TorSettings, ) { + val singlePaneState = remember { SinglePaneState() } + // Always reload from prefs — after key() rebuild, prefs have the latest saved settings var torSettings by remember { mutableStateOf( @@ -683,6 +697,7 @@ fun App( MainContent( layoutMode = layoutMode, deckState = deckState, + singlePaneState = singlePaneState, relayManager = relayManager, localCache = localCache, accountManager = accountManager, @@ -693,7 +708,7 @@ fun App( torStatus = currentTorStatus, onShowComposeDialog = onShowComposeDialog, onShowReplyDialog = onShowReplyDialog, - onShowAddColumnDialog = onShowAddColumnDialog, + onShowAppDrawer = onShowAppDrawer, ) } @@ -707,14 +722,32 @@ fun App( ) } - // Add column dialog - if (showAddColumnDialog) { - AddColumnDialog( - onDismiss = onDismissAddColumnDialog, - onAdd = { type -> - deckState.addColumn(type) - onDismissAddColumnDialog() + // App Drawer overlay + if (showAppDrawer) { + val openColumns by deckState.columns.collectAsState() + AppDrawer( + openColumnTypes = + if (layoutMode == LayoutMode.DECK) { + openColumns.map { it.type.typeKey() }.toSet() + } else { + emptySet() + }, + onSelectScreen = { type -> + when (layoutMode) { + LayoutMode.DECK -> { + if (deckState.hasColumnOfType(type)) { + deckState.focusExistingColumn(type) + } else { + deckState.addColumn(type) + } + } + + LayoutMode.SINGLE_PANE -> { + singlePaneState.navigate(type) + } + } }, + onDismiss = onDismissAppDrawer, ) } } @@ -736,6 +769,7 @@ fun App( fun MainContent( layoutMode: LayoutMode, deckState: DeckState, + singlePaneState: SinglePaneState, relayManager: DesktopRelayConnectionManager, localCache: DesktopLocalCache, accountManager: AccountManager, @@ -746,7 +780,7 @@ fun MainContent( torStatus: com.vitorpamplona.amethyst.commons.tor.TorServiceStatus, onShowComposeDialog: () -> Unit, onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, - onShowAddColumnDialog: () -> Unit, + onShowAppDrawer: () -> Unit, ) { val snackbarHostState = remember { SnackbarHostState() } val scope = rememberCoroutineScope() @@ -893,6 +927,8 @@ fun MainContent( highlightStore = highlightStore, draftStore = draftStore, appScope = appScope, + singlePaneState = singlePaneState, + onOpenAppDrawer = onShowAppDrawer, onShowComposeDialog = onShowComposeDialog, onShowReplyDialog = onShowReplyDialog, onZapFeedback = onZapFeedback, @@ -906,7 +942,7 @@ fun MainContent( LayoutMode.DECK -> { if (!isImmersive) { DeckSidebar( - onAddColumn = onShowAddColumnDialog, + onAddColumn = onShowAppDrawer, onOpenSettings = { if (deckState.hasColumnOfType(DeckColumnType.Settings)) { deckState.focusExistingColumn(DeckColumnType.Settings) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AddColumnDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AddColumnDialog.kt deleted file mode 100644 index f85a4707c..000000000 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AddColumnDialog.kt +++ /dev/null @@ -1,165 +0,0 @@ -/* - * 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.deck - -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.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -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.unit.dp - -private val COLUMN_OPTIONS = - listOf( - DeckColumnType.HomeFeed, - DeckColumnType.Notifications, - DeckColumnType.Messages, - DeckColumnType.Search, - DeckColumnType.Reads, - DeckColumnType.Drafts, - DeckColumnType.MyHighlights, - DeckColumnType.Bookmarks, - DeckColumnType.GlobalFeed, - DeckColumnType.MyProfile, - DeckColumnType.Chess, - ) - -@Composable -fun AddColumnDialog( - onDismiss: () -> Unit, - onAdd: (DeckColumnType) -> Unit, -) { - var hashtagInput by remember { mutableStateOf("") } - var showHashtagInput by remember { mutableStateOf(false) } - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("Add Column") }, - text = { - Column { - if (showHashtagInput) { - Text( - "Enter hashtag:", - style = MaterialTheme.typography.bodyMedium, - ) - Spacer(Modifier.height(8.dp)) - OutlinedTextField( - value = hashtagInput, - onValueChange = { hashtagInput = it.removePrefix("#") }, - label = { Text("Hashtag") }, - placeholder = { Text("bitcoin") }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - } else { - COLUMN_OPTIONS.forEach { type -> - Row( - modifier = - Modifier - .fillMaxWidth() - .clickable { onAdd(type) } - .padding(vertical = 10.dp, horizontal = 4.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start, - ) { - Icon( - imageVector = type.icon(), - contentDescription = null, - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.width(12.dp)) - Text( - type.title(), - style = MaterialTheme.typography.bodyLarge, - ) - } - } - - Row( - modifier = - Modifier - .fillMaxWidth() - .clickable { showHashtagInput = true } - .padding(vertical = 10.dp, horizontal = 4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = DeckColumnType.Hashtag("").icon(), - contentDescription = null, - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.width(12.dp)) - Text( - "Hashtag...", - style = MaterialTheme.typography.bodyLarge, - ) - } - } - } - }, - confirmButton = { - if (showHashtagInput) { - Button( - onClick = { - if (hashtagInput.isNotBlank()) { - onAdd(DeckColumnType.Hashtag(hashtagInput.trim())) - } - }, - enabled = hashtagInput.isNotBlank(), - ) { - Text("Add") - } - } - }, - dismissButton = { - TextButton(onClick = { - if (showHashtagInput) { - showHashtagInput = false - } else { - onDismiss() - } - }) { - Text(if (showHashtagInput) "Back" else "Cancel") - } - }, - ) -} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AppDrawer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AppDrawer.kt new file mode 100644 index 000000000..c483938e1 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AppDrawer.kt @@ -0,0 +1,472 @@ +/* + * 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.deck + +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.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Article +import androidx.compose.material.icons.filled.Explore +import androidx.compose.material.icons.filled.Groups +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.SportsEsports +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.derivedStateOf +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.ExperimentalComposeUiApi +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.graphics.vector.ImageVector +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.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.onPointerEvent +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay + +// -- Screen categories -- + +enum class ScreenCategory( + val title: String, + val icon: ImageVector, +) { + SOCIAL("Social", Icons.Default.Groups), + LONG_FORM("Long-Form", Icons.AutoMirrored.Filled.Article), + DISCOVERY("Discovery", Icons.Default.Explore), + IDENTITY("Identity", Icons.Default.Person), + PLAY("Play", Icons.Default.SportsEsports), +} + +// -- Extensions on DeckColumnType -- + +fun DeckColumnType.category(): ScreenCategory = + when (this) { + DeckColumnType.HomeFeed, + DeckColumnType.Notifications, + DeckColumnType.Messages, + DeckColumnType.GlobalFeed, + -> ScreenCategory.SOCIAL + + DeckColumnType.Reads, + DeckColumnType.Drafts, + is DeckColumnType.Editor, + DeckColumnType.MyHighlights, + -> ScreenCategory.LONG_FORM + + DeckColumnType.Search, + is DeckColumnType.Hashtag, + DeckColumnType.Bookmarks, + -> ScreenCategory.DISCOVERY + + DeckColumnType.MyProfile, + DeckColumnType.Settings, + -> ScreenCategory.IDENTITY + + DeckColumnType.Chess -> ScreenCategory.PLAY + + // Deep-link types — not in LAUNCHABLE_SCREENS but need a category for exhaustiveness + is DeckColumnType.Profile, + is DeckColumnType.Thread, + is DeckColumnType.Article, + -> ScreenCategory.SOCIAL + } + +fun DeckColumnType.requiresInput(): Boolean = + when (this) { + is DeckColumnType.Hashtag -> true + else -> false + } + +val LAUNCHABLE_SCREENS: List = + listOf( + DeckColumnType.HomeFeed, + DeckColumnType.Notifications, + DeckColumnType.Messages, + DeckColumnType.GlobalFeed, + DeckColumnType.Reads, + DeckColumnType.Drafts, + DeckColumnType.Editor(), + DeckColumnType.MyHighlights, + DeckColumnType.Search, + DeckColumnType.Hashtag(""), + DeckColumnType.Bookmarks, + DeckColumnType.MyProfile, + DeckColumnType.Settings, + DeckColumnType.Chess, + ) + +// -- State -- + +@Stable +private class AppDrawerState { + var searchQuery by mutableStateOf("") + var selectedIndex by mutableStateOf(0) + var hashtagInput by mutableStateOf("") + var awaitingHashtag by mutableStateOf(false) + private var consumed by mutableStateOf(false) + + val filteredScreens: List by derivedStateOf { + if (searchQuery.isBlank()) { + LAUNCHABLE_SCREENS + } else { + LAUNCHABLE_SCREENS.filter { + it.title().contains(searchQuery, ignoreCase = true) || + it.category().title.contains(searchQuery, ignoreCase = true) + } + } + } + + val groupedScreens: Map> by derivedStateOf { + filteredScreens.groupBy { it.category() }.filterValues { it.isNotEmpty() } + } + + fun moveSelection(delta: Int) { + val size = filteredScreens.size + if (size > 0) selectedIndex = (selectedIndex + delta).coerceIn(0, size - 1) + } + + fun select( + screen: DeckColumnType, + onSelectScreen: (DeckColumnType) -> Unit, + onDismiss: () -> Unit, + ) { + if (consumed) return + if (screen.requiresInput()) { + awaitingHashtag = true + hashtagInput = "" + } else { + consumed = true + onSelectScreen(screen) + onDismiss() + } + } + + fun confirmHashtag( + onSelectScreen: (DeckColumnType) -> Unit, + onDismiss: () -> Unit, + ) { + if (consumed || hashtagInput.isBlank()) return + consumed = true + onSelectScreen(DeckColumnType.Hashtag(hashtagInput.removePrefix("#").trim())) + onDismiss() + } +} + +// -- Composables -- + +@OptIn(ExperimentalLayoutApi::class, ExperimentalComposeUiApi::class) +@Composable +fun AppDrawer( + openColumnTypes: Set, + onSelectScreen: (DeckColumnType) -> Unit, + onDismiss: () -> Unit, +) { + val state = remember { AppDrawerState() } + val searchFocusRequester = remember { FocusRequester() } + + LaunchedEffect(Unit) { + delay(50) + searchFocusRequester.requestFocus() + } + + Box( + modifier = + Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.5f)) + .onPreviewKeyEvent { event -> + if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false + when (event.key) { + Key.Escape -> { + if (state.awaitingHashtag) { + state.awaitingHashtag = false + } else { + onDismiss() + } + true + } + + Key.DirectionDown -> { + state.moveSelection(1) + true + } + + Key.DirectionUp -> { + state.moveSelection(-1) + true + } + + Key.Enter -> { + if (state.awaitingHashtag) { + state.confirmHashtag(onSelectScreen, onDismiss) + } else { + state.filteredScreens.getOrNull(state.selectedIndex)?.let { + state.select(it, onSelectScreen, onDismiss) + } + } + true + } + + else -> { + false + } + } + }.clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { onDismiss() }, + contentAlignment = Alignment.Center, + ) { + Surface( + modifier = + Modifier + .fillMaxWidth(0.55f) + .fillMaxHeight(0.7f) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { /* consume click — prevent propagation to scrim */ }, + shape = RoundedCornerShape(16.dp), + tonalElevation = 8.dp, + ) { + Column { + TextField( + value = state.searchQuery, + onValueChange = { + state.searchQuery = it + state.selectedIndex = 0 + }, + modifier = + Modifier + .fillMaxWidth() + .focusRequester(searchFocusRequester), + placeholder = { Text("Search screens...") }, + singleLine = true, + leadingIcon = { Icon(Icons.Default.Search, "Search") }, + ) + + if (state.awaitingHashtag) { + HashtagInputSection(state, onSelectScreen, onDismiss) + } else { + DrawerGrid(state, openColumnTypes, onSelectScreen, onDismiss) + } + } + } + } +} + +@OptIn(ExperimentalLayoutApi::class, ExperimentalComposeUiApi::class) +@Composable +private fun DrawerGrid( + state: AppDrawerState, + openColumnTypes: Set, + onSelectScreen: (DeckColumnType) -> Unit, + onDismiss: () -> Unit, +) { + val listState = rememberLazyListState() + + LaunchedEffect(state.selectedIndex) { + val approxItem = (state.selectedIndex / 4).coerceAtLeast(0) + if (approxItem < listState.layoutInfo.totalItemsCount) { + listState.animateScrollToItem(approxItem) + } + } + + LazyColumn( + state = listState, + modifier = Modifier.padding(top = 8.dp), + ) { + var globalIndex = 0 + state.groupedScreens.forEach { (category, screens) -> + stickyHeader(key = "header-${category.name}") { + CategoryHeader(category) + } + val startIndex = globalIndex + item(key = "grid-${category.name}") { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) { + screens.forEachIndexed { localIdx, screen -> + DrawerScreenCard( + type = screen, + isSelected = (startIndex + localIdx) == state.selectedIndex, + isOpen = openColumnTypes.contains(screen.typeKey()), + onClick = { state.select(screen, onSelectScreen, onDismiss) }, + onHover = { state.selectedIndex = startIndex + localIdx }, + ) + } + } + } + globalIndex += screens.size + } + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun DrawerScreenCard( + type: DeckColumnType, + isSelected: Boolean, + isOpen: Boolean, + onClick: () -> Unit, + onHover: () -> Unit, +) { + Surface( + modifier = + Modifier + .size(80.dp) + .clickable(onClick = onClick) + .onPointerEvent(PointerEventType.Enter) { onHover() }, + shape = RoundedCornerShape(12.dp), + tonalElevation = if (isSelected) 8.dp else 2.dp, + color = + if (isSelected) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surface + }, + ) { + Box(contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + type.icon(), + contentDescription = type.title(), + modifier = Modifier.size(28.dp), + ) + Spacer(Modifier.height(4.dp)) + Text( + type.title(), + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + ) + } + if (isOpen) { + Box( + modifier = + Modifier + .align(Alignment.TopEnd) + .padding(4.dp) + .size(6.dp) + .background(MaterialTheme.colorScheme.primary, CircleShape), + ) + } + } + } +} + +@Composable +private fun CategoryHeader(category: ScreenCategory) { + Row( + modifier = + Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface) + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon(category.icon, category.title, Modifier.size(16.dp)) + Text( + category.title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun HashtagInputSection( + state: AppDrawerState, + onSelectScreen: (DeckColumnType) -> Unit, + onDismiss: () -> Unit, +) { + val hashtagFocusRequester = remember { FocusRequester() } + + LaunchedEffect(Unit) { + delay(50) + hashtagFocusRequester.requestFocus() + } + + Column(Modifier.padding(16.dp)) { + Text("Enter hashtag:", style = MaterialTheme.typography.titleSmall) + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = state.hashtagInput, + onValueChange = { state.hashtagInput = it.removePrefix("#") }, + modifier = + Modifier + .fillMaxWidth() + .focusRequester(hashtagFocusRequester), + placeholder = { Text("bitcoin, nostr...") }, + singleLine = true, + ) + Spacer(Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton(onClick = { state.awaitingHashtag = false }) { Text("Back") } + Button( + onClick = { state.confirmHashtag(onSelectScreen, onDismiss) }, + enabled = state.hashtagInput.isNotBlank(), + ) { + Text("Open") + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt index 5e2599627..92640a5cf 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt @@ -31,6 +31,7 @@ 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.Article +import androidx.compose.material.icons.filled.Apps import androidx.compose.material.icons.filled.Bookmark import androidx.compose.material.icons.filled.Email import androidx.compose.material.icons.filled.Extension @@ -49,9 +50,7 @@ import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -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.vector.ImageVector import androidx.compose.ui.text.style.TextOverflow @@ -106,6 +105,8 @@ fun SinglePaneLayout( highlightStore: DesktopHighlightStore, draftStore: com.vitorpamplona.amethyst.desktop.service.drafts.DesktopDraftStore, appScope: CoroutineScope, + singlePaneState: SinglePaneState, + onOpenAppDrawer: () -> Unit, onShowComposeDialog: () -> Unit, onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, onZapFeedback: (ZapFeedback) -> Unit, @@ -114,7 +115,7 @@ fun SinglePaneLayout( lastRelayEventAt: Long? = null, modifier: Modifier = Modifier, ) { - var currentColumnType by remember { mutableStateOf(DeckColumnType.HomeFeed) } + val currentColumnType by singlePaneState.currentScreen.collectAsState() val navState = remember { ColumnNavigationState() } val navStack by navState.stack.collectAsState() val currentOverlay = navStack.lastOrNull() @@ -131,7 +132,7 @@ fun SinglePaneLayout( NavigationRailItem( selected = currentColumnType == item.type && navStack.isEmpty(), onClick = { - currentColumnType = item.type + singlePaneState.navigate(item.type) navState.clear() }, icon = { @@ -152,6 +153,25 @@ fun SinglePaneLayout( ) } + NavigationRailItem( + selected = false, + onClick = onOpenAppDrawer, + icon = { + Icon( + Icons.Default.Apps, + contentDescription = "App Drawer", + modifier = Modifier.size(22.dp), + ) + }, + label = { + Text( + "More", + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + ) + }, + ) + Spacer(Modifier.weight(1f)) // Relay health — shows elapsed time since last event (hidden when <30s) @@ -171,7 +191,7 @@ fun SinglePaneLayout( TorStatusIndicator( status = torState.status, onClick = { - currentColumnType = DeckColumnType.Settings + singlePaneState.navigate(DeckColumnType.Settings) navState.clear() }, modifier = Modifier.padding(bottom = 12.dp), diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneState.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneState.kt new file mode 100644 index 000000000..9b4a69f3b --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneState.kt @@ -0,0 +1,37 @@ +/* + * 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.deck + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Holds current screen for single-pane mode. Mirrors DeckState pattern. + */ +class SinglePaneState { + private val _currentScreen = MutableStateFlow(DeckColumnType.HomeFeed) + val currentScreen: StateFlow = _currentScreen.asStateFlow() + + fun navigate(type: DeckColumnType) { + _currentScreen.value = type + } +} From 28e9b8202f38e741b2b6470eb4b5be1a2a90c2e5 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Fri, 17 Apr 2026 13:37:06 +0300 Subject: [PATCH 02/48] feat(desktop): customizable nav bar with pin/unpin (v1b) Add PinnedNavBarState to manage user-customizable sidebar items. Replace hardcoded navItems in SinglePaneLayout with pinnedScreens from state, persisted as CSV to DesktopPreferences. Right-click context menu on App Drawer items to pin/unpin from sidebar. - Add PinnedNavBarState with pin/unpin/move/save/load - Add pinnedNavItems to DesktopPreferences (CSV persistence) - Update SinglePaneLayout to use pinnedScreens from state - Update AppDrawer with isPinned indicator and context menu - Remove NavItem data class and hardcoded navItems list - HomeFeed and Settings are always pinned (non-removable) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../amethyst/desktop/DesktopPreferences.kt | 8 ++ .../vitorpamplona/amethyst/desktop/Main.kt | 6 + .../amethyst/desktop/ui/deck/AppDrawer.kt | 132 +++++++++++++----- .../desktop/ui/deck/PinnedNavBarState.kt | 111 +++++++++++++++ .../desktop/ui/deck/SinglePaneLayout.kt | 45 ++---- 5 files changed, 228 insertions(+), 74 deletions(-) create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/PinnedNavBarState.kt diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt index f06acf2e7..069c06de4 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt @@ -69,6 +69,14 @@ object DesktopPreferences { prefs.put(KEY_LAYOUT_MODE, value) } + private const val KEY_PINNED_NAV_ITEMS = "pinned_nav_items" + + var pinnedNavItems: String + get() = prefs.get(KEY_PINNED_NAV_ITEMS, "") + set(value) { + prefs.put(KEY_PINNED_NAV_ITEMS, value) + } + private const val KEY_BLOSSOM_SERVERS = "blossom_servers" private const val DEFAULT_BLOSSOM_SERVER = "https://blossom.primal.net" diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 306105f95..a6d9cad7d 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -93,6 +93,7 @@ import com.vitorpamplona.amethyst.desktop.ui.deck.DeckColumnType 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.PinnedNavBarState import com.vitorpamplona.amethyst.desktop.ui.deck.SinglePaneLayout import com.vitorpamplona.amethyst.desktop.ui.deck.SinglePaneState import com.vitorpamplona.amethyst.desktop.ui.media.LocalAwtWindow @@ -492,6 +493,7 @@ fun App( initialTorSettings: com.vitorpamplona.amethyst.commons.tor.TorSettings, ) { val singlePaneState = remember { SinglePaneState() } + val pinnedNavBarState = remember { PinnedNavBarState().also { it.load() } } // Always reload from prefs — after key() rebuild, prefs have the latest saved settings var torSettings by remember { @@ -698,6 +700,7 @@ fun App( layoutMode = layoutMode, deckState = deckState, singlePaneState = singlePaneState, + pinnedNavBarState = pinnedNavBarState, relayManager = relayManager, localCache = localCache, accountManager = accountManager, @@ -732,6 +735,7 @@ fun App( } else { emptySet() }, + pinnedNavBarState = pinnedNavBarState, onSelectScreen = { type -> when (layoutMode) { LayoutMode.DECK -> { @@ -770,6 +774,7 @@ fun MainContent( layoutMode: LayoutMode, deckState: DeckState, singlePaneState: SinglePaneState, + pinnedNavBarState: PinnedNavBarState, relayManager: DesktopRelayConnectionManager, localCache: DesktopLocalCache, accountManager: AccountManager, @@ -928,6 +933,7 @@ fun MainContent( draftStore = draftStore, appScope = appScope, singlePaneState = singlePaneState, + pinnedNavBarState = pinnedNavBarState, onOpenAppDrawer = onShowAppDrawer, onShowComposeDialog = onShowComposeDialog, onShowReplyDialog = onShowReplyDialog, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AppDrawer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AppDrawer.kt index c483938e1..56a360e74 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AppDrawer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AppDrawer.kt @@ -45,6 +45,7 @@ import androidx.compose.material.icons.automirrored.filled.Article import androidx.compose.material.icons.filled.Explore import androidx.compose.material.icons.filled.Groups import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.PushPin import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.SportsEsports import androidx.compose.material3.Button @@ -214,6 +215,7 @@ private class AppDrawerState { @Composable fun AppDrawer( openColumnTypes: Set, + pinnedNavBarState: PinnedNavBarState, onSelectScreen: (DeckColumnType) -> Unit, onDismiss: () -> Unit, ) { @@ -304,7 +306,7 @@ fun AppDrawer( if (state.awaitingHashtag) { HashtagInputSection(state, onSelectScreen, onDismiss) } else { - DrawerGrid(state, openColumnTypes, onSelectScreen, onDismiss) + DrawerGrid(state, openColumnTypes, pinnedNavBarState, onSelectScreen, onDismiss) } } } @@ -316,6 +318,7 @@ fun AppDrawer( private fun DrawerGrid( state: AppDrawerState, openColumnTypes: Set, + pinnedNavBarState: PinnedNavBarState, onSelectScreen: (DeckColumnType) -> Unit, onDismiss: () -> Unit, ) { @@ -349,7 +352,15 @@ private fun DrawerGrid( type = screen, isSelected = (startIndex + localIdx) == state.selectedIndex, isOpen = openColumnTypes.contains(screen.typeKey()), + isPinned = pinnedNavBarState.isPinned(screen), onClick = { state.select(screen, onSelectScreen, onDismiss) }, + onTogglePin = { + if (pinnedNavBarState.isPinned(screen)) { + pinnedNavBarState.unpin(screen) + } else { + pinnedNavBarState.pin(screen) + } + }, onHover = { state.selectedIndex = startIndex + localIdx }, ) } @@ -366,46 +377,93 @@ private fun DrawerScreenCard( type: DeckColumnType, isSelected: Boolean, isOpen: Boolean, + isPinned: Boolean, onClick: () -> Unit, + onTogglePin: () -> Unit, onHover: () -> Unit, ) { - Surface( - modifier = - Modifier - .size(80.dp) - .clickable(onClick = onClick) - .onPointerEvent(PointerEventType.Enter) { onHover() }, - shape = RoundedCornerShape(12.dp), - tonalElevation = if (isSelected) 8.dp else 2.dp, - color = - if (isSelected) { - MaterialTheme.colorScheme.primaryContainer - } else { - MaterialTheme.colorScheme.surface - }, - ) { - Box(contentAlignment = Alignment.Center) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Icon( - type.icon(), - contentDescription = type.title(), - modifier = Modifier.size(28.dp), - ) - Spacer(Modifier.height(4.dp)) - Text( - type.title(), - style = MaterialTheme.typography.labelSmall, - maxLines = 1, - ) + var showMenu by remember { mutableStateOf(false) } + + Box { + Surface( + modifier = + Modifier + .size(80.dp) + .clickable(onClick = onClick) + .onPointerEvent(PointerEventType.Enter) { onHover() } + .onPointerEvent(PointerEventType.Press) { event -> + // Right-click opens context menu + if (event.changes.any { it.pressed && event.button?.index == 2 }) { + showMenu = true + } + }, + shape = RoundedCornerShape(12.dp), + tonalElevation = if (isSelected) 8.dp else 2.dp, + color = + if (isSelected) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surface + }, + ) { + Box(contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + type.icon(), + contentDescription = type.title(), + modifier = Modifier.size(28.dp), + ) + Spacer(Modifier.height(4.dp)) + Text( + type.title(), + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + ) + } + // Open indicator (top-end dot) + if (isOpen) { + Box( + modifier = + Modifier + .align(Alignment.TopEnd) + .padding(4.dp) + .size(6.dp) + .background(MaterialTheme.colorScheme.primary, CircleShape), + ) + } + // Pinned indicator (top-start dot) + if (isPinned) { + Box( + modifier = + Modifier + .align(Alignment.TopStart) + .padding(4.dp) + .size(6.dp) + .background(MaterialTheme.colorScheme.tertiary, CircleShape), + ) + } } - if (isOpen) { - Box( - modifier = - Modifier - .align(Alignment.TopEnd) - .padding(4.dp) - .size(6.dp) - .background(MaterialTheme.colorScheme.primary, CircleShape), + } + + // Context menu for pin/unpin + androidx.compose.material3.DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false }, + ) { + if (PinnedNavBarState.isPinnable(type)) { + androidx.compose.material3.DropdownMenuItem( + text = { Text(if (isPinned) "Unpin from sidebar" else "Pin to sidebar") }, + onClick = { + onTogglePin() + showMenu = false + }, + leadingIcon = { + Icon( + Icons.Default.PushPin, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + }, ) } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/PinnedNavBarState.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/PinnedNavBarState.kt new file mode 100644 index 000000000..fe9252dd4 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/PinnedNavBarState.kt @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.deck + +import com.vitorpamplona.amethyst.desktop.DesktopPreferences +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +/** + * Manages which screens are pinned to the navigation sidebar. + * Persists to DesktopPreferences as CSV of typeKey strings. + */ +class PinnedNavBarState { + private val _pinnedScreens = MutableStateFlow(DEFAULT_PINNED) + val pinnedScreens: StateFlow> = _pinnedScreens.asStateFlow() + + fun isPinned(type: DeckColumnType): Boolean = _pinnedScreens.value.any { it.typeKey() == type.typeKey() } + + fun pin(type: DeckColumnType) { + if (isPinned(type)) return + if (!isPinnable(type)) return + _pinnedScreens.update { it + type } + save() + } + + fun unpin(type: DeckColumnType) { + if (!isUnpinnable(type)) return + _pinnedScreens.update { current -> current.filter { it.typeKey() != type.typeKey() } } + save() + } + + fun move( + fromIndex: Int, + toIndex: Int, + ) { + _pinnedScreens.update { current -> + if (fromIndex !in current.indices || toIndex !in current.indices) return@update current + val mutable = current.toMutableList() + val item = mutable.removeAt(fromIndex) + mutable.add(toIndex, item) + mutable.toList() + } + save() + } + + fun save() { + DesktopPreferences.pinnedNavItems = _pinnedScreens.value.joinToString(",") { it.typeKey() } + } + + fun load() { + val raw = DesktopPreferences.pinnedNavItems + if (raw.isBlank()) { + _pinnedScreens.value = DEFAULT_PINNED + return + } + val keys = raw.split(",").filter { it.isNotBlank() } + val screens = + keys.mapNotNull { key -> + PINNABLE_SCREENS.find { it.typeKey() == key } + } + _pinnedScreens.value = screens.ifEmpty { DEFAULT_PINNED } + } + + companion object { + // Only object types are pinnable (no parameterized types like Hashtag, Editor) + val PINNABLE_SCREENS: List = + LAUNCHABLE_SCREENS.filter { !it.requiresInput() && it !is DeckColumnType.Editor } + + val DEFAULT_PINNED: List = + listOf( + DeckColumnType.HomeFeed, + DeckColumnType.Reads, + DeckColumnType.Drafts, + DeckColumnType.MyHighlights, + DeckColumnType.Search, + DeckColumnType.Bookmarks, + DeckColumnType.Messages, + DeckColumnType.Notifications, + DeckColumnType.MyProfile, + DeckColumnType.Chess, + DeckColumnType.Settings, + ) + + // These screens cannot be unpinned + private val ALWAYS_PINNED = setOf("home", "settings") + + fun isPinnable(type: DeckColumnType): Boolean = PINNABLE_SCREENS.any { it.typeKey() == type.typeKey() } + + fun isUnpinnable(type: DeckColumnType): Boolean = type.typeKey() !in ALWAYS_PINNED + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt index 92640a5cf..873f38eab 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt @@ -30,16 +30,7 @@ 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.Article import androidx.compose.material.icons.filled.Apps -import androidx.compose.material.icons.filled.Bookmark -import androidx.compose.material.icons.filled.Email -import androidx.compose.material.icons.filled.Extension -import androidx.compose.material.icons.filled.Home -import androidx.compose.material.icons.filled.Notifications -import androidx.compose.material.icons.filled.Person -import androidx.compose.material.icons.filled.Search -import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationRail @@ -52,7 +43,6 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.domain.nip46.SignerConnectionState @@ -72,27 +62,6 @@ import com.vitorpamplona.amethyst.desktop.ui.tor.TorStatusIndicator import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm import kotlinx.coroutines.CoroutineScope -private data class NavItem( - val type: DeckColumnType, - val icon: ImageVector, - val label: String, -) - -private val navItems = - listOf( - NavItem(DeckColumnType.HomeFeed, Icons.Default.Home, "Home"), - NavItem(DeckColumnType.Reads, Icons.AutoMirrored.Filled.Article, "Reads"), - NavItem(DeckColumnType.Drafts, Icons.AutoMirrored.Filled.Article, "Drafts"), - NavItem(DeckColumnType.MyHighlights, Icons.AutoMirrored.Filled.Article, "Highlights"), - NavItem(DeckColumnType.Search, Icons.Default.Search, "Search"), - NavItem(DeckColumnType.Bookmarks, Icons.Default.Bookmark, "Bookmarks"), - NavItem(DeckColumnType.Messages, Icons.Default.Email, "Messages"), - NavItem(DeckColumnType.Notifications, Icons.Default.Notifications, "Notifications"), - NavItem(DeckColumnType.MyProfile, Icons.Default.Person, "Profile"), - NavItem(DeckColumnType.Chess, Icons.Default.Extension, "Chess"), - NavItem(DeckColumnType.Settings, Icons.Default.Settings, "Settings"), - ) - @Composable fun SinglePaneLayout( relayManager: DesktopRelayConnectionManager, @@ -106,6 +75,7 @@ fun SinglePaneLayout( draftStore: com.vitorpamplona.amethyst.desktop.service.drafts.DesktopDraftStore, appScope: CoroutineScope, singlePaneState: SinglePaneState, + pinnedNavBarState: PinnedNavBarState, onOpenAppDrawer: () -> Unit, onShowComposeDialog: () -> Unit, onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, @@ -128,23 +98,24 @@ fun SinglePaneLayout( modifier = Modifier.width(80.dp).fillMaxHeight(), containerColor = MaterialTheme.colorScheme.surfaceVariant, ) { - navItems.forEach { item -> + val pinnedScreens by pinnedNavBarState.pinnedScreens.collectAsState() + pinnedScreens.forEach { screenType -> NavigationRailItem( - selected = currentColumnType == item.type && navStack.isEmpty(), + selected = currentColumnType == screenType && navStack.isEmpty(), onClick = { - singlePaneState.navigate(item.type) + singlePaneState.navigate(screenType) navState.clear() }, icon = { Icon( - item.icon, - contentDescription = item.label, + screenType.icon(), + contentDescription = screenType.title(), modifier = Modifier.size(22.dp), ) }, label = { Text( - item.label, + screenType.title(), style = MaterialTheme.typography.labelSmall, maxLines = 1, overflow = TextOverflow.Ellipsis, From e528656ebdea1524023dab59d77f871d2abbaf22 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Fri, 17 Apr 2026 13:42:30 +0300 Subject: [PATCH 03/48] feat(desktop): workspace system with save/switch/restore (v1c) Add workspace presets for different usage modes. Each workspace stores layout mode + column configuration. Switching destroys current layout and rebuilds from saved config (no background state). - Add Workspace data model with WorkspaceColumn - Add WorkspaceManager with save/load/switch/add/delete - Add WorkspaceIcons registry for Material icon resolution - Add WorkspaceBar to AppDrawer footer with workspace chips - Add DeckState.loadFromWorkspace() for column rebuilding - Add workspaces persistence to DesktopPreferences - Default workspace: "Social" (Home + Notifications + DMs) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../amethyst/desktop/DesktopPreferences.kt | 8 + .../vitorpamplona/amethyst/desktop/Main.kt | 10 + .../amethyst/desktop/ui/deck/AppDrawer.kt | 59 ++++- .../amethyst/desktop/ui/deck/DeckState.kt | 14 ++ .../amethyst/desktop/ui/deck/Workspace.kt | 81 +++++++ .../desktop/ui/deck/WorkspaceManager.kt | 225 ++++++++++++++++++ 6 files changed, 396 insertions(+), 1 deletion(-) create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/Workspace.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/WorkspaceManager.kt diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt index 069c06de4..11e9521bb 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt @@ -69,6 +69,14 @@ object DesktopPreferences { prefs.put(KEY_LAYOUT_MODE, value) } + private const val KEY_WORKSPACES = "workspaces" + + var workspaces: String + get() = prefs.get(KEY_WORKSPACES, "") + set(value) { + prefs.put(KEY_WORKSPACES, value) + } + private const val KEY_PINNED_NAV_ITEMS = "pinned_nav_items" var pinnedNavItems: String diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index a6d9cad7d..b16540f6c 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -96,6 +96,7 @@ import com.vitorpamplona.amethyst.desktop.ui.deck.DeckState import com.vitorpamplona.amethyst.desktop.ui.deck.PinnedNavBarState import com.vitorpamplona.amethyst.desktop.ui.deck.SinglePaneLayout import com.vitorpamplona.amethyst.desktop.ui.deck.SinglePaneState +import com.vitorpamplona.amethyst.desktop.ui.deck.WorkspaceManager import com.vitorpamplona.amethyst.desktop.ui.media.LocalAwtWindow import com.vitorpamplona.amethyst.desktop.ui.media.LocalIsImmersiveFullscreen import com.vitorpamplona.amethyst.desktop.ui.media.LocalWindowState @@ -192,6 +193,7 @@ fun main() { var replyToNote by remember { mutableStateOf(null) } val deckScope = rememberCoroutineScope() val deckState = remember { DeckState(deckScope).also { it.load() } } + val workspaceManager = remember { WorkspaceManager(deckScope).also { it.load() } } val accountManager = remember { AccountManager.create() } val accountState by accountManager.accountState.collectAsState() var showAppDrawer by remember { mutableStateOf(false) } @@ -446,6 +448,7 @@ fun main() { App( layoutMode = layoutMode, deckState = deckState, + workspaceManager = workspaceManager, accountManager = accountManager, showComposeDialog = showComposeDialog, showAppDrawer = showAppDrawer, @@ -477,6 +480,7 @@ fun main() { fun App( layoutMode: LayoutMode, deckState: DeckState, + workspaceManager: WorkspaceManager, accountManager: AccountManager, showComposeDialog: Boolean, showAppDrawer: Boolean, @@ -699,6 +703,7 @@ fun App( MainContent( layoutMode = layoutMode, deckState = deckState, + workspaceManager = workspaceManager, singlePaneState = singlePaneState, pinnedNavBarState = pinnedNavBarState, relayManager = relayManager, @@ -736,6 +741,10 @@ fun App( emptySet() }, pinnedNavBarState = pinnedNavBarState, + workspaceManager = workspaceManager, + onSwitchWorkspace = { ws -> + deckState.loadFromWorkspace(ws.columns) + }, onSelectScreen = { type -> when (layoutMode) { LayoutMode.DECK -> { @@ -773,6 +782,7 @@ fun App( fun MainContent( layoutMode: LayoutMode, deckState: DeckState, + workspaceManager: WorkspaceManager, singlePaneState: SinglePaneState, pinnedNavBarState: PinnedNavBarState, relayManager: DesktopRelayConnectionManager, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AppDrawer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AppDrawer.kt index 56a360e74..eabbf78d4 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AppDrawer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AppDrawer.kt @@ -59,6 +59,7 @@ import androidx.compose.material3.TextField import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Stable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -216,6 +217,8 @@ private class AppDrawerState { fun AppDrawer( openColumnTypes: Set, pinnedNavBarState: PinnedNavBarState, + workspaceManager: WorkspaceManager, + onSwitchWorkspace: (Workspace) -> Unit, onSelectScreen: (DeckColumnType) -> Unit, onDismiss: () -> Unit, ) { @@ -306,7 +309,17 @@ fun AppDrawer( if (state.awaitingHashtag) { HashtagInputSection(state, onSelectScreen, onDismiss) } else { - DrawerGrid(state, openColumnTypes, pinnedNavBarState, onSelectScreen, onDismiss) + Box(Modifier.weight(1f)) { + DrawerGrid(state, openColumnTypes, pinnedNavBarState, onSelectScreen, onDismiss) + } + // Workspace bar at the bottom + WorkspaceBar( + workspaceManager = workspaceManager, + onSwitchWorkspace = { ws -> + onSwitchWorkspace(ws) + onDismiss() + }, + ) } } } @@ -528,3 +541,47 @@ private fun HashtagInputSection( } } } + +@Composable +private fun WorkspaceBar( + workspaceManager: WorkspaceManager, + onSwitchWorkspace: (Workspace) -> Unit, +) { + val workspaces by workspaceManager.workspaces.collectAsState() + val activeIndex by workspaceManager.activeIndex.collectAsState() + + androidx.compose.material3.HorizontalDivider() + + Row( + modifier = Modifier.fillMaxWidth().padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "Workspaces", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + workspaces.forEachIndexed { index, ws -> + val isActive = index == activeIndex + androidx.compose.material3.FilterChip( + selected = isActive, + onClick = { + if (!isActive) { + val switched = workspaceManager.switchTo(index) + if (switched != null) onSwitchWorkspace(switched) + } + }, + label = { Text(ws.name, style = MaterialTheme.typography.labelSmall) }, + leadingIcon = { + Icon( + WorkspaceIcons.resolve(ws.iconName), + contentDescription = ws.name, + modifier = Modifier.size(16.dp), + ) + }, + ) + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckState.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckState.kt index bdfb40298..6c033deaf 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckState.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckState.kt @@ -256,6 +256,20 @@ class DeckState( } } + fun loadFromWorkspace(workspaceColumns: List) { + val loaded = + workspaceColumns.mapNotNull { col -> + val entry = mapOf("type" to col.typeKey, "param" to col.param) + val type = parseColumnType(entry) ?: return@mapNotNull null + DeckColumn( + type = type, + width = col.width.coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH), + ) + } + _columns.value = loaded.ifEmpty { DEFAULT_COLUMNS } + _focusedColumnIndex.value = 0 + } + companion object { const val MIN_COLUMN_WIDTH = 300f const val MAX_COLUMN_WIDTH = 800f diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/Workspace.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/Workspace.kt new file mode 100644 index 000000000..0c3e4f801 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/Workspace.kt @@ -0,0 +1,81 @@ +/* + * 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.deck + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Bookmark +import androidx.compose.material.icons.filled.Chat +import androidx.compose.material.icons.filled.Code +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Explore +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.Groups +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.MenuBook +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.SportsEsports +import androidx.compose.material.icons.filled.Star +import androidx.compose.material.icons.filled.Work +import androidx.compose.ui.graphics.vector.ImageVector +import com.vitorpamplona.amethyst.desktop.LayoutMode + +data class Workspace( + val id: String = + java.util.UUID + .randomUUID() + .toString(), + val name: String, + val iconName: String, + val layoutMode: LayoutMode, + val columns: List, + val singlePaneScreen: String? = null, +) { + data class WorkspaceColumn( + val typeKey: String, + val param: String? = null, + val width: Float = 400f, + ) +} + +object WorkspaceIcons { + private val icons: Map = + mapOf( + "Groups" to Icons.Default.Groups, + "Edit" to Icons.Default.Edit, + "MenuBook" to Icons.Default.MenuBook, + "Home" to Icons.Default.Home, + "Chat" to Icons.Default.Chat, + "Search" to Icons.Default.Search, + "SportsEsports" to Icons.Default.SportsEsports, + "Bookmark" to Icons.Default.Bookmark, + "Explore" to Icons.Default.Explore, + "Person" to Icons.Default.Person, + "Star" to Icons.Default.Star, + "Favorite" to Icons.Default.Favorite, + "Work" to Icons.Default.Work, + "Code" to Icons.Default.Code, + ) + + val availableNames: List = icons.keys.sorted() + + fun resolve(name: String): ImageVector = icons[name] ?: Icons.Default.Home +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/WorkspaceManager.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/WorkspaceManager.kt new file mode 100644 index 000000000..4317f10ca --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/WorkspaceManager.kt @@ -0,0 +1,225 @@ +/* + * 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.deck + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import com.vitorpamplona.amethyst.desktop.DesktopPreferences +import com.vitorpamplona.amethyst.desktop.LayoutMode +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +class WorkspaceManager( + private val saveScope: CoroutineScope, +) { + private val _workspaces = MutableStateFlow(listOf(DEFAULT_WORKSPACE)) + val workspaces: StateFlow> = _workspaces.asStateFlow() + + private val _activeIndex = MutableStateFlow(0) + val activeIndex: StateFlow = _activeIndex.asStateFlow() + + val activeWorkspace: Workspace + get() = _workspaces.value.getOrElse(_activeIndex.value) { _workspaces.value.first() } + + private var saveJob: Job? = null + + fun switchTo(index: Int): Workspace? { + if (index !in _workspaces.value.indices) return null + _activeIndex.value = index + scheduleSave() + return activeWorkspace + } + + fun saveCurrentColumns(columns: List) { + _workspaces.update { wsList -> + wsList.mapIndexed { idx, ws -> + if (idx == _activeIndex.value) { + ws.copy( + columns = + columns.map { col -> + Workspace.WorkspaceColumn( + typeKey = col.type.typeKey(), + param = + when (col.type) { + is DeckColumnType.Profile -> col.type.pubKeyHex + is DeckColumnType.Thread -> col.type.noteId + is DeckColumnType.Hashtag -> col.type.tag + else -> null + }, + width = col.width, + ) + }, + ) + } else { + ws + } + } + } + scheduleSave() + } + + fun addWorkspace(workspace: Workspace) { + if (_workspaces.value.size >= MAX_WORKSPACES) return + _workspaces.update { it + workspace } + scheduleSave() + } + + fun updateWorkspace(workspace: Workspace) { + _workspaces.update { wsList -> + wsList.map { if (it.id == workspace.id) workspace else it } + } + scheduleSave() + } + + fun deleteWorkspace(id: String) { + if (_workspaces.value.size <= 1) return + val idx = _workspaces.value.indexOfFirst { it.id == id } + _workspaces.update { it.filter { ws -> ws.id != id } } + if (_activeIndex.value >= _workspaces.value.size) { + _activeIndex.value = _workspaces.value.size - 1 + } + if (idx == _activeIndex.value || _activeIndex.value >= _workspaces.value.size) { + _activeIndex.value = 0 + } + scheduleSave() + } + + private fun scheduleSave() { + saveJob?.cancel() + saveJob = + saveScope.launch { + delay(SAVE_DEBOUNCE_MS) + save() + } + } + + fun save() { + try { + val data = + mapOf( + "activeIndex" to _activeIndex.value, + "workspaces" to + _workspaces.value.map { ws -> + mapOf( + "id" to ws.id, + "name" to ws.name, + "iconName" to ws.iconName, + "layoutMode" to ws.layoutMode.name, + "singlePaneScreen" to ws.singlePaneScreen, + "columns" to + ws.columns.map { col -> + mapOf( + "typeKey" to col.typeKey, + "param" to col.param, + "width" to col.width, + ) + }, + ) + }, + ) + DesktopPreferences.workspaces = mapper.writeValueAsString(data) + } catch (e: Exception) { + println("WorkspaceManager: failed to save: ${e.message}") + } + } + + fun load() { + try { + val json = DesktopPreferences.workspaces + if (json.isBlank()) return + val data: Map = mapper.readValue(json) + val activeIdx = (data["activeIndex"] as? Number)?.toInt() ?: 0 + + @Suppress("UNCHECKED_CAST") + val wsList = data["workspaces"] as? List> ?: return + val loaded = + wsList.mapNotNull { entry -> + try { + val name = entry["name"] as? String ?: return@mapNotNull null + val iconName = entry["iconName"] as? String ?: "Home" + val layoutMode = + try { + LayoutMode.valueOf(entry["layoutMode"] as? String ?: "DECK") + } catch (e: Exception) { + LayoutMode.DECK + } + val singlePaneScreen = entry["singlePaneScreen"] as? String + + @Suppress("UNCHECKED_CAST") + val columns = + (entry["columns"] as? List>)?.map { col -> + Workspace.WorkspaceColumn( + typeKey = col["typeKey"] as? String ?: "home", + param = col["param"] as? String, + width = (col["width"] as? Number)?.toFloat() ?: 400f, + ) + } ?: emptyList() + + Workspace( + id = + entry["id"] as? String ?: java.util.UUID + .randomUUID() + .toString(), + name = name, + iconName = iconName, + layoutMode = layoutMode, + columns = columns, + singlePaneScreen = singlePaneScreen, + ) + } catch (e: Exception) { + null + } + } + if (loaded.isNotEmpty()) { + _workspaces.value = loaded + _activeIndex.value = activeIdx.coerceIn(loaded.indices) + } + } catch (e: Exception) { + println("WorkspaceManager: failed to load: ${e.message}") + } + } + + companion object { + const val MAX_WORKSPACES = 9 + private const val SAVE_DEBOUNCE_MS = 500L + private val mapper = jacksonObjectMapper() + + val DEFAULT_WORKSPACE = + Workspace( + id = "default-social", + name = "Social", + iconName = "Groups", + layoutMode = LayoutMode.DECK, + columns = + listOf( + Workspace.WorkspaceColumn("home"), + Workspace.WorkspaceColumn("notifications"), + Workspace.WorkspaceColumn("messages"), + ), + ) + } +} From 5b8ddf0a20065b960751d6936b4841a7e7b81201 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Fri, 17 Apr 2026 13:47:17 +0300 Subject: [PATCH 04/48] fix(desktop): review fixes for navigation overhaul - Fix right-click detection: use isSecondaryPressed (not button.index==2) - Fix parseColumnType missing drafts/highlights/editor/article cases - Fix param extraction for Editor.draftSlug and Article.addressTag - Fix deleteWorkspace index correction logic Co-Authored-By: Claude Opus 4.6 (1M context) --- .../amethyst/desktop/ui/deck/AppDrawer.kt | 6 ++++-- .../amethyst/desktop/ui/deck/DeckState.kt | 6 ++++++ .../desktop/ui/deck/WorkspaceManager.kt | 17 ++++++++++------- 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AppDrawer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AppDrawer.kt index eabbf78d4..7f74a8e60 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AppDrawer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AppDrawer.kt @@ -78,6 +78,7 @@ import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.isSecondaryPressed import androidx.compose.ui.input.pointer.onPointerEvent import androidx.compose.ui.unit.dp import kotlinx.coroutines.delay @@ -405,8 +406,9 @@ private fun DrawerScreenCard( .clickable(onClick = onClick) .onPointerEvent(PointerEventType.Enter) { onHover() } .onPointerEvent(PointerEventType.Press) { event -> - // Right-click opens context menu - if (event.changes.any { it.pressed && event.button?.index == 2 }) { + if (event.buttons.isSecondaryPressed && + event.changes.any { it.pressed && !it.previousPressed } + ) { showMenu = true } }, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckState.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckState.kt index 6c033deaf..cd66cd024 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckState.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckState.kt @@ -226,6 +226,8 @@ class DeckState( is DeckColumnType.Profile -> col.type.pubKeyHex is DeckColumnType.Thread -> col.type.noteId is DeckColumnType.Hashtag -> col.type.tag + is DeckColumnType.Editor -> col.type.draftSlug + is DeckColumnType.Article -> col.type.addressTag else -> null }, ) @@ -299,6 +301,10 @@ class DeckState( "my_profile" -> DeckColumnType.MyProfile "chess" -> DeckColumnType.Chess "settings" -> DeckColumnType.Settings + "drafts" -> DeckColumnType.Drafts + "highlights" -> DeckColumnType.MyHighlights + "editor" -> DeckColumnType.Editor(param) + "article" -> param?.let { DeckColumnType.Article(it) } "profile" -> param?.let { DeckColumnType.Profile(it) } "thread" -> param?.let { DeckColumnType.Thread(it) } "hashtag" -> param?.let { DeckColumnType.Hashtag(it) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/WorkspaceManager.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/WorkspaceManager.kt index 4317f10ca..f8ebd1e8e 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/WorkspaceManager.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/WorkspaceManager.kt @@ -68,6 +68,8 @@ class WorkspaceManager( is DeckColumnType.Profile -> col.type.pubKeyHex is DeckColumnType.Thread -> col.type.noteId is DeckColumnType.Hashtag -> col.type.tag + is DeckColumnType.Editor -> col.type.draftSlug + is DeckColumnType.Article -> col.type.addressTag else -> null }, width = col.width, @@ -97,14 +99,15 @@ class WorkspaceManager( fun deleteWorkspace(id: String) { if (_workspaces.value.size <= 1) return - val idx = _workspaces.value.indexOfFirst { it.id == id } + val deletedIdx = _workspaces.value.indexOfFirst { it.id == id } + if (deletedIdx < 0) return _workspaces.update { it.filter { ws -> ws.id != id } } - if (_activeIndex.value >= _workspaces.value.size) { - _activeIndex.value = _workspaces.value.size - 1 - } - if (idx == _activeIndex.value || _activeIndex.value >= _workspaces.value.size) { - _activeIndex.value = 0 - } + _activeIndex.value = + when { + deletedIdx < _activeIndex.value -> _activeIndex.value - 1 + deletedIdx == _activeIndex.value -> 0 + else -> _activeIndex.value + }.coerceIn(_workspaces.value.indices) scheduleSave() } From 09f76f036a7fab8179a0d63a86386600275791bb Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Fri, 17 Apr 2026 14:47:24 +0300 Subject: [PATCH 05/48] feat(desktop): workspace management UX with tabs, editor, unified search Add two-tab App Drawer (Screens/Workspaces), workspace cards with CRUD, editor dialog with icon picker and column configuration, unified search across screens and workspaces, layout mode auto-switching, and Cmd+Shift+S to save current layout as workspace. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../vitorpamplona/amethyst/desktop/Main.kt | 62 +- .../amethyst/desktop/ui/deck/AppDrawer.kt | 641 ++++++++++++++++-- .../amethyst/desktop/ui/deck/DeckState.kt | 5 + 3 files changed, 659 insertions(+), 49 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index b16540f6c..321e2b118 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -96,6 +96,7 @@ import com.vitorpamplona.amethyst.desktop.ui.deck.DeckState import com.vitorpamplona.amethyst.desktop.ui.deck.PinnedNavBarState import com.vitorpamplona.amethyst.desktop.ui.deck.SinglePaneLayout import com.vitorpamplona.amethyst.desktop.ui.deck.SinglePaneState +import com.vitorpamplona.amethyst.desktop.ui.deck.Workspace import com.vitorpamplona.amethyst.desktop.ui.deck.WorkspaceManager import com.vitorpamplona.amethyst.desktop.ui.media.LocalAwtWindow import com.vitorpamplona.amethyst.desktop.ui.media.LocalIsImmersiveFullscreen @@ -241,6 +242,44 @@ fun main() { }, onClick = { showComposeDialog = true }, ) + Item( + "Save as Workspace", + shortcut = + if (isMacOS) { + KeyShortcut(Key.S, meta = true, shift = true) + } else { + KeyShortcut(Key.S, ctrl = true, shift = true) + }, + onClick = { + if (workspaceManager.workspaces.value.size < WorkspaceManager.MAX_WORKSPACES) { + val columns = + deckState.columns.value.map { col -> + Workspace.WorkspaceColumn( + typeKey = col.type.typeKey(), + param = + when (col.type) { + is DeckColumnType.Hashtag -> col.type.tag + is DeckColumnType.Editor -> col.type.draftSlug + is DeckColumnType.Article -> col.type.addressTag + is DeckColumnType.Profile -> col.type.pubKeyHex + is DeckColumnType.Thread -> col.type.noteId + else -> null + }, + width = col.width, + ) + } + val ws = + Workspace( + name = "Workspace ${workspaceManager.workspaces.value.size + 1}", + iconName = "Star", + layoutMode = layoutMode, + columns = columns, + ) + workspaceManager.addWorkspace(ws) + } + }, + enabled = workspaceManager.workspaces.value.size < WorkspaceManager.MAX_WORKSPACES, + ) Separator() Item( "Settings", @@ -447,6 +486,10 @@ fun main() { key(appRestartKey) { App( layoutMode = layoutMode, + onLayoutModeChange = { newMode -> + layoutMode = newMode + DesktopPreferences.layoutMode = newMode.name + }, deckState = deckState, workspaceManager = workspaceManager, accountManager = accountManager, @@ -479,6 +522,7 @@ fun main() { @Composable fun App( layoutMode: LayoutMode, + onLayoutModeChange: (LayoutMode) -> Unit, deckState: DeckState, workspaceManager: WorkspaceManager, accountManager: AccountManager, @@ -743,7 +787,23 @@ fun App( pinnedNavBarState = pinnedNavBarState, workspaceManager = workspaceManager, onSwitchWorkspace = { ws -> - deckState.loadFromWorkspace(ws.columns) + // Switch layout mode to match workspace + onLayoutModeChange(ws.layoutMode) + // Load columns or single pane screen + when (ws.layoutMode) { + LayoutMode.DECK -> { + deckState.loadFromWorkspace(ws.columns) + } + + LayoutMode.SINGLE_PANE -> { + val screenKey = + ws.singlePaneScreen + ?: ws.columns.firstOrNull()?.typeKey + ?: "home" + val type = DeckState.parseColumnTypeFromKey(screenKey) + if (type != null) singlePaneState.navigate(type) + } + } }, onSelectScreen = { type -> when (layoutMode) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AppDrawer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AppDrawer.kt index 7f74a8e60..be80058d0 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AppDrawer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AppDrawer.kt @@ -36,23 +36,40 @@ 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.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Article +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Explore import androidx.compose.material.icons.filled.Groups import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.PushPin import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.SportsEsports +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +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.PrimaryTabRow +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.Surface +import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TextField @@ -80,7 +97,9 @@ import androidx.compose.ui.input.key.type import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.isSecondaryPressed import androidx.compose.ui.input.pointer.onPointerEvent +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.desktop.LayoutMode import kotlinx.coroutines.delay // -- Screen categories -- @@ -154,12 +173,20 @@ val LAUNCHABLE_SCREENS: List = DeckColumnType.Chess, ) +// -- Tabs -- + +enum class AppDrawerTab { + SCREENS, + WORKSPACES, +} + // -- State -- @Stable private class AppDrawerState { var searchQuery by mutableStateOf("") var selectedIndex by mutableStateOf(0) + var activeTab by mutableStateOf(AppDrawerTab.SCREENS) var hashtagInput by mutableStateOf("") var awaitingHashtag by mutableStateOf(false) private var consumed by mutableStateOf(false) @@ -179,11 +206,36 @@ private class AppDrawerState { filteredScreens.groupBy { it.category() }.filterValues { it.isNotEmpty() } } + val isSearching: Boolean by derivedStateOf { searchQuery.isNotBlank() } + + fun filteredWorkspaces(allWorkspaces: List): List = + if (searchQuery.isBlank()) { + allWorkspaces + } else { + allWorkspaces.filter { ws -> + ws.name.contains(searchQuery, ignoreCase = true) || + ws.columns.any { col -> + val displayName = + DeckState.parseColumnTypeFromKey(col.typeKey, col.param)?.title() + ?: col.typeKey + displayName.contains(searchQuery, ignoreCase = true) + } + } + } + fun moveSelection(delta: Int) { val size = filteredScreens.size if (size > 0) selectedIndex = (selectedIndex + delta).coerceIn(0, size - 1) } + fun moveSelectionUnified( + delta: Int, + wsCount: Int, + ) { + val total = wsCount + filteredScreens.size + if (total > 0) selectedIndex = (selectedIndex + delta).coerceIn(0, total - 1) + } + fun select( screen: DeckColumnType, onSelectScreen: (DeckColumnType) -> Unit, @@ -200,6 +252,30 @@ private class AppDrawerState { } } + fun selectCurrent( + filteredWs: List, + onSelectScreen: (DeckColumnType) -> Unit, + onSwitchWorkspace: (Workspace) -> Unit, + onDismiss: () -> Unit, + ) { + if (consumed) return + if (!isSearching) { + // Tab-based: only screens use Enter + filteredScreens.getOrNull(selectedIndex)?.let { select(it, onSelectScreen, onDismiss) } + return + } + // Unified: workspaces first, then screens + val wsCount = filteredWs.size + if (selectedIndex < wsCount) { + consumed = true + onSwitchWorkspace(filteredWs[selectedIndex]) + onDismiss() + } else { + val screenIdx = selectedIndex - wsCount + filteredScreens.getOrNull(screenIdx)?.let { select(it, onSelectScreen, onDismiss) } + } + } + fun confirmHashtag( onSelectScreen: (DeckColumnType) -> Unit, onDismiss: () -> Unit, @@ -213,7 +289,7 @@ private class AppDrawerState { // -- Composables -- -@OptIn(ExperimentalLayoutApi::class, ExperimentalComposeUiApi::class) +@OptIn(ExperimentalLayoutApi::class, ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class) @Composable fun AppDrawer( openColumnTypes: Set, @@ -225,6 +301,10 @@ fun AppDrawer( ) { val state = remember { AppDrawerState() } val searchFocusRequester = remember { FocusRequester() } + val allWorkspaces by workspaceManager.workspaces.collectAsState() + val filteredWs by remember(allWorkspaces, state.searchQuery) { + derivedStateOf { state.filteredWorkspaces(allWorkspaces) } + } LaunchedEffect(Unit) { delay(50) @@ -249,12 +329,20 @@ fun AppDrawer( } Key.DirectionDown -> { - state.moveSelection(1) + if (state.isSearching) { + state.moveSelectionUnified(1, filteredWs.size) + } else { + state.moveSelection(1) + } true } Key.DirectionUp -> { - state.moveSelection(-1) + if (state.isSearching) { + state.moveSelectionUnified(-1, filteredWs.size) + } else { + state.moveSelection(-1) + } true } @@ -262,9 +350,12 @@ fun AppDrawer( if (state.awaitingHashtag) { state.confirmHashtag(onSelectScreen, onDismiss) } else { - state.filteredScreens.getOrNull(state.selectedIndex)?.let { - state.select(it, onSelectScreen, onDismiss) - } + state.selectCurrent( + filteredWs, + onSelectScreen, + onSwitchWorkspace, + onDismiss, + ) } true } @@ -302,25 +393,66 @@ fun AppDrawer( Modifier .fillMaxWidth() .focusRequester(searchFocusRequester), - placeholder = { Text("Search screens...") }, + placeholder = { Text("Search screens and workspaces...") }, singleLine = true, leadingIcon = { Icon(Icons.Default.Search, "Search") }, ) if (state.awaitingHashtag) { HashtagInputSection(state, onSelectScreen, onDismiss) - } else { + } else if (state.isSearching) { Box(Modifier.weight(1f)) { - DrawerGrid(state, openColumnTypes, pinnedNavBarState, onSelectScreen, onDismiss) + UnifiedSearchResults( + state = state, + filteredWs = filteredWs, + workspaceManager = workspaceManager, + onSwitchWorkspace = onSwitchWorkspace, + onSelectScreen = onSelectScreen, + openColumnTypes = openColumnTypes, + pinnedNavBarState = pinnedNavBarState, + onDismiss = onDismiss, + ) + } + } else { + // Tab header + PrimaryTabRow(selectedTabIndex = state.activeTab.ordinal) { + AppDrawerTab.entries.forEach { tab -> + Tab( + selected = state.activeTab == tab, + onClick = { state.activeTab = tab }, + text = { + Text( + tab.name.lowercase().replaceFirstChar { it.uppercase() }, + ) + }, + ) + } + } + + Box(Modifier.weight(1f)) { + when (state.activeTab) { + AppDrawerTab.SCREENS -> { + DrawerGrid( + state, + openColumnTypes, + pinnedNavBarState, + onSelectScreen, + onDismiss, + ) + } + + AppDrawerTab.WORKSPACES -> { + WorkspacesGrid( + workspaceManager = workspaceManager, + onSwitchWorkspace = { + onSwitchWorkspace(it) + onDismiss() + }, + onDismiss = onDismiss, + ) + } + } } - // Workspace bar at the bottom - WorkspaceBar( - workspaceManager = workspaceManager, - onSwitchWorkspace = { ws -> - onSwitchWorkspace(ws) - onDismiss() - }, - ) } } } @@ -461,12 +593,12 @@ private fun DrawerScreenCard( } // Context menu for pin/unpin - androidx.compose.material3.DropdownMenu( + DropdownMenu( expanded = showMenu, onDismissRequest = { showMenu = false }, ) { if (PinnedNavBarState.isPinnable(type)) { - androidx.compose.material3.DropdownMenuItem( + DropdownMenuItem( text = { Text(if (isPinned) "Unpin from sidebar" else "Pin to sidebar") }, onClick = { onTogglePin() @@ -544,46 +676,459 @@ private fun HashtagInputSection( } } +// -- Workspaces Tab -- + @Composable -private fun WorkspaceBar( +private fun WorkspacesGrid( workspaceManager: WorkspaceManager, onSwitchWorkspace: (Workspace) -> Unit, + onDismiss: () -> Unit, ) { val workspaces by workspaceManager.workspaces.collectAsState() val activeIndex by workspaceManager.activeIndex.collectAsState() + var showEditor by remember { mutableStateOf(false) } + var editTarget by remember { mutableStateOf(null) } - androidx.compose.material3.HorizontalDivider() - - Row( - modifier = Modifier.fillMaxWidth().padding(12.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - "Workspaces", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - workspaces.forEachIndexed { index, ws -> - val isActive = index == activeIndex - androidx.compose.material3.FilterChip( - selected = isActive, - onClick = { - if (!isActive) { - val switched = workspaceManager.switchTo(index) - if (switched != null) onSwitchWorkspace(switched) + LazyColumn(Modifier.padding(8.dp)) { + itemsIndexed(workspaces) { index, ws -> + WorkspaceCard( + workspace = ws, + isActive = index == activeIndex, + onSwitch = { + val switched = workspaceManager.switchTo(index) + if (switched != null) { + onSwitchWorkspace(switched) } }, - label = { Text(ws.name, style = MaterialTheme.typography.labelSmall) }, - leadingIcon = { - Icon( - WorkspaceIcons.resolve(ws.iconName), - contentDescription = ws.name, - modifier = Modifier.size(16.dp), - ) + onEdit = { + editTarget = ws + showEditor = true }, + onDelete = { workspaceManager.deleteWorkspace(ws.id) }, + canDelete = workspaces.size > 1, + ) + } + // "+" card + item { + AddWorkspaceCard( + onClick = { + editTarget = null + showEditor = true + }, + enabled = workspaces.size < WorkspaceManager.MAX_WORKSPACES, + ) + } + } + + if (showEditor) { + WorkspaceEditorDialog( + initial = editTarget, + onSave = { ws -> + if (editTarget != null) { + workspaceManager.updateWorkspace(ws) + } else { + workspaceManager.addWorkspace(ws) + } + showEditor = false + }, + onDismiss = { showEditor = false }, + ) + } +} + +@Composable +private fun WorkspaceCard( + workspace: Workspace, + isActive: Boolean, + onSwitch: () -> Unit, + onEdit: () -> Unit, + onDelete: () -> Unit, + canDelete: Boolean, +) { + Surface( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .clickable(onClick = onSwitch), + shape = RoundedCornerShape(12.dp), + tonalElevation = if (isActive) 8.dp else 2.dp, + color = + if (isActive) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surface + }, + ) { + Row( + Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + WorkspaceIcons.resolve(workspace.iconName), + workspace.name, + Modifier.size(28.dp), + ) + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f)) { + Text(workspace.name, style = MaterialTheme.typography.titleSmall) + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + // Layout mode badge — non-interactive Surface+Text + Surface( + shape = RoundedCornerShape(4.dp), + color = MaterialTheme.colorScheme.secondaryContainer, + ) { + Text( + if (workspace.layoutMode == LayoutMode.DECK) "Deck" else "Single", + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + style = MaterialTheme.typography.labelSmall, + ) + } + } + // Column preview with display names + Text( + workspace.columns.joinToString(", ") { col -> + DeckState.parseColumnTypeFromKey(col.typeKey, col.param)?.title() + ?: col.typeKey + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + // Active indicator + if (isActive) { + Icon( + Icons.Default.Check, + "Active", + Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } + IconButton(onClick = onEdit) { + Icon(Icons.Default.Edit, "Edit", Modifier.size(18.dp)) + } + IconButton(onClick = onDelete, enabled = canDelete) { + Icon(Icons.Default.Delete, "Delete", Modifier.size(18.dp)) + } + } + } +} + +@Composable +private fun AddWorkspaceCard( + onClick: () -> Unit, + enabled: Boolean, +) { + Surface( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .clickable(enabled = enabled, onClick = onClick), + shape = RoundedCornerShape(12.dp), + tonalElevation = 1.dp, + ) { + Row( + Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + Icon(Icons.Default.Add, "Add workspace", Modifier.size(24.dp)) + Spacer(Modifier.width(8.dp)) + Text( + "New Workspace", + style = MaterialTheme.typography.titleSmall, + color = + if (enabled) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + }, ) } } } + +// -- Workspace Editor Dialog -- + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +private fun WorkspaceEditorDialog( + initial: Workspace?, + onSave: (Workspace) -> Unit, + onDismiss: () -> Unit, +) { + var name by remember { mutableStateOf(initial?.name ?: "") } + var iconName by remember { mutableStateOf(initial?.iconName ?: "Home") } + var layoutMode by remember { mutableStateOf(initial?.layoutMode ?: LayoutMode.DECK) } + var columns by remember { + mutableStateOf(initial?.columns ?: listOf(Workspace.WorkspaceColumn("home"))) + } + var singlePaneScreen by remember { mutableStateOf(initial?.singlePaneScreen) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(if (initial != null) "Edit Workspace" else "New Workspace") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + // Name + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("Name") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + // Icon picker + Text("Icon", style = MaterialTheme.typography.labelMedium) + FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + WorkspaceIcons.availableNames.forEach { iName -> + IconButton(onClick = { iconName = iName }) { + Icon( + WorkspaceIcons.resolve(iName), + iName, + tint = + if (iName == iconName) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } + } + // Layout mode toggle + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Layout:", style = MaterialTheme.typography.labelMedium) + Spacer(Modifier.width(8.dp)) + SingleChoiceSegmentedButtonRow { + SegmentedButton( + selected = layoutMode == LayoutMode.SINGLE_PANE, + onClick = { layoutMode = LayoutMode.SINGLE_PANE }, + shape = SegmentedButtonDefaults.itemShape(0, 2), + ) { + Text("Single Pane") + } + SegmentedButton( + selected = layoutMode == LayoutMode.DECK, + onClick = { layoutMode = LayoutMode.DECK }, + shape = SegmentedButtonDefaults.itemShape(1, 2), + ) { + Text("Deck") + } + } + } + // Deck: column list + if (layoutMode == LayoutMode.DECK) { + DeckColumnEditor(columns = columns, onColumnsChange = { columns = it }) + } + // Single Pane: screen picker + if (layoutMode == LayoutMode.SINGLE_PANE) { + SinglePaneScreenPicker( + selected = singlePaneScreen, + onSelect = { singlePaneScreen = it }, + ) + } + } + }, + confirmButton = { + Button( + onClick = { + onSave( + Workspace( + id = + initial?.id ?: java.util.UUID + .randomUUID() + .toString(), + name = name.ifBlank { "Untitled" }, + iconName = iconName, + layoutMode = layoutMode, + columns = columns, + singlePaneScreen = singlePaneScreen, + ), + ) + }, + enabled = name.isNotBlank(), + ) { + Text("Save") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Cancel") } + }, + ) +} + +@Composable +private fun DeckColumnEditor( + columns: List, + onColumnsChange: (List) -> Unit, +) { + Column { + Text("Columns", style = MaterialTheme.typography.labelMedium) + columns.forEachIndexed { idx, col -> + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + DeckState.parseColumnTypeFromKey(col.typeKey, col.param)?.title() ?: col.typeKey, + Modifier.weight(1f), + ) + IconButton( + onClick = { + if (columns.size > 1) { + onColumnsChange(columns.toMutableList().apply { removeAt(idx) }) + } + }, + enabled = columns.size > 1, + ) { + Icon(Icons.Default.Close, "Remove") + } + } + } + // Add column dropdown + var expanded by remember { mutableStateOf(false) } + TextButton(onClick = { expanded = true }) { Text("+ Add Column") } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + LAUNCHABLE_SCREENS.filter { !it.requiresInput() }.forEach { screen -> + DropdownMenuItem( + text = { Text(screen.title()) }, + onClick = { + onColumnsChange(columns + Workspace.WorkspaceColumn(screen.typeKey())) + expanded = false + }, + ) + } + } + } +} + +@Composable +private fun SinglePaneScreenPicker( + selected: String?, + onSelect: (String) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + val selectedTitle = + selected?.let { DeckState.parseColumnTypeFromKey(it)?.title() } ?: "Select screen" + + Column { + Text("Screen", style = MaterialTheme.typography.labelMedium) + Spacer(Modifier.height(4.dp)) + TextButton(onClick = { expanded = true }) { Text(selectedTitle) } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + LAUNCHABLE_SCREENS.filter { !it.requiresInput() }.forEach { screen -> + DropdownMenuItem( + text = { Text(screen.title()) }, + onClick = { + onSelect(screen.typeKey()) + expanded = false + }, + ) + } + } + } +} + +// -- Unified Search Results -- + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun UnifiedSearchResults( + state: AppDrawerState, + filteredWs: List, + workspaceManager: WorkspaceManager, + onSwitchWorkspace: (Workspace) -> Unit, + onSelectScreen: (DeckColumnType) -> Unit, + openColumnTypes: Set, + pinnedNavBarState: PinnedNavBarState, + onDismiss: () -> Unit, +) { + val activeIndex by workspaceManager.activeIndex.collectAsState() + val allWorkspaces by workspaceManager.workspaces.collectAsState() + + LazyColumn(Modifier.padding(8.dp)) { + // Workspace results first + if (filteredWs.isNotEmpty()) { + stickyHeader { + Text( + "Workspaces", + style = MaterialTheme.typography.titleSmall, + modifier = + Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface) + .padding(horizontal = 8.dp, vertical = 4.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + itemsIndexed(filteredWs) { idx, ws -> + val wsIdx = allWorkspaces.indexOf(ws) + val isHighlighted = idx == state.selectedIndex + Surface( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 2.dp), + tonalElevation = if (isHighlighted) 8.dp else 0.dp, + ) { + WorkspaceCard( + workspace = ws, + isActive = wsIdx == activeIndex, + onSwitch = { + val switched = workspaceManager.switchTo(wsIdx) + if (switched != null) { + onSwitchWorkspace(switched) + onDismiss() + } + }, + onEdit = { /* not supported in search results */ }, + onDelete = { workspaceManager.deleteWorkspace(ws.id) }, + canDelete = allWorkspaces.size > 1, + ) + } + } + } + // Screen results below + if (state.filteredScreens.isNotEmpty()) { + stickyHeader { + Text( + "Screens", + style = MaterialTheme.typography.titleSmall, + modifier = + Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface) + .padding(horizontal = 8.dp, vertical = 4.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + item { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(horizontal = 8.dp, vertical = 8.dp), + ) { + val wsOffset = filteredWs.size + state.filteredScreens.forEachIndexed { localIdx, screen -> + DrawerScreenCard( + type = screen, + isSelected = (wsOffset + localIdx) == state.selectedIndex, + isOpen = openColumnTypes.contains(screen.typeKey()), + isPinned = pinnedNavBarState.isPinned(screen), + onClick = { state.select(screen, onSelectScreen, onDismiss) }, + onTogglePin = { + if (pinnedNavBarState.isPinned(screen)) { + pinnedNavBarState.unpin(screen) + } else { + pinnedNavBarState.pin(screen) + } + }, + onHover = { state.selectedIndex = wsOffset + localIdx }, + ) + } + } + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckState.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckState.kt index cd66cd024..6d932c685 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckState.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckState.kt @@ -287,6 +287,11 @@ class DeckState( private val mapper = jacksonObjectMapper() + fun parseColumnTypeFromKey( + typeKey: String, + param: String? = null, + ): DeckColumnType? = parseColumnType(mapOf("type" to typeKey, "param" to param)) + private fun parseColumnType(entry: Map): DeckColumnType? { val typeKey = entry["type"] as? String ?: return null val param = entry["param"] as? String From ac349665eb5ef02106de08ee4638b2c750c6b1d7 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Fri, 17 Apr 2026 14:55:25 +0300 Subject: [PATCH 06/48] fix(desktop): review fixes for workspace management UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove consumed flag (stale on re-open, double-fire is harmless) - Fix keyboard nav for WORKSPACES tab (was broken, only worked for screens) - Reset selectedIndex on tab switch - Fix delete active workspace → reload columns for new active workspace - Extract DeckColumnType.param() extension to eliminate 3x duplication - Simplify moveSelection to handle all modes (tabs + unified search) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../vitorpamplona/amethyst/desktop/Main.kt | 11 +- .../amethyst/desktop/ui/deck/AppDrawer.kt | 103 +++++++++++------- .../amethyst/desktop/ui/deck/DeckState.kt | 10 +- .../desktop/ui/deck/WorkspaceManager.kt | 10 +- 4 files changed, 67 insertions(+), 67 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 321e2b118..3a76d9e14 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -98,6 +98,7 @@ import com.vitorpamplona.amethyst.desktop.ui.deck.SinglePaneLayout import com.vitorpamplona.amethyst.desktop.ui.deck.SinglePaneState import com.vitorpamplona.amethyst.desktop.ui.deck.Workspace import com.vitorpamplona.amethyst.desktop.ui.deck.WorkspaceManager +import com.vitorpamplona.amethyst.desktop.ui.deck.param import com.vitorpamplona.amethyst.desktop.ui.media.LocalAwtWindow import com.vitorpamplona.amethyst.desktop.ui.media.LocalIsImmersiveFullscreen import com.vitorpamplona.amethyst.desktop.ui.media.LocalWindowState @@ -256,15 +257,7 @@ fun main() { deckState.columns.value.map { col -> Workspace.WorkspaceColumn( typeKey = col.type.typeKey(), - param = - when (col.type) { - is DeckColumnType.Hashtag -> col.type.tag - is DeckColumnType.Editor -> col.type.draftSlug - is DeckColumnType.Article -> col.type.addressTag - is DeckColumnType.Profile -> col.type.pubKeyHex - is DeckColumnType.Thread -> col.type.noteId - else -> null - }, + param = col.type.param(), width = col.width, ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AppDrawer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AppDrawer.kt index be80058d0..5a004bba4 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AppDrawer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AppDrawer.kt @@ -155,6 +155,16 @@ fun DeckColumnType.requiresInput(): Boolean = else -> false } +fun DeckColumnType.param(): String? = + when (this) { + is DeckColumnType.Profile -> pubKeyHex + is DeckColumnType.Thread -> noteId + is DeckColumnType.Hashtag -> tag + is DeckColumnType.Editor -> draftSlug + is DeckColumnType.Article -> addressTag + else -> null + } + val LAUNCHABLE_SCREENS: List = listOf( DeckColumnType.HomeFeed, @@ -189,7 +199,6 @@ private class AppDrawerState { var activeTab by mutableStateOf(AppDrawerTab.SCREENS) var hashtagInput by mutableStateOf("") var awaitingHashtag by mutableStateOf(false) - private var consumed by mutableStateOf(false) val filteredScreens: List by derivedStateOf { if (searchQuery.isBlank()) { @@ -223,17 +232,22 @@ private class AppDrawerState { } } - fun moveSelection(delta: Int) { - val size = filteredScreens.size - if (size > 0) selectedIndex = (selectedIndex + delta).coerceIn(0, size - 1) + fun switchTab(tab: AppDrawerTab) { + activeTab = tab + selectedIndex = 0 } - fun moveSelectionUnified( + fun moveSelection( delta: Int, - wsCount: Int, + wsCount: Int = 0, ) { - val total = wsCount + filteredScreens.size - if (total > 0) selectedIndex = (selectedIndex + delta).coerceIn(0, total - 1) + val size = + when { + isSearching -> wsCount + filteredScreens.size + activeTab == AppDrawerTab.WORKSPACES -> wsCount + else -> filteredScreens.size + } + if (size > 0) selectedIndex = (selectedIndex + delta).coerceIn(0, size - 1) } fun select( @@ -241,12 +255,10 @@ private class AppDrawerState { onSelectScreen: (DeckColumnType) -> Unit, onDismiss: () -> Unit, ) { - if (consumed) return if (screen.requiresInput()) { awaitingHashtag = true hashtagInput = "" } else { - consumed = true onSelectScreen(screen) onDismiss() } @@ -258,21 +270,29 @@ private class AppDrawerState { onSwitchWorkspace: (Workspace) -> Unit, onDismiss: () -> Unit, ) { - if (consumed) return - if (!isSearching) { - // Tab-based: only screens use Enter - filteredScreens.getOrNull(selectedIndex)?.let { select(it, onSelectScreen, onDismiss) } - return - } - // Unified: workspaces first, then screens - val wsCount = filteredWs.size - if (selectedIndex < wsCount) { - consumed = true - onSwitchWorkspace(filteredWs[selectedIndex]) - onDismiss() + if (isSearching) { + // Unified: workspaces first, then screens + val wsCount = filteredWs.size + if (selectedIndex < wsCount) { + onSwitchWorkspace(filteredWs[selectedIndex]) + onDismiss() + } else { + val screenIdx = selectedIndex - wsCount + filteredScreens.getOrNull(screenIdx)?.let { select(it, onSelectScreen, onDismiss) } + } } else { - val screenIdx = selectedIndex - wsCount - filteredScreens.getOrNull(screenIdx)?.let { select(it, onSelectScreen, onDismiss) } + when (activeTab) { + AppDrawerTab.SCREENS -> { + filteredScreens.getOrNull(selectedIndex)?.let { select(it, onSelectScreen, onDismiss) } + } + + AppDrawerTab.WORKSPACES -> { + filteredWs.getOrNull(selectedIndex)?.let { + onSwitchWorkspace(it) + onDismiss() + } + } + } } } @@ -280,8 +300,7 @@ private class AppDrawerState { onSelectScreen: (DeckColumnType) -> Unit, onDismiss: () -> Unit, ) { - if (consumed || hashtagInput.isBlank()) return - consumed = true + if (hashtagInput.isBlank()) return onSelectScreen(DeckColumnType.Hashtag(hashtagInput.removePrefix("#").trim())) onDismiss() } @@ -329,20 +348,12 @@ fun AppDrawer( } Key.DirectionDown -> { - if (state.isSearching) { - state.moveSelectionUnified(1, filteredWs.size) - } else { - state.moveSelection(1) - } + state.moveSelection(1, filteredWs.size) true } Key.DirectionUp -> { - if (state.isSearching) { - state.moveSelectionUnified(-1, filteredWs.size) - } else { - state.moveSelection(-1) - } + state.moveSelection(-1, filteredWs.size) true } @@ -419,7 +430,7 @@ fun AppDrawer( AppDrawerTab.entries.forEach { tab -> Tab( selected = state.activeTab == tab, - onClick = { state.activeTab = tab }, + onClick = { state.switchTab(tab) }, text = { Text( tab.name.lowercase().replaceFirstChar { it.uppercase() }, @@ -704,7 +715,13 @@ private fun WorkspacesGrid( editTarget = ws showEditor = true }, - onDelete = { workspaceManager.deleteWorkspace(ws.id) }, + onDelete = { + val wasActive = index == activeIndex + workspaceManager.deleteWorkspace(ws.id) + if (wasActive) { + onSwitchWorkspace(workspaceManager.activeWorkspace) + } + }, canDelete = workspaces.size > 1, ) } @@ -1082,8 +1099,14 @@ private fun UnifiedSearchResults( onDismiss() } }, - onEdit = { /* not supported in search results */ }, - onDelete = { workspaceManager.deleteWorkspace(ws.id) }, + onEdit = { /* edit via Workspaces tab */ }, + onDelete = { + val wasActive = wsIdx == activeIndex + workspaceManager.deleteWorkspace(ws.id) + if (wasActive) { + onSwitchWorkspace(workspaceManager.activeWorkspace) + } + }, canDelete = allWorkspaces.size > 1, ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckState.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckState.kt index 6d932c685..653042d2a 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckState.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckState.kt @@ -221,15 +221,7 @@ class DeckState( "id" to col.id, "type" to col.type.typeKey(), "width" to col.width, - "param" to - when (col.type) { - is DeckColumnType.Profile -> col.type.pubKeyHex - is DeckColumnType.Thread -> col.type.noteId - is DeckColumnType.Hashtag -> col.type.tag - is DeckColumnType.Editor -> col.type.draftSlug - is DeckColumnType.Article -> col.type.addressTag - else -> null - }, + "param" to col.type.param(), ) } DesktopPreferences.deckColumns = mapper.writeValueAsString(data) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/WorkspaceManager.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/WorkspaceManager.kt index f8ebd1e8e..d4edbd1e9 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/WorkspaceManager.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/WorkspaceManager.kt @@ -63,15 +63,7 @@ class WorkspaceManager( columns.map { col -> Workspace.WorkspaceColumn( typeKey = col.type.typeKey(), - param = - when (col.type) { - is DeckColumnType.Profile -> col.type.pubKeyHex - is DeckColumnType.Thread -> col.type.noteId - is DeckColumnType.Hashtag -> col.type.tag - is DeckColumnType.Editor -> col.type.draftSlug - is DeckColumnType.Article -> col.type.addressTag - else -> null - }, + param = col.type.param(), width = col.width, ) }, From 75c96115cbb8945b957a70f02dbee1cd96f08fec Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 15:53:41 +0000 Subject: [PATCH 07/48] feat(feeds): overlay content warning on blurhash at media size In the picture and video feeds, render the sensitivity warning over the media's blurhash placeholder using the media's aspect ratio, instead of a fixed-size dialog that hides the context entirely. Falls back to the existing dialog when no blurhash is available. --- .../ui/components/SensitivityWarning.kt | 157 ++++++++++++++++++ .../loggedIn/pictures/PictureCardCompose.kt | 14 +- .../loggedIn/shorts/VideoCardCompose.kt | 11 +- 3 files changed, 179 insertions(+), 3 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt index 29eb4d8a1..387c6c99c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt @@ -20,10 +20,13 @@ */ package com.vitorpamplona.amethyst.ui.components +import androidx.compose.foundation.background 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.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -44,6 +47,7 @@ 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.layout.ContentScale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview @@ -72,6 +76,31 @@ fun SensitivityWarning( note.event?.let { SensitivityWarning(it, accountViewModel, content) } } +@Composable +fun SensitivityWarning( + note: Note, + blurhash: String?, + ratio: Float?, + description: String?, + accountViewModel: AccountViewModel, + content: @Composable () -> Unit, +) { + val event = note.event + if (event == null) { + content() + return + } + + val hasSensitiveContent = remember(event) { event.isSensitiveOrNSFW() } + + if (hasSensitiveContent) { + val reason = remember(event) { event.contentWarningReason() } + ObserveSensitivityWarningOverBlurhash(reason, blurhash, ratio, description, accountViewModel, content) + } else { + content() + } +} + @Composable fun SensitivityWarning( event: Event, @@ -120,6 +149,32 @@ fun ObserveSensitivityWarning( } } +@Composable +fun ObserveSensitivityWarningOverBlurhash( + reason: String?, + blurhash: String?, + ratio: Float?, + description: String?, + accountViewModel: AccountViewModel, + content: @Composable () -> Unit, +) { + val accountState = accountViewModel.showSensitiveContent().collectAsStateWithLifecycle() + + var showContentWarningNote by remember(accountState) { mutableStateOf(accountState.value != true) } + + CrossfadeIfEnabled(targetState = showContentWarningNote, accountViewModel = accountViewModel) { + if (it) { + if (blurhash != null) { + ContentWarningOverBlurhash(reason, blurhash, ratio, description) { showContentWarningNote = false } + } else { + ContentWarningNote(reason) { showContentWarningNote = false } + } + } else { + content() + } + } +} + @Preview @Composable fun ContentWarningNotePreview() { @@ -144,6 +199,108 @@ fun ContentWarningNoteWithBigReasonPreview() { } } +@Preview +@Composable +fun ContentWarningOverBlurhashPreview() { + ThemeComparisonColumn { + ContentWarningOverBlurhash( + reason = "Spoilers", + blurhash = "LEHV6nWB2yk8pyo0adR*.7kCMdnj", + ratio = 16f / 9f, + description = null, + onDismiss = {}, + ) + } +} + +@Composable +fun ContentWarningOverBlurhash( + reason: String?, + blurhash: String, + ratio: Float?, + description: String?, + onDismiss: () -> Unit, +) { + val sizingModifier = + if (ratio != null) { + Modifier.fillMaxWidth().aspectRatio(ratio) + } else { + Modifier.fillMaxWidth() + } + + Box(modifier = sizingModifier) { + DisplayBlurHash( + blurhash = blurhash, + description = description, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + + Box( + modifier = + Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.35f)), + contentAlignment = Alignment.Center, + ) { + Column( + modifier = Modifier.padding(horizontal = 12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + Modifier + .height(80.dp) + .width(90.dp), + ) { + Icon( + imageVector = Icons.Default.Visibility, + contentDescription = stringRes(R.string.content_warning), + modifier = + Modifier + .size(70.dp) + .align(Alignment.BottomStart), + tint = Color.White, + ) + Icon( + imageVector = Icons.Rounded.Warning, + contentDescription = stringRes(R.string.content_warning), + modifier = + Modifier + .size(30.dp) + .align(Alignment.TopEnd), + tint = Color.White, + ) + } + + Text( + text = + if (reason.isNullOrBlank()) { + stringRes(R.string.content_warning) + } else { + stringRes(R.string.content_warning_with_reason, reason) + }, + fontWeight = FontWeight.Bold, + fontSize = 18.sp, + color = Color.White, + softWrap = true, + textAlign = TextAlign.Center, + ) + + FilledTonalButton( + modifier = Modifier.padding(top = 10.dp), + onClick = onDismiss, + shape = ButtonBorder, + contentPadding = ButtonPadding, + ) { + Text( + text = stringRes(R.string.show_anyway), + ) + } + } + } + } +} + @Composable fun ContentWarningNote( reason: String?, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureCardCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureCardCompose.kt index 864eaacc8..325e3313b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureCardCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureCardCompose.kt @@ -39,6 +39,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage +import com.vitorpamplona.amethyst.model.MediaAspectRatioCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.AutoNonlazyGrid import com.vitorpamplona.amethyst.ui.components.SensitivityWarning @@ -112,10 +113,19 @@ private fun PictureCardImage( } if (images.isNotEmpty()) { - SensitivityWarning(note = note, accountViewModel = accountViewModel) { + val first = images.first() + val ratio = first.dim?.aspectRatio() ?: MediaAspectRatioCache.get(first.url) + + SensitivityWarning( + note = note, + blurhash = first.blurhash, + ratio = ratio, + description = first.description, + accountViewModel = accountViewModel, + ) { if (images.size == 1) { ZoomableContentView( - content = images.first(), + content = first, images = images, roundedCorner = false, contentScale = ContentScale.FillWidth, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/VideoCardCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/VideoCardCompose.kt index ba77fe0b6..18dbd921d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/VideoCardCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/VideoCardCompose.kt @@ -42,6 +42,7 @@ import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo import com.vitorpamplona.amethyst.commons.richtext.RichTextParser +import com.vitorpamplona.amethyst.model.MediaAspectRatioCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.ui.components.ZoomableContentView @@ -130,7 +131,15 @@ private fun VideoCardImage( ) } - SensitivityWarning(note = note, accountViewModel = accountViewModel) { + val ratio = imeta.dimension?.aspectRatio() ?: MediaAspectRatioCache.get(imeta.url) + + SensitivityWarning( + note = note, + blurhash = imeta.blurhash, + ratio = ratio, + description = content.description, + accountViewModel = accountViewModel, + ) { ZoomableContentView( content = content, roundedCorner = false, From 11666541493f1c9e35b23ddec5110d79fbe3f1f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 18:51:17 +0000 Subject: [PATCH 08/48] refactor(feeds): push content warning into ZoomableContentView per media Move the blurhash-aware sensitivity warning from feed card wrappers into ZoomableContentView so every media item shows its own warning sized to its own aspect ratio over its own blurhash. Grid posts now warn per image instead of once over the whole grid. - Add isSensitive to MediaUrl{Image,Video} (defaults to contentWarning != null) - Populate isSensitive and contentWarning when building media in the picture, video, and file-header feeds plus their display variants - Drop the outer SensitivityWarning wrappers now handled inside ZoomableContentView via SensitivityWarningOverBlurhash --- .../ui/components/SensitivityWarning.kt | 83 +++++++------------ .../ui/components/ZoomableContentView.kt | 20 ++++- .../amethyst/ui/note/types/PictureDisplay.kt | 83 ++++++++++--------- .../amethyst/ui/note/types/VideoDisplay.kt | 23 +++-- .../loggedIn/pictures/PictureCardCompose.kt | 43 ++++------ .../loggedIn/shorts/VideoCardCompose.kt | 30 +++---- .../loggedIn/video/FileHeaderCardCompose.kt | 23 +++-- .../commons/richtext/MediaContentModels.kt | 8 +- 8 files changed, 158 insertions(+), 155 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt index 387c6c99c..0375cfa1a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt @@ -76,31 +76,6 @@ fun SensitivityWarning( note.event?.let { SensitivityWarning(it, accountViewModel, content) } } -@Composable -fun SensitivityWarning( - note: Note, - blurhash: String?, - ratio: Float?, - description: String?, - accountViewModel: AccountViewModel, - content: @Composable () -> Unit, -) { - val event = note.event - if (event == null) { - content() - return - } - - val hasSensitiveContent = remember(event) { event.isSensitiveOrNSFW() } - - if (hasSensitiveContent) { - val reason = remember(event) { event.contentWarningReason() } - ObserveSensitivityWarningOverBlurhash(reason, blurhash, ratio, description, accountViewModel, content) - } else { - content() - } -} - @Composable fun SensitivityWarning( event: Event, @@ -130,6 +105,38 @@ fun SensitivityWarning( } } +@Composable +fun SensitivityWarningOverBlurhash( + isSensitive: Boolean, + reason: String?, + blurhash: String?, + ratio: Float?, + description: String?, + accountViewModel: AccountViewModel, + content: @Composable () -> Unit, +) { + if (!isSensitive) { + content() + return + } + + val accountState = accountViewModel.showSensitiveContent().collectAsStateWithLifecycle() + + var showContentWarningNote by remember(accountState) { mutableStateOf(accountState.value != true) } + + CrossfadeIfEnabled(targetState = showContentWarningNote, accountViewModel = accountViewModel) { + if (it) { + if (blurhash != null) { + ContentWarningOverBlurhash(reason, blurhash, ratio, description) { showContentWarningNote = false } + } else { + ContentWarningNote(reason) { showContentWarningNote = false } + } + } else { + content() + } + } +} + @Composable fun ObserveSensitivityWarning( reason: String?, @@ -149,32 +156,6 @@ fun ObserveSensitivityWarning( } } -@Composable -fun ObserveSensitivityWarningOverBlurhash( - reason: String?, - blurhash: String?, - ratio: Float?, - description: String?, - accountViewModel: AccountViewModel, - content: @Composable () -> Unit, -) { - val accountState = accountViewModel.showSensitiveContent().collectAsStateWithLifecycle() - - var showContentWarningNote by remember(accountState) { mutableStateOf(accountState.value != true) } - - CrossfadeIfEnabled(targetState = showContentWarningNote, accountViewModel = accountViewModel) { - if (it) { - if (blurhash != null) { - ContentWarningOverBlurhash(reason, blurhash, ratio, description) { showContentWarningNote = false } - } else { - ContentWarningNote(reason) { showContentWarningNote = false } - } - } else { - content() - } - } -} - @Preview @Composable fun ContentWarningNotePreview() { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index 08f3ccc1c..1de17f9ea 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -150,7 +150,15 @@ fun ZoomableContentView( when (content) { is MediaUrlImage -> { - SensitivityWarning(content.contentWarning, accountViewModel) { + val ratio = content.dim?.aspectRatio() ?: MediaAspectRatioCache.get(content.url) + SensitivityWarningOverBlurhash( + isSensitive = content.isSensitive, + reason = content.contentWarning, + blurhash = content.blurhash, + ratio = ratio, + description = content.description, + accountViewModel = accountViewModel, + ) { TwoSecondController(content) { controllerVisible -> val mainImageModifier = Modifier @@ -164,7 +172,15 @@ fun ZoomableContentView( } is MediaUrlVideo -> { - SensitivityWarning(content.contentWarning, accountViewModel) { + val ratio = content.dim?.aspectRatio() ?: MediaAspectRatioCache.get(content.url) + SensitivityWarningOverBlurhash( + isSensitive = content.isSensitive, + reason = content.contentWarning, + blurhash = content.blurhash, + ratio = ratio, + description = content.description, + accountViewModel = accountViewModel, + ) { Box( modifier = Modifier.fillMaxWidth().then(boundsTrackingModifier), contentAlignment = Alignment.Center, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PictureDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PictureDisplay.kt index 825a9d9f4..f6ac70130 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PictureDisplay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PictureDisplay.kt @@ -39,12 +39,13 @@ import com.vitorpamplona.amethyst.commons.model.EmptyTagList import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.AutoNonlazyGrid -import com.vitorpamplona.amethyst.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.components.ZoomableContentView import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason +import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW import com.vitorpamplona.quartz.nip68Picture.PictureEvent import kotlinx.collections.immutable.toImmutableList @@ -60,6 +61,8 @@ fun PictureDisplay( ) { val event = (note.event as? PictureEvent) ?: return val uri = note.toNostrUri() + val isSensitive = event.isSensitiveOrNSFW() + val contentWarning = event.contentWarningReason() val images by remember(note) { @@ -74,6 +77,8 @@ fun PictureDisplay( blurhash = it.blurhash, dim = it.dimension, uri = uri, + contentWarning = contentWarning, + isSensitive = isSensitive, mimeType = it.mimeType, ) }.toImmutableList(), @@ -85,53 +90,51 @@ fun PictureDisplay( if (first != null) { val title = event.title() - SensitivityWarning(note = note, accountViewModel = accountViewModel) { - Column { - if (title != null) { - Text( - modifier = Modifier.padding(padding), - text = title, - style = MaterialTheme.typography.bodyLarge, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } else { - Spacer(StdVertSpacer) - } + Column { + if (title != null) { + Text( + modifier = Modifier.padding(padding), + text = title, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } else { + Spacer(StdVertSpacer) + } - if (images.size == 1) { + if (images.size == 1) { + ZoomableContentView( + content = images.first(), + images = images, + roundedCorner = roundedCorner, + contentScale = ContentScale.FillWidth, + accountViewModel = accountViewModel, + ) + } else { + AutoNonlazyGrid(images.size) { ZoomableContentView( - content = images.first(), + content = images[it], images = images, roundedCorner = roundedCorner, - contentScale = ContentScale.FillWidth, + contentScale = ContentScale.Crop, accountViewModel = accountViewModel, ) - } else { - AutoNonlazyGrid(images.size) { - ZoomableContentView( - content = images[it], - images = images, - roundedCorner = roundedCorner, - contentScale = ContentScale.Crop, - accountViewModel = accountViewModel, - ) - } } - - TranslatableRichTextViewer( - content = event.content, - canPreview = false, - quotesLeft = 0, - modifier = Modifier.padding(padding), - tags = EmptyTagList, - backgroundColor = backgroundColor, - id = note.idHex, - callbackUri = uri, - accountViewModel = accountViewModel, - nav = nav, - ) } + + TranslatableRichTextViewer( + content = event.content, + canPreview = false, + quotesLeft = 0, + modifier = Modifier.padding(padding), + tags = EmptyTagList, + backgroundColor = backgroundColor, + id = note.idHex, + callbackUri = uri, + accountViewModel = accountViewModel, + nav = nav, + ) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VideoDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VideoDisplay.kt index 581ef978b..a5b73df9e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VideoDisplay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VideoDisplay.kt @@ -30,11 +30,12 @@ import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.ui.components.ZoomableContentView import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason +import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW import com.vitorpamplona.quartz.nip71Video.VideoEvent @Composable @@ -53,6 +54,8 @@ fun JustVideoDisplay( remember(note) { val description = event.content.ifEmpty { null } ?: imeta.alt ?: event.alt() val isImage = imeta.mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(imeta.url) + val isSensitive = event.isSensitiveOrNSFW() + val contentWarning = event.contentWarningReason() mutableStateOf( if (isImage) { @@ -63,6 +66,8 @@ fun JustVideoDisplay( blurhash = imeta.blurhash, dim = imeta.dimension, uri = note.toNostrUri(), + contentWarning = contentWarning, + isSensitive = isSensitive, mimeType = imeta.mimeType, ) } else { @@ -74,18 +79,18 @@ fun JustVideoDisplay( dim = imeta.dimension, uri = note.toNostrUri(), authorName = note.author?.toBestDisplayName(), + contentWarning = contentWarning, + isSensitive = isSensitive, mimeType = imeta.mimeType, ) }, ) } - SensitivityWarning(note = note, accountViewModel = accountViewModel) { - ZoomableContentView( - content = content, - roundedCorner = roundedCorner, - contentScale = contentScale, - accountViewModel = accountViewModel, - ) - } + ZoomableContentView( + content = content, + roundedCorner = roundedCorner, + contentScale = contentScale, + accountViewModel = accountViewModel, + ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureCardCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureCardCompose.kt index 325e3313b..8cb831ca8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureCardCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureCardCompose.kt @@ -39,15 +39,15 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage -import com.vitorpamplona.amethyst.model.MediaAspectRatioCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.AutoNonlazyGrid -import com.vitorpamplona.amethyst.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.ui.components.ZoomableContentView import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.ReactionsRow import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.UserCardHeader +import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason +import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW import com.vitorpamplona.quartz.nip68Picture.PictureEvent import kotlinx.collections.immutable.toImmutableList @@ -92,6 +92,8 @@ private fun PictureCardImage( accountViewModel: AccountViewModel, ) { val uri = note.toNostrUri() + val isSensitive = event.isSensitiveOrNSFW() + val contentWarning = event.contentWarningReason() val images by remember(note) { @@ -106,6 +108,8 @@ private fun PictureCardImage( blurhash = it.blurhash, dim = it.dimension, uri = uri, + contentWarning = contentWarning, + isSensitive = isSensitive, mimeType = it.mimeType, ) }.toImmutableList(), @@ -113,34 +117,23 @@ private fun PictureCardImage( } if (images.isNotEmpty()) { - val first = images.first() - val ratio = first.dim?.aspectRatio() ?: MediaAspectRatioCache.get(first.url) - - SensitivityWarning( - note = note, - blurhash = first.blurhash, - ratio = ratio, - description = first.description, - accountViewModel = accountViewModel, - ) { - if (images.size == 1) { + if (images.size == 1) { + ZoomableContentView( + content = images.first(), + images = images, + roundedCorner = false, + contentScale = ContentScale.FillWidth, + accountViewModel = accountViewModel, + ) + } else { + AutoNonlazyGrid(images.size) { ZoomableContentView( - content = first, + content = images[it], images = images, roundedCorner = false, - contentScale = ContentScale.FillWidth, + contentScale = ContentScale.Crop, accountViewModel = accountViewModel, ) - } else { - AutoNonlazyGrid(images.size) { - ZoomableContentView( - content = images[it], - images = images, - roundedCorner = false, - contentScale = ContentScale.Crop, - accountViewModel = accountViewModel, - ) - } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/VideoCardCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/VideoCardCompose.kt index 18dbd921d..21cab732e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/VideoCardCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/VideoCardCompose.kt @@ -42,9 +42,7 @@ import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo import com.vitorpamplona.amethyst.commons.richtext.RichTextParser -import com.vitorpamplona.amethyst.model.MediaAspectRatioCache import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.ui.components.ZoomableContentView import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.ReactionsRow @@ -52,6 +50,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.UserCardHeader import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason +import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW import com.vitorpamplona.quartz.nip71Video.VideoEvent import kotlin.text.ifEmpty @@ -104,6 +104,8 @@ private fun VideoCardImage( remember(note) { val description = event.content.ifEmpty { null } ?: imeta.alt ?: event.alt() val isImage = imeta.mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(imeta.url) + val isSensitive = event.isSensitiveOrNSFW() + val contentWarning = event.contentWarningReason() mutableStateOf( if (isImage) { @@ -114,6 +116,8 @@ private fun VideoCardImage( blurhash = imeta.blurhash, dim = imeta.dimension, uri = note.toNostrUri(), + contentWarning = contentWarning, + isSensitive = isSensitive, mimeType = imeta.mimeType, ) } else { @@ -125,28 +129,20 @@ private fun VideoCardImage( dim = imeta.dimension, uri = note.toNostrUri(), authorName = note.author?.toBestDisplayName(), + contentWarning = contentWarning, + isSensitive = isSensitive, mimeType = imeta.mimeType, ) }, ) } - val ratio = imeta.dimension?.aspectRatio() ?: MediaAspectRatioCache.get(imeta.url) - - SensitivityWarning( - note = note, - blurhash = imeta.blurhash, - ratio = ratio, - description = content.description, + ZoomableContentView( + content = content, + roundedCorner = false, + contentScale = ContentScale.FillWidth, accountViewModel = accountViewModel, - ) { - ZoomableContentView( - content = content, - roundedCorner = false, - contentScale = ContentScale.FillWidth, - accountViewModel = accountViewModel, - ) - } + ) } @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/FileHeaderCardCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/FileHeaderCardCompose.kt index 3b6dca98a..c117b8eba 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/FileHeaderCardCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/FileHeaderCardCompose.kt @@ -43,13 +43,14 @@ import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.ui.components.ZoomableContentView import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.ReactionsRow import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason +import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent import kotlin.text.ifEmpty @@ -103,6 +104,8 @@ private fun FileHeaderCardImage( val isImage = event.mimeType()?.startsWith("image/") == true || RichTextParser.isImageUrl(fullUrl) val uri = note.toNostrUri() val mimeType = event.mimeType() + val isSensitive = event.isSensitiveOrNSFW() + val contentWarning = event.contentWarningReason() mutableStateOf( if (isImage) { @@ -113,6 +116,8 @@ private fun FileHeaderCardImage( blurhash = blurHash, dim = dimensions, uri = uri, + contentWarning = contentWarning, + isSensitive = isSensitive, mimeType = mimeType, ) } else { @@ -124,20 +129,20 @@ private fun FileHeaderCardImage( dim = dimensions, uri = uri, authorName = note.author?.toBestDisplayName(), + contentWarning = contentWarning, + isSensitive = isSensitive, mimeType = mimeType, ) }, ) } - SensitivityWarning(note = note, accountViewModel = accountViewModel) { - ZoomableContentView( - content = content, - roundedCorner = false, - contentScale = ContentScale.FillWidth, - accountViewModel = accountViewModel, - ) - } + ZoomableContentView( + content = content, + roundedCorner = false, + contentScale = ContentScale.FillWidth, + accountViewModel = accountViewModel, + ) } @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt index d390a1834..5f9d30c06 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt @@ -51,6 +51,7 @@ open class MediaUrlImage( dim: DimensionTag? = null, uri: String? = null, val contentWarning: String? = null, + val isSensitive: Boolean = contentWarning != null, mimeType: String? = null, ) : MediaUrlContent(url, description, hash, dim, blurhash, uri, mimeType) @@ -62,11 +63,12 @@ class EncryptedMediaUrlImage( dim: DimensionTag? = null, uri: String? = null, contentWarning: String? = null, + isSensitive: Boolean = contentWarning != null, mimeType: String? = null, val encryptionAlgo: String, val encryptionKey: ByteArray, val encryptionNonce: ByteArray, -) : MediaUrlImage(url, description, hash, blurhash, dim, uri, contentWarning, mimeType) +) : MediaUrlImage(url, description, hash, blurhash, dim, uri, contentWarning, isSensitive, mimeType) @Immutable open class MediaUrlVideo( @@ -79,6 +81,7 @@ open class MediaUrlVideo( val authorName: String? = null, blurhash: String? = null, val contentWarning: String? = null, + val isSensitive: Boolean = contentWarning != null, mimeType: String? = null, ) : MediaUrlContent(url, description, hash, dim, blurhash, uri, mimeType) @@ -93,11 +96,12 @@ class EncryptedMediaUrlVideo( authorName: String? = null, blurhash: String? = null, contentWarning: String? = null, + isSensitive: Boolean = contentWarning != null, mimeType: String? = null, val encryptionAlgo: String, val encryptionKey: ByteArray, val encryptionNonce: ByteArray, -) : MediaUrlVideo(url, description, hash, dim, uri, artworkUri, authorName, blurhash, contentWarning, mimeType) +) : MediaUrlVideo(url, description, hash, dim, uri, artworkUri, authorName, blurhash, contentWarning, isSensitive, mimeType) @Immutable abstract class MediaPreloadedContent( From 25ecd94487be84347156cfce439c3b545a696b34 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 19:12:47 +0000 Subject: [PATCH 09/48] feat(home): add favourite-DVM top-nav filter Lets users mark NIP-90 content-discovery DVMs (kind 31990 with k=5300) as favourite and surface each as a chip in the Home top-nav alongside hashtags/communities. Selecting a chip publishes the 5300 request, listens for 6300/7000 responses, and renders the curated feed in place. A banner above the feed reports processing / payment-required / error status and reuses the NWC pay flow extracted from DvmContentDiscoveryScreen. - quartz: FavoriteDvmListEvent (NIP-51-style replaceable, kind 10090) - model: FavoriteDvmListState + backup-on-save + Account mutators - model: FavoriteDvmOrchestrator for the 5300 request/6300 response lifecycle, exposing a per-address StateFlow - topNavFeeds/favoriteDvm: TopFilter.FavoriteDvm variant + filter classes + FeedFlow wired into FeedTopNavFilterState - home: FilterHomePostsByDvmIds dispatched by HomeOutboxEventsEoseManager - UI: FavoriteDvmToggle (star icon on DVM cards + DvmTopBar), HomeDvmStatusBanner, new DVMS group and icon in FeedFilterSpinner --- .../vitorpamplona/amethyst/model/Account.kt | 16 ++ .../amethyst/model/AccountSettings.kt | 17 ++ .../model/dvms/FavoriteDvmOrchestrator.kt | 185 +++++++++++++++++ .../FavoriteDvmListDecryptionCache.kt | 36 ++++ .../favoriteDvmLists/FavoriteDvmListState.kt | 128 ++++++++++++ .../topNavFeeds/FeedTopNavFilterState.kt | 12 ++ .../favoriteDvm/FavoriteDvmFeedFlow.kt | 69 +++++++ .../favoriteDvm/FavoriteDvmTopNavFilter.kt | 68 ++++++ .../FavoriteDvmTopNavPerRelayFilter.kt | 33 +++ .../FavoriteDvmTopNavPerRelayFilterSet.kt | 28 +++ .../navigation/topbars/FeedFilterSpinner.kt | 27 +++ .../amethyst/ui/screen/TopNavFilterState.kt | 23 ++- .../ui/screen/loggedIn/AccountViewModel.kt | 7 + .../loggedIn/discover/nip90DVMs/DVMCard.kt | 8 + .../dvms/DvmContentDiscoveryScreen.kt | 153 +++++++------- .../ui/screen/loggedIn/dvms/DvmTopBar.kt | 19 +- .../screen/loggedIn/dvms/FavoriteDvmToggle.kt | 82 ++++++++ .../screen/loggedIn/home/DvmStatusBanner.kt | 195 ++++++++++++++++++ .../ui/screen/loggedIn/home/HomeScreen.kt | 1 + .../HomeOutboxEventsEoseManager.kt | 3 + .../nip90Dvms/FilterHomePostsByDvmIds.kt | 99 +++++++++ amethyst/src/main/res/values/strings.xml | 9 + .../favoriteDvmList/FavoriteDvmListEvent.kt | 192 +++++++++++++++++ .../favoriteDvmList/TagArrayBuilderExt.kt | 28 +++ .../nip51Lists/favoriteDvmList/TagArrayExt.kt | 28 +++ 25 files changed, 1392 insertions(+), 74 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/dvms/FavoriteDvmOrchestrator.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteDvmLists/FavoriteDvmListDecryptionCache.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteDvmLists/FavoriteDvmListState.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmFeedFlow.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilter.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilter.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilterSet.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/FavoriteDvmToggle.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/DvmStatusBanner.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90Dvms/FilterHomePostsByDvmIds.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/FavoriteDvmListEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/TagArrayBuilderExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/TagArrayExt.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 025bf0ffc..ec3df21db 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -38,6 +38,7 @@ import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.UserStatusActi import com.vitorpamplona.amethyst.commons.model.nip56Reports.ReportAction import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.logTime +import com.vitorpamplona.amethyst.model.dvms.FavoriteDvmOrchestrator import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListDecryptionCache import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListState import com.vitorpamplona.amethyst.model.localRelays.ForwardKind0ToLocalRelayState @@ -66,6 +67,8 @@ import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayLis import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayListState import com.vitorpamplona.amethyst.model.nip51Lists.broadcastRelays.BroadcastRelayListDecryptionCache import com.vitorpamplona.amethyst.model.nip51Lists.broadcastRelays.BroadcastRelayListState +import com.vitorpamplona.amethyst.model.nip51Lists.favoriteDvmLists.FavoriteDvmListDecryptionCache +import com.vitorpamplona.amethyst.model.nip51Lists.favoriteDvmLists.FavoriteDvmListState import com.vitorpamplona.amethyst.model.nip51Lists.geohashLists.GeohashListDecryptionCache import com.vitorpamplona.amethyst.model.nip51Lists.geohashLists.GeohashListState import com.vitorpamplona.amethyst.model.nip51Lists.hashtagLists.HashtagListDecryptionCache @@ -134,6 +137,7 @@ import com.vitorpamplona.quartz.experimental.profileGallery.mimeType import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageUtils import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore +import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -186,6 +190,7 @@ import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark import com.vitorpamplona.quartz.nip56Reports.ReportType import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent @@ -322,6 +327,10 @@ class Account( val hashtagListDecryptionCache = HashtagListDecryptionCache(signer) val hashtagList = HashtagListState(signer, cache, hashtagListDecryptionCache, scope, settings) + val favoriteDvmListDecryptionCache = FavoriteDvmListDecryptionCache(signer) + val favoriteDvmList = FavoriteDvmListState(signer, cache, favoriteDvmListDecryptionCache, scope, settings) + val favoriteDvmOrchestrator = FavoriteDvmOrchestrator(this, scope) + val geohashListDecryptionCache = GeohashListDecryptionCache(signer) val geohashList = GeohashListState(signer, cache, geohashListDecryptionCache, scope, settings) @@ -417,6 +426,7 @@ class Account( caches = feedDecryptionCaches, signer = signer, scope = scope, + favoriteDvmOrchestrator = favoriteDvmOrchestrator, ).flow // App-ready Feeds @@ -1012,6 +1022,12 @@ class Account( suspend fun unfollowHashtag(tag: String) = sendMyPublicAndPrivateOutbox(hashtagList.unfollow(tag)) + suspend fun followFavoriteDvm(dvm: AddressBookmark) = sendMyPublicAndPrivateOutbox(favoriteDvmList.follow(dvm)) + + suspend fun unfollowFavoriteDvm(dvm: Address) = sendMyPublicAndPrivateOutbox(favoriteDvmList.unfollow(dvm)) + + fun isFavoriteDvm(dvm: Address): Boolean = favoriteDvmList.flow.value.contains(dvm) + suspend fun followGeohash(geohash: String) = sendMyPublicAndPrivateOutbox(geohashList.follow(geohash)) suspend fun unfollowGeohash(geohash: String) = sendMyPublicAndPrivateOutbox(geohashList.unfollow(geohash)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index d091bf5c3..0f327a174 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -43,6 +43,7 @@ import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayList import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.favoriteDvmList.FavoriteDvmListEvent import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent @@ -154,6 +155,11 @@ sealed class TopFilter( class Relay( val url: String, ) : TopFilter("Relay/$url") + + @Serializable + class FavoriteDvm( + val address: Address, + ) : TopFilter("FavoriteDvm/${address.toValue()}") } @Stable @@ -195,6 +201,7 @@ class AccountSettings( var backupChannelList: ChannelListEvent? = null, var backupCommunityList: CommunityListEvent? = null, var backupHashtagList: HashtagListEvent? = null, + var backupFavoriteDvmList: FavoriteDvmListEvent? = null, var backupGeohashList: GeohashListEvent? = null, var backupEphemeralChatList: EphemeralChatListEvent? = null, var backupTrustProviderList: TrustProviderListEvent? = null, @@ -718,6 +725,16 @@ class AccountSettings( } } + fun updateFavoriteDvmListTo(newFavoriteDvmList: FavoriteDvmListEvent?) { + if (newFavoriteDvmList == null || newFavoriteDvmList.tags.isEmpty()) return + + // Events might be different objects, we have to compare their ids. + if (backupFavoriteDvmList?.id != newFavoriteDvmList.id) { + backupFavoriteDvmList = newFavoriteDvmList + saveAccountSettings() + } + } + fun updateCommunityListTo(newCommunityList: CommunityListEvent?) { if (newCommunityList == null || newCommunityList.tags.isEmpty()) return diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/dvms/FavoriteDvmOrchestrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/dvms/FavoriteDvmOrchestrator.kt new file mode 100644 index 000000000..f4058cccb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/dvms/FavoriteDvmOrchestrator.kt @@ -0,0 +1,185 @@ +/* + * 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.model.dvms + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * Immutable snapshot of a favourite DVM's current request/response state. + * + * - [requestId] is the id of the most recently published kind-5300 request. + * - [ids] and [addresses] are the note references returned by the latest kind-6300 response. + * - [latestStatus] is the latest kind-7000 status event (processing, payment-required, error, …). + * - [errorMessage] captures any client-side failure while publishing the request. + */ +data class FavoriteDvmSnapshot( + val requestId: HexKey? = null, + val ids: Set = emptySet(), + val addresses: Set = emptySet(), + val latestStatus: NIP90StatusEvent? = null, + val errorMessage: String? = null, +) + +/** + * Manages the NIP-90 content-discovery RPC cycle for each favourite DVM the user + * pins to the top-nav. + * + * The orchestrator is lazy: it starts a request/response cycle the first time any + * consumer calls [observe] for a given DVM address, and keeps emitting updated + * snapshots until [stop] (or account tear-down). Call [refresh] to re-issue the + * kind-5300 request (e.g. pull-to-refresh). + * + * This class does not own the relay subscriptions that fetch DVM responses and + * matching notes. Those are issued by `HomeOutboxEventsEoseManager` while the + * user has a `TopFilter.FavoriteDvm` selected. The orchestrator merely observes + * what the relays deliver into `LocalCache`. + */ +class FavoriteDvmOrchestrator( + val account: Account, + val scope: CoroutineScope, +) { + private val flows = mutableMapOf>() + private val jobs = mutableMapOf() + private val mutex = Mutex() + + fun observe(dvmAddress: Address): StateFlow { + flows[dvmAddress]?.let { return it.asStateFlow() } + + val seed = MutableStateFlow(FavoriteDvmSnapshot()) + flows[dvmAddress] = seed + scope.launch { startFor(dvmAddress, seed) } + return seed.asStateFlow() + } + + fun refresh(dvmAddress: Address) { + val seed = flows[dvmAddress] ?: return + scope.launch { + mutex.withLock { + jobs.remove(dvmAddress)?.cancel() + } + startFor(dvmAddress, seed) + } + } + + fun stop(dvmAddress: Address) { + scope.launch { + mutex.withLock { + jobs.remove(dvmAddress)?.cancel() + flows.remove(dvmAddress) + } + } + } + + private suspend fun startFor( + dvmAddress: Address, + seed: MutableStateFlow, + ) { + val user = account.cache.checkGetOrCreateUser(dvmAddress.pubKeyHex) ?: return + val job = + scope.launch(Dispatchers.IO) { + try { + account.requestDVMContentDiscovery(user) { request -> + seed.update { + it.copy( + requestId = request.id, + ids = emptySet(), + addresses = emptySet(), + latestStatus = null, + errorMessage = null, + ) + } + } + + val requestId = seed.value.requestId ?: return@launch + + launch { + account.cache + .observeLatestEvent( + Filter( + kinds = listOf(NIP90ContentDiscoveryResponseEvent.KIND), + tags = mapOf("e" to listOf(requestId)), + limit = 1, + ), + ).collectLatest { response -> + if (response == null) return@collectLatest + val (eventIds, addresses) = splitInnerTags(response.innerTags()) + seed.update { + it.copy( + ids = eventIds, + addresses = addresses, + ) + } + } + } + + launch { + account.cache + .observeLatestEvent( + Filter( + kinds = listOf(NIP90StatusEvent.KIND), + tags = mapOf("e" to listOf(requestId)), + limit = 1, + ), + ).collectLatest { status -> + seed.update { it.copy(latestStatus = status) } + } + } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.w("FavoriteDvmOrchestrator", "Failed to start DVM request: ${e.message}", e) + seed.update { it.copy(errorMessage = e.message ?: "Unknown error") } + } + } + + mutex.withLock { jobs[dvmAddress] = job } + } + + private fun splitInnerTags(innerTags: List): Pair, Set> { + val ids = mutableSetOf() + val addresses = mutableSetOf() + innerTags.forEach { value -> + if (value.contains(':')) { + addresses.add(value) + } else if (value.length == 64) { + ids.add(value) + } + } + return ids to addresses + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteDvmLists/FavoriteDvmListDecryptionCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteDvmLists/FavoriteDvmListDecryptionCache.kt new file mode 100644 index 000000000..8af3bfa97 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteDvmLists/FavoriteDvmListDecryptionCache.kt @@ -0,0 +1,36 @@ +/* + * 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.model.nip51Lists.favoriteDvmLists + +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEventCache +import com.vitorpamplona.quartz.nip51Lists.favoriteDvmList.FavoriteDvmListEvent +import com.vitorpamplona.quartz.nip51Lists.favoriteDvmList.favoriteDvmSet + +class FavoriteDvmListDecryptionCache( + val signer: NostrSigner, +) { + val cachedPrivateLists = PrivateTagArrayEventCache(signer) + + fun cachedFavoriteDvms(event: FavoriteDvmListEvent) = cachedPrivateLists.mergeTagListPrecached(event).favoriteDvmSet() + + suspend fun favoriteDvms(event: FavoriteDvmListEvent) = cachedPrivateLists.mergeTagList(event).favoriteDvmSet() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteDvmLists/FavoriteDvmListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteDvmLists/FavoriteDvmListState.kt new file mode 100644 index 000000000..db41026d5 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteDvmLists/FavoriteDvmListState.kt @@ -0,0 +1,128 @@ +/* + * 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.model.nip51Lists.favoriteDvmLists + +import com.vitorpamplona.amethyst.model.AccountSettings +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.NoteState +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark +import com.vitorpamplona.quartz.nip51Lists.favoriteDvmList.FavoriteDvmListEvent +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.transformLatest +import kotlinx.coroutines.launch + +class FavoriteDvmListState( + val signer: NostrSigner, + val cache: LocalCache, + val decryptionCache: FavoriteDvmListDecryptionCache, + val scope: CoroutineScope, + val settings: AccountSettings, +) { + // Creates a long-term reference for this note so that the GC doesn't collect the note itself + val favoriteDvmListNote = cache.getOrCreateAddressableNote(getFavoriteDvmListAddress()) + + fun getFavoriteDvmListAddress() = FavoriteDvmListEvent.createAddress(signer.pubKey) + + fun getFavoriteDvmListFlow(): StateFlow = favoriteDvmListNote.flow().metadata.stateFlow + + fun getFavoriteDvmList(): FavoriteDvmListEvent? = favoriteDvmListNote.event as? FavoriteDvmListEvent + + suspend fun favoriteDvmListWithBackup(note: Note): Set
{ + val event = note.event as? FavoriteDvmListEvent ?: settings.backupFavoriteDvmList + return event?.let { decryptionCache.favoriteDvms(it) } ?: emptySet() + } + + @OptIn(ExperimentalCoroutinesApi::class) + val flow: StateFlow> = + getFavoriteDvmListFlow() + .transformLatest { noteState -> + emit(favoriteDvmListWithBackup(noteState.note)) + }.onStart { + emit(favoriteDvmListWithBackup(favoriteDvmListNote)) + }.flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + emptySet(), + ) + + @OptIn(ExperimentalCoroutinesApi::class) + val flowNotes: StateFlow> = + flow + .map { addresses -> + addresses.map { cache.getOrCreateAddressableNote(it) } + }.onStart { + emit(flow.value.map { cache.getOrCreateAddressableNote(it) }) + }.flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + emptyList(), + ) + + suspend fun follow(dvm: AddressBookmark): FavoriteDvmListEvent { + val list = getFavoriteDvmList() + return if (list == null) { + FavoriteDvmListEvent.create(dvm, false, signer) + } else { + FavoriteDvmListEvent.add(list, dvm, false, signer) + } + } + + suspend fun unfollow(dvm: Address): FavoriteDvmListEvent? { + val list = getFavoriteDvmList() ?: return null + return FavoriteDvmListEvent.remove(list, dvm, signer) + } + + init { + settings.backupFavoriteDvmList?.let { event -> + Log.d("AccountRegisterObservers") { "Loading saved Favorite DVM list ${event.toJson()}" } + @OptIn(DelicateCoroutinesApi::class) + scope.launch(Dispatchers.IO) { + LocalCache.justConsumeMyOwnEvent(event) + } + } + + scope.launch(Dispatchers.IO) { + Log.d("AccountRegisterObservers", "Favorite DVM List Collector Start") + getFavoriteDvmListFlow().collect { + Log.d("AccountRegisterObservers") { "Favorite DVM List for ${signer.pubKey}" } + (it.note.event as? FavoriteDvmListEvent)?.let { + settings.updateFavoriteDvmListTo(it) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt index e9ae1dbc7..27635f019 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.model.topNavFeeds import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.TopFilter +import com.vitorpamplona.amethyst.model.dvms.FavoriteDvmOrchestrator import com.vitorpamplona.amethyst.model.nip02FollowLists.Kind3FollowListState import com.vitorpamplona.amethyst.model.serverList.MergedFollowListsState import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsFeedFlow @@ -30,6 +31,7 @@ import com.vitorpamplona.amethyst.model.topNavFeeds.allUserFollows.Kind3UserFoll import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.AroundMeFeedFlow import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.GeohashFeedFlow import com.vitorpamplona.amethyst.model.topNavFeeds.chess.ChessFeedFlow +import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteDvm.FavoriteDvmFeedFlow import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalFeedFlow import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagFeedFlow import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.NoteFeedFlow @@ -62,6 +64,7 @@ class FeedTopNavFilterState( val caches: FeedDecryptionCaches, val signer: NostrSigner, val scope: CoroutineScope, + val favoriteDvmOrchestrator: FavoriteDvmOrchestrator, ) { fun loadFlowsFor(listName: TopFilter): IFeedFlowsType = when (listName) { @@ -142,6 +145,15 @@ class FeedTopNavFilterState( is TopFilter.Relay -> { RelayFeedFlow(listName.url.normalizeRelayUrl()) } + + is TopFilter.FavoriteDvm -> { + FavoriteDvmFeedFlow( + dvmAddress = listName.address, + orchestrator = favoriteDvmOrchestrator, + outboxRelays = followsRelays, + proxyRelays = proxyRelays, + ) + } } @OptIn(ExperimentalCoroutinesApi::class) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmFeedFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmFeedFlow.kt new file mode 100644 index 000000000..f919d62a0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmFeedFlow.kt @@ -0,0 +1,69 @@ +/* + * 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.model.topNavFeeds.favoriteDvm + +import com.vitorpamplona.amethyst.model.dvms.FavoriteDvmOrchestrator +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedFlowsType +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine + +class FavoriteDvmFeedFlow( + val dvmAddress: Address, + val orchestrator: FavoriteDvmOrchestrator, + val outboxRelays: StateFlow>, + val proxyRelays: StateFlow>, +) : IFeedFlowsType { + private fun resolveRelays( + outbox: Set, + proxy: Set, + ): Set = if (proxy.isNotEmpty()) proxy else outbox + + private fun buildFilter( + snapshot: com.vitorpamplona.amethyst.model.dvms.FavoriteDvmSnapshot, + relays: Set, + ) = FavoriteDvmTopNavFilter( + dvmAddress = dvmAddress, + acceptedIds = snapshot.ids, + acceptedAddresses = snapshot.addresses, + relayList = relays, + requestId = snapshot.requestId, + ) + + override fun flow(): Flow = + combine(orchestrator.observe(dvmAddress), outboxRelays, proxyRelays) { snap, outbox, proxy -> + buildFilter(snap, resolveRelays(outbox, proxy)) + } + + override fun startValue(): FavoriteDvmTopNavFilter = + buildFilter( + snapshot = orchestrator.observe(dvmAddress).value, + relays = resolveRelays(outboxRelays.value, proxyRelays.value), + ) + + override suspend fun startValue(collector: FlowCollector) { + collector.emit(startValue()) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilter.kt new file mode 100644 index 000000000..093fc390d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilter.kt @@ -0,0 +1,68 @@ +/* + * 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.model.topNavFeeds.favoriteDvm + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +/** + * Top-nav filter backed by the latest kind-6300 response from a favourite DVM. + * + * The filter is a pure immutable membership check: [match] accepts a note only if the + * DVM's latest response included it. When a new response arrives, a new instance is + * emitted through [FavoriteDvmFeedFlow] and replaces the active filter. + */ +@Immutable +class FavoriteDvmTopNavFilter( + val dvmAddress: Address, + val acceptedIds: Set, + val acceptedAddresses: Set, + val relayList: Set, + val requestId: HexKey?, +) : IFeedTopNavFilter { + override fun matchAuthor(pubkey: HexKey): Boolean = true + + override fun match(noteEvent: Event): Boolean = + noteEvent.id in acceptedIds || + (noteEvent is AddressableEvent && noteEvent.addressTag() in acceptedAddresses) + + override fun toPerRelayFlow(cache: LocalCache): Flow = MutableStateFlow(startValue(cache)) + + override fun startValue(cache: LocalCache): FavoriteDvmTopNavPerRelayFilterSet = + FavoriteDvmTopNavPerRelayFilterSet( + relayList.associateWith { + FavoriteDvmTopNavPerRelayFilter( + dvmPubkey = dvmAddress.pubKeyHex, + requestId = requestId, + ids = acceptedIds, + addresses = acceptedAddresses, + ) + }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilter.kt new file mode 100644 index 000000000..5a8f3d7f6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilter.kt @@ -0,0 +1,33 @@ +/* + * 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.model.topNavFeeds.favoriteDvm + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilter +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +@Immutable +class FavoriteDvmTopNavPerRelayFilter( + val dvmPubkey: HexKey, + val requestId: HexKey?, + val ids: Set, + val addresses: Set, +) : IFeedTopNavPerRelayFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilterSet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilterSet.kt new file mode 100644 index 000000000..4ec47da11 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilterSet.kt @@ -0,0 +1,28 @@ +/* + * 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.model.topNavFeeds.favoriteDvm + +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +class FavoriteDvmTopNavPerRelayFilterSet( + val set: Map, +) : IFeedTopNavPerRelayFilterSet diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt index c3f7bba7c..64c3ac1e9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt @@ -40,6 +40,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.ViewList import androidx.compose.material.icons.automirrored.outlined.VolumeOff import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.outlined.AutoAwesome import androidx.compose.material.icons.outlined.Groups import androidx.compose.material.icons.outlined.LocationOn import androidx.compose.material.icons.outlined.Person @@ -84,6 +85,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNo import com.vitorpamplona.amethyst.ui.components.LoadingAnimation import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName import com.vitorpamplona.amethyst.ui.screen.CommunityName +import com.vitorpamplona.amethyst.ui.screen.FavoriteDvmName import com.vitorpamplona.amethyst.ui.screen.FeedDefinition import com.vitorpamplona.amethyst.ui.screen.GeoHashName import com.vitorpamplona.amethyst.ui.screen.HashtagName @@ -100,6 +102,7 @@ import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent +import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import kotlinx.collections.immutable.ImmutableList @OptIn(ExperimentalPermissionsApi::class) @@ -329,6 +332,20 @@ fun RenderOption( color = MaterialTheme.colorScheme.onSurface, ) } + + is FavoriteDvmName -> { + val noteState by observeNote(option.note, accountViewModel) + val name = + (noteState.note.event as? AppDefinitionEvent) + ?.appMetaData() + ?.name + ?.takeIf { it.isNotBlank() } ?: option.note.dTag() + Text( + text = name, + fontSize = Font14SP, + color = MaterialTheme.colorScheme.onSurface, + ) + } } } @@ -346,6 +363,7 @@ private enum class FeedGroup( COMMUNITIES(R.string.feed_group_communities), LOCATIONS(R.string.feed_group_locations), LISTS(R.string.feed_group_lists), + DVMS(R.string.feed_group_dvms), RELAYS(R.string.feed_group_relays), } @@ -373,6 +391,10 @@ private fun groupFeedDefinitions(options: ImmutableList): Map { + FeedGroup.DVMS + } + is ResourceName -> { when (entry.item.code) { is TopFilter.AroundMe -> FeedGroup.LOCATIONS @@ -541,12 +563,17 @@ private fun FeedIcon( Icons.AutoMirrored.Outlined.ViewList } + is TopFilter.FavoriteDvm -> { + Icons.Outlined.AutoAwesome + } + else -> { when (item.name) { is GeoHashName -> Icons.Outlined.LocationOn is RelayName -> Icons.Outlined.Storage is CommunityName -> Icons.Outlined.Groups is PeopleListName -> Icons.AutoMirrored.Outlined.ViewList + is FavoriteDvmName -> Icons.Outlined.AutoAwesome else -> Icons.Outlined.Person } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt index 50763b54d..811ff1508 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt @@ -34,6 +34,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent +import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -140,6 +141,7 @@ class TopNavFilterState( geotagList: Set, communityList: List, relayList: Set, + favoriteDvmList: List, ): List { val hashtags = hashtagList.map { @@ -173,7 +175,15 @@ class TopNavFilterState( ) } - return (communities + hashtags + geotags + relays).sortedBy { it.name.name() } + val favoriteDvms = + favoriteDvmList.map { dvmNote -> + FeedDefinition( + TopFilter.FavoriteDvm(dvmNote.address), + FavoriteDvmName(dvmNote), + ) + } + + return (communities + hashtags + geotags + relays + favoriteDvms).sortedBy { it.name.name() } } @OptIn(ExperimentalCoroutinesApi::class) @@ -183,6 +193,7 @@ class TopNavFilterState( account.geohashList.flow, account.communityList.flowNotes, account.relayFeedsList.flow, + account.favoriteDvmList.flowNotes, ::mergeInterests, ).onStart { emit( @@ -191,6 +202,7 @@ class TopNavFilterState( account.geohashList.flow.value, account.communityList.flowNotes.value, account.relayFeedsList.flow.value, + account.favoriteDvmList.flowNotes.value, ), ) } @@ -298,6 +310,15 @@ class CommunityName( override fun name() = "/n/${(note.dTag())}" } +@Stable +class FavoriteDvmName( + val note: AddressableNote, +) : Name() { + override fun name(): String = + (note.event as? AppDefinitionEvent)?.appMetaData()?.name?.takeIf { it.isNotBlank() } + ?: note.dTag() +} + @Immutable class FeedDefinition( val code: TopFilter, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 56d164093..c8ebc9643 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -138,6 +138,7 @@ import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response import com.vitorpamplona.quartz.nip51Lists.PinListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent import com.vitorpamplona.quartz.nip56Reports.ReportType import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent @@ -1088,6 +1089,12 @@ class AccountViewModel( fun unfollowHashtag(tag: String) = launchSigner { account.unfollowHashtag(tag) } + fun followFavoriteDvm(dvm: AddressBookmark) = launchSigner { account.followFavoriteDvm(dvm) } + + fun unfollowFavoriteDvm(dvm: Address) = launchSigner { account.unfollowFavoriteDvm(dvm) } + + fun refreshFavoriteDvm(dvm: Address) = account.favoriteDvmOrchestrator.refresh(dvm) + fun followRelayFeed(url: NormalizedRelayUrl) = launchSigner { account.followRelayFeed(url) } fun unfollowRelayFeed(url: NormalizedRelayUrl) = launchSigner { account.unfollowRelayFeed(url) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DVMCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DVMCard.kt index 6fa904415..4ac46b109 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DVMCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DVMCard.kt @@ -42,6 +42,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.components.MyAsyncImage @@ -51,6 +52,7 @@ import com.vitorpamplona.amethyst.ui.note.LikeReaction import com.vitorpamplona.amethyst.ui.note.ZapReaction import com.vitorpamplona.amethyst.ui.note.elements.BannerImage import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.FavoriteDvmToggle import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.observeAppDefinition import com.vitorpamplona.amethyst.ui.theme.HalfTopPadding import com.vitorpamplona.amethyst.ui.theme.RowColSpacing5dp @@ -123,6 +125,12 @@ fun RenderContentDVMThumb( verticalAlignment = CenterVertically, horizontalArrangement = RowColSpacing5dp, ) { + if (baseNote is AddressableNote) { + FavoriteDvmToggle( + appDefinitionNote = baseNote, + accountViewModel = accountViewModel, + ) + } LikeReaction( baseNote = baseNote, grayTint = MaterialTheme.colorScheme.onSurface, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt index 0d81a4cc8..3f5dea38e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt @@ -354,82 +354,95 @@ fun FeedDVM( Spacer(modifier = DoubleVertSpacer) Text(currentStatus, textAlign = TextAlign.Center) - if (status.code == "payment-required") { - val amountTag = latestStatus.firstAmount() - val amount = amountTag?.amount + DvmPaymentActions( + latestStatus = latestStatus, + accountViewModel = accountViewModel, + nav = nav, + onStatusUpdate = { currentStatus = it }, + ) + } +} - val invoice = amountTag?.lnInvoice +@Composable +fun DvmPaymentActions( + latestStatus: NIP90StatusEvent, + accountViewModel: AccountViewModel, + nav: INav, + onStatusUpdate: (String) -> Unit, +) { + val status = latestStatus.status() ?: return - val thankYou = stringRes(id = R.string.dvm_waiting_to_confirm_payment) - val nwcPaymentRequest = stringRes(id = R.string.nwc_payment_request) + if (status.code != "payment-required") return - if (invoice != null) { - val context = LocalContext.current - Button(onClick = { - if (accountViewModel.account.nip47SignerState.hasWalletConnectSetup()) { - accountViewModel.sendZapPaymentRequestFor( - bolt11 = invoice, - zappedNote = null, - onSent = { - currentStatus = nwcPaymentRequest - }, - onResponse = { response -> - currentStatus = - if (response is PayInvoiceErrorResponse) { - stringRes( - context, - R.string.wallet_connect_pay_invoice_error_error, - response.error?.message - ?: response.error?.code?.toString() ?: "Error parsing error message", - ) - } else { - thankYou - } + val amountTag = latestStatus.firstAmount() + val amount = amountTag?.amount + val invoice = amountTag?.lnInvoice + + val thankYou = stringRes(id = R.string.dvm_waiting_to_confirm_payment) + val nwcPaymentRequest = stringRes(id = R.string.nwc_payment_request) + + if (invoice != null) { + val context = LocalContext.current + Button(onClick = { + if (accountViewModel.account.nip47SignerState.hasWalletConnectSetup()) { + accountViewModel.sendZapPaymentRequestFor( + bolt11 = invoice, + zappedNote = null, + onSent = { + onStatusUpdate(nwcPaymentRequest) + }, + onResponse = { response -> + onStatusUpdate( + if (response is PayInvoiceErrorResponse) { + stringRes( + context, + R.string.wallet_connect_pay_invoice_error_error, + response.error?.message + ?: response.error?.code?.toString() ?: "Error parsing error message", + ) + } else { + thankYou }, ) - } else { - payViaIntent( - invoice, - context, - onPaid = { - currentStatus = thankYou - }, - onError = { - currentStatus = it - }, - ) - } - }) { - val amountInInvoice = - try { - LnInvoiceUtil.getAmountInSats(invoice).toLong() - } catch (_: Exception) { - null - } - - if (amountInInvoice != null) { - Text(text = "Pay $amountInInvoice sats to the DVM") - } else { - Text(text = "Pay Invoice from the DVM") - } - } - } else if (amount != null) { - LoadNote(baseNoteHex = latestStatus.id, accountViewModel = accountViewModel) { stateNote -> - stateNote?.let { - ZapDVMButton( - baseNote = it, - amount = amount, - grayTint = MaterialTheme.colorScheme.onPrimary, - accountViewModel = accountViewModel, - nav = nav, - ) - } - } + }, + ) + } else { + payViaIntent( + invoice, + context, + onPaid = { + onStatusUpdate(thankYou) + }, + onError = { + onStatusUpdate(it) + }, + ) + } + }) { + val amountInInvoice = + try { + LnInvoiceUtil.getAmountInSats(invoice).toLong() + } catch (_: Exception) { + null + } + + if (amountInInvoice != null) { + Text(text = "Pay $amountInInvoice sats to the DVM") + } else { + Text(text = "Pay Invoice from the DVM") + } + } + } else if (amount != null) { + LoadNote(baseNoteHex = latestStatus.id, accountViewModel = accountViewModel) { stateNote -> + stateNote?.let { + ZapDVMButton( + baseNote = it, + amount = amount, + grayTint = MaterialTheme.colorScheme.onPrimary, + accountViewModel = accountViewModel, + nav = nav, + ) } - } else if (status.code == "processing") { - currentStatus = status.description - } else if (status.code == "error") { - currentStatus = status.description } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmTopBar.kt index 928a2d9a8..d3dcecc66 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmTopBar.kt @@ -21,15 +21,18 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms import androidx.compose.foundation.layout.Spacer +import androidx.compose.material3.IconButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow +import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.ui.components.LoadNote import com.vitorpamplona.amethyst.ui.components.MyAsyncImage import com.vitorpamplona.amethyst.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton +import com.vitorpamplona.amethyst.ui.navigation.topbars.MyExtensibleTopAppBar +import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon import com.vitorpamplona.amethyst.ui.note.elements.BannerImage import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer @@ -41,7 +44,7 @@ fun DvmTopBar( accountViewModel: AccountViewModel, nav: INav, ) { - TopBarExtensibleWithBackButton( + MyExtensibleTopAppBar( title = { LoadNote(baseNoteHex = appDefinitionId, accountViewModel = accountViewModel) { appDefinitionNote -> if (appDefinitionNote != null) { @@ -82,6 +85,16 @@ fun DvmTopBar( } } }, - popBack = nav::popBack, + navigationIcon = { IconButton(onClick = nav::popBack) { ArrowBackIcon() } }, + actions = { + LoadNote(baseNoteHex = appDefinitionId, accountViewModel = accountViewModel) { appDefinitionNote -> + if (appDefinitionNote is AddressableNote) { + FavoriteDvmToggle( + appDefinitionNote = appDefinitionNote, + accountViewModel = accountViewModel, + ) + } + } + }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/FavoriteDvmToggle.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/FavoriteDvmToggle.kt new file mode 100644 index 000000000..f79acafcd --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/FavoriteDvmToggle.kt @@ -0,0 +1,82 @@ +/* + * 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.ui.screen.loggedIn.dvms + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Star +import androidx.compose.material.icons.outlined.StarBorder +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size20Modifier +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark + +@Composable +fun FavoriteDvmToggle( + appDefinitionNote: AddressableNote, + accountViewModel: AccountViewModel, + modifier: Modifier = Modifier, +) { + val favorites by accountViewModel.account.favoriteDvmList.flow + .collectAsStateWithLifecycle() + + val isFavorite = favorites.contains(appDefinitionNote.address) + + IconButton( + onClick = { + if (isFavorite) { + accountViewModel.unfollowFavoriteDvm(appDefinitionNote.address) + } else { + accountViewModel.followFavoriteDvm( + AddressBookmark( + address = appDefinitionNote.address, + relayHint = appDefinitionNote.relayHintUrl(), + ), + ) + } + }, + modifier = modifier, + ) { + if (isFavorite) { + Icon( + imageVector = Icons.Filled.Star, + contentDescription = stringRes(R.string.remove_dvm_from_favorites), + modifier = Size20Modifier, + tint = MaterialTheme.colorScheme.primary, + ) + } else { + Icon( + imageVector = Icons.Outlined.StarBorder, + contentDescription = stringRes(R.string.add_dvm_to_favorites), + modifier = Size20Modifier, + tint = MaterialTheme.colorScheme.onSurface, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/DvmStatusBanner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/DvmStatusBanner.kt new file mode 100644 index 000000000..487a9524b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/DvmStatusBanner.kt @@ -0,0 +1,195 @@ +/* + * 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.ui.screen.loggedIn.home + +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.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +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.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.TopFilter +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteAndMap +import com.vitorpamplona.amethyst.ui.components.LoadNote +import com.vitorpamplona.amethyst.ui.components.LoadingAnimation +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.DvmPaymentActions +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent + +@Composable +fun HomeDvmStatusBanner( + accountViewModel: AccountViewModel, + nav: INav, +) { + val topFilter by accountViewModel.account.settings.defaultHomeFollowList + .collectAsStateWithLifecycle() + + val favDvm = topFilter as? TopFilter.FavoriteDvm ?: return + + val snapshot by accountViewModel.account.favoriteDvmOrchestrator + .observe(favDvm.address) + .collectAsStateWithLifecycle() + + // Hide the banner when the feed is already populated. + if (snapshot.ids.isNotEmpty() || snapshot.addresses.isNotEmpty()) return + + val dvmAddressValue = favDvm.address.toValue() + + LoadNote(baseNoteHex = dvmAddressValue, accountViewModel = accountViewModel) { dvmNote -> + val resolvedName by + observeNoteAndMap(dvmNote ?: return@LoadNote, accountViewModel) { note -> + (note.event as? AppDefinitionEvent) + ?.appMetaData() + ?.name + ?.takeIf { it.isNotBlank() } + ?: (note as? com.vitorpamplona.amethyst.model.AddressableNote)?.dTag() + ?: "" + } + + Surface( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 6.dp), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceContainerLow, + ) { + Column(modifier = Modifier.padding(12.dp)) { + val status = snapshot.latestStatus?.status() + + when { + snapshot.errorMessage != null -> { + BannerMessageRow( + message = stringRes(R.string.dvm_home_status_error), + showSpinner = false, + ) + Spacer(modifier = StdVertSpacer) + RetryButton(favDvm, accountViewModel) + } + + status?.code == "payment-required" -> { + BannerMessageRow( + message = + status.description.ifBlank { + stringRes(R.string.dvm_home_status_payment_required) + }, + showSpinner = false, + ) + Spacer(modifier = StdVertSpacer) + var statusOverride by remember { mutableStateOf(null) } + val msg = statusOverride + if (msg != null) { + Text(text = msg, style = MaterialTheme.typography.bodySmall) + Spacer(modifier = StdVertSpacer) + } + snapshot.latestStatus?.let { + DvmPaymentActions( + latestStatus = it, + accountViewModel = accountViewModel, + nav = nav, + onStatusUpdate = { statusOverride = it }, + ) + } + } + + status?.code == "error" -> { + BannerMessageRow( + message = + status.description.ifBlank { + stringRes(R.string.dvm_home_status_error) + }, + showSpinner = false, + ) + Spacer(modifier = StdVertSpacer) + RetryButton(favDvm, accountViewModel) + } + + status?.code == "processing" -> { + BannerMessageRow( + message = + status.description.ifBlank { + stringRes(R.string.dvm_home_status_processing) + }, + showSpinner = true, + ) + } + + else -> { + BannerMessageRow( + message = stringRes(R.string.dvm_home_status_requesting, resolvedName), + showSpinner = true, + ) + } + } + } + } + } +} + +@Composable +private fun BannerMessageRow( + message: String, + showSpinner: Boolean, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start, + ) { + if (showSpinner) { + LoadingAnimation(indicatorSize = 14.dp, circleWidth = 2.dp) + Spacer(modifier = StdHorzSpacer) + } + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } +} + +@Composable +private fun RetryButton( + favDvm: TopFilter.FavoriteDvm, + accountViewModel: AccountViewModel, +) { + OutlinedButton(onClick = { accountViewModel.refreshFavoriteDvm(favDvm.address) }) { + Text(stringRes(R.string.dvm_home_retry)) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt index 77adf8355..38e38dae6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt @@ -179,6 +179,7 @@ private fun HomePages( topBar = { Column { HomeTopBar(accountViewModel, nav) + HomeDvmStatusBanner(accountViewModel, nav) SecondaryTabRow( containerColor = Color.Transparent, contentColor = MaterialTheme.colorScheme.onBackground, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/HomeOutboxEventsEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/HomeOutboxEventsEoseManager.kt index 39459be38..29abc2ae6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/HomeOutboxEventsEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/HomeOutboxEventsEoseManager.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.chess.ChessTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteDvm.FavoriteDvmTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet @@ -42,6 +43,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip01Core.f import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip64Chess.filterHomePostsByChess import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip72Communities.filterHomePostsByAllCommunities import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip72Communities.filterHomePostsByCommunity +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip90Dvms.filterHomePostsByDvmIds import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription @@ -74,6 +76,7 @@ class HomeOutboxEventsEoseManager( is MutedAuthorsTopNavPerRelayFilterSet -> filterHomePostsByAuthors(feedSettings, since, newThreadSince, repliesSince) is RelayTopNavPerRelayFilterSet -> filterHomePostsByRelay(feedSettings, since, newThreadSince, repliesSince) is SingleCommunityTopNavPerRelayFilterSet -> filterHomePostsByCommunity(feedSettings, since, newThreadSince) + is FavoriteDvmTopNavPerRelayFilterSet -> filterHomePostsByDvmIds(feedSettings, since, newThreadSince) else -> emptyList() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90Dvms/FilterHomePostsByDvmIds.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90Dvms/FilterHomePostsByDvmIds.kt new file mode 100644 index 000000000..644cc1f54 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90Dvms/FilterHomePostsByDvmIds.kt @@ -0,0 +1,99 @@ +/* + * 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.ui.screen.loggedIn.home.datasource.nip90Dvms + +import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteDvm.FavoriteDvmTopNavPerRelayFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteDvm.FavoriteDvmTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent + +/** + * Builds relay REQ filters for a favourite-DVM home feed. + * + * Three filters are issued per relay: + * - fetch notes whose ids are in the DVM's latest kind-6300 response + * - fetch addressable notes referenced by `a` tags in the DVM's response + * - subscribe to the DVM's future kind 6300 / 7000 events so the filter snapshot + * keeps up to date while this filter is active + */ +fun filterHomePostsByDvmIds( + set: FavoriteDvmTopNavPerRelayFilterSet, + @Suppress("UNUSED_PARAMETER") since: SincePerRelayMap?, + @Suppress("UNUSED_PARAMETER") defaultSince: Long?, +): List = + set.set.flatMap { (relay, filter) -> + buildFiltersFor(relay, filter) + } + +private fun buildFiltersFor( + relay: NormalizedRelayUrl, + filter: FavoriteDvmTopNavPerRelayFilter, +): List { + val out = mutableListOf() + + if (filter.ids.isNotEmpty()) { + out += + RelayBasedFilter( + relay = relay, + filter = + Filter( + ids = filter.ids.toList(), + limit = filter.ids.size, + ), + ) + } + + if (filter.addresses.isNotEmpty()) { + out += + RelayBasedFilter( + relay = relay, + filter = + Filter( + tags = mapOf("a" to filter.addresses.toList()), + limit = filter.addresses.size, + ), + ) + } + + val requestId = filter.requestId + if (requestId != null) { + out += + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = + listOf( + NIP90ContentDiscoveryResponseEvent.KIND, + NIP90StatusEvent.KIND, + ), + tags = mapOf("e" to listOf(requestId)), + limit = 10, + ), + ) + } + + return out +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index e2ffc91ef..9b74fd43b 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1728,8 +1728,17 @@ Locations Communities Lists + DVMs Relays + Add DVM to favorites + Remove from favorites + Asking %1$s for a feed… + Processing your feed… + This DVM requires payment + DVM returned an error + Retry + Log off on device lock Private Message Public Message diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/FavoriteDvmListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/FavoriteDvmListEvent.kt new file mode 100644 index 000000000..34f28bf7c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/FavoriteDvmListEvent.kt @@ -0,0 +1,192 @@ +/* + * 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.quartz.nip51Lists.favoriteDvmList + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.core.fastAny +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark +import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent +import com.vitorpamplona.quartz.nip51Lists.remove +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class FavoriteDvmListEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun publicFavoriteDvms(): List = tags.mapNotNull(AddressBookmark::parse) + + suspend fun privateFavoriteDvms(signer: NostrSigner): List? = privateTags(signer)?.mapNotNull(AddressBookmark::parse) + + companion object { + const val KIND = 10090 + const val ALT = "Favorite DVM list" + const val FIXED_D_TAG = "" + + fun createAddress(pubKey: HexKey) = Address(KIND, pubKey, FIXED_D_TAG) + + suspend fun create( + dvm: AddressBookmark, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): FavoriteDvmListEvent = + if (isPrivate) { + create( + publicDvms = emptyList(), + privateDvms = listOf(dvm), + signer = signer, + createdAt = createdAt, + ) + } else { + create( + publicDvms = listOf(dvm), + privateDvms = emptyList(), + signer = signer, + createdAt = createdAt, + ) + } + + suspend fun add( + earlierVersion: FavoriteDvmListEvent, + dvm: AddressBookmark, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): FavoriteDvmListEvent = + if (isPrivate) { + val privateTags = + earlierVersion.privateTags(signer) + ?: throw SignerExceptions.UnauthorizedDecryptionException() + resign( + tags = earlierVersion.tags, + privateTags = privateTags.remove(dvm.toTagIdOnly()) + dvm.toTagArray(), + signer = signer, + createdAt = createdAt, + ) + } else { + resign( + content = earlierVersion.content, + tags = earlierVersion.tags.remove(dvm.toTagIdOnly()) + dvm.toTagArray(), + signer = signer, + createdAt = createdAt, + ) + } + + suspend fun remove( + earlierVersion: FavoriteDvmListEvent, + dvm: Address, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): FavoriteDvmListEvent { + val idOnly = AddressBookmark.assemble(dvm, null) + val privateTags = earlierVersion.privateTags(signer) + return if (privateTags != null) { + resign( + privateTags = privateTags.remove(idOnly), + tags = earlierVersion.tags.remove(idOnly), + signer = signer, + createdAt = createdAt, + ) + } else { + resign( + content = earlierVersion.content, + tags = earlierVersion.tags.remove(idOnly), + signer = signer, + createdAt = createdAt, + ) + } + } + + suspend fun resign( + tags: TagArray, + privateTags: TagArray, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ) = resign( + content = PrivateTagsInContent.encryptNip44(privateTags, signer), + tags = tags, + signer = signer, + createdAt = createdAt, + ) + + suspend fun resign( + content: String, + tags: TagArray, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): FavoriteDvmListEvent { + val newTags = + if (tags.fastAny(AltTag::match)) { + tags + } else { + tags + AltTag.assemble(ALT) + } + + return signer.sign(createdAt, KIND, newTags, content) + } + + suspend fun create( + publicDvms: List = emptyList(), + privateDvms: List = emptyList(), + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): FavoriteDvmListEvent { + val template = build(publicDvms, privateDvms, signer, createdAt) + return signer.sign(template) + } + + suspend fun build( + publicDvms: List = emptyList(), + privateDvms: List = emptyList(), + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate( + kind = KIND, + description = + PrivateTagsInContent.encryptNip44( + privateDvms.map { it.toTagArray() }.toTypedArray(), + signer, + ), + createdAt = createdAt, + ) { + alt(ALT) + favoriteDvms(publicDvms) + + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/TagArrayBuilderExt.kt new file mode 100644 index 000000000..6f49fd98b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/TagArrayBuilderExt.kt @@ -0,0 +1,28 @@ +/* + * 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.quartz.nip51Lists.favoriteDvmList + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark + +fun TagArrayBuilder.favoriteDvm(app: AddressBookmark) = add(app.toTagArray()) + +fun TagArrayBuilder.favoriteDvms(apps: List) = addAll(apps.map { it.toTagArray() }) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/TagArrayExt.kt new file mode 100644 index 000000000..0f2d3501c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/TagArrayExt.kt @@ -0,0 +1,28 @@ +/* + * 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.quartz.nip51Lists.favoriteDvmList + +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark + +fun TagArray.favoriteDvmList() = mapNotNull(AddressBookmark::parseAddress) + +fun TagArray.favoriteDvmSet() = mapNotNullTo(mutableSetOf(), AddressBookmark::parseAddress) From 9cca64f6ac2aa153e7dd339d5308e79504cd535d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 19:34:12 +0000 Subject: [PATCH 10/48] perf: tune image/video OkHttp dispatcher and connection pool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OkHttp's default dispatcher caps inflight requests per host at 5 and total inflight at 64. Amethyst feeds typically pull most media from a single host (e.g. a primary Blossom/imgproxy server), so that per-host cap serialized feed loading — the browser-feels-faster effect. - Give the images/videos factory a dedicated Dispatcher (16 per host, 128 total on device; 5/64 on emulator). - Give it a larger ConnectionPool (32 idle, 5 min keep-alive) so HTTP/2 connections to the common media host stay warm across scrolls and avoid repeated TLS handshakes. - Extract isEmulator() to a shared helper used by both the relay and image/video factories. The relay factory is untouched (already tuned for websockets). https://claude.ai/code/session_01PUbqGyUc6oq6V1MdmLi8sw --- .../amethyst/service/okhttp/IsEmulator.kt | 39 +++++++++++++++++++ .../service/okhttp/OkHttpClientFactory.kt | 24 ++++++++++++ .../okhttp/OkHttpClientFactoryForRelays.kt | 17 -------- 3 files changed, 63 insertions(+), 17 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/IsEmulator.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/IsEmulator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/IsEmulator.kt new file mode 100644 index 000000000..48764445e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/IsEmulator.kt @@ -0,0 +1,39 @@ +/* + * 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.service.okhttp + +import android.os.Build + +internal fun isEmulator(): Boolean = + Build.FINGERPRINT.startsWith("generic") || + Build.FINGERPRINT.lowercase().contains("emulator") || + Build.MODEL.contains("google_sdk") || + Build.MODEL.lowercase().contains("droid4x") || + Build.MODEL.contains("Emulator") || + Build.MODEL.contains("Android SDK built for x86") || + Build.MANUFACTURER.contains("Genymotion") || + (Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic")) || + "google_sdk" == Build.PRODUCT || + Build.HARDWARE.contains("goldfish") || + Build.HARDWARE.contains("ranchu") || + Build.HARDWARE.contains("vbox86") || + Build.HARDWARE.contains("nox") || + Build.HARDWARE.contains("cuttlefish") diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactory.kt index 0dcdbb60b..e409c6c5e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactory.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactory.kt @@ -24,10 +24,13 @@ import com.vitorpamplona.amethyst.service.okhttp.OkHttpClientFactoryForRelays.Co import com.vitorpamplona.amethyst.service.okhttp.OkHttpClientFactoryForRelays.Companion.DEFAULT_SOCKS_PORT import com.vitorpamplona.amethyst.service.okhttp.OkHttpClientFactoryForRelays.Companion.DEFAULT_TIMEOUT_ON_MOBILE_SECS import com.vitorpamplona.amethyst.service.okhttp.OkHttpClientFactoryForRelays.Companion.DEFAULT_TIMEOUT_ON_WIFI_SECS +import okhttp3.ConnectionPool +import okhttp3.Dispatcher import okhttp3.OkHttpClient import java.net.InetSocketAddress import java.net.Proxy import java.time.Duration +import java.util.concurrent.TimeUnit class OkHttpClientFactory( keyCache: EncryptionKeyCache, @@ -36,9 +39,30 @@ class OkHttpClientFactory( // val logging = LoggingInterceptor() val keyDecryptor = EncryptedBlobInterceptor(keyCache) + // Most images/videos in a feed come from a small set of hosts (e.g. a single + // Blossom/imgproxy server). OkHttp's default dispatcher caps inflight requests + // per host at 5, which serializes feed loading. Raise the limits so the feed + // can parallelize downloads the way a browser does. + private val dispatcher = + Dispatcher().apply { + if (!isEmulator()) { + maxRequestsPerHost = 16 + maxRequests = 128 + } else { + maxRequestsPerHost = 5 + maxRequests = 64 + } + } + + // Keep more HTTP/2 connections warm so scrolling doesn't repeatedly re-TLS + // to the same media host. + private val connectionPool = ConnectionPool(32, 5, TimeUnit.MINUTES) + private val rootClient = OkHttpClient .Builder() + .dispatcher(dispatcher) + .connectionPool(connectionPool) .followRedirects(true) .followSslRedirects(true) .addInterceptor(DefaultContentTypeInterceptor(userAgent)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactoryForRelays.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactoryForRelays.kt index 556c91bf0..e4b81b31f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactoryForRelays.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactoryForRelays.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.service.okhttp -import android.os.Build import com.vitorpamplona.quartz.utils.Log import okhttp3.Dispatcher import okhttp3.OkHttpClient @@ -38,22 +37,6 @@ class OkHttpClientFactoryForRelays( const val DEFAULT_TIMEOUT_ON_WIFI_SECS: Int = 10 const val DEFAULT_TIMEOUT_ON_MOBILE_SECS: Int = 30 const val WEBSOCKET_PING_INTERVAL_SECS: Long = 120 - - private fun isEmulator(): Boolean = - Build.FINGERPRINT.startsWith("generic") || - Build.FINGERPRINT.lowercase().contains("emulator") || - Build.MODEL.contains("google_sdk") || - Build.MODEL.lowercase().contains("droid4x") || - Build.MODEL.contains("Emulator") || - Build.MODEL.contains("Android SDK built for x86") || - Build.MANUFACTURER.contains("Genymotion") || - (Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic")) || - "google_sdk" == Build.PRODUCT || - Build.HARDWARE.contains("goldfish") || - Build.HARDWARE.contains("ranchu") || - Build.HARDWARE.contains("vbox86") || - Build.HARDWARE.contains("nox") || - Build.HARDWARE.contains("cuttlefish") } val myDispatcher = From 79cb480ea4e87a0a75c4d0fd243d2fef40a51e74 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 20:46:53 +0000 Subject: [PATCH 11/48] perf(media-http): log phase timings and dispatcher queuing Adds MediaCallEventListener on the images/videos OkHttpClient. For each call it records DNS, TCP, TLS, TTFB, total time, connection reuse, and the dispatcher queue depth at call start. Release builds log only when a call is slow (>= 1.5s), was queued by the dispatcher (saturation of maxRequests / maxRequestsPerHost), or failed. Debug builds log every call. Purpose: give us signal on whether the new 16/host + 128/total limits are still the ceiling, and whether remaining latency is DNS, TLS, or server-side. https://claude.ai/code/session_01PUbqGyUc6oq6V1MdmLi8sw --- .../service/okhttp/MediaCallEventListener.kt | 174 ++++++++++++++++++ .../service/okhttp/OkHttpClientFactory.kt | 1 + 2 files changed, 175 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/MediaCallEventListener.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/MediaCallEventListener.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/MediaCallEventListener.kt new file mode 100644 index 000000000..18875d661 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/MediaCallEventListener.kt @@ -0,0 +1,174 @@ +/* + * 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.service.okhttp + +import com.vitorpamplona.amethyst.isDebug +import com.vitorpamplona.quartz.utils.Log +import okhttp3.Call +import okhttp3.ConnectionPool +import okhttp3.Dispatcher +import okhttp3.EventListener +import okhttp3.Handshake +import okhttp3.Protocol +import java.io.IOException +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.Proxy + +/** + * Records phase timings per call so we can see when media loads are slow + * because of DNS, TCP, TLS, first-byte, or because the dispatcher queued + * the call (i.e. we hit maxRequestsPerHost / maxRequests). + * + * Release builds log only slow calls, queued calls, and errors. Debug + * builds log every completed call. + */ +class MediaCallEventListener( + private val dispatcher: Dispatcher, + private val connectionPool: ConnectionPool, +) : EventListener() { + private var callStartNanos = 0L + private var dnsStartNanos = 0L + private var dnsElapsedMs = -1L + private var connectStartNanos = 0L + private var connectElapsedMs = -1L + private var secureStartNanos = 0L + private var secureElapsedMs = -1L + private var responseHeadersNanos = 0L + + // stays true unless connectStart fires (a new connection was needed) + private var connectionReused = true + private var queuedAtStart = 0 + + override fun callStart(call: Call) { + callStartNanos = System.nanoTime() + queuedAtStart = dispatcher.queuedCallsCount() + } + + override fun dnsStart( + call: Call, + domainName: String, + ) { + dnsStartNanos = System.nanoTime() + } + + override fun dnsEnd( + call: Call, + domainName: String, + inetAddressList: List, + ) { + dnsElapsedMs = (System.nanoTime() - dnsStartNanos) / 1_000_000 + } + + override fun connectStart( + call: Call, + inetSocketAddress: InetSocketAddress, + proxy: Proxy, + ) { + connectStartNanos = System.nanoTime() + connectionReused = false + } + + override fun connectEnd( + call: Call, + inetSocketAddress: InetSocketAddress, + proxy: Proxy, + protocol: Protocol?, + ) { + connectElapsedMs = (System.nanoTime() - connectStartNanos) / 1_000_000 + } + + override fun secureConnectStart(call: Call) { + secureStartNanos = System.nanoTime() + } + + override fun secureConnectEnd( + call: Call, + handshake: Handshake?, + ) { + secureElapsedMs = (System.nanoTime() - secureStartNanos) / 1_000_000 + } + + override fun responseHeadersStart(call: Call) { + responseHeadersNanos = System.nanoTime() + } + + override fun callEnd(call: Call) = finish(call, null) + + override fun callFailed( + call: Call, + ioe: IOException, + ) = finish(call, ioe) + + private fun finish( + call: Call, + error: IOException?, + ) { + val totalMs = (System.nanoTime() - callStartNanos) / 1_000_000 + val isSlow = totalMs >= SLOW_CALL_THRESHOLD_MS + val wasQueued = queuedAtStart > 0 + + if (error == null && !isSlow && !wasQueued && !isDebug) return + + val ttfbMs = if (responseHeadersNanos > 0) (responseHeadersNanos - callStartNanos) / 1_000_000 else -1L + val host = call.request().url.host + val reuseTag = if (connectionReused) "reused" else "new" + + val msg = + buildString { + append(host) + append(" total=").append(totalMs).append("ms") + append(" ttfb=").append(ttfbMs).append("ms") + append(" conn=").append(reuseTag) + if (!connectionReused) { + if (dnsElapsedMs >= 0) append(" dns=").append(dnsElapsedMs).append("ms") + if (connectElapsedMs >= 0) append(" tcp=").append(connectElapsedMs).append("ms") + if (secureElapsedMs >= 0) append(" tls=").append(secureElapsedMs).append("ms") + } + if (wasQueued) { + append(" QUEUED(depth=").append(queuedAtStart) + append(" running=").append(dispatcher.runningCallsCount()) + append(" pool=").append(connectionPool.connectionCount()) + append('/').append(connectionPool.idleConnectionCount()) + append(')') + } + if (error != null) append(" error=").append(error.javaClass.simpleName).append(':').append(error.message) + } + + when { + error != null -> Log.w(TAG, msg) + isSlow || wasQueued -> Log.i(TAG, msg) + else -> Log.d(TAG, msg) + } + } + + companion object { + const val TAG = "MediaHttp" + const val SLOW_CALL_THRESHOLD_MS = 1500L + } +} + +class MediaCallEventListenerFactory( + private val dispatcher: Dispatcher, + private val connectionPool: ConnectionPool, +) : EventListener.Factory { + override fun create(call: Call): EventListener = MediaCallEventListener(dispatcher, connectionPool) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactory.kt index e409c6c5e..61f3255f6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactory.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactory.kt @@ -63,6 +63,7 @@ class OkHttpClientFactory( .Builder() .dispatcher(dispatcher) .connectionPool(connectionPool) + .eventListenerFactory(MediaCallEventListenerFactory(dispatcher, connectionPool)) .followRedirects(true) .followSslRedirects(true) .addInterceptor(DefaultContentTypeInterceptor(userAgent)) From 6dc723ae2c274269a94a7dd43e3fcccc1582209f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 21:07:16 +0000 Subject: [PATCH 12/48] feat: auto-unpin deleted posts from NIP-51 pin list When a kind-5 deletion event arrives for a note that is currently pinned, rewrite the user's PinListEvent to drop the deleted entries and broadcast the new list. Mirrors the existing deletedNotes() pattern used by peopleLists, followLists, and labeledBookmarkLists in Account. Previously the Pinned Notes screen silently hid deleted posts via the FeedContentState deletion filter, leaving orphan entries in the pin list that the user had no way to clean up. --- .../vitorpamplona/amethyst/model/Account.kt | 8 ++++++++ .../amethyst/model/nip51Lists/PinListState.kt | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 025bf0ffc..441a1be26 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -2293,6 +2293,13 @@ class Account( } } + suspend fun removeDeletedPins(deletedNotes: Set) { + if (!isWriteable()) return + + val event = pinState.removeDeletedPins(deletedNotes) ?: return + sendMyPublicAndPrivateOutbox(event) + } + suspend fun createAddPinEvent(note: Note): Pair>? { if (!isWriteable() || note.isDraft()) return null @@ -2674,6 +2681,7 @@ class Account( peopleLists.deletedNotes(deletedNotes) followLists.deletedNotes(deletedNotes) labeledBookmarkLists.deletedNotes(deletedNotes) + removeDeletedPins(deletedNotes) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/PinListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/PinListState.kt index fbe711408..f19d84630 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/PinListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/PinListState.kt @@ -127,4 +127,22 @@ class PinListState( signer = signer, ) } + + suspend fun removeDeletedPins(deletedNotes: Set): PinListEvent? { + val currentList = getPinList() ?: return null + val deletedIds = deletedNotes.mapTo(HashSet()) { it.idHex } + val pinsToRemove = currentList.pinnedEvents().filter { it.eventId in deletedIds } + if (pinsToRemove.isEmpty()) return null + + var working: PinListEvent = currentList + for (pin in pinsToRemove) { + working = + PinListEvent.remove( + earlierVersion = working, + pin = pin, + signer = signer, + ) + } + return working + } } From 843da0b3839a5ab1b468e31abe820aba1cf0771f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 21:27:13 +0000 Subject: [PATCH 13/48] fix(home): restrict favourite DVM list to content-discovery DVMs Only NIP-90 content discovery (kind 5300) DVMs can produce a feed. Other DVM types (image generation, translation, search, etc.) would silently hang the home banner waiting for a 6300 reply that will never come. Defence in depth: - FavoriteDvmToggle hides itself when the AppDefinitionEvent doesn't advertise kind 5300, so users can't favourite the wrong type. - TopNavFilterState.mergeInterests filters out any list entry whose AppDefinitionEvent doesn't include kind 5300, so a stale or cross-client entry won't surface as a Home chip. --- .../amethyst/ui/screen/TopNavFilterState.kt | 11 ++++++++++- .../ui/screen/loggedIn/dvms/FavoriteDvmToggle.kt | 12 ++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt index 811ff1508..a4d9cb8b6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt @@ -35,6 +35,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent +import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.NIP90ContentDiscoveryRequestEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -175,8 +176,16 @@ class TopNavFilterState( ) } + // Only DVMs that advertise NIP-90 content discovery (kind 5300) can produce a + // home feed. Hide entries whose AppDefinitionEvent isn't loaded yet OR doesn't + // include kind 5300 so the chip doesn't appear for image-generation, translation, + // search, or other DVM kinds that would never reply. val favoriteDvms = - favoriteDvmList.map { dvmNote -> + favoriteDvmList.mapNotNull { dvmNote -> + val supports5300 = + (dvmNote.event as? AppDefinitionEvent) + ?.includeKind(NIP90ContentDiscoveryRequestEvent.KIND) == true + if (!supports5300) return@mapNotNull null FeedDefinition( TopFilter.FavoriteDvm(dvmNote.address), FavoriteDvmName(dvmNote), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/FavoriteDvmToggle.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/FavoriteDvmToggle.kt index f79acafcd..98cd9f062 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/FavoriteDvmToggle.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/FavoriteDvmToggle.kt @@ -32,10 +32,13 @@ import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteAndMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark +import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent +import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.NIP90ContentDiscoveryRequestEvent @Composable fun FavoriteDvmToggle( @@ -43,6 +46,15 @@ fun FavoriteDvmToggle( accountViewModel: AccountViewModel, modifier: Modifier = Modifier, ) { + // Only NIP-90 content-discovery DVMs (kind 5300) produce a feed; hide the toggle + // for any other DVM type so users don't favourite something that would never reply. + val supportsContentDiscovery by + observeNoteAndMap(appDefinitionNote, accountViewModel) { note -> + (note.event as? AppDefinitionEvent)?.includeKind(NIP90ContentDiscoveryRequestEvent.KIND) == true + } + + if (!supportsContentDiscovery) return + val favorites by accountViewModel.account.favoriteDvmList.flow .collectAsStateWithLifecycle() From ee94dba570ae897bedbd40fa34507db1b77e4cec Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 21:57:56 +0000 Subject: [PATCH 14/48] fix(home): route DVM listen subscription to DVM relays + wire pull-to-refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues from the first pass: 1. Response routing. The kind-5300 request goes to the DVM's own inbox/used relays, but `filterHomePostsByDvmIds` was subscribing for kind 6300/7000 on the user's outbox/proxy relays. Most DVMs only publish their replies on their own relays, so the listen subscription would silently miss every response. Fix: `Account.requestDVMContentDiscovery` now exposes the relay set the request was sent to, the orchestrator stores it on the snapshot as `responseRelays`, and `FavoriteDvmTopNavPerRelayFilterSet` carries two distinct relay sets — `contentFetches` (user's outbox, for the actual notes) and `listenRelays` (DVM's relays, for the 6300/7000 reply subscription). `filterHomePostsByDvmIds` issues each on the right relay. 2. Pull-to-refresh. Swiping down on Home only re-rendered the cached feed. When a `TopFilter.FavoriteDvm` is active it now also calls `orchestrator.refresh(addr)` so a fresh kind-5300 request is published to the DVM. --- .../vitorpamplona/amethyst/model/Account.kt | 4 +- .../model/dvms/FavoriteDvmOrchestrator.kt | 8 ++- .../favoriteDvm/FavoriteDvmFeedFlow.kt | 7 +- .../favoriteDvm/FavoriteDvmTopNavFilter.kt | 20 +++--- .../FavoriteDvmTopNavPerRelayFilter.kt | 2 - .../FavoriteDvmTopNavPerRelayFilterSet.kt | 13 +++- .../ui/screen/loggedIn/AccountViewModel.kt | 4 +- .../ui/screen/loggedIn/home/HomeScreen.kt | 33 ++++++++- .../nip90Dvms/FilterHomePostsByDvmIds.kt | 72 ++++++++++++------- 9 files changed, 114 insertions(+), 49 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index ec3df21db..c6562a000 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -2357,7 +2357,7 @@ class Account( suspend fun requestDVMContentDiscovery( dvmPublicKey: User, - onReady: (event: NIP90ContentDiscoveryRequestEvent) -> Unit, + onReady: (event: NIP90ContentDiscoveryRequestEvent, relays: Set) -> Unit, ) { val relays = nip65RelayList.inboxFlow.value.toSet() val request = signer.sign(NIP90ContentDiscoveryRequestEvent.build(dvmPublicKey.pubkeyHex, signer.pubKey, relays)) @@ -2367,7 +2367,7 @@ class Account( ?: (dvmPublicKey.allUsedRelays() + cache.relayHints.hintsForKey(dvmPublicKey.pubkeyHex)) cache.justConsumeMyOwnEvent(request) - onReady(request) + onReady(request, relayList.toSet()) delay(100) client.publish(request, relayList) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/dvms/FavoriteDvmOrchestrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/dvms/FavoriteDvmOrchestrator.kt index f4058cccb..cd92afa0f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/dvms/FavoriteDvmOrchestrator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/dvms/FavoriteDvmOrchestrator.kt @@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent import com.vitorpamplona.quartz.utils.Log @@ -44,12 +45,16 @@ import kotlinx.coroutines.sync.withLock * Immutable snapshot of a favourite DVM's current request/response state. * * - [requestId] is the id of the most recently published kind-5300 request. + * - [responseRelays] is the relay set the kind-5300 was sent to — the same set on + * which the DVM will publish its 6300/7000 responses, so the home subscription + * manager must listen there (not on the user's own outbox). * - [ids] and [addresses] are the note references returned by the latest kind-6300 response. * - [latestStatus] is the latest kind-7000 status event (processing, payment-required, error, …). * - [errorMessage] captures any client-side failure while publishing the request. */ data class FavoriteDvmSnapshot( val requestId: HexKey? = null, + val responseRelays: Set = emptySet(), val ids: Set = emptySet(), val addresses: Set = emptySet(), val latestStatus: NIP90StatusEvent? = null, @@ -114,10 +119,11 @@ class FavoriteDvmOrchestrator( val job = scope.launch(Dispatchers.IO) { try { - account.requestDVMContentDiscovery(user) { request -> + account.requestDVMContentDiscovery(user) { request, relays -> seed.update { it.copy( requestId = request.id, + responseRelays = relays, ids = emptySet(), addresses = emptySet(), latestStatus = null, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmFeedFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmFeedFlow.kt index f919d62a0..04d35e0d6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmFeedFlow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmFeedFlow.kt @@ -43,12 +43,13 @@ class FavoriteDvmFeedFlow( private fun buildFilter( snapshot: com.vitorpamplona.amethyst.model.dvms.FavoriteDvmSnapshot, - relays: Set, + contentRelays: Set, ) = FavoriteDvmTopNavFilter( dvmAddress = dvmAddress, acceptedIds = snapshot.ids, acceptedAddresses = snapshot.addresses, - relayList = relays, + contentRelays = contentRelays, + listenRelays = snapshot.responseRelays, requestId = snapshot.requestId, ) @@ -60,7 +61,7 @@ class FavoriteDvmFeedFlow( override fun startValue(): FavoriteDvmTopNavFilter = buildFilter( snapshot = orchestrator.observe(dvmAddress).value, - relays = resolveRelays(outboxRelays.value, proxyRelays.value), + contentRelays = resolveRelays(outboxRelays.value, proxyRelays.value), ) override suspend fun startValue(collector: FlowCollector) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilter.kt index 093fc390d..e521c6d5b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilter.kt @@ -43,7 +43,8 @@ class FavoriteDvmTopNavFilter( val dvmAddress: Address, val acceptedIds: Set, val acceptedAddresses: Set, - val relayList: Set, + val contentRelays: Set, + val listenRelays: Set, val requestId: HexKey?, ) : IFeedTopNavFilter { override fun matchAuthor(pubkey: HexKey): Boolean = true @@ -56,13 +57,14 @@ class FavoriteDvmTopNavFilter( override fun startValue(cache: LocalCache): FavoriteDvmTopNavPerRelayFilterSet = FavoriteDvmTopNavPerRelayFilterSet( - relayList.associateWith { - FavoriteDvmTopNavPerRelayFilter( - dvmPubkey = dvmAddress.pubKeyHex, - requestId = requestId, - ids = acceptedIds, - addresses = acceptedAddresses, - ) - }, + contentFetches = + contentRelays.associateWith { + FavoriteDvmTopNavPerRelayFilter( + ids = acceptedIds, + addresses = acceptedAddresses, + ) + }, + listenRelays = listenRelays, + requestId = requestId, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilter.kt index 5a8f3d7f6..4dcbeddaa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilter.kt @@ -26,8 +26,6 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey @Immutable class FavoriteDvmTopNavPerRelayFilter( - val dvmPubkey: HexKey, - val requestId: HexKey?, val ids: Set, val addresses: Set, ) : IFeedTopNavPerRelayFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilterSet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilterSet.kt index 4ec47da11..321f5ca2c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilterSet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilterSet.kt @@ -21,8 +21,19 @@ package com.vitorpamplona.amethyst.model.topNavFeeds.favoriteDvm import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +/** + * Two relay sets, two distinct subscriptions: + * + * - [contentFetches] — for each user-configured content relay, the ids/addresses + * we want to pull (the actual notes the DVM curated). + * - [listenRelays] — the DVM's own publish relays (where it will deliver future + * kind 6300 / 7000 events for this request). + */ class FavoriteDvmTopNavPerRelayFilterSet( - val set: Map, + val contentFetches: Map, + val listenRelays: Set, + val requestId: HexKey?, ) : IFeedTopNavPerRelayFilterSet diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index c8ebc9643..97b01c336 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -1809,8 +1809,8 @@ class AccountViewModel( onReady: (event: Note) -> Unit, ) { launchSigner { - account.requestDVMContentDiscovery(dvmPublicKey) { - onReady(LocalCache.getOrCreateNote(it.id)) + account.requestDVMContentDiscovery(dvmPublicKey) { request, _ -> + onReady(LocalCache.getOrCreateNote(request.id)) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt index 38e38dae6..70a539a9e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt @@ -282,7 +282,20 @@ fun HomeFeeds( accountViewModel: AccountViewModel, nav: INav, ) { - RefresheableBox(feedState, enablePullRefresh) { + val activeFilter by accountViewModel.account.settings.defaultHomeFollowList + .collectAsStateWithLifecycle() + val activeDvm = activeFilter as? TopFilter.FavoriteDvm + + val onRefresh: () -> Unit = { + feedState.invalidateData() + if (activeDvm != null) { + // Swiping down on Home should also re-issue the kind-5300 request so the + // DVM produces a fresh feed, not just re-render whatever's cached. + accountViewModel.refreshFavoriteDvm(activeDvm.address) + } + } + + RefresheableHomeBox(onRefresh, enablePullRefresh) { SaveableFeedContentState(feedState, scrollStateKey) { listState -> RenderFeedContentState( feedContentState = feedState, @@ -291,12 +304,28 @@ fun HomeFeeds( nav = nav, routeForLastRead = routeForLastRead, onLoaded = { FeedLoaded(it, listState, routeForLastRead, liveSection, accountViewModel, nav) }, - onEmpty = { HomeFeedEmpty(feedState::invalidateData) }, + onEmpty = { HomeFeedEmpty(onRefresh) }, ) } } } +@Composable +private fun RefresheableHomeBox( + onRefresh: () -> Unit, + enablePullRefresh: Boolean, + content: @Composable androidx.compose.foundation.layout.BoxScope.() -> Unit, +) { + if (enablePullRefresh) { + RefresheableBox(onRefresh = onRefresh, content = content) + } else { + androidx.compose.foundation.layout.Box( + Modifier.fillMaxSize(), + content = content, + ) + } +} + @OptIn(ExperimentalFoundationApi::class) @Composable fun FeedLoaded( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90Dvms/FilterHomePostsByDvmIds.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90Dvms/FilterHomePostsByDvmIds.kt index 644cc1f54..856f6fd9c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90Dvms/FilterHomePostsByDvmIds.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90Dvms/FilterHomePostsByDvmIds.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip90Dvms import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteDvm.FavoriteDvmTopNavPerRelayFilter import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteDvm.FavoriteDvmTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -32,22 +33,40 @@ import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent /** * Builds relay REQ filters for a favourite-DVM home feed. * - * Three filters are issued per relay: - * - fetch notes whose ids are in the DVM's latest kind-6300 response - * - fetch addressable notes referenced by `a` tags in the DVM's response - * - subscribe to the DVM's future kind 6300 / 7000 events so the filter snapshot - * keeps up to date while this filter is active + * Two distinct subscription kinds with two distinct relay sets: + * + * - **Content fetch** — for each of the user's outbox/proxy relays, request the + * note IDs and addressable references the DVM curated. Notes typically live on + * the user's normal relays, so this is where we fetch them. + * + * - **Response listen** — for each relay the DVM advertised (where it received + * the kind-5300 request and will publish its 6300/7000 reply), subscribe to + * future kind 6300 / 7000 events tagged with the request id. The DVM almost + * never publishes responses on the user's outbox, so listening anywhere else + * would silently miss them. */ fun filterHomePostsByDvmIds( set: FavoriteDvmTopNavPerRelayFilterSet, @Suppress("UNUSED_PARAMETER") since: SincePerRelayMap?, @Suppress("UNUSED_PARAMETER") defaultSince: Long?, -): List = - set.set.flatMap { (relay, filter) -> - buildFiltersFor(relay, filter) +): List { + val out = mutableListOf() + + set.contentFetches.forEach { (relay, filter) -> + out += contentFetchFilters(relay, filter) } -private fun buildFiltersFor( + val requestId = set.requestId + if (requestId != null) { + set.listenRelays.forEach { relay -> + out += responseListenFilter(relay, requestId) + } + } + + return out +} + +private fun contentFetchFilters( relay: NormalizedRelayUrl, filter: FavoriteDvmTopNavPerRelayFilter, ): List { @@ -77,23 +96,22 @@ private fun buildFiltersFor( ) } - val requestId = filter.requestId - if (requestId != null) { - out += - RelayBasedFilter( - relay = relay, - filter = - Filter( - kinds = - listOf( - NIP90ContentDiscoveryResponseEvent.KIND, - NIP90StatusEvent.KIND, - ), - tags = mapOf("e" to listOf(requestId)), - limit = 10, - ), - ) - } - return out } + +private fun responseListenFilter( + relay: NormalizedRelayUrl, + requestId: HexKey, +) = RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = + listOf( + NIP90ContentDiscoveryResponseEvent.KIND, + NIP90StatusEvent.KIND, + ), + tags = mapOf("e" to listOf(requestId)), + limit = 10, + ), +) From 00ab199e3215aaffab361ee9820423cf878051bf Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 22:36:04 +0000 Subject: [PATCH 15/48] fix(feeds): clip + preload content warning on cropped grid cells - Pass contentScale through to ContentWarningOverBlurhash so grid cells (ContentScale.Crop) use fillMaxSize instead of forcing the image's native aspect ratio, keeping the blurhash and overlay within the cell. - Clip the overlay to its bounds to stop the icon/button from drawing over neighboring cells or other components. - Preload the image through Coil while the warning is visible so the image is cached by the time the user taps "Show anyway". --- .../ui/components/SensitivityWarning.kt | 30 +++++++++++++++---- .../ui/components/ZoomableContentView.kt | 4 +++ 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt index 0375cfa1a..6a86c1fe2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt @@ -40,20 +40,25 @@ 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.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.imageLoader +import coil3.request.ImageRequest import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled @@ -112,6 +117,8 @@ fun SensitivityWarningOverBlurhash( blurhash: String?, ratio: Float?, description: String?, + contentScale: ContentScale, + preloadUrl: String?, accountViewModel: AccountViewModel, content: @Composable () -> Unit, ) { @@ -124,10 +131,19 @@ fun SensitivityWarningOverBlurhash( var showContentWarningNote by remember(accountState) { mutableStateOf(accountState.value != true) } + if (showContentWarningNote && preloadUrl != null) { + val context = LocalContext.current + LaunchedEffect(preloadUrl) { + runCatching { + context.imageLoader.enqueue(ImageRequest.Builder(context).data(preloadUrl).build()) + } + } + } + CrossfadeIfEnabled(targetState = showContentWarningNote, accountViewModel = accountViewModel) { if (it) { if (blurhash != null) { - ContentWarningOverBlurhash(reason, blurhash, ratio, description) { showContentWarningNote = false } + ContentWarningOverBlurhash(reason, blurhash, ratio, description, contentScale) { showContentWarningNote = false } } else { ContentWarningNote(reason) { showContentWarningNote = false } } @@ -189,6 +205,7 @@ fun ContentWarningOverBlurhashPreview() { blurhash = "LEHV6nWB2yk8pyo0adR*.7kCMdnj", ratio = 16f / 9f, description = null, + contentScale = ContentScale.FillWidth, onDismiss = {}, ) } @@ -200,16 +217,17 @@ fun ContentWarningOverBlurhash( blurhash: String, ratio: Float?, description: String?, + contentScale: ContentScale, onDismiss: () -> Unit, ) { val sizingModifier = - if (ratio != null) { - Modifier.fillMaxWidth().aspectRatio(ratio) - } else { - Modifier.fillMaxWidth() + when { + contentScale == ContentScale.Crop -> Modifier.fillMaxSize() + ratio != null -> Modifier.fillMaxWidth().aspectRatio(ratio) + else -> Modifier.fillMaxWidth() } - Box(modifier = sizingModifier) { + Box(modifier = sizingModifier.clipToBounds()) { DisplayBlurHash( blurhash = blurhash, description = description, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index 1de17f9ea..5602f9aa1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -157,6 +157,8 @@ fun ZoomableContentView( blurhash = content.blurhash, ratio = ratio, description = content.description, + contentScale = contentScale, + preloadUrl = content.url, accountViewModel = accountViewModel, ) { TwoSecondController(content) { controllerVisible -> @@ -179,6 +181,8 @@ fun ZoomableContentView( blurhash = content.blurhash, ratio = ratio, description = content.description, + contentScale = contentScale, + preloadUrl = null, accountViewModel = accountViewModel, ) { Box( From 8555074c3f4822347bd2bb4e19e3c7d6e4c1f51a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 22:48:55 +0000 Subject: [PATCH 16/48] feat(dvm-favorites): settings page + revert DVM card regression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove FavoriteDvmToggle from DVMCard's title row; the extra IconButton was expanding the row and pushing the title down so the whole Discover→DVMs card layout looked broken. The toggle stays on the DvmContentDiscoveryScreen top bar, which was the user's expected place to follow/unfollow. - Add a "Favourite DVMs" page under Settings (Route.EditFavoriteDvms, modelled after the Blossom servers settings) listing each favourited content-discovery DVM with its avatar/name/description and a delete button. Tapping a row opens the DVM detail screen. Empty state points users to Discover for adding more. --- .../amethyst/ui/navigation/AppNavigation.kt | 2 + .../amethyst/ui/navigation/routes/Routes.kt | 2 + .../loggedIn/discover/nip90DVMs/DVMCard.kt | 8 - .../dvms/favorites/FavoriteDvmListScreen.kt | 226 ++++++++++++++++++ .../loggedIn/settings/AllSettingsScreen.kt | 8 + amethyst/src/main/res/values/strings.xml | 3 + 6 files changed, 241 insertions(+), 8 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/favorites/FavoriteDvmListScreen.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 510234327..972befbcd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -95,6 +95,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.Long import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.NewProductScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.drafts.DraftListScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.DvmContentDiscoveryScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.favorites.FavoriteDvmListScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.FollowPackFeedScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.GeoHashPostScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.GeoHashScreen @@ -277,6 +278,7 @@ fun BuildNavigation( composableFromEnd { RequestToVanishScreen(accountViewModel, nav) } composableFromEnd { VanishEventsScreen(accountViewModel, nav) } composableFromEndArgs { AllMediaServersScreen(accountViewModel, nav) } + composableFromEnd { FavoriteDvmListScreen(accountViewModel, nav) } composableFromEnd { PaymentTargetsScreen(accountViewModel, nav) } composableFromEndArgs { UpdateReactionTypeScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index fdba69034..057a24397 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -171,6 +171,8 @@ sealed class Route { @Serializable object EditMediaServers : Route() + @Serializable object EditFavoriteDvms : Route() + @Serializable object EditPaymentTargets : Route() @Serializable object UpdateReactionType : Route() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DVMCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DVMCard.kt index 4ac46b109..6fa904415 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DVMCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DVMCard.kt @@ -42,7 +42,6 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.components.MyAsyncImage @@ -52,7 +51,6 @@ import com.vitorpamplona.amethyst.ui.note.LikeReaction import com.vitorpamplona.amethyst.ui.note.ZapReaction import com.vitorpamplona.amethyst.ui.note.elements.BannerImage import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.FavoriteDvmToggle import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.observeAppDefinition import com.vitorpamplona.amethyst.ui.theme.HalfTopPadding import com.vitorpamplona.amethyst.ui.theme.RowColSpacing5dp @@ -125,12 +123,6 @@ fun RenderContentDVMThumb( verticalAlignment = CenterVertically, horizontalArrangement = RowColSpacing5dp, ) { - if (baseNote is AddressableNote) { - FavoriteDvmToggle( - appDefinitionNote = baseNote, - accountViewModel = accountViewModel, - ) - } LikeReaction( baseNote = baseNote, grayTint = MaterialTheme.colorScheme.onSurface, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/favorites/FavoriteDvmListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/favorites/FavoriteDvmListScreen.kt new file mode 100644 index 000000000..0f22ae4ec --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/favorites/FavoriteDvmListScreen.kt @@ -0,0 +1,226 @@ +/* + * 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.ui.screen.loggedIn.dvms.favorites + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +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.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Alignment.Companion.BottomStart +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.ui.components.MyAsyncImage +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.note.elements.BannerImage +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.observeAppDefinition +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.FeedPadding +import com.vitorpamplona.amethyst.ui.theme.SimpleImage35Modifier +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.amethyst.ui.theme.grayText + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FavoriteDvmListScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + Scaffold( + topBar = { + TopBarWithBackButton( + caption = stringRes(R.string.favorite_dvms_title), + popBack = nav::popBack, + ) + }, + ) { padding -> + Column( + modifier = + Modifier + .fillMaxSize() + .padding( + start = 16.dp, + top = padding.calculateTopPadding(), + end = 16.dp, + bottom = padding.calculateBottomPadding(), + ).consumeWindowInsets(padding), + ) { + Text( + text = stringRes(R.string.favorite_dvms_explainer), + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.grayText, + ) + + FavoriteDvmList(accountViewModel, nav) + } + } +} + +@Composable +private fun FavoriteDvmList( + accountViewModel: AccountViewModel, + nav: INav, +) { + val favorites by accountViewModel.account.favoriteDvmList.flowNotes + .collectAsStateWithLifecycle() + + if (favorites.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize().padding(24.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringRes(R.string.favorite_dvms_empty), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.grayText, + ) + } + return + } + + LazyColumn( + verticalArrangement = Arrangement.spacedBy(4.dp), + contentPadding = FeedPadding, + ) { + items( + items = favorites, + key = { it.address.toValue() }, + ) { dvmNote -> + FavoriteDvmRow( + dvmNote = dvmNote, + accountViewModel = accountViewModel, + onOpen = { nav.nav(Route.ContentDiscovery(dvmNote.idHex)) }, + onRemove = { accountViewModel.unfollowFavoriteDvm(dvmNote.address) }, + ) + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun FavoriteDvmRow( + dvmNote: AddressableNote, + accountViewModel: AccountViewModel, + onOpen: () -> Unit, + onRemove: () -> Unit, +) { + val card = observeAppDefinition(dvmNote, accountViewModel) + + Row( + modifier = + Modifier + .fillMaxWidth() + .combinedClickable(onClick = onOpen) + .padding(vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + card.cover?.let { cover -> + Box(contentAlignment = BottomStart) { + MyAsyncImage( + imageUrl = cover, + contentDescription = card.name, + contentScale = ContentScale.Crop, + mainImageModifier = Modifier, + loadedImageModifier = SimpleImage35Modifier, + accountViewModel = accountViewModel, + onLoadingBackground = { + dvmNote.author?.let { author -> + BannerImage(author, SimpleImage35Modifier, accountViewModel) + } + }, + onError = { + dvmNote.author?.let { author -> + BannerImage(author, SimpleImage35Modifier, accountViewModel) + } + }, + ) + } + } ?: run { + dvmNote.author?.let { author -> + BannerImage(author, SimpleImage35Modifier, accountViewModel) + } + } + + Spacer(modifier = DoubleHorzSpacer) + + Column( + modifier = Modifier.weight(1f), + ) { + Text( + text = card.name.ifBlank { dvmNote.dTag() }, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyLarge, + ) + card.description?.takeIf { it.isNotBlank() }?.let { + Spacer(modifier = StdVertSpacer) + Text( + text = it, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.grayText, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + + IconButton(onClick = onRemove) { + Icon( + imageVector = Icons.Rounded.Delete, + contentDescription = stringRes(R.string.remove_dvm_from_favorites), + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt index 7d0796c8e..69207a03b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt @@ -29,6 +29,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.AutoAwesome import androidx.compose.material.icons.outlined.Bolt import androidx.compose.material.icons.outlined.CloudUpload import androidx.compose.material.icons.outlined.DeleteForever @@ -122,6 +123,13 @@ fun AllSettingsScreen( onClick = { nav.nav(Route.EditMediaServers) }, ) HorizontalDivider() + SettingsNavigationRow( + title = R.string.favorite_dvms_title, + icon = Icons.Outlined.AutoAwesome, + tint = tint, + onClick = { nav.nav(Route.EditFavoriteDvms) }, + ) + HorizontalDivider() SettingsNavigationRow( title = R.string.reactions, icon = Icons.Outlined.FavoriteBorder, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 9b74fd43b..aa11b8aa3 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1733,6 +1733,9 @@ Add DVM to favorites Remove from favorites + Favourite DVMs + Content-discovery DVMs you starred here appear as filter chips on the Home feed. Open Discover to add more. + No favourite DVMs yet. Open Discover, tap a content-discovery DVM, and star it to add it here. Asking %1$s for a feed… Processing your feed… This DVM requires payment From 85fcf50df923488cfb1fb907c2077e915e02e0ab Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 22:58:54 +0000 Subject: [PATCH 17/48] feat: warn and offer cleanup for deleted items in pin and bookmark lists Adds a dismissible top-of-screen banner to the Pinned Notes, Bookmarks, Old Bookmarks, and Bookmark Set (detail) screens that appears when one or more listed items have been deleted by their author (kind-5 deletion, pubkey-matched via DeletionIndex). Tapping "Remove from list" rewrites the underlying list event (NIP-51 kind 10001/10003/30001/30003) with the deleted entries stripped out and broadcasts it once. The scan is scoped to each screen's loaded list (typically <20 items) and runs only while composed, so it avoids the performance hit of hooking Account.deletedEventBundles, which fires for every kind-5 deletion in the firehose. Public and private (NIP-44 encrypted) bookmarks are both cleaned in a single resign() call per list. --- .../vitorpamplona/amethyst/model/Account.kt | 20 ++++- .../amethyst/model/nip51Lists/PinListState.kt | 25 +++--- .../LabeledBookmarkListsState.kt | 54 +++++++++++++ .../ui/components/DeletedItemsBanner.kt | 76 +++++++++++++++++++ .../ui/screen/loggedIn/AccountViewModel.kt | 18 +++++ .../default/BookmarkListScreen.kt | 43 ++++++++++- .../display/BookmarkGroupScreen.kt | 71 +++++++++++++++++ .../display/BookmarkGroupViewModel.kt | 13 ++++ .../old/OldBookmarkListScreen.kt | 43 ++++++++++- .../loggedIn/pinnednotes/PinnedNotesScreen.kt | 29 ++++++- amethyst/src/main/res/values/strings.xml | 4 + .../model/nip51Lists/BookmarkListState.kt | 44 +++++++++++ .../model/nip51Lists/OldBookmarkListState.kt | 29 +++++++ 13 files changed, 452 insertions(+), 17 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/DeletedItemsBanner.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 441a1be26..21b19ec83 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -134,6 +134,7 @@ import com.vitorpamplona.quartz.experimental.profileGallery.mimeType import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageUtils import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore +import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -2195,6 +2196,24 @@ class Account( } } + suspend fun removeDeletedBookmarks( + deletedEventIds: Set, + deletedAddresses: Set
, + ) { + if (!isWriteable()) return + val event = bookmarkState.removeDeletedBookmarks(deletedEventIds, deletedAddresses) ?: return + sendMyPublicAndPrivateOutbox(event) + } + + suspend fun removeDeletedOldBookmarks( + deletedEventIds: Set, + deletedAddresses: Set
, + ) { + if (!isWriteable()) return + val event = oldBookmarkState.removeDeletedBookmarks(deletedEventIds, deletedAddresses) ?: return + sendMyPublicAndPrivateOutbox(event) + } + /** * Creates a bookmark event without sending it. * Returns the event and target relays for tracked broadcasting. @@ -2681,7 +2700,6 @@ class Account( peopleLists.deletedNotes(deletedNotes) followLists.deletedNotes(deletedNotes) labeledBookmarkLists.deletedNotes(deletedNotes) - removeDeletedPins(deletedNotes) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/PinListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/PinListState.kt index f19d84630..4521b3deb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/PinListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/PinListState.kt @@ -130,19 +130,18 @@ class PinListState( suspend fun removeDeletedPins(deletedNotes: Set): PinListEvent? { val currentList = getPinList() ?: return null - val deletedIds = deletedNotes.mapTo(HashSet()) { it.idHex } - val pinsToRemove = currentList.pinnedEvents().filter { it.eventId in deletedIds } - if (pinsToRemove.isEmpty()) return null + if (deletedNotes.isEmpty()) return null - var working: PinListEvent = currentList - for (pin in pinsToRemove) { - working = - PinListEvent.remove( - earlierVersion = working, - pin = pin, - signer = signer, - ) - } - return working + val deletedIds = deletedNotes.mapTo(HashSet()) { it.idHex } + val newTags = + currentList.tags + .filter { tag -> + val bookmark = EventBookmark.parse(tag) + bookmark == null || bookmark.eventId !in deletedIds + }.toTypedArray() + + if (newTags.size == currentList.tags.size) return null + + return PinListEvent.resign(tags = newTags, signer = signer) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/labeledBookmarkLists/LabeledBookmarkListsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/labeledBookmarkLists/LabeledBookmarkListsState.kt index 54c03aec6..6c1a5aa94 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/labeledBookmarkLists/LabeledBookmarkListsState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/labeledBookmarkLists/LabeledBookmarkListsState.kt @@ -29,10 +29,13 @@ import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.filter +import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.update import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.BookmarkIdTag +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.LabeledBookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.description import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.image @@ -298,4 +301,55 @@ class LabeledBookmarkListsState( ) account.sendMyPublicAndPrivateOutbox(updatedList) } + + suspend fun removeDeletedBookmarksFromList( + bookmarkListIdentifier: String, + deletedEventIds: Set, + deletedAddresses: Set
, + account: Account, + ) { + if (deletedEventIds.isEmpty() && deletedAddresses.isEmpty()) return + + val currentList = getLabeledBookmarkListNote(bookmarkListIdentifier)?.event as? LabeledBookmarkListEvent ?: return + + val newPublicTags = + currentList.tags + .filter { tag -> + when (val bookmark = BookmarkIdTag.parse(tag)) { + is EventBookmark -> bookmark.eventId !in deletedEventIds + is AddressBookmark -> bookmark.address !in deletedAddresses + null -> true + } + }.toTypedArray() + + val oldPrivateTags = currentList.privateTags(account.signer) + + val updatedList = + if (oldPrivateTags == null) { + if (newPublicTags.size == currentList.tags.size) return + LabeledBookmarkListEvent.resign( + content = currentList.content, + tags = newPublicTags, + signer = account.signer, + ) + } else { + val newPrivateTags = + oldPrivateTags + .filter { tag -> + when (val bookmark = BookmarkIdTag.parse(tag)) { + is EventBookmark -> bookmark.eventId !in deletedEventIds + is AddressBookmark -> bookmark.address !in deletedAddresses + null -> true + } + }.toTypedArray() + if (newPublicTags.size == currentList.tags.size && newPrivateTags.size == oldPrivateTags.size) return + LabeledBookmarkListEvent.resign( + tags = newPublicTags, + privateTags = newPrivateTags, + signer = account.signer, + ) + } + + account.sendMyPublicAndPrivateOutbox(updatedList) + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/DeletedItemsBanner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/DeletedItemsBanner.kt new file mode 100644 index 000000000..13c3e23dc --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/DeletedItemsBanner.kt @@ -0,0 +1,76 @@ +/* + * 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.ui.components + +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.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.BigPadding +import com.vitorpamplona.amethyst.ui.theme.StdPadding +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.amethyst.ui.theme.imageModifier + +@Composable +fun DeletedItemsBanner( + count: Int, + onRemove: () -> Unit, + onDismiss: () -> Unit, +) { + if (count <= 0) return + + Column(modifier = StdPadding) { + Card( + modifier = MaterialTheme.colorScheme.imageModifier, + ) { + Column(modifier = BigPadding) { + Text( + text = stringRes(R.string.deleted_items_banner_title, count), + style = MaterialTheme.typography.bodyMedium, + ) + + Spacer(modifier = StdVertSpacer) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + TextButton(onClick = onDismiss) { + Text(text = stringRes(R.string.deleted_items_banner_dismiss)) + } + Button(onClick = onRemove) { + Text(text = stringRes(R.string.deleted_items_banner_remove)) + } + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 56d164093..d068d43b8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -919,6 +919,10 @@ class AccountViewModel( } } + fun removeDeletedPins(deletedNotes: Set) { + launchSigner { account.removeDeletedPins(deletedNotes) } + } + fun addPrivateBookmark(note: Note) { if (settings.isCompleteUIMode()) { launchSigner { @@ -988,6 +992,20 @@ class AccountViewModel( } } + fun removeDeletedBookmarks( + deletedEventIds: Set, + deletedAddresses: Set
, + ) { + launchSigner { account.removeDeletedBookmarks(deletedEventIds, deletedAddresses) } + } + + fun removeDeletedOldBookmarks( + deletedEventIds: Set, + deletedAddresses: Set
, + ) { + launchSigner { account.removeDeletedOldBookmarks(deletedEventIds, deletedAddresses) } + } + fun broadcast(note: Note) = launchSigner { account.broadcast(note) } fun timestamp(note: Note) = launchSigner { account.otsState.timestamp(note) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/BookmarkListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/BookmarkListScreen.kt index 7ee7752c3..f32fd3684 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/BookmarkListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/BookmarkListScreen.kt @@ -34,14 +34,18 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect 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.Modifier import androidx.compose.ui.graphics.Color import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState +import com.vitorpamplona.amethyst.ui.components.DeletedItemsBanner import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton @@ -81,7 +85,7 @@ fun BookmarkListScreen( // Preload all bookmarked events so they don't load one-by-one when scrolling PreloadBookmarkEvents(bookmarkState, accountViewModel) - RenderBookmarkScreen(publicFeedViewModel, privateFeedViewModel, accountViewModel, nav) + RenderBookmarkScreen(publicFeedViewModel, privateFeedViewModel, bookmarkState, accountViewModel, nav) } @Composable @@ -89,12 +93,36 @@ fun BookmarkListScreen( private fun RenderBookmarkScreen( publicFeedViewModel: BookmarkPublicFeedViewModel, privateFeedViewModel: BookmarkPrivateFeedViewModel, + bookmarkState: com.vitorpamplona.amethyst.commons.model.nip51Lists.BookmarkListState.BookmarkList?, accountViewModel: AccountViewModel, nav: INav, ) { val pagerState = rememberPagerState { 2 } val coroutineScope = rememberCoroutineScope() + val cache = accountViewModel.account.cache + val deletedEventIds = remember(bookmarkState) { mutableSetOf() } + val deletedAddresses = remember(bookmarkState) { mutableSetOf() } + val deletedCount = + remember(bookmarkState) { + deletedEventIds.clear() + deletedAddresses.clear() + val all = bookmarkState?.public.orEmpty() + bookmarkState?.private.orEmpty() + all.forEach { note -> + val event = note.event + if (event != null && cache.hasBeenDeleted(event)) { + deletedEventIds.add(note.idHex) + if (note is AddressableNote) deletedAddresses.add(note.address) + } + } + deletedEventIds.size + deletedAddresses.size + } + + var bannerDismissed by remember { mutableStateOf(false) } + LaunchedEffect(deletedCount) { + if (deletedCount == 0) bannerDismissed = false + } + DisappearingScaffold( isInvertedLayout = false, topBar = { @@ -122,6 +150,19 @@ private fun RenderBookmarkScreen( accountViewModel = accountViewModel, ) { Column(Modifier.padding(it).fillMaxHeight()) { + if (!bannerDismissed) { + DeletedItemsBanner( + count = deletedCount, + onRemove = { + accountViewModel.removeDeletedBookmarks( + deletedEventIds.toSet(), + deletedAddresses.toSet(), + ) + bannerDismissed = true + }, + onDismiss = { bannerDismissed = true }, + ) + } HorizontalPager(state = pagerState) { page -> when (page) { 0 -> { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupScreen.kt index 18795d5e8..6ef2e1fd2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupScreen.kt @@ -44,10 +44,12 @@ import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults 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.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextOverflow @@ -56,7 +58,9 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.ui.components.ClickableBox +import com.vitorpamplona.amethyst.ui.components.DeletedItemsBanner import com.vitorpamplona.amethyst.ui.components.M3ActionDialog import com.vitorpamplona.amethyst.ui.components.M3ActionRow import com.vitorpamplona.amethyst.ui.components.M3ActionSection @@ -70,6 +74,7 @@ import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ButtonBorder import com.vitorpamplona.amethyst.ui.theme.StdPadding import com.vitorpamplona.amethyst.ui.theme.TabRowHeight +import com.vitorpamplona.quartz.nip01Core.core.Address import kotlinx.coroutines.launch @Composable @@ -158,6 +163,11 @@ fun BookmarkGroupScreenView( ).consumeWindowInsets(padding) .imePadding(), ) { + DeletedBookmarksBanner( + bookmarkGroupViewModel = bookmarkGroupViewModel, + bookmarkType = bookmarkType, + accountViewModel = accountViewModel, + ) when (bookmarkType) { BookmarkType.PostBookmark -> { RenderPostList( @@ -217,6 +227,67 @@ fun BookmarkGroupScreenView( } } +@Composable +private fun DeletedBookmarksBanner( + bookmarkGroupViewModel: BookmarkGroupViewModel, + bookmarkType: BookmarkType, + accountViewModel: AccountViewModel, +) { + val postsFlow = remember(bookmarkGroupViewModel) { bookmarkGroupViewModel.publicPosts() } + val privatePostsFlow = remember(bookmarkGroupViewModel) { bookmarkGroupViewModel.privatePosts() } + val articlesFlow = remember(bookmarkGroupViewModel) { bookmarkGroupViewModel.publicArticles() } + val privateArticlesFlow = remember(bookmarkGroupViewModel) { bookmarkGroupViewModel.privateArticles() } + + val publicPosts by postsFlow.collectAsStateWithLifecycle() + val privatePosts by privatePostsFlow.collectAsStateWithLifecycle() + val publicArticles by articlesFlow.collectAsStateWithLifecycle() + val privateArticles by privateArticlesFlow.collectAsStateWithLifecycle() + + val cache = accountViewModel.account.cache + + val deletedEventIds = remember(publicPosts, privatePosts, publicArticles, privateArticles, bookmarkType) { mutableSetOf() } + val deletedAddresses = remember(publicPosts, privatePosts, publicArticles, privateArticles, bookmarkType) { mutableSetOf
() } + val deletedCount = + remember(publicPosts, privatePosts, publicArticles, privateArticles, bookmarkType) { + deletedEventIds.clear() + deletedAddresses.clear() + val scope = + when (bookmarkType) { + BookmarkType.PostBookmark -> publicPosts + privatePosts + BookmarkType.ArticleBookmark -> publicArticles + privateArticles + } + scope.forEach { note -> + val event = note.event + if (event != null && cache.hasBeenDeleted(event)) { + deletedEventIds.add(note.idHex) + if (note is AddressableNote) deletedAddresses.add(note.address) + } + } + deletedEventIds.size + deletedAddresses.size + } + + var bannerDismissed by remember(bookmarkType) { mutableStateOf(false) } + LaunchedEffect(deletedCount) { + if (deletedCount == 0) bannerDismissed = false + } + + if (!bannerDismissed) { + DeletedItemsBanner( + count = deletedCount, + onRemove = { + accountViewModel.launchSigner { + bookmarkGroupViewModel.removeDeletedBookmarksFromGroup( + deletedEventIds = deletedEventIds.toSet(), + deletedAddresses = deletedAddresses.toSet(), + ) + } + bannerDismissed = true + }, + onDismiss = { bannerDismissed = true }, + ) + } +} + @Composable private fun TitleAndDescription(viewModel: BookmarkGroupViewModel) { val selectedSetState = viewModel.selectedBookmarkGroupFlow.collectAsStateWithLifecycle() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupViewModel.kt index d58040c70..9cba1b5ad 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupViewModel.kt @@ -155,6 +155,19 @@ class BookmarkGroupViewModel( ) } + suspend fun removeDeletedBookmarksFromGroup( + groupIdentifier: String = bookmarkGroupIdentifier, + deletedEventIds: Set, + deletedAddresses: Set
, + ) { + account.labeledBookmarkLists.removeDeletedBookmarksFromList( + groupIdentifier, + deletedEventIds, + deletedAddresses, + account, + ) + } + @Suppress("UNCHECKED_CAST") class Initializer( val account: Account, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/old/OldBookmarkListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/old/OldBookmarkListScreen.kt index 3e0b8176a..e4d281a92 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/old/OldBookmarkListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/old/OldBookmarkListScreen.kt @@ -40,15 +40,19 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect 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.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState +import com.vitorpamplona.amethyst.ui.components.DeletedItemsBanner import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton @@ -88,7 +92,7 @@ fun OldBookmarkListScreen( // Preload all bookmarked events so they don't load one-by-one when scrolling PreloadOldBookmarkEvents(bookmarkState, accountViewModel) - RenderOldBookmarkScreen(publicFeedViewModel, privateFeedViewModel, accountViewModel, nav) + RenderOldBookmarkScreen(publicFeedViewModel, privateFeedViewModel, bookmarkState, accountViewModel, nav) } @SuppressLint("LocalContextGetResourceValueCall") @@ -97,6 +101,7 @@ fun OldBookmarkListScreen( private fun RenderOldBookmarkScreen( publicFeedViewModel: OldBookmarkPublicFeedViewModel, privateFeedViewModel: OldBookmarkPrivateFeedViewModel, + bookmarkState: com.vitorpamplona.amethyst.commons.model.nip51Lists.OldBookmarkListState.BookmarkList?, accountViewModel: AccountViewModel, nav: INav, ) { @@ -104,6 +109,29 @@ private fun RenderOldBookmarkScreen( val coroutineScope = rememberCoroutineScope() val context = LocalContext.current + val cache = accountViewModel.account.cache + val deletedEventIds = remember(bookmarkState) { mutableSetOf() } + val deletedAddresses = remember(bookmarkState) { mutableSetOf() } + val deletedCount = + remember(bookmarkState) { + deletedEventIds.clear() + deletedAddresses.clear() + val all = bookmarkState?.public.orEmpty() + bookmarkState?.private.orEmpty() + all.forEach { note -> + val event = note.event + if (event != null && cache.hasBeenDeleted(event)) { + deletedEventIds.add(note.idHex) + if (note is AddressableNote) deletedAddresses.add(note.address) + } + } + deletedEventIds.size + deletedAddresses.size + } + + var bannerDismissed by remember { mutableStateOf(false) } + LaunchedEffect(deletedCount) { + if (deletedCount == 0) bannerDismissed = false + } + DisappearingScaffold( isInvertedLayout = false, topBar = { @@ -156,6 +184,19 @@ private fun RenderOldBookmarkScreen( accountViewModel = accountViewModel, ) { Column(Modifier.padding(it).fillMaxHeight()) { + if (!bannerDismissed) { + DeletedItemsBanner( + count = deletedCount, + onRemove = { + accountViewModel.removeDeletedOldBookmarks( + deletedEventIds.toSet(), + deletedAddresses.toSet(), + ) + bannerDismissed = true + }, + onDismiss = { bannerDismissed = true }, + ) + } HorizontalPager(state = pagerState) { page -> when (page) { 0 -> { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pinnednotes/PinnedNotesScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pinnednotes/PinnedNotesScreen.kt index 97b1880d1..31c89cb5c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pinnednotes/PinnedNotesScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pinnednotes/PinnedNotesScreen.kt @@ -27,13 +27,16 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState +import com.vitorpamplona.amethyst.ui.components.DeletedItemsBanner import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton @@ -63,15 +66,29 @@ fun PinnedNotesScreen( // Preload all pinned events so they don't load one-by-one when scrolling PreloadPinnedEvents(pinState, accountViewModel) - RenderPinnedNotesScreen(pinnedNotesFeedViewModel, accountViewModel, nav) + RenderPinnedNotesScreen(pinnedNotesFeedViewModel, pinState, accountViewModel, nav) } @Composable private fun RenderPinnedNotesScreen( pinnedNotesFeedViewModel: PinnedNotesFeedViewModel, + pinState: List?, accountViewModel: AccountViewModel, nav: INav, ) { + var bannerDismissed by remember { mutableStateOf(false) } + val deletedPins = + remember(pinState) { + pinState + ?.filter { note -> + note.event?.let(accountViewModel.account.cache::hasBeenDeleted) == true + }.orEmpty() + } + + LaunchedEffect(deletedPins) { + if (deletedPins.isEmpty()) bannerDismissed = false + } + DisappearingScaffold( isInvertedLayout = false, topBar = { @@ -80,6 +97,16 @@ private fun RenderPinnedNotesScreen( accountViewModel = accountViewModel, ) { Column(Modifier.padding(it).fillMaxHeight()) { + if (!bannerDismissed) { + DeletedItemsBanner( + count = deletedPins.size, + onRemove = { + accountViewModel.removeDeletedPins(deletedPins.toSet()) + bannerDismissed = true + }, + onDismiss = { bannerDismissed = true }, + ) + } RefresheableFeedView( pinnedNotesFeedViewModel, null, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index e2ffc91ef..fcf878f13 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -435,6 +435,10 @@ Pin to Profile Unpin from Profile + %1$d item(s) in this list have been deleted by their author. + Remove from list + Dismiss + Bookmark Lists Icon for bookmark list New Bookmark List diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/BookmarkListState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/BookmarkListState.kt index c3fdee824..8b56b6e2b 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/BookmarkListState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/BookmarkListState.kt @@ -25,6 +25,8 @@ import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.NoteState import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.TagArray import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark @@ -298,4 +300,46 @@ class BookmarkListState( null } } + + suspend fun removeDeletedBookmarks( + deletedEventIds: Set, + deletedAddresses: Set
, + ): BookmarkListEvent? { + val currentList = getBookmarkList() ?: return null + if (deletedEventIds.isEmpty() && deletedAddresses.isEmpty()) return null + + val newPublicTags = filterOutDeletedBookmarks(currentList.tags, deletedEventIds, deletedAddresses) + val oldPrivateTags = currentList.privateTags(signer) + + return if (oldPrivateTags == null) { + if (newPublicTags.size == currentList.tags.size) return null + BookmarkListEvent.resign( + content = currentList.content, + tags = newPublicTags, + signer = signer, + ) + } else { + val newPrivateTags = filterOutDeletedBookmarks(oldPrivateTags, deletedEventIds, deletedAddresses) + if (newPublicTags.size == currentList.tags.size && newPrivateTags.size == oldPrivateTags.size) return null + BookmarkListEvent.resign( + tags = newPublicTags, + privateTags = newPrivateTags, + signer = signer, + ) + } + } } + +internal fun filterOutDeletedBookmarks( + tags: TagArray, + deletedEventIds: Set, + deletedAddresses: Set
, +): TagArray = + tags + .filter { tag -> + when (val bookmark = BookmarkIdTag.parse(tag)) { + is EventBookmark -> bookmark.eventId !in deletedEventIds + is AddressBookmark -> bookmark.address !in deletedAddresses + null -> true + } + }.toTypedArray() diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/OldBookmarkListState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/OldBookmarkListState.kt index e9fe062b8..67f499deb 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/OldBookmarkListState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/OldBookmarkListState.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.NoteState import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark @@ -210,4 +211,32 @@ class OldBookmarkListState( } else { publicBookmarkEventIdSet.value.contains(note.idHex) } + + suspend fun removeDeletedBookmarks( + deletedEventIds: Set, + deletedAddresses: Set
, + ): OldBookmarkListEvent? { + val currentList = getBookmarkList() ?: return null + if (deletedEventIds.isEmpty() && deletedAddresses.isEmpty()) return null + + val newPublicTags = filterOutDeletedBookmarks(currentList.tags, deletedEventIds, deletedAddresses) + val oldPrivateTags = currentList.privateTags(signer) + + return if (oldPrivateTags == null) { + if (newPublicTags.size == currentList.tags.size) return null + OldBookmarkListEvent.resign( + content = currentList.content, + tags = newPublicTags, + signer = signer, + ) + } else { + val newPrivateTags = filterOutDeletedBookmarks(oldPrivateTags, deletedEventIds, deletedAddresses) + if (newPublicTags.size == currentList.tags.size && newPrivateTags.size == oldPrivateTags.size) return null + OldBookmarkListEvent.resign( + tags = newPublicTags, + privateTags = newPrivateTags, + signer = signer, + ) + } + } } From d34b5702a5dee9fd0d565779c77d3fbb088b4dfb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 22:59:39 +0000 Subject: [PATCH 18/48] feat(feeds): single grid-level content warning with distinct reasons Multi-image picture posts now show one warning covering the whole grid instead of one per image (which required tapping each individually). The warning backdrop is a grid of the images' blurhashes, and the overlay lists any distinct reasons collected from both the event's content-warning tag and per-imeta content-warning fragments as chips. Preloads every image in the grid while the warning is visible. --- .../ui/components/SensitivityWarning.kt | 243 +++++++++++++----- .../ui/components/ZoomableContentView.kt | 4 +- .../amethyst/ui/note/types/PictureDisplay.kt | 33 ++- .../loggedIn/pictures/PictureCardCompose.kt | 35 ++- 4 files changed, 228 insertions(+), 87 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt index 6a86c1fe2..2a6b2a491 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt @@ -24,6 +24,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize @@ -35,6 +36,8 @@ import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.rounded.Warning +import androidx.compose.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -60,6 +63,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.imageLoader import coil3.request.ImageRequest import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -69,8 +73,10 @@ import com.vitorpamplona.amethyst.ui.theme.ButtonPadding import com.vitorpamplona.amethyst.ui.theme.PaddingHorizontal12Modifier import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip36SensitiveContent.ContentWarningTag import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW +import com.vitorpamplona.quartz.nip92IMeta.imetas @Composable fun SensitivityWarning( @@ -113,7 +119,7 @@ fun SensitivityWarning( @Composable fun SensitivityWarningOverBlurhash( isSensitive: Boolean, - reason: String?, + reasons: Set, blurhash: String?, ratio: Float?, description: String?, @@ -143,9 +149,9 @@ fun SensitivityWarningOverBlurhash( CrossfadeIfEnabled(targetState = showContentWarningNote, accountViewModel = accountViewModel) { if (it) { if (blurhash != null) { - ContentWarningOverBlurhash(reason, blurhash, ratio, description, contentScale) { showContentWarningNote = false } + ContentWarningOverBlurhash(reasons, blurhash, ratio, description, contentScale) { showContentWarningNote = false } } else { - ContentWarningNote(reason) { showContentWarningNote = false } + ContentWarningNote(reasons.firstOrNull()) { showContentWarningNote = false } } } else { content() @@ -153,6 +159,43 @@ fun SensitivityWarningOverBlurhash( } } +@Composable +fun SensitivityWarningOverBlurhashGrid( + isSensitive: Boolean, + reasons: Set, + media: List, + accountViewModel: AccountViewModel, + content: @Composable () -> Unit, +) { + if (!isSensitive || media.isEmpty()) { + content() + return + } + + val accountState = accountViewModel.showSensitiveContent().collectAsStateWithLifecycle() + + var showContentWarningNote by remember(accountState) { mutableStateOf(accountState.value != true) } + + if (showContentWarningNote) { + val context = LocalContext.current + LaunchedEffect(media) { + media.forEach { item -> + runCatching { + context.imageLoader.enqueue(ImageRequest.Builder(context).data(item.url).build()) + } + } + } + } + + CrossfadeIfEnabled(targetState = showContentWarningNote, accountViewModel = accountViewModel) { + if (it) { + ContentWarningOverBlurhashGrid(reasons, media) { showContentWarningNote = false } + } else { + content() + } + } +} + @Composable fun ObserveSensitivityWarning( reason: String?, @@ -201,7 +244,22 @@ fun ContentWarningNoteWithBigReasonPreview() { fun ContentWarningOverBlurhashPreview() { ThemeComparisonColumn { ContentWarningOverBlurhash( - reason = "Spoilers", + reasons = setOf("Spoilers"), + blurhash = "LEHV6nWB2yk8pyo0adR*.7kCMdnj", + ratio = 16f / 9f, + description = null, + contentScale = ContentScale.FillWidth, + onDismiss = {}, + ) + } +} + +@Preview +@Composable +fun ContentWarningOverBlurhashMultiReasonPreview() { + ThemeComparisonColumn { + ContentWarningOverBlurhash( + reasons = setOf("Spoilers", "Violence", "Nudity"), blurhash = "LEHV6nWB2yk8pyo0adR*.7kCMdnj", ratio = 16f / 9f, description = null, @@ -213,7 +271,7 @@ fun ContentWarningOverBlurhashPreview() { @Composable fun ContentWarningOverBlurhash( - reason: String?, + reasons: Set, blurhash: String, ratio: Float?, description: String?, @@ -234,68 +292,119 @@ fun ContentWarningOverBlurhash( contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize(), ) + ContentWarningOverlayBody(reasons, onDismiss) + } +} - Box( - modifier = - Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = 0.35f)), - contentAlignment = Alignment.Center, - ) { - Column( - modifier = Modifier.padding(horizontal = 12.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Box( - Modifier - .height(80.dp) - .width(90.dp), - ) { - Icon( - imageVector = Icons.Default.Visibility, - contentDescription = stringRes(R.string.content_warning), - modifier = - Modifier - .size(70.dp) - .align(Alignment.BottomStart), - tint = Color.White, - ) - Icon( - imageVector = Icons.Rounded.Warning, - contentDescription = stringRes(R.string.content_warning), - modifier = - Modifier - .size(30.dp) - .align(Alignment.TopEnd), - tint = Color.White, - ) - } - - Text( - text = - if (reason.isNullOrBlank()) { - stringRes(R.string.content_warning) - } else { - stringRes(R.string.content_warning_with_reason, reason) - }, - fontWeight = FontWeight.Bold, - fontSize = 18.sp, - color = Color.White, - softWrap = true, - textAlign = TextAlign.Center, +@Composable +fun ContentWarningOverBlurhashGrid( + reasons: Set, + media: List, + onDismiss: () -> Unit, +) { + Box(modifier = Modifier.fillMaxWidth().aspectRatio(1f).clipToBounds()) { + AutoNonlazyGrid(media.size) { idx -> + val item = media[idx] + if (item.blurhash != null) { + DisplayBlurHash( + blurhash = item.blurhash, + description = item.description, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), ) + } + } + ContentWarningOverlayBody(reasons, onDismiss) + } +} - FilledTonalButton( - modifier = Modifier.padding(top = 10.dp), - onClick = onDismiss, - shape = ButtonBorder, - contentPadding = ButtonPadding, +@Composable +private fun ContentWarningOverlayBody( + reasons: Set, + onDismiss: () -> Unit, +) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.35f)), + contentAlignment = Alignment.Center, + ) { + Column( + modifier = Modifier.padding(horizontal = 12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + Modifier + .height(80.dp) + .width(90.dp), + ) { + Icon( + imageVector = Icons.Default.Visibility, + contentDescription = stringRes(R.string.content_warning), + modifier = + Modifier + .size(70.dp) + .align(Alignment.BottomStart), + tint = Color.White, + ) + Icon( + imageVector = Icons.Rounded.Warning, + contentDescription = stringRes(R.string.content_warning), + modifier = + Modifier + .size(30.dp) + .align(Alignment.TopEnd), + tint = Color.White, + ) + } + + Text( + text = stringRes(R.string.content_warning), + fontWeight = FontWeight.Bold, + fontSize = 18.sp, + color = Color.White, + softWrap = true, + textAlign = TextAlign.Center, + ) + + if (reasons.isNotEmpty()) { + FlowRow( + modifier = Modifier.padding(top = 6.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally), + verticalArrangement = Arrangement.spacedBy(4.dp), ) { - Text( - text = stringRes(R.string.show_anyway), - ) + reasons.forEach { reason -> + AssistChip( + onClick = {}, + enabled = false, + label = { + Text( + text = reason, + color = Color.White, + ) + }, + colors = + AssistChipDefaults.assistChipColors( + disabledContainerColor = Color.White.copy(alpha = 0.15f), + disabledLabelColor = Color.White, + ), + border = null, + ) + } } } + + FilledTonalButton( + modifier = Modifier.padding(top = 10.dp), + onClick = onDismiss, + shape = ButtonBorder, + contentPadding = ButtonPadding, + ) { + Text( + text = stringRes(R.string.show_anyway), + ) + } } } } @@ -377,3 +486,15 @@ fun ContentWarningNote( } } } + +fun collectPictureReasons(event: Event): Set { + val reasons = linkedSetOf() + event.contentWarningReason()?.takeIf { it.isNotBlank() }?.let { reasons.add(it) } + event.imetas().forEach { iMeta -> + iMeta.properties[ContentWarningTag.TAG_NAME] + ?.firstOrNull() + ?.takeIf { it.isNotBlank() } + ?.let { reasons.add(it) } + } + return reasons +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index 5602f9aa1..e101247aa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -153,7 +153,7 @@ fun ZoomableContentView( val ratio = content.dim?.aspectRatio() ?: MediaAspectRatioCache.get(content.url) SensitivityWarningOverBlurhash( isSensitive = content.isSensitive, - reason = content.contentWarning, + reasons = setOfNotNull(content.contentWarning), blurhash = content.blurhash, ratio = ratio, description = content.description, @@ -177,7 +177,7 @@ fun ZoomableContentView( val ratio = content.dim?.aspectRatio() ?: MediaAspectRatioCache.get(content.url) SensitivityWarningOverBlurhash( isSensitive = content.isSensitive, - reason = content.contentWarning, + reasons = setOfNotNull(content.contentWarning), blurhash = content.blurhash, ratio = ratio, description = content.description, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PictureDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PictureDisplay.kt index f6ac70130..f08939a36 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PictureDisplay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PictureDisplay.kt @@ -39,12 +39,13 @@ import com.vitorpamplona.amethyst.commons.model.EmptyTagList import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.AutoNonlazyGrid +import com.vitorpamplona.amethyst.ui.components.SensitivityWarningOverBlurhashGrid import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.components.ZoomableContentView +import com.vitorpamplona.amethyst.ui.components.collectPictureReasons import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer -import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW import com.vitorpamplona.quartz.nip68Picture.PictureEvent import kotlinx.collections.immutable.toImmutableList @@ -62,7 +63,8 @@ fun PictureDisplay( val event = (note.event as? PictureEvent) ?: return val uri = note.toNostrUri() val isSensitive = event.isSensitiveOrNSFW() - val contentWarning = event.contentWarningReason() + val reasons = collectPictureReasons(event) + val inGrid = event.imetaTags().size > 1 val images by remember(note) { @@ -77,8 +79,8 @@ fun PictureDisplay( blurhash = it.blurhash, dim = it.dimension, uri = uri, - contentWarning = contentWarning, - isSensitive = isSensitive, + contentWarning = reasons.firstOrNull(), + isSensitive = isSensitive && !inGrid, mimeType = it.mimeType, ) }.toImmutableList(), @@ -112,14 +114,21 @@ fun PictureDisplay( accountViewModel = accountViewModel, ) } else { - AutoNonlazyGrid(images.size) { - ZoomableContentView( - content = images[it], - images = images, - roundedCorner = roundedCorner, - contentScale = ContentScale.Crop, - accountViewModel = accountViewModel, - ) + SensitivityWarningOverBlurhashGrid( + isSensitive = isSensitive, + reasons = reasons, + media = images, + accountViewModel = accountViewModel, + ) { + AutoNonlazyGrid(images.size) { + ZoomableContentView( + content = images[it], + images = images, + roundedCorner = roundedCorner, + contentScale = ContentScale.Crop, + accountViewModel = accountViewModel, + ) + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureCardCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureCardCompose.kt index 8cb831ca8..23031d6bc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureCardCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureCardCompose.kt @@ -41,12 +41,13 @@ import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.AutoNonlazyGrid +import com.vitorpamplona.amethyst.ui.components.SensitivityWarningOverBlurhashGrid import com.vitorpamplona.amethyst.ui.components.ZoomableContentView +import com.vitorpamplona.amethyst.ui.components.collectPictureReasons import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.ReactionsRow import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.UserCardHeader -import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW import com.vitorpamplona.quartz.nip68Picture.PictureEvent import kotlinx.collections.immutable.toImmutableList @@ -93,7 +94,8 @@ private fun PictureCardImage( ) { val uri = note.toNostrUri() val isSensitive = event.isSensitiveOrNSFW() - val contentWarning = event.contentWarningReason() + val reasons = collectPictureReasons(event) + val inGrid = event.imetaTags().size > 1 val images by remember(note) { @@ -108,8 +110,10 @@ private fun PictureCardImage( blurhash = it.blurhash, dim = it.dimension, uri = uri, - contentWarning = contentWarning, - isSensitive = isSensitive, + contentWarning = reasons.firstOrNull(), + // In grid mode the warning is handled at the grid level to avoid + // one overlay per image. Single-image posts still warn per media. + isSensitive = isSensitive && !inGrid, mimeType = it.mimeType, ) }.toImmutableList(), @@ -126,14 +130,21 @@ private fun PictureCardImage( accountViewModel = accountViewModel, ) } else { - AutoNonlazyGrid(images.size) { - ZoomableContentView( - content = images[it], - images = images, - roundedCorner = false, - contentScale = ContentScale.Crop, - accountViewModel = accountViewModel, - ) + SensitivityWarningOverBlurhashGrid( + isSensitive = isSensitive, + reasons = reasons, + media = images, + accountViewModel = accountViewModel, + ) { + AutoNonlazyGrid(images.size) { + ZoomableContentView( + content = images[it], + images = images, + roundedCorner = false, + contentScale = ContentScale.Crop, + accountViewModel = accountViewModel, + ) + } } } } From 309e474a3dd4c570d1a86edfce41962a594b1ab4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 23:02:53 +0000 Subject: [PATCH 19/48] fix(dvm-card): star top-right + move reactions to bottom row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Putting the star back where the user actually sees DVMs first — the Discover Content card. Two changes that together stop the layout from breaking: - FavoriteDvmToggle now uses ClickableBox + small Icon (no IconButton padding) so it occupies the same compact footprint as LikeReaction. Adding it to LeftPictureLayout's title row no longer inflates the row height. - DVMCard's title row now holds only the name (weight 1) plus the star toggle on the right. LikeReaction and ZapReaction move down to the bottom row, pushed to the far right with a Spacer(weight 1) so the amount/personalised chips stay on the left. --- .../loggedIn/discover/nip90DVMs/DVMCard.kt | 98 +++++++++---------- .../screen/loggedIn/dvms/FavoriteDvmToggle.kt | 24 +++-- 2 files changed, 61 insertions(+), 61 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DVMCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DVMCard.kt index 6fa904415..0ca21209b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DVMCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DVMCard.kt @@ -21,11 +21,9 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -40,8 +38,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.components.MyAsyncImage @@ -51,12 +49,12 @@ import com.vitorpamplona.amethyst.ui.note.LikeReaction import com.vitorpamplona.amethyst.ui.note.ZapReaction import com.vitorpamplona.amethyst.ui.note.elements.BannerImage import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.FavoriteDvmToggle import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.observeAppDefinition import com.vitorpamplona.amethyst.ui.theme.HalfTopPadding import com.vitorpamplona.amethyst.ui.theme.RowColSpacing5dp import com.vitorpamplona.amethyst.ui.theme.SimpleImageBorder import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer -import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.bitcoinColor import com.vitorpamplona.amethyst.ui.theme.grayText import com.vitorpamplona.amethyst.ui.theme.nip05 @@ -118,25 +116,12 @@ fun RenderContentDVMThumb( overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f), ) - Spacer(modifier = StdVertSpacer) - Row( - verticalAlignment = CenterVertically, - horizontalArrangement = RowColSpacing5dp, - ) { - LikeReaction( - baseNote = baseNote, - grayTint = MaterialTheme.colorScheme.onSurface, + if (baseNote is AddressableNote) { + FavoriteDvmToggle( + appDefinitionNote = baseNote, accountViewModel = accountViewModel, - nav, ) } - Spacer(modifier = StdHorzSpacer) - ZapReaction( - baseNote = baseNote, - grayTint = MaterialTheme.colorScheme.onSurface, - accountViewModel = accountViewModel, - nav = nav, - ) }, onDescription = { card.description?.let { @@ -168,22 +153,16 @@ fun RenderContentDVMThumb( color = MaterialTheme.colorScheme.primary amount = card.amount + " Sats" } - Row( - verticalAlignment = CenterVertically, - horizontalArrangement = Arrangement.Absolute.Right, - ) { - Text( - textAlign = TextAlign.End, - text = " $amount ", - color = color, - maxLines = 3, - modifier = - Modifier - .weight(1f, fill = false) - .border(Dp(.1f), color, shape = RoundedCornerShape(20)), - fontSize = 12.sp, - ) - } + Text( + textAlign = TextAlign.End, + text = " $amount ", + color = color, + maxLines = 1, + modifier = + Modifier + .border(Dp(.1f), color, shape = RoundedCornerShape(20)), + fontSize = 12.sp, + ) } Spacer(modifier = StdHorzSpacer) card.personalized?.let { @@ -196,24 +175,35 @@ fun RenderContentDVMThumb( color = MaterialTheme.colorScheme.nip05 name = "Generic" } - Spacer(modifier = StdVertSpacer) - Row( - verticalAlignment = CenterVertically, - horizontalArrangement = Arrangement.Absolute.Right, - ) { - Text( - textAlign = TextAlign.End, - text = " $name ", - color = color, - maxLines = 3, - modifier = - Modifier - .padding(start = 4.dp) - .weight(1f, fill = false) - .border(Dp(.1f), color, shape = RoundedCornerShape(20)), - fontSize = 12.sp, - ) - } + Text( + textAlign = TextAlign.End, + text = " $name ", + color = color, + maxLines = 1, + modifier = + Modifier + .border(Dp(.1f), color, shape = RoundedCornerShape(20)), + fontSize = 12.sp, + ) + } + Spacer(modifier = Modifier.weight(1f)) + Row( + verticalAlignment = CenterVertically, + horizontalArrangement = RowColSpacing5dp, + ) { + LikeReaction( + baseNote = baseNote, + grayTint = MaterialTheme.colorScheme.onSurface, + accountViewModel = accountViewModel, + nav, + ) + Spacer(modifier = StdHorzSpacer) + ZapReaction( + baseNote = baseNote, + grayTint = MaterialTheme.colorScheme.onSurface, + accountViewModel = accountViewModel, + nav = nav, + ) } }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/FavoriteDvmToggle.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/FavoriteDvmToggle.kt index 98cd9f062..1bed004b7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/FavoriteDvmToggle.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/FavoriteDvmToggle.kt @@ -24,7 +24,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.outlined.StarBorder import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -33,6 +32,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteAndMap +import com.vitorpamplona.amethyst.ui.components.ClickableBox import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size20Modifier @@ -40,14 +40,24 @@ import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.NIP90ContentDiscoveryRequestEvent +/** + * Inline star toggle that follows / unfollows a NIP-90 content-discovery DVM. + * + * Uses [ClickableBox] (no [androidx.compose.material3.IconButton] padding) so it + * fits in a `LeftPictureLayout` title row alongside other compact reactions + * without inflating the row to 48dp. + * + * Hidden until the underlying [AppDefinitionEvent] loads and we can confirm the + * DVM advertises kind 5300 — favouriting any other DVM type would only stall on + * a 6300 reply that never comes. + */ @Composable fun FavoriteDvmToggle( appDefinitionNote: AddressableNote, accountViewModel: AccountViewModel, modifier: Modifier = Modifier, + iconSizeModifier: Modifier = Size20Modifier, ) { - // Only NIP-90 content-discovery DVMs (kind 5300) produce a feed; hide the toggle - // for any other DVM type so users don't favourite something that would never reply. val supportsContentDiscovery by observeNoteAndMap(appDefinitionNote, accountViewModel) { note -> (note.event as? AppDefinitionEvent)?.includeKind(NIP90ContentDiscoveryRequestEvent.KIND) == true @@ -60,7 +70,8 @@ fun FavoriteDvmToggle( val isFavorite = favorites.contains(appDefinitionNote.address) - IconButton( + ClickableBox( + modifier = modifier, onClick = { if (isFavorite) { accountViewModel.unfollowFavoriteDvm(appDefinitionNote.address) @@ -73,20 +84,19 @@ fun FavoriteDvmToggle( ) } }, - modifier = modifier, ) { if (isFavorite) { Icon( imageVector = Icons.Filled.Star, contentDescription = stringRes(R.string.remove_dvm_from_favorites), - modifier = Size20Modifier, + modifier = iconSizeModifier, tint = MaterialTheme.colorScheme.primary, ) } else { Icon( imageVector = Icons.Outlined.StarBorder, contentDescription = stringRes(R.string.add_dvm_to_favorites), - modifier = Size20Modifier, + modifier = iconSizeModifier, tint = MaterialTheme.colorScheme.onSurface, ) } From ecdbc80fc1721e79c19668caeb76e47ccfead0d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 23:06:49 +0000 Subject: [PATCH 20/48] feat: preview PDF links inline with first-page thumbnail and full pager Adds MediaUrlPdf / PdfSegment to the rich-text pipeline so PDF URLs (detected by .pdf extension, NIP-92 imeta m tag, or application/pdf Content-Type) render a card showing the first page, filename, and page count. Tapping opens a full-screen HorizontalPager over every page rendered on demand with PdfRenderer. Long-press surfaces the existing share menu. Uses only the built-in Android PdfRenderer; no new dependencies. Desktop continues to fall back to a clickable link. --- .../amethyst/service/previews/UrlPreview.kt | 2 + .../amethyst/ui/components/LoadUrlPreview.kt | 10 + .../amethyst/ui/components/RichTextViewer.kt | 5 + .../ui/components/ZoomableContentView.kt | 39 ++- .../amethyst/ui/components/pdf/PdfFetcher.kt | 89 +++++ .../ui/components/pdf/PdfPreviewCard.kt | 210 ++++++++++++ .../ui/components/pdf/PdfViewerDialog.kt | 305 ++++++++++++++++++ .../commons/richtext/MediaContentModels.kt | 11 + .../commons/richtext/RichTextParser.kt | 38 ++- .../richtext/RichTextParserSegments.kt | 5 + .../commons/richtext/PdfParserTest.kt | 69 ++++ 11 files changed, 772 insertions(+), 11 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfFetcher.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfPreviewCard.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfViewerDialog.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/PdfParserTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/UrlPreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/UrlPreview.kt index 5deeb41ee..db4c9ac9d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/UrlPreview.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/UrlPreview.kt @@ -71,6 +71,8 @@ class UrlPreview { UrlInfoItem(url, image = url, mimeType = mimeType.toString()) } else if (mimeType.type == "video") { UrlInfoItem(url, image = url, mimeType = mimeType.toString()) + } else if (mimeType.type == "application" && mimeType.subtype == "pdf") { + UrlInfoItem(url, image = url, mimeType = mimeType.toString()) } else { throw IllegalArgumentException("Website returned unknown encoding for previews: $mimeType") } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadUrlPreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadUrlPreview.kt index b99fb85e9..371d21b58 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadUrlPreview.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadUrlPreview.kt @@ -26,6 +26,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.produceState import androidx.compose.ui.layout.ContentScale import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage +import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo import com.vitorpamplona.amethyst.model.UrlCachedPreviewer import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled @@ -112,6 +113,15 @@ fun RenderLoaded( accountViewModel = accountViewModel, ) } + } else if (state.previewInfo.mimeType.startsWith("application/pdf")) { + Box(modifier = HalfVertPadding) { + ZoomableContentView( + content = MediaUrlPdf(url, uri = callbackUri, mimeType = state.previewInfo.mimeType), + roundedCorner = true, + contentScale = ContentScale.FillWidth, + accountViewModel = accountViewModel, + ) + } } else { UrlPreviewCard(url, state.previewInfo) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index fe147eb19..133ecdab4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -81,6 +81,7 @@ import com.vitorpamplona.amethyst.commons.richtext.ImageSegment import com.vitorpamplona.amethyst.commons.richtext.InvoiceSegment import com.vitorpamplona.amethyst.commons.richtext.LinkSegment import com.vitorpamplona.amethyst.commons.richtext.ParagraphState +import com.vitorpamplona.amethyst.commons.richtext.PdfSegment import com.vitorpamplona.amethyst.commons.richtext.PhoneSegment import com.vitorpamplona.amethyst.commons.richtext.RegularTextSegment import com.vitorpamplona.amethyst.commons.richtext.RelayUrlSegment @@ -480,6 +481,9 @@ private fun RenderWordWithoutPreview( // Don't preview Videos is VideoSegment -> ClickableUrl(word.segmentText, word.segmentText) + // Don't preview PDFs + is PdfSegment -> ClickableUrl(word.segmentText, word.segmentText) + is LinkSegment -> ClickableUrl(word.segmentText, word.segmentText) is EmojiSegment -> RenderCustomEmoji(word.segmentText, state) @@ -529,6 +533,7 @@ private fun RenderWordWithPreview( when (word) { is ImageSegment -> ZoomableContentView(word.segmentText, state, accountViewModel) is VideoSegment -> ZoomableContentView(word.segmentText, state, accountViewModel) + is PdfSegment -> ZoomableContentView(word.segmentText, state, accountViewModel) is LinkSegment -> LoadUrlPreview(word.segmentText, word.segmentText, callbackUri, accountViewModel) is EmojiSegment -> RenderCustomEmoji(word.segmentText, state) is InvoiceSegment -> MayBeInvoicePreview(word.segmentText, accountViewModel) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index 08f3ccc1c..469afe4ff 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -86,6 +86,7 @@ import com.vitorpamplona.amethyst.commons.richtext.MediaLocalVideo import com.vitorpamplona.amethyst.commons.richtext.MediaPreloadedContent import com.vitorpamplona.amethyst.commons.richtext.MediaUrlContent import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage +import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo import com.vitorpamplona.amethyst.model.MediaAspectRatioCache import com.vitorpamplona.amethyst.service.images.BlurhashWrapper @@ -93,6 +94,8 @@ import com.vitorpamplona.amethyst.service.playback.composable.VideoView import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.openBlossomUriAsIntent import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.actions.InformationDialog +import com.vitorpamplona.amethyst.ui.components.pdf.PdfPreviewCard +import com.vitorpamplona.amethyst.ui.components.pdf.PdfViewerDialog import com.vitorpamplona.amethyst.ui.components.util.setText import com.vitorpamplona.amethyst.ui.note.BlankNote import com.vitorpamplona.amethyst.ui.note.DownloadForOfflineIcon @@ -221,18 +224,36 @@ fun ZoomableContentView( } } } + + is MediaUrlPdf -> { + Box(modifier = Modifier.fillMaxWidth().then(boundsTrackingModifier)) { + PdfPreviewCard( + content = content, + accountViewModel = accountViewModel, + onOpen = { dialogOpen = true }, + ) + } + } } if (dialogOpen) { - ZoomableImageDialog( - imageUrl = content, - allImages = images, - sourceBounds = sourceBounds, - onDismiss = { - dialogOpen = false - }, - accountViewModel = accountViewModel, - ) + if (content is MediaUrlPdf) { + PdfViewerDialog( + content = content, + accountViewModel = accountViewModel, + onDismiss = { dialogOpen = false }, + ) + } else { + ZoomableImageDialog( + imageUrl = content, + allImages = images, + sourceBounds = sourceBounds, + onDismiss = { + dialogOpen = false + }, + accountViewModel = accountViewModel, + ) + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfFetcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfFetcher.kt new file mode 100644 index 000000000..fdc71ef55 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfFetcher.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.components.pdf + +import android.content.Context +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.sha256.sha256 +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.coroutines.executeAsync +import okio.sink +import java.io.File +import java.io.IOException + +object PdfFetcher { + private const val CACHE_DIR_NAME = "pdf_cache" + + private fun cacheDir(context: Context): File = File(context.cacheDir, CACHE_DIR_NAME).also { it.mkdirs() } + + fun cachedFileOrNull( + context: Context, + url: String, + ): File? { + val file = cacheFile(context, url) + return if (file.exists() && file.length() > 0) file else null + } + + fun cacheFile( + context: Context, + url: String, + ): File { + val key = sha256(url.toByteArray()).toHexKey() + return File(cacheDir(context), "$key.pdf") + } + + suspend fun fetch( + context: Context, + url: String, + okHttpClient: (String) -> OkHttpClient, + ): File = + withContext(Dispatchers.IO) { + val file = cacheFile(context, url) + if (file.exists() && file.length() > 0) return@withContext file + + val request = + Request + .Builder() + .url(url) + .get() + .build() + + okHttpClient(url).newCall(request).executeAsync().use { response -> + if (!response.isSuccessful) { + throw IOException("PDF download failed: ${response.code}") + } + val tmp = File(file.parentFile, "${file.name}.tmp") + tmp.outputStream().use { out -> + val bytes = response.body.source().readAll(out.sink()) + if (bytes == 0L) throw IOException("PDF download failed: empty response body") + } + if (!tmp.renameTo(file)) { + tmp.copyTo(file, overwrite = true) + tmp.delete() + } + } + + file + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfPreviewCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfPreviewCard.kt new file mode 100644 index 000000000..15855f317 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfPreviewCard.kt @@ -0,0 +1,210 @@ +/* + * 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.ui.components.pdf + +import android.graphics.Bitmap +import android.graphics.pdf.PdfRenderer +import android.os.ParcelFileDescriptor +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.combinedClickable +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.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.PictureAsPdf +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf +import com.vitorpamplona.amethyst.ui.components.ClickableUrl +import com.vitorpamplona.amethyst.ui.components.DisplayUrlWithLoadingSymbol +import com.vitorpamplona.amethyst.ui.components.ShareMediaAction +import com.vitorpamplona.amethyst.ui.components.WaitAndDisplay +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer +import com.vitorpamplona.amethyst.ui.theme.MaxWidthWithHorzPadding +import com.vitorpamplona.amethyst.ui.theme.Size20Modifier +import com.vitorpamplona.amethyst.ui.theme.innerPostModifier +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +data class PdfPreview( + val thumbnail: Bitmap, + val pageCount: Int, + val aspectRatio: Float, +) + +private sealed class PdfLoadState { + data object Loading : PdfLoadState() + + data class Ready( + val preview: PdfPreview, + ) : PdfLoadState() + + data object Failed : PdfLoadState() +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun PdfPreviewCard( + content: MediaUrlPdf, + accountViewModel: AccountViewModel, + onOpen: () -> Unit, +) { + val context = LocalContext.current + val sharePopupExpanded = remember { mutableStateOf(false) } + + @Suppress("ProduceStateDoesNotAssignValue") + val state by produceState(initialValue = PdfLoadState.Loading, key1 = content.url) { + value = + try { + val file = + PdfFetcher.fetch(context, content.url) { url -> + accountViewModel.httpClientBuilder.okHttpClientForPreview(url) + } + withContext(Dispatchers.IO) { + ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY).use { pfd -> + PdfRenderer(pfd).use { renderer -> + val pageCount = renderer.pageCount + if (pageCount <= 0) { + PdfLoadState.Failed + } else { + renderer.openPage(0).use { page -> + val bitmap = Bitmap.createBitmap(page.width, page.height, Bitmap.Config.ARGB_8888) + bitmap.eraseColor(android.graphics.Color.WHITE) + page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY) + PdfLoadState.Ready( + PdfPreview( + thumbnail = bitmap, + pageCount = pageCount, + aspectRatio = page.width.toFloat() / page.height.toFloat(), + ), + ) + } + } + } + } + } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.w("PdfPreviewCard", "Failed to render PDF preview: ${content.url}", e) + PdfLoadState.Failed + } + } + + ShareMediaAction( + accountViewModel = accountViewModel, + popupExpanded = sharePopupExpanded, + content = content, + onDismiss = { sharePopupExpanded.value = false }, + ) + + when (val current = state) { + is PdfLoadState.Loading -> { + WaitAndDisplay { + DisplayUrlWithLoadingSymbol(content, accountViewModel.toastManager::toast) + } + } + + is PdfLoadState.Failed -> { + ClickableUrl(urlText = content.url, url = content.url) + } + + is PdfLoadState.Ready -> { + val filename = remember(content.url) { extractFilename(content.url) } + Column( + modifier = + MaterialTheme.colorScheme.innerPostModifier + .combinedClickable( + onClick = onOpen, + onLongClick = { sharePopupExpanded.value = true }, + ), + ) { + Image( + bitmap = current.preview.thumbnail.asImageBitmap(), + contentDescription = content.description ?: filename, + contentScale = ContentScale.FillWidth, + modifier = + Modifier + .fillMaxWidth() + .aspectRatio(current.preview.aspectRatio.coerceAtLeast(0.2f)), + ) + + Row( + modifier = MaxWidthWithHorzPadding.padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = Icons.Outlined.PictureAsPdf, + contentDescription = null, + modifier = Size20Modifier, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = filename, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = pageCountLabel(current.preview.pageCount), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } + } + + Spacer(modifier = DoubleVertSpacer) + } + } + } +} + +private fun extractFilename(url: String): String { + val afterQuery = url.substringBefore('?').substringBefore('#') + val name = afterQuery.substringAfterLast('/', afterQuery) + return if (name.isBlank()) url else name +} + +private fun pageCountLabel(pageCount: Int): String = if (pageCount == 1) "1 page" else "$pageCount pages" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfViewerDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfViewerDialog.kt new file mode 100644 index 000000000..c9a8a21ff --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfViewerDialog.kt @@ -0,0 +1,305 @@ +/* + * 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.ui.components.pdf + +import android.graphics.Bitmap +import android.graphics.pdf.PdfRenderer +import android.os.ParcelFileDescriptor +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement.spacedBy +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +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.statusBarsPadding +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Share +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +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.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf +import com.vitorpamplona.amethyst.ui.components.ShareMediaAction +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size10dp +import com.vitorpamplona.amethyst.ui.theme.Size15dp +import com.vitorpamplona.amethyst.ui.theme.Size20Modifier +import com.vitorpamplona.amethyst.ui.theme.Size5dp +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import net.engawapg.lib.zoomable.rememberZoomState +import net.engawapg.lib.zoomable.zoomable +import java.io.File + +private class PdfDocumentHandle( + val file: File, + val pfd: ParcelFileDescriptor, + val renderer: PdfRenderer, +) { + val pageCount: Int get() = renderer.pageCount + val mutex: Mutex = Mutex() + + fun close() { + try { + renderer.close() + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.w("PdfViewerDialog", "Failed to close PdfRenderer", e) + } + try { + pfd.close() + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.w("PdfViewerDialog", "Failed to close ParcelFileDescriptor", e) + } + } +} + +@Composable +fun PdfViewerDialog( + content: MediaUrlPdf, + accountViewModel: AccountViewModel, + onDismiss: () -> Unit, +) { + Dialog( + onDismissRequest = onDismiss, + properties = + DialogProperties( + usePlatformDefaultWidth = false, + decorFitsSystemWindows = false, + ), + ) { + Surface(modifier = Modifier.fillMaxSize(), color = Color.Black) { + PdfViewerContent( + content = content, + accountViewModel = accountViewModel, + onDismiss = onDismiss, + ) + } + } +} + +@Composable +private fun PdfViewerContent( + content: MediaUrlPdf, + accountViewModel: AccountViewModel, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + + @Suppress("ProduceStateDoesNotAssignValue") + val handleState by produceState(initialValue = null, key1 = content.url) { + value = + try { + val file = + PdfFetcher.fetch(context, content.url) { url -> + accountViewModel.httpClientBuilder.okHttpClientForPreview(url) + } + withContext(Dispatchers.IO) { + val pfd = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY) + val renderer = PdfRenderer(pfd) + PdfDocumentHandle(file, pfd, renderer) + } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.w("PdfViewerDialog", "Failed to open PDF: ${content.url}", e) + null + } + } + + DisposableEffect(handleState) { + onDispose { + handleState?.close() + } + } + + val sharePopupExpanded = remember { mutableStateOf(false) } + + ShareMediaAction( + accountViewModel = accountViewModel, + popupExpanded = sharePopupExpanded, + content = content, + onDismiss = { sharePopupExpanded.value = false }, + ) + + val handle = handleState + if (handle == null) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator(color = Color.White) + } + } else if (handle.pageCount == 0) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + text = "Unable to open PDF", + color = Color.White, + ) + } + } else { + val pagerState = rememberPagerState { handle.pageCount } + val pageCache = remember(handle) { mutableStateMapOf() } + + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxSize(), + ) { pageIndex -> + PdfPageView( + handle = handle, + pageIndex = pageIndex, + cache = pageCache, + ) + } + + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = Size15dp, vertical = Size10dp) + .statusBarsPadding() + .systemBarsPadding(), + horizontalArrangement = spacedBy(Size10dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedButton( + onClick = onDismiss, + contentPadding = PaddingValues(horizontal = Size5dp), + colors = ButtonDefaults.outlinedButtonColors().copy(containerColor = MaterialTheme.colorScheme.background), + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringRes(R.string.back), + ) + } + + Spacer(modifier = Modifier.weight(1f)) + + Text( + text = "${pagerState.currentPage + 1} / ${handle.pageCount}", + color = Color.White, + modifier = + Modifier + .background(Color.Black.copy(alpha = 0.4f), shape = MaterialTheme.shapes.small) + .padding(horizontal = Size10dp, vertical = Size5dp), + ) + + Spacer(modifier = Modifier.weight(1f)) + + OutlinedButton( + onClick = { sharePopupExpanded.value = true }, + contentPadding = PaddingValues(horizontal = Size5dp), + colors = ButtonDefaults.outlinedButtonColors().copy(containerColor = MaterialTheme.colorScheme.background), + ) { + Icon( + imageVector = Icons.Default.Share, + modifier = Size20Modifier, + contentDescription = stringRes(R.string.quick_action_share), + ) + } + } + } +} + +@Composable +private fun PdfPageView( + handle: PdfDocumentHandle, + pageIndex: Int, + cache: MutableMap, +) { + val cached = cache[pageIndex] + + @Suppress("ProduceStateDoesNotAssignValue") + val bitmap by produceState(initialValue = cached, key1 = handle, key2 = pageIndex) { + if (value != null) return@produceState + val rendered = + try { + handle.mutex.withLock { + withContext(Dispatchers.IO) { + handle.renderer.openPage(pageIndex).use { page -> + val scale = 2f + val width = (page.width * scale).toInt().coerceAtLeast(1) + val height = (page.height * scale).toInt().coerceAtLeast(1) + val bmp = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + bmp.eraseColor(android.graphics.Color.WHITE) + page.render(bmp, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY) + bmp + } + } + } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.w("PdfViewerDialog", "Failed to render page $pageIndex", e) + null + } + + rendered?.let { cache[pageIndex] = it } + value = rendered + } + + val zoomState = rememberZoomState() + val current = bitmap + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + if (current != null) { + Image( + bitmap = current.asImageBitmap(), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = + Modifier + .fillMaxSize() + .zoomable(zoomState), + ) + } else { + CircularProgressIndicator(color = Color.White) + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt index d390a1834..9b1a36cbb 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt @@ -68,6 +68,17 @@ class EncryptedMediaUrlImage( val encryptionNonce: ByteArray, ) : MediaUrlImage(url, description, hash, blurhash, dim, uri, contentWarning, mimeType) +@Immutable +open class MediaUrlPdf( + url: String, + description: String? = null, + hash: String? = null, + blurhash: String? = null, + dim: DimensionTag? = null, + uri: String? = null, + mimeType: String? = null, +) : MediaUrlContent(url, description, hash, dim, blurhash, uri, mimeType) + @Immutable open class MediaUrlVideo( url: String, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt index bfc0b30cf..e832568c0 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt @@ -58,17 +58,21 @@ class RichTextParser { val isImage: Boolean val isVideo: Boolean + val isPdf: Boolean if (contentType != null) { isImage = contentType.startsWith("image/") isVideo = contentType.startsWith("video/") || contentType.startsWith("audio/") + isPdf = contentType.startsWith("application/pdf") } else if (fullUrl.startsWith("data:")) { isImage = fullUrl.startsWith("data:image/") isVideo = fullUrl.startsWith("data:video/") || fullUrl.startsWith("data:audio/") + isPdf = fullUrl.startsWith("data:application/pdf") } else { val removedParamsFromUrl = removeQueryParamsForExtensionComparison(fullUrl) isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) } isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) } + isPdf = pdfExtensions.any { removedParamsFromUrl.endsWith(it) } } return if (isImage) { @@ -93,6 +97,16 @@ class RichTextParser { uri = callbackUri, mimeType = contentType, ) + } else if (isPdf) { + MediaUrlPdf( + url = fullUrl, + description = description ?: frags[AltTag.TAG_NAME] ?: tags[AltTag.TAG_NAME]?.firstOrNull(), + hash = frags[HashSha256Tag.TAG_NAME] ?: tags[HashSha256Tag.TAG_NAME]?.firstOrNull(), + blurhash = frags[BlurhashTag.TAG_NAME] ?: tags[BlurhashTag.TAG_NAME]?.firstOrNull(), + dim = frags[DimensionTag.TAG_NAME]?.let { DimensionTag.parse(it) } ?: tags[DimensionTag.TAG_NAME]?.firstOrNull()?.let { DimensionTag.parse(it) }, + uri = callbackUri, + mimeType = contentType, + ) } else { null } @@ -158,6 +172,7 @@ class RichTextParser { val imageUrls = mediaForPager.filterValues { it is MediaUrlImage }.keys val videoUrls = mediaForPager.filterValues { it is MediaUrlVideo }.keys + val pdfUrls = mediaForPager.filterValues { it is MediaUrlPdf }.keys val emojiMap = CustomEmoji.createEmojiMap(tags.lists) @@ -165,7 +180,7 @@ class RichTextParser { val newContent = fixMissingSpaces(content, allUrls) - val segments = findTextSegments(newContent, imageUrls, videoUrls, urlSet, emojiMap, tags) + val segments = findTextSegments(newContent, imageUrls, videoUrls, pdfUrls, urlSet, emojiMap, tags) val mediaForPagerWithBase64 = mediaForPager + @@ -197,6 +212,7 @@ class RichTextParser { content: String, images: Set, videos: Set, + pdfs: Set, urls: Urls, emojis: Map, tags: ImmutableListOfLists, @@ -211,7 +227,7 @@ class RichTextParser { val segments = ArrayList(wordList.size) wordList.forEach { word -> - segments.add(wordIdentifier(word, images, videos, urls, emojis, tags)) + segments.add(wordIdentifier(word, images, videos, pdfs, urls, emojis, tags)) } paragraphSegments.add(ParagraphState(segments.toPersistentList(), isRTL)) @@ -262,6 +278,7 @@ class RichTextParser { word: String, images: Set, videos: Set, + pdfs: Set, urls: Urls, emojis: Map, tags: ImmutableListOfLists, @@ -288,6 +305,14 @@ class RichTextParser { } } + if (pdfs.contains(word)) { + return if (urls.withoutScheme.contains(word)) { + PdfSegment("https://$word") + } else { + PdfSegment(word) + } + } + if (urls.withoutScheme.contains(word)) return SchemelessUrlSegment(word) if (urls.withScheme.contains(word)) return LinkSegment(word) @@ -377,9 +402,11 @@ class RichTextParser { val imageExt = listOf("png", "jpg", "gif", "bmp", "jpeg", "webp", "svg", "avif") val videoExt = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov", "mp3", "m3u8", "ogg", "wav", "flac", "aac", "opus", "m4a") + val pdfExt = listOf("pdf") val imageExtensions = imageExt + imageExt.map { it.uppercase() } val videoExtensions = videoExt + videoExt.map { it.uppercase() } + val pdfExtensions = pdfExt + pdfExt.map { it.uppercase() } val tagIndex = Regex("\\#\\[([0-9]+)\\](.*)") val hashTagsPattern: Regex = @@ -421,6 +448,11 @@ class RichTextParser { return videoExtensions.any { removedParamsFromUrl.endsWith(it) } } + fun isPdfUrl(url: String): Boolean { + val removedParamsFromUrl = removeQueryParamsForExtensionComparison(url) + return pdfExtensions.any { removedParamsFromUrl.endsWith(it) } + } + fun isValidURL(url: String?): Boolean = try { if (url != null) { @@ -496,4 +528,6 @@ val mimeTypeMap: Map = "m4a" to "audio/mp4", "aac" to "audio/aac", "flac" to "audio/flac", + // Documents + "pdf" to "application/pdf", ) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt index 79de431fd..c0aa426c0 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt @@ -62,6 +62,11 @@ class VideoSegment( segment: String, ) : Segment(segment) +@Immutable +class PdfSegment( + segment: String, +) : Segment(segment) + @Immutable class LinkSegment( segment: String, diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/PdfParserTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/PdfParserTest.kt new file mode 100644 index 000000000..039d8b4e2 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/PdfParserTest.kt @@ -0,0 +1,69 @@ +/* + * 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.commons.richtext + +import com.vitorpamplona.amethyst.commons.model.EmptyTagList +import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class PdfParserTest { + @Test + fun detectsPdfByExtension() { + val url = "https://example.com/docs/paper.pdf" + val state = RichTextParser().parseText(url, EmptyTagList, null) + + val pdfMedia = state.mediaForPager[url] + assertTrue(pdfMedia is MediaUrlPdf, "Expected MediaUrlPdf for .pdf URL") + + val segment = state.paragraphs[0].words[0] + assertTrue(segment is PdfSegment, "Expected PdfSegment for .pdf URL, got ${segment::class.simpleName}") + assertEquals(url, segment.segmentText) + } + + @Test + fun detectsPdfFromImetaMimeTypeWithoutExtension() { + val url = "https://files.example.com/abcd1234" + val tags = + ImmutableListOfLists( + arrayOf( + arrayOf("imeta", "url $url", "m application/pdf"), + ), + ) + + val state = RichTextParser().parseText(url, tags, null) + + val pdfMedia = state.mediaForPager[url] + assertTrue(pdfMedia is MediaUrlPdf, "Expected MediaUrlPdf from imeta MIME tag") + assertEquals("application/pdf", (pdfMedia as MediaUrlPdf).mimeType) + + val segment = state.paragraphs[0].words[0] + assertTrue(segment is PdfSegment, "Expected PdfSegment from imeta MIME tag, got ${segment::class.simpleName}") + } + + @Test + fun isPdfUrlHelperMatchesPdfExtension() { + assertTrue(RichTextParser.isPdfUrl("https://example.com/doc.pdf")) + assertTrue(RichTextParser.isPdfUrl("https://example.com/doc.PDF")) + assertTrue(RichTextParser.isPdfUrl("https://example.com/doc.pdf?sig=abc")) + } +} From 60a56440d121d7ed8805f62e2afcdc8e21683766 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 23:58:36 +0000 Subject: [PATCH 21/48] refactor(feeds): unify content warning behind ContentWarningGate Collapse the per-media, per-blurhash and grid-of-blurhash variants into a single ContentWarningGate that takes a backdrop slot and a sizing modifier. Callers choose the backdrop (single blurhash, grid of blurhashes, or none). Remove the isSensitive field from MediaUrl{Image,Video}; it was a rendering concern leaked into a media model. Sensitivity is now only expressed to the gate. Picture/Video feeds and their display variants compute isSensitive + reasons themselves and wrap with the gate. ZoomableContentView keeps a default internal gate (fires when content.contentWarning != null) so the 14 callers that don't need grid-level handling still get the blurhash overlay automatically. Also rename collectPictureReasons to collectContentWarningReasons. --- .../ui/components/SensitivityWarning.kt | 163 +++++------------- .../ui/components/ZoomableContentView.kt | 24 ++- .../amethyst/ui/note/types/PictureDisplay.kt | 47 +++-- .../amethyst/ui/note/types/VideoDisplay.kt | 37 ++-- .../loggedIn/pictures/PictureCardCompose.kt | 67 ++++--- .../loggedIn/shorts/VideoCardCompose.kt | 37 ++-- .../loggedIn/video/FileHeaderCardCompose.kt | 42 +++-- .../commons/richtext/MediaContentModels.kt | 8 +- 8 files changed, 202 insertions(+), 223 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt index 2a6b2a491..85e57f6d4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt @@ -117,15 +117,13 @@ fun SensitivityWarning( } @Composable -fun SensitivityWarningOverBlurhash( +fun ContentWarningGate( isSensitive: Boolean, reasons: Set, - blurhash: String?, - ratio: Float?, - description: String?, - contentScale: ContentScale, - preloadUrl: String?, + preloadUrls: List, accountViewModel: AccountViewModel, + modifier: Modifier = Modifier.fillMaxWidth(), + backdrop: (@Composable () -> Unit)? = null, content: @Composable () -> Unit, ) { if (!isSensitive) { @@ -137,51 +135,12 @@ fun SensitivityWarningOverBlurhash( var showContentWarningNote by remember(accountState) { mutableStateOf(accountState.value != true) } - if (showContentWarningNote && preloadUrl != null) { + if (showContentWarningNote && preloadUrls.isNotEmpty()) { val context = LocalContext.current - LaunchedEffect(preloadUrl) { - runCatching { - context.imageLoader.enqueue(ImageRequest.Builder(context).data(preloadUrl).build()) - } - } - } - - CrossfadeIfEnabled(targetState = showContentWarningNote, accountViewModel = accountViewModel) { - if (it) { - if (blurhash != null) { - ContentWarningOverBlurhash(reasons, blurhash, ratio, description, contentScale) { showContentWarningNote = false } - } else { - ContentWarningNote(reasons.firstOrNull()) { showContentWarningNote = false } - } - } else { - content() - } - } -} - -@Composable -fun SensitivityWarningOverBlurhashGrid( - isSensitive: Boolean, - reasons: Set, - media: List, - accountViewModel: AccountViewModel, - content: @Composable () -> Unit, -) { - if (!isSensitive || media.isEmpty()) { - content() - return - } - - val accountState = accountViewModel.showSensitiveContent().collectAsStateWithLifecycle() - - var showContentWarningNote by remember(accountState) { mutableStateOf(accountState.value != true) } - - if (showContentWarningNote) { - val context = LocalContext.current - LaunchedEffect(media) { - media.forEach { item -> + LaunchedEffect(preloadUrls) { + preloadUrls.forEach { url -> runCatching { - context.imageLoader.enqueue(ImageRequest.Builder(context).data(item.url).build()) + context.imageLoader.enqueue(ImageRequest.Builder(context).data(url).build()) } } } @@ -189,7 +148,14 @@ fun SensitivityWarningOverBlurhashGrid( CrossfadeIfEnabled(targetState = showContentWarningNote, accountViewModel = accountViewModel) { if (it) { - ContentWarningOverBlurhashGrid(reasons, media) { showContentWarningNote = false } + if (backdrop != null) { + Box(modifier = modifier.clipToBounds()) { + backdrop() + ContentWarningOverlayBody(reasons) { showContentWarningNote = false } + } + } else { + ContentWarningNote(reasons.firstOrNull()) { showContentWarningNote = false } + } } else { content() } @@ -239,85 +205,44 @@ fun ContentWarningNoteWithBigReasonPreview() { } } -@Preview @Composable -fun ContentWarningOverBlurhashPreview() { - ThemeComparisonColumn { - ContentWarningOverBlurhash( - reasons = setOf("Spoilers"), - blurhash = "LEHV6nWB2yk8pyo0adR*.7kCMdnj", - ratio = 16f / 9f, - description = null, - contentScale = ContentScale.FillWidth, - onDismiss = {}, - ) - } -} - -@Preview -@Composable -fun ContentWarningOverBlurhashMultiReasonPreview() { - ThemeComparisonColumn { - ContentWarningOverBlurhash( - reasons = setOf("Spoilers", "Violence", "Nudity"), - blurhash = "LEHV6nWB2yk8pyo0adR*.7kCMdnj", - ratio = 16f / 9f, - description = null, - contentScale = ContentScale.FillWidth, - onDismiss = {}, - ) - } -} - -@Composable -fun ContentWarningOverBlurhash( - reasons: Set, +fun BlurhashBackdrop( blurhash: String, - ratio: Float?, description: String?, - contentScale: ContentScale, - onDismiss: () -> Unit, ) { - val sizingModifier = - when { - contentScale == ContentScale.Crop -> Modifier.fillMaxSize() - ratio != null -> Modifier.fillMaxWidth().aspectRatio(ratio) - else -> Modifier.fillMaxWidth() - } - - Box(modifier = sizingModifier.clipToBounds()) { - DisplayBlurHash( - blurhash = blurhash, - description = description, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize(), - ) - ContentWarningOverlayBody(reasons, onDismiss) - } + DisplayBlurHash( + blurhash = blurhash, + description = description, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) } @Composable -fun ContentWarningOverBlurhashGrid( - reasons: Set, - media: List, - onDismiss: () -> Unit, -) { - Box(modifier = Modifier.fillMaxWidth().aspectRatio(1f).clipToBounds()) { - AutoNonlazyGrid(media.size) { idx -> - val item = media[idx] - if (item.blurhash != null) { - DisplayBlurHash( - blurhash = item.blurhash, - description = item.description, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize(), - ) - } +fun BlurhashGridBackdrop(media: List) { + AutoNonlazyGrid(media.size) { idx -> + val item = media[idx] + if (item.blurhash != null) { + DisplayBlurHash( + blurhash = item.blurhash, + description = item.description, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) } - ContentWarningOverlayBody(reasons, onDismiss) } } +fun mediaSizingModifier( + ratio: Float?, + contentScale: ContentScale, +): Modifier = + when { + contentScale == ContentScale.Crop -> Modifier.fillMaxSize() + ratio != null -> Modifier.fillMaxWidth().aspectRatio(ratio) + else -> Modifier.fillMaxWidth() + } + @Composable private fun ContentWarningOverlayBody( reasons: Set, @@ -487,7 +412,7 @@ fun ContentWarningNote( } } -fun collectPictureReasons(event: Event): Set { +fun collectContentWarningReasons(event: Event): Set { val reasons = linkedSetOf() event.contentWarningReason()?.takeIf { it.isNotBlank() }?.let { reasons.add(it) } event.imetas().forEach { iMeta -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index e101247aa..a90440d19 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -151,15 +151,13 @@ fun ZoomableContentView( when (content) { is MediaUrlImage -> { val ratio = content.dim?.aspectRatio() ?: MediaAspectRatioCache.get(content.url) - SensitivityWarningOverBlurhash( - isSensitive = content.isSensitive, + ContentWarningGate( + isSensitive = content.contentWarning != null, reasons = setOfNotNull(content.contentWarning), - blurhash = content.blurhash, - ratio = ratio, - description = content.description, - contentScale = contentScale, - preloadUrl = content.url, + preloadUrls = listOf(content.url), accountViewModel = accountViewModel, + modifier = mediaSizingModifier(ratio, contentScale), + backdrop = content.blurhash?.let { blurhash -> { BlurhashBackdrop(blurhash, content.description) } }, ) { TwoSecondController(content) { controllerVisible -> val mainImageModifier = @@ -175,15 +173,13 @@ fun ZoomableContentView( is MediaUrlVideo -> { val ratio = content.dim?.aspectRatio() ?: MediaAspectRatioCache.get(content.url) - SensitivityWarningOverBlurhash( - isSensitive = content.isSensitive, + ContentWarningGate( + isSensitive = content.contentWarning != null, reasons = setOfNotNull(content.contentWarning), - blurhash = content.blurhash, - ratio = ratio, - description = content.description, - contentScale = contentScale, - preloadUrl = null, + preloadUrls = emptyList(), accountViewModel = accountViewModel, + modifier = mediaSizingModifier(ratio, contentScale), + backdrop = content.blurhash?.let { blurhash -> { BlurhashBackdrop(blurhash, content.description) } }, ) { Box( modifier = Modifier.fillMaxWidth().then(boundsTrackingModifier), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PictureDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PictureDisplay.kt index f08939a36..466764b6a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PictureDisplay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PictureDisplay.kt @@ -23,6 +23,8 @@ package com.vitorpamplona.amethyst.ui.note.types import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -37,12 +39,16 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow import com.vitorpamplona.amethyst.commons.model.EmptyTagList import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage +import com.vitorpamplona.amethyst.model.MediaAspectRatioCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.AutoNonlazyGrid -import com.vitorpamplona.amethyst.ui.components.SensitivityWarningOverBlurhashGrid +import com.vitorpamplona.amethyst.ui.components.BlurhashBackdrop +import com.vitorpamplona.amethyst.ui.components.BlurhashGridBackdrop +import com.vitorpamplona.amethyst.ui.components.ContentWarningGate import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.components.ZoomableContentView -import com.vitorpamplona.amethyst.ui.components.collectPictureReasons +import com.vitorpamplona.amethyst.ui.components.collectContentWarningReasons +import com.vitorpamplona.amethyst.ui.components.mediaSizingModifier import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer @@ -63,8 +69,7 @@ fun PictureDisplay( val event = (note.event as? PictureEvent) ?: return val uri = note.toNostrUri() val isSensitive = event.isSensitiveOrNSFW() - val reasons = collectPictureReasons(event) - val inGrid = event.imetaTags().size > 1 + val reasons = collectContentWarningReasons(event) val images by remember(note) { @@ -79,8 +84,6 @@ fun PictureDisplay( blurhash = it.blurhash, dim = it.dimension, uri = uri, - contentWarning = reasons.firstOrNull(), - isSensitive = isSensitive && !inGrid, mimeType = it.mimeType, ) }.toImmutableList(), @@ -106,19 +109,31 @@ fun PictureDisplay( } if (images.size == 1) { - ZoomableContentView( - content = images.first(), - images = images, - roundedCorner = roundedCorner, - contentScale = ContentScale.FillWidth, - accountViewModel = accountViewModel, - ) - } else { - SensitivityWarningOverBlurhashGrid( + val ratio = first.dim?.aspectRatio() ?: MediaAspectRatioCache.get(first.url) + ContentWarningGate( isSensitive = isSensitive, reasons = reasons, - media = images, + preloadUrls = listOf(first.url), accountViewModel = accountViewModel, + modifier = mediaSizingModifier(ratio, ContentScale.FillWidth), + backdrop = first.blurhash?.let { blurhash -> { BlurhashBackdrop(blurhash, first.description) } }, + ) { + ZoomableContentView( + content = first, + images = images, + roundedCorner = roundedCorner, + contentScale = ContentScale.FillWidth, + accountViewModel = accountViewModel, + ) + } + } else { + ContentWarningGate( + isSensitive = isSensitive, + reasons = reasons, + preloadUrls = images.map { it.url }, + accountViewModel = accountViewModel, + modifier = Modifier.fillMaxWidth().aspectRatio(1f), + backdrop = { BlurhashGridBackdrop(images) }, ) { AutoNonlazyGrid(images.size) { ZoomableContentView( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VideoDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VideoDisplay.kt index a5b73df9e..50041196f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VideoDisplay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VideoDisplay.kt @@ -29,12 +29,16 @@ import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo import com.vitorpamplona.amethyst.commons.richtext.RichTextParser +import com.vitorpamplona.amethyst.model.MediaAspectRatioCache import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.BlurhashBackdrop +import com.vitorpamplona.amethyst.ui.components.ContentWarningGate import com.vitorpamplona.amethyst.ui.components.ZoomableContentView +import com.vitorpamplona.amethyst.ui.components.collectContentWarningReasons +import com.vitorpamplona.amethyst.ui.components.mediaSizingModifier import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip31Alts.alt -import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW import com.vitorpamplona.quartz.nip71Video.VideoEvent @@ -49,13 +53,13 @@ fun JustVideoDisplay( val event = (videoEvent as? Event) ?: return val imeta = videoEvent.imetaTags().getOrNull(0) ?: return + val isSensitive = event.isSensitiveOrNSFW() + val reasons = collectContentWarningReasons(event) + val isImage = imeta.mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(imeta.url) val content by remember(note) { val description = event.content.ifEmpty { null } ?: imeta.alt ?: event.alt() - val isImage = imeta.mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(imeta.url) - val isSensitive = event.isSensitiveOrNSFW() - val contentWarning = event.contentWarningReason() mutableStateOf( if (isImage) { @@ -66,8 +70,6 @@ fun JustVideoDisplay( blurhash = imeta.blurhash, dim = imeta.dimension, uri = note.toNostrUri(), - contentWarning = contentWarning, - isSensitive = isSensitive, mimeType = imeta.mimeType, ) } else { @@ -79,18 +81,27 @@ fun JustVideoDisplay( dim = imeta.dimension, uri = note.toNostrUri(), authorName = note.author?.toBestDisplayName(), - contentWarning = contentWarning, - isSensitive = isSensitive, mimeType = imeta.mimeType, ) }, ) } - ZoomableContentView( - content = content, - roundedCorner = roundedCorner, - contentScale = contentScale, + val ratio = imeta.dimension?.aspectRatio() ?: MediaAspectRatioCache.get(imeta.url) + + ContentWarningGate( + isSensitive = isSensitive, + reasons = reasons, + preloadUrls = if (isImage) listOf(imeta.url) else emptyList(), accountViewModel = accountViewModel, - ) + modifier = mediaSizingModifier(ratio, contentScale), + backdrop = imeta.blurhash?.let { blurhash -> { BlurhashBackdrop(blurhash, content.description) } }, + ) { + ZoomableContentView( + content = content, + roundedCorner = roundedCorner, + contentScale = contentScale, + accountViewModel = accountViewModel, + ) + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureCardCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureCardCompose.kt index 23031d6bc..d6c210eda 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureCardCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureCardCompose.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -39,11 +40,15 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage +import com.vitorpamplona.amethyst.model.MediaAspectRatioCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.AutoNonlazyGrid -import com.vitorpamplona.amethyst.ui.components.SensitivityWarningOverBlurhashGrid +import com.vitorpamplona.amethyst.ui.components.BlurhashBackdrop +import com.vitorpamplona.amethyst.ui.components.BlurhashGridBackdrop +import com.vitorpamplona.amethyst.ui.components.ContentWarningGate import com.vitorpamplona.amethyst.ui.components.ZoomableContentView -import com.vitorpamplona.amethyst.ui.components.collectPictureReasons +import com.vitorpamplona.amethyst.ui.components.collectContentWarningReasons +import com.vitorpamplona.amethyst.ui.components.mediaSizingModifier import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.ReactionsRow import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -94,8 +99,7 @@ private fun PictureCardImage( ) { val uri = note.toNostrUri() val isSensitive = event.isSensitiveOrNSFW() - val reasons = collectPictureReasons(event) - val inGrid = event.imetaTags().size > 1 + val reasons = collectContentWarningReasons(event) val images by remember(note) { @@ -110,41 +114,50 @@ private fun PictureCardImage( blurhash = it.blurhash, dim = it.dimension, uri = uri, - contentWarning = reasons.firstOrNull(), - // In grid mode the warning is handled at the grid level to avoid - // one overlay per image. Single-image posts still warn per media. - isSensitive = isSensitive && !inGrid, mimeType = it.mimeType, ) }.toImmutableList(), ) } - if (images.isNotEmpty()) { - if (images.size == 1) { + if (images.isEmpty()) return + + if (images.size == 1) { + val single = images.first() + val ratio = single.dim?.aspectRatio() ?: MediaAspectRatioCache.get(single.url) + ContentWarningGate( + isSensitive = isSensitive, + reasons = reasons, + preloadUrls = listOf(single.url), + accountViewModel = accountViewModel, + modifier = mediaSizingModifier(ratio, ContentScale.FillWidth), + backdrop = single.blurhash?.let { blurhash -> { BlurhashBackdrop(blurhash, single.description) } }, + ) { ZoomableContentView( - content = images.first(), + content = single, images = images, roundedCorner = false, contentScale = ContentScale.FillWidth, accountViewModel = accountViewModel, ) - } else { - SensitivityWarningOverBlurhashGrid( - isSensitive = isSensitive, - reasons = reasons, - media = images, - accountViewModel = accountViewModel, - ) { - AutoNonlazyGrid(images.size) { - ZoomableContentView( - content = images[it], - images = images, - roundedCorner = false, - contentScale = ContentScale.Crop, - accountViewModel = accountViewModel, - ) - } + } + } else { + ContentWarningGate( + isSensitive = isSensitive, + reasons = reasons, + preloadUrls = images.map { it.url }, + accountViewModel = accountViewModel, + modifier = Modifier.fillMaxWidth().aspectRatio(1f), + backdrop = { BlurhashGridBackdrop(images) }, + ) { + AutoNonlazyGrid(images.size) { + ZoomableContentView( + content = images[it], + images = images, + roundedCorner = false, + contentScale = ContentScale.Crop, + accountViewModel = accountViewModel, + ) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/VideoCardCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/VideoCardCompose.kt index 21cab732e..5b0cace57 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/VideoCardCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/VideoCardCompose.kt @@ -42,15 +42,19 @@ import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo import com.vitorpamplona.amethyst.commons.richtext.RichTextParser +import com.vitorpamplona.amethyst.model.MediaAspectRatioCache import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.BlurhashBackdrop +import com.vitorpamplona.amethyst.ui.components.ContentWarningGate import com.vitorpamplona.amethyst.ui.components.ZoomableContentView +import com.vitorpamplona.amethyst.ui.components.collectContentWarningReasons +import com.vitorpamplona.amethyst.ui.components.mediaSizingModifier import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.ReactionsRow import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.UserCardHeader import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip31Alts.alt -import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW import com.vitorpamplona.quartz.nip71Video.VideoEvent import kotlin.text.ifEmpty @@ -99,13 +103,13 @@ private fun VideoCardImage( val event = (event as? Event) ?: return val imeta = videoEvent.imetaTags().getOrNull(0) ?: return + val isSensitive = event.isSensitiveOrNSFW() + val reasons = collectContentWarningReasons(event) + val isImage = imeta.mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(imeta.url) val content by remember(note) { val description = event.content.ifEmpty { null } ?: imeta.alt ?: event.alt() - val isImage = imeta.mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(imeta.url) - val isSensitive = event.isSensitiveOrNSFW() - val contentWarning = event.contentWarningReason() mutableStateOf( if (isImage) { @@ -116,8 +120,6 @@ private fun VideoCardImage( blurhash = imeta.blurhash, dim = imeta.dimension, uri = note.toNostrUri(), - contentWarning = contentWarning, - isSensitive = isSensitive, mimeType = imeta.mimeType, ) } else { @@ -129,20 +131,29 @@ private fun VideoCardImage( dim = imeta.dimension, uri = note.toNostrUri(), authorName = note.author?.toBestDisplayName(), - contentWarning = contentWarning, - isSensitive = isSensitive, mimeType = imeta.mimeType, ) }, ) } - ZoomableContentView( - content = content, - roundedCorner = false, - contentScale = ContentScale.FillWidth, + val ratio = imeta.dimension?.aspectRatio() ?: MediaAspectRatioCache.get(imeta.url) + + ContentWarningGate( + isSensitive = isSensitive, + reasons = reasons, + preloadUrls = if (isImage) listOf(imeta.url) else emptyList(), accountViewModel = accountViewModel, - ) + modifier = mediaSizingModifier(ratio, ContentScale.FillWidth), + backdrop = imeta.blurhash?.let { blurhash -> { BlurhashBackdrop(blurhash, content.description) } }, + ) { + ZoomableContentView( + content = content, + roundedCorner = false, + contentScale = ContentScale.FillWidth, + accountViewModel = accountViewModel, + ) + } } @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/FileHeaderCardCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/FileHeaderCardCompose.kt index c117b8eba..8d90d907d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/FileHeaderCardCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/FileHeaderCardCompose.kt @@ -42,14 +42,18 @@ import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo import com.vitorpamplona.amethyst.commons.richtext.RichTextParser +import com.vitorpamplona.amethyst.model.MediaAspectRatioCache import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.BlurhashBackdrop +import com.vitorpamplona.amethyst.ui.components.ContentWarningGate import com.vitorpamplona.amethyst.ui.components.ZoomableContentView +import com.vitorpamplona.amethyst.ui.components.collectContentWarningReasons +import com.vitorpamplona.amethyst.ui.components.mediaSizingModifier import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.ReactionsRow import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip31Alts.alt -import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent import kotlin.text.ifEmpty @@ -96,16 +100,17 @@ private fun FileHeaderCardImage( ) { val fullUrl = event.url() ?: return + val isSensitive = event.isSensitiveOrNSFW() + val reasons = collectContentWarningReasons(event) + val isImage = event.mimeType()?.startsWith("image/") == true || RichTextParser.isImageUrl(fullUrl) + val blurHash = event.blurhash() + val dimensions = event.dimensions() + val content by remember(note) { - val blurHash = event.blurhash() val hash = event.hash() - val dimensions = event.dimensions() val description = event.content.ifEmpty { null } ?: event.alt() - val isImage = event.mimeType()?.startsWith("image/") == true || RichTextParser.isImageUrl(fullUrl) val uri = note.toNostrUri() val mimeType = event.mimeType() - val isSensitive = event.isSensitiveOrNSFW() - val contentWarning = event.contentWarningReason() mutableStateOf( if (isImage) { @@ -116,8 +121,6 @@ private fun FileHeaderCardImage( blurhash = blurHash, dim = dimensions, uri = uri, - contentWarning = contentWarning, - isSensitive = isSensitive, mimeType = mimeType, ) } else { @@ -129,20 +132,29 @@ private fun FileHeaderCardImage( dim = dimensions, uri = uri, authorName = note.author?.toBestDisplayName(), - contentWarning = contentWarning, - isSensitive = isSensitive, mimeType = mimeType, ) }, ) } - ZoomableContentView( - content = content, - roundedCorner = false, - contentScale = ContentScale.FillWidth, + val ratio = dimensions?.aspectRatio() ?: MediaAspectRatioCache.get(fullUrl) + + ContentWarningGate( + isSensitive = isSensitive, + reasons = reasons, + preloadUrls = if (isImage) listOf(fullUrl) else emptyList(), accountViewModel = accountViewModel, - ) + modifier = mediaSizingModifier(ratio, ContentScale.FillWidth), + backdrop = blurHash?.let { blurhash -> { BlurhashBackdrop(blurhash, content.description) } }, + ) { + ZoomableContentView( + content = content, + roundedCorner = false, + contentScale = ContentScale.FillWidth, + accountViewModel = accountViewModel, + ) + } } @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt index 5f9d30c06..d390a1834 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt @@ -51,7 +51,6 @@ open class MediaUrlImage( dim: DimensionTag? = null, uri: String? = null, val contentWarning: String? = null, - val isSensitive: Boolean = contentWarning != null, mimeType: String? = null, ) : MediaUrlContent(url, description, hash, dim, blurhash, uri, mimeType) @@ -63,12 +62,11 @@ class EncryptedMediaUrlImage( dim: DimensionTag? = null, uri: String? = null, contentWarning: String? = null, - isSensitive: Boolean = contentWarning != null, mimeType: String? = null, val encryptionAlgo: String, val encryptionKey: ByteArray, val encryptionNonce: ByteArray, -) : MediaUrlImage(url, description, hash, blurhash, dim, uri, contentWarning, isSensitive, mimeType) +) : MediaUrlImage(url, description, hash, blurhash, dim, uri, contentWarning, mimeType) @Immutable open class MediaUrlVideo( @@ -81,7 +79,6 @@ open class MediaUrlVideo( val authorName: String? = null, blurhash: String? = null, val contentWarning: String? = null, - val isSensitive: Boolean = contentWarning != null, mimeType: String? = null, ) : MediaUrlContent(url, description, hash, dim, blurhash, uri, mimeType) @@ -96,12 +93,11 @@ class EncryptedMediaUrlVideo( authorName: String? = null, blurhash: String? = null, contentWarning: String? = null, - isSensitive: Boolean = contentWarning != null, mimeType: String? = null, val encryptionAlgo: String, val encryptionKey: ByteArray, val encryptionNonce: ByteArray, -) : MediaUrlVideo(url, description, hash, dim, uri, artworkUri, authorName, blurhash, contentWarning, isSensitive, mimeType) +) : MediaUrlVideo(url, description, hash, dim, uri, artworkUri, authorName, blurhash, contentWarning, mimeType) @Immutable abstract class MediaPreloadedContent( From 9fbe8eba78df6f4b066a87a1592665d6fa1184b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 23:59:50 +0000 Subject: [PATCH 22/48] refactor(pdf): share Coil disk cache, gate on showImages, cap bitmap size - PdfFetcher now reuses Amethyst.instance.diskCache (Coil) via openSnapshot/openEditor instead of a custom cacheDir folder. PDFs share the same LRU budget as images and benefit from automatic eviction. - PdfPreviewCard now respects accountViewModel.settings.showImages(): when disabled, shows a lightweight "Tap to load PDF" placeholder and only downloads+renders after the user opts in. - Both the card thumbnail (1600px) and viewer page (2048px) bitmaps are capped to a maximum longest-side dimension, preventing OOM on very large or unusually tall PDF pages. - PdfViewerDialog holds the cache snapshot for the dialog's lifetime so the underlying file can't be evicted mid-view, and closes it in DisposableEffect alongside the renderer and ParcelFileDescriptor. --- .../amethyst/ui/components/pdf/PdfFetcher.kt | 88 +++----- .../ui/components/pdf/PdfPreviewCard.kt | 211 ++++++++++++------ .../ui/components/pdf/PdfViewerDialog.kt | 48 ++-- 3 files changed, 206 insertions(+), 141 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfFetcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfFetcher.kt index fdc71ef55..4932b0a97 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfFetcher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfFetcher.kt @@ -20,70 +20,56 @@ */ package com.vitorpamplona.amethyst.ui.components.pdf -import android.content.Context -import com.vitorpamplona.quartz.nip01Core.core.toHexKey -import com.vitorpamplona.quartz.utils.sha256.sha256 +import coil3.disk.DiskCache +import com.vitorpamplona.amethyst.Amethyst import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.coroutines.executeAsync -import okio.sink -import java.io.File import java.io.IOException object PdfFetcher { - private const val CACHE_DIR_NAME = "pdf_cache" - - private fun cacheDir(context: Context): File = File(context.cacheDir, CACHE_DIR_NAME).also { it.mkdirs() } - - fun cachedFileOrNull( - context: Context, - url: String, - ): File? { - val file = cacheFile(context, url) - return if (file.exists() && file.length() > 0) file else null - } - - fun cacheFile( - context: Context, - url: String, - ): File { - val key = sha256(url.toByteArray()).toHexKey() - return File(cacheDir(context), "$key.pdf") - } - - suspend fun fetch( - context: Context, + /** + * Returns a snapshot of the cached PDF for [url], downloading it if necessary. The caller is + * responsible for closing the returned snapshot; while it's open the cache entry cannot be + * evicted, so the underlying file stays valid for `PdfRenderer`. + * + * Reuses the Coil disk cache (`Amethyst.instance.diskCache`) so PDFs share the same LRU + * eviction and disk budget as images. + */ + suspend fun fetchSnapshot( url: String, okHttpClient: (String) -> OkHttpClient, - ): File = - withContext(Dispatchers.IO) { - val file = cacheFile(context, url) - if (file.exists() && file.length() > 0) return@withContext file + ): DiskCache.Snapshot { + val diskCache = Amethyst.instance.diskCache + diskCache.openSnapshot(url)?.let { return it } - val request = - Request - .Builder() - .url(url) - .get() - .build() + return withContext(Dispatchers.IO) { + val editor = diskCache.openEditor(url) ?: throw IOException("Unable to open cache editor for $url") + try { + val request = + Request + .Builder() + .url(url) + .get() + .build() - okHttpClient(url).newCall(request).executeAsync().use { response -> - if (!response.isSuccessful) { - throw IOException("PDF download failed: ${response.code}") - } - val tmp = File(file.parentFile, "${file.name}.tmp") - tmp.outputStream().use { out -> - val bytes = response.body.source().readAll(out.sink()) - if (bytes == 0L) throw IOException("PDF download failed: empty response body") - } - if (!tmp.renameTo(file)) { - tmp.copyTo(file, overwrite = true) - tmp.delete() + okHttpClient(url).newCall(request).executeAsync().use { response -> + if (!response.isSuccessful) { + throw IOException("PDF download failed: ${response.code}") + } + diskCache.fileSystem.write(editor.data) { + val bytes = writeAll(response.body.source()) + if (bytes == 0L) throw IOException("PDF download failed: empty response body") + } } + + editor.commitAndOpenSnapshot() ?: throw IOException("Unable to commit cache editor for $url") + } catch (t: Throwable) { + runCatching { editor.abort() } + throw t } - - file } + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfPreviewCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfPreviewCard.kt index 15855f317..5d01fcb21 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfPreviewCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfPreviewCard.kt @@ -47,14 +47,13 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf import com.vitorpamplona.amethyst.ui.components.ClickableUrl -import com.vitorpamplona.amethyst.ui.components.DisplayUrlWithLoadingSymbol import com.vitorpamplona.amethyst.ui.components.ShareMediaAction -import com.vitorpamplona.amethyst.ui.components.WaitAndDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer import com.vitorpamplona.amethyst.ui.theme.MaxWidthWithHorzPadding @@ -65,6 +64,9 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +// Hard ceiling on the inline thumbnail bitmap, in pixels. Prevents OOM on very tall/large pages. +private const val THUMBNAIL_MAX_DIM_PX = 1600 + data class PdfPreview( val thumbnail: Bitmap, val pageCount: Int, @@ -81,47 +83,55 @@ private sealed class PdfLoadState { data object Failed : PdfLoadState() } -@OptIn(ExperimentalFoundationApi::class) @Composable fun PdfPreviewCard( content: MediaUrlPdf, accountViewModel: AccountViewModel, onOpen: () -> Unit, ) { - val context = LocalContext.current + val showPdf = remember { mutableStateOf(accountViewModel.settings.showImages()) } + + if (showPdf.value) { + LoadedPdfPreviewCard(content, accountViewModel, onOpen) + } else { + PlaceholderPdfCard(content) { showPdf.value = true } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun LoadedPdfPreviewCard( + content: MediaUrlPdf, + accountViewModel: AccountViewModel, + onOpen: () -> Unit, +) { val sharePopupExpanded = remember { mutableStateOf(false) } + val density = LocalDensity.current + val configuration = LocalConfiguration.current + val targetWidthPx = + remember(density, configuration) { + val screenPx = + with(density) { + configuration.screenWidthDp.dp + .toPx() + .toInt() + } + screenPx.coerceAtMost(THUMBNAIL_MAX_DIM_PX).coerceAtLeast(1) + } + @Suppress("ProduceStateDoesNotAssignValue") - val state by produceState(initialValue = PdfLoadState.Loading, key1 = content.url) { + val state by produceState(initialValue = PdfLoadState.Loading, key1 = content.url, key2 = targetWidthPx) { value = try { - val file = - PdfFetcher.fetch(context, content.url) { url -> + PdfFetcher + .fetchSnapshot(content.url) { url -> accountViewModel.httpClientBuilder.okHttpClientForPreview(url) - } - withContext(Dispatchers.IO) { - ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY).use { pfd -> - PdfRenderer(pfd).use { renderer -> - val pageCount = renderer.pageCount - if (pageCount <= 0) { - PdfLoadState.Failed - } else { - renderer.openPage(0).use { page -> - val bitmap = Bitmap.createBitmap(page.width, page.height, Bitmap.Config.ARGB_8888) - bitmap.eraseColor(android.graphics.Color.WHITE) - page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY) - PdfLoadState.Ready( - PdfPreview( - thumbnail = bitmap, - pageCount = pageCount, - aspectRatio = page.width.toFloat() / page.height.toFloat(), - ), - ) - } - } + }.use { snapshot -> + withContext(Dispatchers.IO) { + renderFirstPage(snapshot.data.toFile(), targetWidthPx) } } - } } catch (e: Exception) { if (e is CancellationException) throw e Log.w("PdfPreviewCard", "Failed to render PDF preview: ${content.url}", e) @@ -136,11 +146,11 @@ fun PdfPreviewCard( onDismiss = { sharePopupExpanded.value = false }, ) + val filename = remember(content.url) { extractFilename(content.url) } + when (val current = state) { is PdfLoadState.Loading -> { - WaitAndDisplay { - DisplayUrlWithLoadingSymbol(content, accountViewModel.toastManager::toast) - } + PdfSkeletonCard(filename) } is PdfLoadState.Failed -> { @@ -148,7 +158,6 @@ fun PdfPreviewCard( } is PdfLoadState.Ready -> { - val filename = remember(content.url) { extractFilename(content.url) } Column( modifier = MaterialTheme.colorScheme.innerPostModifier @@ -167,33 +176,7 @@ fun PdfPreviewCard( .aspectRatio(current.preview.aspectRatio.coerceAtLeast(0.2f)), ) - Row( - modifier = MaxWidthWithHorzPadding.padding(vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Icon( - imageVector = Icons.Outlined.PictureAsPdf, - contentDescription = null, - modifier = Size20Modifier, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - Column(modifier = Modifier.weight(1f)) { - Text( - text = filename, - style = MaterialTheme.typography.bodyMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - text = pageCountLabel(current.preview.pageCount), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - ) - } - } + FilenameRow(filename = filename, subtitle = pageCountLabel(current.preview.pageCount)) Spacer(modifier = DoubleVertSpacer) } @@ -201,10 +184,112 @@ fun PdfPreviewCard( } } -private fun extractFilename(url: String): String { +@Composable +private fun PlaceholderPdfCard( + content: MediaUrlPdf, + onLoad: () -> Unit, +) { + val filename = remember(content.url) { extractFilename(content.url) } + Column( + modifier = + MaterialTheme.colorScheme.innerPostModifier + .fillMaxWidth() + .combinedClickable(onClick = onLoad, onLongClick = onLoad), + ) { + FilenameRow(filename = filename, subtitle = "Tap to load PDF") + Spacer(modifier = DoubleVertSpacer) + } +} + +@Composable +private fun PdfSkeletonCard(filename: String) { + Column(modifier = MaterialTheme.colorScheme.innerPostModifier.fillMaxWidth()) { + FilenameRow(filename = filename, subtitle = "Loading…") + Spacer(modifier = DoubleVertSpacer) + } +} + +@Composable +private fun FilenameRow( + filename: String, + subtitle: String, +) { + Row( + modifier = MaxWidthWithHorzPadding.padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = Icons.Outlined.PictureAsPdf, + contentDescription = null, + modifier = Size20Modifier, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = filename, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } + } +} + +private fun renderFirstPage( + file: java.io.File, + targetWidthPx: Int, +): PdfLoadState = + ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY).use { pfd -> + PdfRenderer(pfd).use { renderer -> + val pageCount = renderer.pageCount + if (pageCount <= 0) return@use PdfLoadState.Failed + + renderer.openPage(0).use { page -> + val (renderW, renderH) = cappedRenderSize(page.width, page.height, targetWidthPx) + val bitmap = Bitmap.createBitmap(renderW, renderH, Bitmap.Config.ARGB_8888) + bitmap.eraseColor(android.graphics.Color.WHITE) + page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY) + PdfLoadState.Ready( + PdfPreview( + thumbnail = bitmap, + pageCount = pageCount, + aspectRatio = page.width.toFloat() / page.height.toFloat(), + ), + ) + } + } + } + +/** + * Scales the page's native point size to fit within [maxDim] on the longest side, preserving + * aspect ratio. Falls back to native size if it's already smaller. + */ +internal fun cappedRenderSize( + pageWidth: Int, + pageHeight: Int, + maxDim: Int, +): Pair { + if (pageWidth <= 0 || pageHeight <= 0) return 1 to 1 + val longest = maxOf(pageWidth, pageHeight) + if (longest <= maxDim) return pageWidth to pageHeight + val scale = maxDim.toFloat() / longest + val w = (pageWidth * scale).toInt().coerceAtLeast(1) + val h = (pageHeight * scale).toInt().coerceAtLeast(1) + return w to h +} + +internal fun extractFilename(url: String): String { val afterQuery = url.substringBefore('?').substringBefore('#') val name = afterQuery.substringAfterLast('/', afterQuery) return if (name.isBlank()) url else name } -private fun pageCountLabel(pageCount: Int): String = if (pageCount == 1) "1 page" else "$pageCount pages" +internal fun pageCountLabel(pageCount: Int): String = if (pageCount == 1) "1 page" else "$pageCount pages" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfViewerDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfViewerDialog.kt index c9a8a21ff..eb7402988 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfViewerDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfViewerDialog.kt @@ -60,9 +60,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties +import coil3.disk.DiskCache import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf import com.vitorpamplona.amethyst.ui.components.ShareMediaAction @@ -80,10 +80,12 @@ import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import net.engawapg.lib.zoomable.rememberZoomState import net.engawapg.lib.zoomable.zoomable -import java.io.File + +// Hard ceiling on each rendered page bitmap, in pixels. Prevents OOM on very large pages. +private const val VIEWER_MAX_DIM_PX = 2048 private class PdfDocumentHandle( - val file: File, + val snapshot: DiskCache.Snapshot, val pfd: ParcelFileDescriptor, val renderer: PdfRenderer, ) { @@ -91,18 +93,9 @@ private class PdfDocumentHandle( val mutex: Mutex = Mutex() fun close() { - try { - renderer.close() - } catch (e: Exception) { - if (e is CancellationException) throw e - Log.w("PdfViewerDialog", "Failed to close PdfRenderer", e) - } - try { - pfd.close() - } catch (e: Exception) { - if (e is CancellationException) throw e - Log.w("PdfViewerDialog", "Failed to close ParcelFileDescriptor", e) - } + runCatching { renderer.close() }.onFailure { Log.w("PdfViewerDialog", "renderer close failed", it) } + runCatching { pfd.close() }.onFailure { Log.w("PdfViewerDialog", "pfd close failed", it) } + runCatching { snapshot.close() }.onFailure { Log.w("PdfViewerDialog", "snapshot close failed", it) } } } @@ -136,20 +129,23 @@ private fun PdfViewerContent( accountViewModel: AccountViewModel, onDismiss: () -> Unit, ) { - val context = LocalContext.current - @Suppress("ProduceStateDoesNotAssignValue") val handleState by produceState(initialValue = null, key1 = content.url) { value = try { - val file = - PdfFetcher.fetch(context, content.url) { url -> - accountViewModel.httpClientBuilder.okHttpClientForPreview(url) - } withContext(Dispatchers.IO) { - val pfd = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY) - val renderer = PdfRenderer(pfd) - PdfDocumentHandle(file, pfd, renderer) + val snapshot = + PdfFetcher.fetchSnapshot(content.url) { url -> + accountViewModel.httpClientBuilder.okHttpClientForPreview(url) + } + try { + val pfd = ParcelFileDescriptor.open(snapshot.data.toFile(), ParcelFileDescriptor.MODE_READ_ONLY) + val renderer = PdfRenderer(pfd) + PdfDocumentHandle(snapshot, pfd, renderer) + } catch (t: Throwable) { + runCatching { snapshot.close() } + throw t + } } } catch (e: Exception) { if (e is CancellationException) throw e @@ -265,9 +261,7 @@ private fun PdfPageView( handle.mutex.withLock { withContext(Dispatchers.IO) { handle.renderer.openPage(pageIndex).use { page -> - val scale = 2f - val width = (page.width * scale).toInt().coerceAtLeast(1) - val height = (page.height * scale).toInt().coerceAtLeast(1) + val (width, height) = cappedRenderSize(page.width, page.height, VIEWER_MAX_DIM_PX) val bmp = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) bmp.eraseColor(android.graphics.Color.WHITE) page.render(bmp, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY) From 9ba2d2f5cc8819aae9f9508bc52dab8be3154966 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 00:12:26 +0000 Subject: [PATCH 23/48] feat(dvm-favorites): timeout, tests, and merged "All favourite DVMs" chip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. DVM response timeout. FavoriteDvmOrchestrator now times out after 20s if neither a 6300 response nor any 7000 status arrives, and sets errorMessage = "timeout" on the snapshot so the home banner switches from the "Asking…" spinner to a Retry button instead of hanging forever. 2. Tests. FavoriteDvmListEventTest covers create/add/remove round trips and the fixed-empty d-tag invariant; FavoriteDvmTopNavFilter match by id and by `a` address; FilterHomePostsByDvmIdsTest covers the two-relay-set split (content fetch on user relays, listen on DVM relays) and the multi-requestId merge case. Also registered kind 10090 in EventFactory so Quartz can deserialise FavoriteDvmListEvent (required for the round-trip tests and for reading the list back from relays). 3. Merged "All favourite DVMs" chip. New TopFilter.AllFavoriteDvms that unions every favourite's latest 6300 response into one feed. AllFavoriteDvmsFeedFlow uses flatMapLatest over the favourite-list flow so subscriptions rewire when the user adds/removes a DVM. FavoriteDvmTopNavPerRelayFilterSet now carries Set requestIds (was a single nullable) so the filter can subscribe to N kind 6300/7000 streams in one REQ per DVM relay. Banner renders "Asking your favourite DVMs for feeds…" while all are pending and a single Retry-all on collective error; pull-to-refresh re-issues every DVM's kind-5300. --- .../vitorpamplona/amethyst/model/Account.kt | 1 + .../amethyst/model/AccountSettings.kt | 2 + .../model/dvms/FavoriteDvmOrchestrator.kt | 20 ++ .../topNavFeeds/FeedTopNavFilterState.kt | 12 + .../favoriteDvm/AllFavoriteDvmsFeedFlow.kt | 115 ++++++++++ .../AllFavoriteDvmsTopNavFilter.kt | 67 ++++++ .../favoriteDvm/FavoriteDvmTopNavFilter.kt | 2 +- .../FavoriteDvmTopNavPerRelayFilterSet.kt | 9 +- .../navigation/topbars/FeedFilterSpinner.kt | 5 + .../amethyst/ui/screen/TopNavFilterState.kt | 13 +- .../screen/loggedIn/home/DvmStatusBanner.kt | 206 +++++++++++------- .../ui/screen/loggedIn/home/HomeScreen.kt | 13 +- .../nip90Dvms/FilterHomePostsByDvmIds.kt | 12 +- amethyst/src/main/res/values/strings.xml | 2 + .../FavoriteDvmTopNavFilterTest.kt | 129 +++++++++++ .../nip90Dvms/FilterHomePostsByDvmIdsTest.kt | 136 ++++++++++++ .../quartz/utils/EventFactory.kt | 2 + .../FavoriteDvmListEventTest.kt | 159 ++++++++++++++ 18 files changed, 814 insertions(+), 91 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/AllFavoriteDvmsFeedFlow.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/AllFavoriteDvmsTopNavFilter.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilterTest.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90Dvms/FilterHomePostsByDvmIdsTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/FavoriteDvmListEventTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index c6562a000..4f52e43d7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -427,6 +427,7 @@ class Account( signer = signer, scope = scope, favoriteDvmOrchestrator = favoriteDvmOrchestrator, + favoriteDvmAddresses = favoriteDvmList.flow, ).flow // App-ready Feeds diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index 0f327a174..15332850e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -160,6 +160,8 @@ sealed class TopFilter( class FavoriteDvm( val address: Address, ) : TopFilter("FavoriteDvm/${address.toValue()}") + + @Serializable object AllFavoriteDvms : TopFilter(" All Favourite DVMs ") } @Stable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/dvms/FavoriteDvmOrchestrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/dvms/FavoriteDvmOrchestrator.kt index cd92afa0f..ddf24813a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/dvms/FavoriteDvmOrchestrator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/dvms/FavoriteDvmOrchestrator.kt @@ -32,6 +32,7 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -41,6 +42,8 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +private const val RESPONSE_TIMEOUT_MS = 20_000L + /** * Immutable snapshot of a favourite DVM's current request/response state. * @@ -166,6 +169,23 @@ class FavoriteDvmOrchestrator( seed.update { it.copy(latestStatus = status) } } } + + // If nothing arrives within RESPONSE_TIMEOUT_MS (neither a 6300 + // response nor any 7000 status), surface an error so the banner + // can show Retry instead of spinning forever. + launch { + delay(RESPONSE_TIMEOUT_MS) + val current = seed.value + val stillWaiting = + current.requestId == requestId && + current.ids.isEmpty() && + current.addresses.isEmpty() && + current.latestStatus == null && + current.errorMessage == null + if (stillWaiting) { + seed.update { it.copy(errorMessage = "timeout") } + } + } } catch (e: Exception) { if (e is CancellationException) throw e Log.w("FavoriteDvmOrchestrator", "Failed to start DVM request: ${e.message}", e) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt index 27635f019..be72d1b26 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt @@ -31,12 +31,14 @@ import com.vitorpamplona.amethyst.model.topNavFeeds.allUserFollows.Kind3UserFoll import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.AroundMeFeedFlow import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.GeohashFeedFlow import com.vitorpamplona.amethyst.model.topNavFeeds.chess.ChessFeedFlow +import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteDvm.AllFavoriteDvmsFeedFlow import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteDvm.FavoriteDvmFeedFlow import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalFeedFlow import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagFeedFlow import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.NoteFeedFlow import com.vitorpamplona.amethyst.model.topNavFeeds.relay.RelayFeedFlow import com.vitorpamplona.amethyst.service.location.LocationState +import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner @@ -65,6 +67,7 @@ class FeedTopNavFilterState( val signer: NostrSigner, val scope: CoroutineScope, val favoriteDvmOrchestrator: FavoriteDvmOrchestrator, + val favoriteDvmAddresses: StateFlow>, ) { fun loadFlowsFor(listName: TopFilter): IFeedFlowsType = when (listName) { @@ -154,6 +157,15 @@ class FeedTopNavFilterState( proxyRelays = proxyRelays, ) } + + TopFilter.AllFavoriteDvms -> { + AllFavoriteDvmsFeedFlow( + favoriteDvmAddresses = favoriteDvmAddresses, + orchestrator = favoriteDvmOrchestrator, + outboxRelays = followsRelays, + proxyRelays = proxyRelays, + ) + } } @OptIn(ExperimentalCoroutinesApi::class) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/AllFavoriteDvmsFeedFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/AllFavoriteDvmsFeedFlow.kt new file mode 100644 index 000000000..8f880a4b1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/AllFavoriteDvmsFeedFlow.kt @@ -0,0 +1,115 @@ +/* + * 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.model.topNavFeeds.favoriteDvm + +import com.vitorpamplona.amethyst.model.dvms.FavoriteDvmOrchestrator +import com.vitorpamplona.amethyst.model.dvms.FavoriteDvmSnapshot +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedFlowsType +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest + +/** + * Feed flow that merges snapshots from every currently-favourited DVM into a + * single [AllFavoriteDvmsTopNavFilter]. Re-wires subscriptions whenever the + * favourite set changes. + */ +class AllFavoriteDvmsFeedFlow( + val favoriteDvmAddresses: StateFlow>, + val orchestrator: FavoriteDvmOrchestrator, + val outboxRelays: StateFlow>, + val proxyRelays: StateFlow>, +) : IFeedFlowsType { + private fun resolveContentRelays( + outbox: Set, + proxy: Set, + ): Set = if (proxy.isNotEmpty()) proxy else outbox + + private fun merge( + snapshots: List, + contentRelays: Set, + ): AllFavoriteDvmsTopNavFilter { + val ids = mutableSetOf() + val addresses = mutableSetOf() + val listen = mutableSetOf() + val requestIds = mutableSetOf() + snapshots.forEach { snap -> + ids += snap.ids + addresses += snap.addresses + listen += snap.responseRelays + snap.requestId?.let { requestIds += it } + } + return AllFavoriteDvmsTopNavFilter( + acceptedIds = ids, + acceptedAddresses = addresses, + contentRelays = contentRelays, + listenRelays = listen, + requestIds = requestIds, + ) + } + + private fun emptyFilter(contentRelays: Set): AllFavoriteDvmsTopNavFilter = + AllFavoriteDvmsTopNavFilter( + acceptedIds = emptySet(), + acceptedAddresses = emptySet(), + contentRelays = contentRelays, + listenRelays = emptySet(), + requestIds = emptySet(), + ) + + @OptIn(ExperimentalCoroutinesApi::class) + override fun flow(): Flow = + favoriteDvmAddresses.flatMapLatest { addresses -> + if (addresses.isEmpty()) { + combine(outboxRelays, proxyRelays) { outbox, proxy -> + emptyFilter(resolveContentRelays(outbox, proxy)) + } + } else { + val snapshotFlows: List> = addresses.map { orchestrator.observe(it) } + combine(snapshotFlows) { it.toList() } + .let { merged -> + combine(merged, outboxRelays, proxyRelays) { snaps, outbox, proxy -> + merge(snaps, resolveContentRelays(outbox, proxy)) + } + } + } + } + + override fun startValue(): AllFavoriteDvmsTopNavFilter { + val contentRelays = resolveContentRelays(outboxRelays.value, proxyRelays.value) + val addresses = favoriteDvmAddresses.value + return if (addresses.isEmpty()) { + emptyFilter(contentRelays) + } else { + merge(addresses.map { orchestrator.observe(it).value }, contentRelays) + } + } + + override suspend fun startValue(collector: FlowCollector) { + collector.emit(startValue()) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/AllFavoriteDvmsTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/AllFavoriteDvmsTopNavFilter.kt new file mode 100644 index 000000000..f686bec2b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/AllFavoriteDvmsTopNavFilter.kt @@ -0,0 +1,67 @@ +/* + * 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.model.topNavFeeds.favoriteDvm + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter +import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +/** + * Top-nav filter that unions the latest kind-6300 responses from every currently + * favourited DVM. Behaves like [FavoriteDvmTopNavFilter] (pure membership check + * against a snapshot), but the accepted set is the union across N DVMs and the + * request-id list carries one entry per DVM for the relay-listen subscription. + */ +@Immutable +class AllFavoriteDvmsTopNavFilter( + val acceptedIds: Set, + val acceptedAddresses: Set, + val contentRelays: Set, + val listenRelays: Set, + val requestIds: Set, +) : IFeedTopNavFilter { + override fun matchAuthor(pubkey: HexKey): Boolean = true + + override fun match(noteEvent: Event): Boolean = + noteEvent.id in acceptedIds || + (noteEvent is AddressableEvent && noteEvent.addressTag() in acceptedAddresses) + + override fun toPerRelayFlow(cache: LocalCache): Flow = MutableStateFlow(startValue(cache)) + + override fun startValue(cache: LocalCache): FavoriteDvmTopNavPerRelayFilterSet = + FavoriteDvmTopNavPerRelayFilterSet( + contentFetches = + contentRelays.associateWith { + FavoriteDvmTopNavPerRelayFilter( + ids = acceptedIds, + addresses = acceptedAddresses, + ) + }, + listenRelays = listenRelays, + requestIds = requestIds, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilter.kt index e521c6d5b..88924a2a4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilter.kt @@ -65,6 +65,6 @@ class FavoriteDvmTopNavFilter( ) }, listenRelays = listenRelays, - requestId = requestId, + requestIds = setOfNotNull(requestId), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilterSet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilterSet.kt index 321f5ca2c..399d2ab31 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilterSet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilterSet.kt @@ -29,11 +29,14 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl * * - [contentFetches] — for each user-configured content relay, the ids/addresses * we want to pull (the actual notes the DVM curated). - * - [listenRelays] — the DVM's own publish relays (where it will deliver future - * kind 6300 / 7000 events for this request). + * - [listenRelays] — the union of DVM publish relays across all active DVMs + * (where they will deliver future kind 6300 / 7000 events for their requests). + * - [requestIds] — the set of currently-active kind-5300 request ids to listen + * for. A single-DVM filter carries one; the merged "All favourite DVMs" + * filter carries one per favourite DVM. */ class FavoriteDvmTopNavPerRelayFilterSet( val contentFetches: Map, val listenRelays: Set, - val requestId: HexKey?, + val requestIds: Set, ) : IFeedTopNavPerRelayFilterSet diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt index 64c3ac1e9..c08cf8ba4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt @@ -399,6 +399,7 @@ private fun groupFeedDefinitions(options: ImmutableList): Map FeedGroup.LOCATIONS is TopFilter.Global -> FeedGroup.RELAYS + is TopFilter.AllFavoriteDvms -> FeedGroup.DVMS else -> FeedGroup.FEEDS } } @@ -567,6 +568,10 @@ private fun FeedIcon( Icons.Outlined.AutoAwesome } + is TopFilter.AllFavoriteDvms -> { + Icons.Outlined.AutoAwesome + } + else -> { when (item.name) { is GeoHashName -> Icons.Outlined.LocationOn diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt index a4d9cb8b6..7d2003e85 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt @@ -98,6 +98,12 @@ class TopNavFilterState( name = ResourceName(R.string.follow_list_chess), ) + val allFavoriteDvmsFollow = + FeedDefinition( + code = TopFilter.AllFavoriteDvms, + name = ResourceName(R.string.follow_list_all_favorite_dvms), + ) + val defaultLists = persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, muteListFollow) fun mergePeopleLists( @@ -192,7 +198,12 @@ class TopNavFilterState( ) } - return (communities + hashtags + geotags + relays + favoriteDvms).sortedBy { it.name.name() } + // Only show the "All favourite DVMs" meta-chip when there is at least one + // real favourite to merge; otherwise the chip opens to an empty feed. + val allFavorites = + if (favoriteDvms.isNotEmpty()) listOf(allFavoriteDvmsFollow) else emptyList() + + return (communities + hashtags + geotags + relays + allFavorites + favoriteDvms).sortedBy { it.name.name() } } @OptIn(ExperimentalCoroutinesApi::class) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/DvmStatusBanner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/DvmStatusBanner.kt index 487a9524b..de3335425 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/DvmStatusBanner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/DvmStatusBanner.kt @@ -42,6 +42,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.TopFilter +import com.vitorpamplona.amethyst.model.dvms.FavoriteDvmSnapshot import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteAndMap import com.vitorpamplona.amethyst.ui.components.LoadNote import com.vitorpamplona.amethyst.ui.components.LoadingAnimation @@ -61,8 +62,19 @@ fun HomeDvmStatusBanner( val topFilter by accountViewModel.account.settings.defaultHomeFollowList .collectAsStateWithLifecycle() - val favDvm = topFilter as? TopFilter.FavoriteDvm ?: return + when (val filter = topFilter) { + is TopFilter.FavoriteDvm -> SingleDvmBanner(filter, accountViewModel, nav) + is TopFilter.AllFavoriteDvms -> AllFavoriteDvmsBanner(accountViewModel) + else -> Unit + } +} +@Composable +private fun SingleDvmBanner( + favDvm: TopFilter.FavoriteDvm, + accountViewModel: AccountViewModel, + nav: INav, +) { val snapshot by accountViewModel.account.favoriteDvmOrchestrator .observe(favDvm.address) .collectAsStateWithLifecycle() @@ -83,86 +95,133 @@ fun HomeDvmStatusBanner( ?: "" } - Surface( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 6.dp), - shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.surfaceContainerLow, - ) { - Column(modifier = Modifier.padding(12.dp)) { - val status = snapshot.latestStatus?.status() + BannerCard { + val status = snapshot.latestStatus?.status() - when { - snapshot.errorMessage != null -> { - BannerMessageRow( - message = stringRes(R.string.dvm_home_status_error), - showSpinner = false, - ) + when { + snapshot.errorMessage != null -> { + BannerMessageRow( + message = stringRes(R.string.dvm_home_status_error), + showSpinner = false, + ) + Spacer(modifier = StdVertSpacer) + RetryButton { accountViewModel.refreshFavoriteDvm(favDvm.address) } + } + + status?.code == "payment-required" -> { + BannerMessageRow( + message = + status.description.ifBlank { + stringRes(R.string.dvm_home_status_payment_required) + }, + showSpinner = false, + ) + Spacer(modifier = StdVertSpacer) + var statusOverride by remember { mutableStateOf(null) } + val msg = statusOverride + if (msg != null) { + Text(text = msg, style = MaterialTheme.typography.bodySmall) Spacer(modifier = StdVertSpacer) - RetryButton(favDvm, accountViewModel) } - - status?.code == "payment-required" -> { - BannerMessageRow( - message = - status.description.ifBlank { - stringRes(R.string.dvm_home_status_payment_required) - }, - showSpinner = false, - ) - Spacer(modifier = StdVertSpacer) - var statusOverride by remember { mutableStateOf(null) } - val msg = statusOverride - if (msg != null) { - Text(text = msg, style = MaterialTheme.typography.bodySmall) - Spacer(modifier = StdVertSpacer) - } - snapshot.latestStatus?.let { - DvmPaymentActions( - latestStatus = it, - accountViewModel = accountViewModel, - nav = nav, - onStatusUpdate = { statusOverride = it }, - ) - } - } - - status?.code == "error" -> { - BannerMessageRow( - message = - status.description.ifBlank { - stringRes(R.string.dvm_home_status_error) - }, - showSpinner = false, - ) - Spacer(modifier = StdVertSpacer) - RetryButton(favDvm, accountViewModel) - } - - status?.code == "processing" -> { - BannerMessageRow( - message = - status.description.ifBlank { - stringRes(R.string.dvm_home_status_processing) - }, - showSpinner = true, - ) - } - - else -> { - BannerMessageRow( - message = stringRes(R.string.dvm_home_status_requesting, resolvedName), - showSpinner = true, + snapshot.latestStatus?.let { + DvmPaymentActions( + latestStatus = it, + accountViewModel = accountViewModel, + nav = nav, + onStatusUpdate = { statusOverride = it }, ) } } + + status?.code == "error" -> { + BannerMessageRow( + message = + status.description.ifBlank { + stringRes(R.string.dvm_home_status_error) + }, + showSpinner = false, + ) + Spacer(modifier = StdVertSpacer) + RetryButton { accountViewModel.refreshFavoriteDvm(favDvm.address) } + } + + status?.code == "processing" -> { + BannerMessageRow( + message = + status.description.ifBlank { + stringRes(R.string.dvm_home_status_processing) + }, + showSpinner = true, + ) + } + + else -> { + BannerMessageRow( + message = stringRes(R.string.dvm_home_status_requesting, resolvedName), + showSpinner = true, + ) + } } } } } +@Composable +private fun AllFavoriteDvmsBanner(accountViewModel: AccountViewModel) { + val addresses by accountViewModel.account.favoriteDvmList.flow + .collectAsStateWithLifecycle() + + if (addresses.isEmpty()) return + + // Observe each DVM's snapshot so we can decide whether to hide the banner + // based on the aggregate state. Hide it as soon as any DVM has produced a + // feed; only error out when every one of them has errored. + val snapshots: List = + addresses.map { address -> + val snap by accountViewModel.account.favoriteDvmOrchestrator + .observe(address) + .collectAsStateWithLifecycle() + snap + } + + val anyResponded = snapshots.any { it.ids.isNotEmpty() || it.addresses.isNotEmpty() } + if (anyResponded) return + + val allErrored = snapshots.all { it.errorMessage != null || it.latestStatus?.status()?.code == "error" } + + BannerCard { + if (allErrored) { + BannerMessageRow( + message = stringRes(R.string.dvm_home_status_error), + showSpinner = false, + ) + Spacer(modifier = StdVertSpacer) + RetryButton { + addresses.forEach { accountViewModel.refreshFavoriteDvm(it) } + } + } else { + BannerMessageRow( + message = stringRes(R.string.dvm_home_status_requesting_all), + showSpinner = true, + ) + } + } +} + +@Composable +private fun BannerCard(content: @Composable () -> Unit) { + Surface( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 6.dp), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceContainerLow, + ) { + Column(modifier = Modifier.padding(12.dp)) { content() } + } +} + @Composable private fun BannerMessageRow( message: String, @@ -185,11 +244,8 @@ private fun BannerMessageRow( } @Composable -private fun RetryButton( - favDvm: TopFilter.FavoriteDvm, - accountViewModel: AccountViewModel, -) { - OutlinedButton(onClick = { accountViewModel.refreshFavoriteDvm(favDvm.address) }) { +private fun RetryButton(onClick: () -> Unit) { + OutlinedButton(onClick = onClick) { Text(stringRes(R.string.dvm_home_retry)) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt index 70a539a9e..76b574544 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt @@ -284,14 +284,17 @@ fun HomeFeeds( ) { val activeFilter by accountViewModel.account.settings.defaultHomeFollowList .collectAsStateWithLifecycle() - val activeDvm = activeFilter as? TopFilter.FavoriteDvm + val favoriteDvmAddresses by accountViewModel.account.favoriteDvmList.flow + .collectAsStateWithLifecycle() val onRefresh: () -> Unit = { feedState.invalidateData() - if (activeDvm != null) { - // Swiping down on Home should also re-issue the kind-5300 request so the - // DVM produces a fresh feed, not just re-render whatever's cached. - accountViewModel.refreshFavoriteDvm(activeDvm.address) + // Swiping down on Home should also re-issue the kind-5300 request(s) so the + // DVM(s) produce fresh feeds, not just re-render whatever's cached. + when (val filter = activeFilter) { + is TopFilter.FavoriteDvm -> accountViewModel.refreshFavoriteDvm(filter.address) + is TopFilter.AllFavoriteDvms -> favoriteDvmAddresses.forEach { accountViewModel.refreshFavoriteDvm(it) } + else -> Unit } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90Dvms/FilterHomePostsByDvmIds.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90Dvms/FilterHomePostsByDvmIds.kt index 856f6fd9c..57afdd401 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90Dvms/FilterHomePostsByDvmIds.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90Dvms/FilterHomePostsByDvmIds.kt @@ -56,10 +56,10 @@ fun filterHomePostsByDvmIds( out += contentFetchFilters(relay, filter) } - val requestId = set.requestId - if (requestId != null) { + if (set.requestIds.isNotEmpty()) { + val requestIds = set.requestIds.toList() set.listenRelays.forEach { relay -> - out += responseListenFilter(relay, requestId) + out += responseListenFilter(relay, requestIds) } } @@ -101,7 +101,7 @@ private fun contentFetchFilters( private fun responseListenFilter( relay: NormalizedRelayUrl, - requestId: HexKey, + requestIds: List, ) = RelayBasedFilter( relay = relay, filter = @@ -111,7 +111,7 @@ private fun responseListenFilter( NIP90ContentDiscoveryResponseEvent.KIND, NIP90StatusEvent.KIND, ), - tags = mapOf("e" to listOf(requestId)), - limit = 10, + tags = mapOf("e" to requestIds), + limit = 10 * requestIds.size, ), ) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index aa11b8aa3..cbd3e0154 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1729,6 +1729,7 @@ Communities Lists DVMs + All favourite DVMs Relays Add DVM to favorites @@ -1737,6 +1738,7 @@ Content-discovery DVMs you starred here appear as filter chips on the Home feed. Open Discover to add more. No favourite DVMs yet. Open Discover, tap a content-discovery DVM, and star it to add it here. Asking %1$s for a feed… + Asking your favourite DVMs for feeds… Processing your feed… This DVM requires payment DVM returned an error diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilterTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilterTest.kt new file mode 100644 index 000000000..c55e3927e --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilterTest.kt @@ -0,0 +1,129 @@ +/* + * 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.model.topNavFeeds.favoriteDvm + +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class FavoriteDvmTopNavFilterTest { + private fun textNote(id: String) = TextNoteEvent(id = id, pubKey = "a".repeat(64), createdAt = 1, tags = emptyArray(), content = "", sig = "x".repeat(128)) + + private fun longFormNote( + pubkey: String, + dTag: String, + ) = LongTextNoteEvent( + id = "0".repeat(64), + pubKey = pubkey, + createdAt = 1, + tags = arrayOf(arrayOf("d", dTag)), + content = "", + sig = "x".repeat(128), + ) + + private val dvmAddress = Address(31990, "d".repeat(64), "content") + + @Test + fun matchesNoteWhoseIdIsInAcceptedSet() { + val filter = + FavoriteDvmTopNavFilter( + dvmAddress = dvmAddress, + acceptedIds = setOf("1".repeat(64)), + acceptedAddresses = emptySet(), + contentRelays = emptySet(), + listenRelays = emptySet(), + requestId = null, + ) + + assertTrue(filter.match(textNote("1".repeat(64)))) + } + + @Test + fun rejectsNoteNotInAcceptedSet() { + val filter = + FavoriteDvmTopNavFilter( + dvmAddress = dvmAddress, + acceptedIds = setOf("1".repeat(64)), + acceptedAddresses = emptySet(), + contentRelays = emptySet(), + listenRelays = emptySet(), + requestId = null, + ) + + assertFalse(filter.match(textNote("2".repeat(64)))) + } + + @Test + fun matchesAddressableEventByAddressTag() { + val articleAuthor = "c".repeat(64) + val articleDTag = "my-post" + val articleAddress = "30023:$articleAuthor:$articleDTag" + + val filter = + FavoriteDvmTopNavFilter( + dvmAddress = dvmAddress, + acceptedIds = emptySet(), + acceptedAddresses = setOf(articleAddress), + contentRelays = emptySet(), + listenRelays = emptySet(), + requestId = null, + ) + + assertTrue(filter.match(longFormNote(articleAuthor, articleDTag))) + } + + @Test + fun nullRequestIdCollapsesToEmptyRequestIdsInFilterSet() { + val filter = + FavoriteDvmTopNavFilter( + dvmAddress = dvmAddress, + acceptedIds = emptySet(), + acceptedAddresses = emptySet(), + contentRelays = emptySet(), + listenRelays = emptySet(), + requestId = null, + ) + + // passing a LocalCache is only needed because the method demands it; + // FavoriteDvmTopNavFilter.startValue doesn't actually consult it. + val set = filter.startValue(com.vitorpamplona.amethyst.model.LocalCache) + assertTrue(set.requestIds.isEmpty()) + } + + @Test + fun nonNullRequestIdProducesSingletonInFilterSet() { + val filter = + FavoriteDvmTopNavFilter( + dvmAddress = dvmAddress, + acceptedIds = emptySet(), + acceptedAddresses = emptySet(), + contentRelays = emptySet(), + listenRelays = emptySet(), + requestId = "9".repeat(64), + ) + + val set = filter.startValue(com.vitorpamplona.amethyst.model.LocalCache) + assertTrue(set.requestIds == setOf("9".repeat(64))) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90Dvms/FilterHomePostsByDvmIdsTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90Dvms/FilterHomePostsByDvmIdsTest.kt new file mode 100644 index 000000000..62086f8b3 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90Dvms/FilterHomePostsByDvmIdsTest.kt @@ -0,0 +1,136 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip90Dvms + +import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteDvm.FavoriteDvmTopNavPerRelayFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteDvm.FavoriteDvmTopNavPerRelayFilterSet +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class FilterHomePostsByDvmIdsTest { + private val userRelay = RelayUrlNormalizer.normalizeOrNull("wss://user.example/")!! + private val dvmRelay = RelayUrlNormalizer.normalizeOrNull("wss://dvm.example/")!! + + @Test + fun emptyFilterSetProducesNoRequests() { + val set = + FavoriteDvmTopNavPerRelayFilterSet( + contentFetches = emptyMap(), + listenRelays = emptySet(), + requestIds = emptySet(), + ) + + assertTrue(filterHomePostsByDvmIds(set, since = null, defaultSince = null).isEmpty()) + } + + @Test + fun contentFetchIssuedOnUserRelayWithIdsFilter() { + val ids = setOf("a".repeat(64), "b".repeat(64)) + val set = + FavoriteDvmTopNavPerRelayFilterSet( + contentFetches = + mapOf(userRelay to FavoriteDvmTopNavPerRelayFilter(ids = ids, addresses = emptySet())), + listenRelays = emptySet(), + requestIds = emptySet(), + ) + + val filters = filterHomePostsByDvmIds(set, since = null, defaultSince = null) + + assertEquals(1, filters.size) + val single = filters.single() + assertEquals(userRelay, single.relay) + assertEquals(ids.sorted(), single.filter.ids?.sorted()) + // Content fetch should not be restricted to a kind — the DVM curates freely. + assertEquals(null, single.filter.kinds) + } + + @Test + fun listenFilterIssuedOnDvmRelayWithKinds6300And7000() { + val requestId = "9".repeat(64) + val set = + FavoriteDvmTopNavPerRelayFilterSet( + contentFetches = emptyMap(), + listenRelays = setOf(dvmRelay), + requestIds = setOf(requestId), + ) + + val filters = filterHomePostsByDvmIds(set, since = null, defaultSince = null) + + assertEquals(1, filters.size) + val listen = filters.single() + assertEquals(dvmRelay, listen.relay) + assertEquals( + listOf(NIP90ContentDiscoveryResponseEvent.KIND, NIP90StatusEvent.KIND), + listen.filter.kinds, + ) + val eTag = listen.filter.tags?.get("e") + assertNotNull(eTag) + assertEquals(listOf(requestId), eTag) + } + + @Test + fun mergedRequestIdsAllRideOnOneListenFilterPerRelay() { + val req1 = "1".repeat(64) + val req2 = "2".repeat(64) + val set = + FavoriteDvmTopNavPerRelayFilterSet( + contentFetches = emptyMap(), + listenRelays = setOf(dvmRelay), + requestIds = setOf(req1, req2), + ) + + val filters = filterHomePostsByDvmIds(set, since = null, defaultSince = null) + + assertEquals(1, filters.size) + val eTag = + filters + .single() + .filter.tags + ?.get("e") + .orEmpty() + assertTrue(eTag.containsAll(listOf(req1, req2))) + assertEquals(2, eTag.size) + } + + @Test + fun contentAndListenSubscriptionsSplitAcrossTheirRespectiveRelays() { + val ids = setOf("a".repeat(64)) + val requestId = "9".repeat(64) + val set = + FavoriteDvmTopNavPerRelayFilterSet( + contentFetches = + mapOf(userRelay to FavoriteDvmTopNavPerRelayFilter(ids = ids, addresses = emptySet())), + listenRelays = setOf(dvmRelay), + requestIds = setOf(requestId), + ) + + val filters = filterHomePostsByDvmIds(set, since = null, defaultSince = null) + + assertEquals(2, filters.size) + assertTrue(filters.any { it.relay == userRelay && it.filter.ids != null }) + assertTrue(filters.any { it.relay == dvmRelay && it.filter.kinds != null }) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index 2ebafdbad..1e525e148 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -134,6 +134,7 @@ import com.vitorpamplona.quartz.nip51Lists.appCurationSet.AppCurationSetEvent import com.vitorpamplona.quartz.nip51Lists.articleCurationSet.ArticleCurationSetEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.favoriteDvmList.FavoriteDvmListEvent import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent import com.vitorpamplona.quartz.nip51Lists.gitAuthorList.GitAuthorListEvent @@ -422,6 +423,7 @@ class EventFactory { GoodWikiAuthorListEvent.KIND -> GoodWikiAuthorListEvent(id, pubKey, createdAt, tags, content, sig) GoodWikiRelayListEvent.KIND -> GoodWikiRelayListEvent(id, pubKey, createdAt, tags, content, sig) GoalEvent.KIND -> GoalEvent(id, pubKey, createdAt, tags, content, sig) + FavoriteDvmListEvent.KIND -> FavoriteDvmListEvent(id, pubKey, createdAt, tags, content, sig) HashtagListEvent.KIND -> HashtagListEvent(id, pubKey, createdAt, tags, content, sig) HighlightEvent.KIND -> HighlightEvent(id, pubKey, createdAt, tags, content, sig) HTTPAuthorizationEvent.KIND -> HTTPAuthorizationEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/FavoriteDvmListEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/FavoriteDvmListEventTest.kt new file mode 100644 index 000000000..88c36c6e4 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/FavoriteDvmListEventTest.kt @@ -0,0 +1,159 @@ +/* + * 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.quartz.nip51Lists.favoriteDvmList + +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark +import com.vitorpamplona.quartz.utils.nsecToKeyPair +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class FavoriteDvmListEventTest { + private val signer = NostrSignerInternal("nsec10g0wheggqn9dawlc0yuv6adnat6n09anr7eyykevw2dm8xa5fffs0wsdsr".nsecToKeyPair()) + + private fun dvm( + pubkey: String, + dTag: String = "content-discovery", + ) = AddressBookmark(Address(31990, pubkey, dTag)) + + @Test + fun kindMatchesSpec() { + assertEquals(10090, FavoriteDvmListEvent.KIND) + } + + @Test + fun addressesAreReplaceableWithFixedDTag() { + val address = FavoriteDvmListEvent.createAddress("a".repeat(64)) + assertEquals(10090, address.kind) + assertEquals("", address.dTag) + } + + @Test + fun createStoresDvmAsATag() = + runTest { + val dvm = dvm("a".repeat(64)) + + val event = + FavoriteDvmListEvent.create( + dvm = dvm, + isPrivate = false, + signer = signer, + createdAt = 1740669816, + ) + + assertEquals(10090, event.kind) + assertTrue( + event.tags.any { it.size >= 2 && it[0] == "a" && it[1] == dvm.address.toValue() }, + "public a tag for the favourited DVM should be present", + ) + val favorites = event.publicFavoriteDvms() + assertEquals(1, favorites.size) + assertEquals(dvm.address, favorites.first().address) + } + + @Test + fun addAppendsWithoutDuplicatingExistingEntry() = + runTest { + val dvm = dvm("a".repeat(64)) + + val initial = + FavoriteDvmListEvent.create( + dvm = dvm, + isPrivate = false, + signer = signer, + createdAt = 1740669816, + ) + + val afterDupeAdd = + FavoriteDvmListEvent.add( + earlierVersion = initial, + dvm = dvm, + isPrivate = false, + signer = signer, + createdAt = 1740669817, + ) + + assertEquals( + 1, + afterDupeAdd.publicFavoriteDvms().count { it.address == dvm.address }, + "re-adding the same DVM must not produce a duplicate tag", + ) + } + + @Test + fun addPreservesOtherFavorites() = + runTest { + val first = dvm("a".repeat(64)) + val second = dvm("b".repeat(64)) + + val initial = + FavoriteDvmListEvent.create( + dvm = first, + isPrivate = false, + signer = signer, + createdAt = 1740669816, + ) + + val after = + FavoriteDvmListEvent.add( + earlierVersion = initial, + dvm = second, + isPrivate = false, + signer = signer, + createdAt = 1740669817, + ) + + val addresses = after.publicFavoriteDvms().map { it.address }.toSet() + assertTrue(first.address in addresses) + assertTrue(second.address in addresses) + } + + @Test + fun removeDropsTheRequestedDvmOnly() = + runTest { + val first = dvm("a".repeat(64)) + val second = dvm("b".repeat(64)) + + val initial = + FavoriteDvmListEvent.create( + publicDvms = listOf(first, second), + privateDvms = emptyList(), + signer = signer, + createdAt = 1740669816, + ) + + val after = + FavoriteDvmListEvent.remove( + earlierVersion = initial, + dvm = first.address, + signer = signer, + createdAt = 1740669817, + ) + + val addresses = after.publicFavoriteDvms().map { it.address }.toSet() + assertFalse(first.address in addresses, "removed DVM should not survive") + assertTrue(second.address in addresses, "other DVMs should be preserved") + } +} From 0455d1f8c6b438deebbbc0cd632aaf575e0f9725 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Sun, 19 Apr 2026 05:21:12 +0300 Subject: [PATCH 24/48] feat(desktop): single-pane workspaces store nav bar screen list Change Workspace.singlePaneScreen (single string) to singlePaneScreens (list of typeKey strings). First screen is the default. On workspace switch, loads the screen list into PinnedNavBarState and navigates to the first screen. - Add SinglePaneScreensEditor to workspace editor dialog - Add PinnedNavBarState.loadFromList() for workspace-driven nav - Backward compat: load old "singlePaneScreen" format as single-item list - Cmd+Shift+S captures current deck columns as singlePaneScreens Co-Authored-By: Claude Opus 4.6 (1M context) --- .../vitorpamplona/amethyst/desktop/Main.kt | 23 ++++-- .../amethyst/desktop/ui/deck/AppDrawer.kt | 74 +++++++++++++------ .../desktop/ui/deck/PinnedNavBarState.kt | 5 ++ .../amethyst/desktop/ui/deck/Workspace.kt | 2 +- .../desktop/ui/deck/WorkspaceManager.kt | 13 +++- 5 files changed, 83 insertions(+), 34 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 3a76d9e14..bbac3c026 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -267,6 +267,12 @@ fun main() { iconName = "Star", layoutMode = layoutMode, columns = columns, + singlePaneScreens = + if (layoutMode == LayoutMode.SINGLE_PANE) { + columns.map { it.typeKey } + } else { + emptyList() + }, ) workspaceManager.addWorkspace(ws) } @@ -789,12 +795,17 @@ fun App( } LayoutMode.SINGLE_PANE -> { - val screenKey = - ws.singlePaneScreen - ?: ws.columns.firstOrNull()?.typeKey - ?: "home" - val type = DeckState.parseColumnTypeFromKey(screenKey) - if (type != null) singlePaneState.navigate(type) + // Set nav bar screens from workspace + if (ws.singlePaneScreens.isNotEmpty()) { + val screens = + ws.singlePaneScreens.mapNotNull { + DeckState.parseColumnTypeFromKey(it) + } + if (screens.isNotEmpty()) { + pinnedNavBarState.loadFromList(screens) + singlePaneState.navigate(screens.first()) + } + } } } }, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AppDrawer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AppDrawer.kt index 5a004bba4..0adca7e11 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AppDrawer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AppDrawer.kt @@ -55,6 +55,7 @@ import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.PushPin import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.SportsEsports +import androidx.compose.material.icons.filled.Star import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.DropdownMenu @@ -883,7 +884,9 @@ private fun WorkspaceEditorDialog( var columns by remember { mutableStateOf(initial?.columns ?: listOf(Workspace.WorkspaceColumn("home"))) } - var singlePaneScreen by remember { mutableStateOf(initial?.singlePaneScreen) } + var singlePaneScreens by remember { + mutableStateOf(initial?.singlePaneScreens ?: listOf("home")) + } AlertDialog( onDismissRequest = onDismiss, @@ -941,11 +944,11 @@ private fun WorkspaceEditorDialog( if (layoutMode == LayoutMode.DECK) { DeckColumnEditor(columns = columns, onColumnsChange = { columns = it }) } - // Single Pane: screen picker + // Single Pane: nav bar screen selector (first = default) if (layoutMode == LayoutMode.SINGLE_PANE) { - SinglePaneScreenPicker( - selected = singlePaneScreen, - onSelect = { singlePaneScreen = it }, + SinglePaneScreensEditor( + screens = singlePaneScreens, + onScreensChange = { singlePaneScreens = it }, ) } } @@ -963,7 +966,7 @@ private fun WorkspaceEditorDialog( iconName = iconName, layoutMode = layoutMode, columns = columns, - singlePaneScreen = singlePaneScreen, + singlePaneScreens = singlePaneScreens, ), ) }, @@ -1021,29 +1024,52 @@ private fun DeckColumnEditor( } @Composable -private fun SinglePaneScreenPicker( - selected: String?, - onSelect: (String) -> Unit, +private fun SinglePaneScreensEditor( + screens: List, + onScreensChange: (List) -> Unit, ) { - var expanded by remember { mutableStateOf(false) } - val selectedTitle = - selected?.let { DeckState.parseColumnTypeFromKey(it)?.title() } ?: "Select screen" - Column { - Text("Screen", style = MaterialTheme.typography.labelMedium) + Text("Nav Bar Screens", style = MaterialTheme.typography.labelMedium) + Text( + "First screen is the default", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) Spacer(Modifier.height(4.dp)) - TextButton(onClick = { expanded = true }) { Text(selectedTitle) } - DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { - LAUNCHABLE_SCREENS.filter { !it.requiresInput() }.forEach { screen -> - DropdownMenuItem( - text = { Text(screen.title()) }, - onClick = { - onSelect(screen.typeKey()) - expanded = false - }, - ) + screens.forEachIndexed { idx, key -> + val displayName = DeckState.parseColumnTypeFromKey(key)?.title() ?: key + Row(verticalAlignment = Alignment.CenterVertically) { + if (idx == 0) { + Icon( + Icons.Default.Star, + "Default", + Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.width(4.dp)) + } + Text(displayName, Modifier.weight(1f)) + IconButton(onClick = { + onScreensChange(screens.toMutableList().apply { removeAt(idx) }) + }) { Icon(Icons.Default.Close, "Remove", Modifier.size(16.dp)) } } } + // Add screen dropdown + var expanded by remember { mutableStateOf(false) } + TextButton(onClick = { expanded = true }) { Text("+ Add Screen") } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + PinnedNavBarState.PINNABLE_SCREENS + .filter { screen -> screens.none { it == screen.typeKey() } } + .forEach { screen -> + DropdownMenuItem( + text = { Text(screen.title()) }, + onClick = { + onScreensChange(screens + screen.typeKey()) + expanded = false + }, + ) + } + } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/PinnedNavBarState.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/PinnedNavBarState.kt index fe9252dd4..00d5f5eb9 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/PinnedNavBarState.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/PinnedNavBarState.kt @@ -67,6 +67,11 @@ class PinnedNavBarState { DesktopPreferences.pinnedNavItems = _pinnedScreens.value.joinToString(",") { it.typeKey() } } + fun loadFromList(screens: List) { + _pinnedScreens.value = screens.ifEmpty { DEFAULT_PINNED } + save() + } + fun load() { val raw = DesktopPreferences.pinnedNavItems if (raw.isBlank()) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/Workspace.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/Workspace.kt index 0c3e4f801..0e2d2c62a 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/Workspace.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/Workspace.kt @@ -47,7 +47,7 @@ data class Workspace( val iconName: String, val layoutMode: LayoutMode, val columns: List, - val singlePaneScreen: String? = null, + val singlePaneScreens: List = emptyList(), ) { data class WorkspaceColumn( val typeKey: String, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/WorkspaceManager.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/WorkspaceManager.kt index d4edbd1e9..2de3e9cf4 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/WorkspaceManager.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/WorkspaceManager.kt @@ -124,7 +124,7 @@ class WorkspaceManager( "name" to ws.name, "iconName" to ws.iconName, "layoutMode" to ws.layoutMode.name, - "singlePaneScreen" to ws.singlePaneScreen, + "singlePaneScreens" to ws.singlePaneScreens, "columns" to ws.columns.map { col -> mapOf( @@ -162,7 +162,14 @@ class WorkspaceManager( } catch (e: Exception) { LayoutMode.DECK } - val singlePaneScreen = entry["singlePaneScreen"] as? String + + @Suppress("UNCHECKED_CAST") + val singlePaneScreens = + (entry["singlePaneScreens"] as? List) ?: run { + // Backward compat: old format had single "singlePaneScreen" string + val legacy = entry["singlePaneScreen"] as? String + if (legacy != null) listOf(legacy) else emptyList() + } @Suppress("UNCHECKED_CAST") val columns = @@ -183,7 +190,7 @@ class WorkspaceManager( iconName = iconName, layoutMode = layoutMode, columns = columns, - singlePaneScreen = singlePaneScreen, + singlePaneScreens = singlePaneScreens, ) } catch (e: Exception) { null From 200c5227fcc2f48777291a04078670b0fc002269 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Sun, 19 Apr 2026 05:40:28 +0300 Subject: [PATCH 25/48] refactor(desktop): pin/unpin syncs to active workspace PinnedNavBarState now takes WorkspaceManager reference. Pin/unpin actions update the active workspace's singlePaneScreens list, making sidebar customization and workspace editing the same action. - PinnedNavBarState.syncToWorkspace() updates active workspace on pin/unpin - PinnedNavBarState.loadFromWorkspace() loads from active workspace - Remove separate DesktopPreferences.pinnedNavItems persistence - Workspace is the single source of truth for nav bar screens Co-Authored-By: Claude Opus 4.6 (1M context) --- .../vitorpamplona/amethyst/desktop/Main.kt | 19 +++----- .../desktop/ui/deck/PinnedNavBarState.kt | 46 +++++++++---------- 2 files changed, 29 insertions(+), 36 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index bbac3c026..f8c28cd80 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -540,7 +540,7 @@ fun App( initialTorSettings: com.vitorpamplona.amethyst.commons.tor.TorSettings, ) { val singlePaneState = remember { SinglePaneState() } - val pinnedNavBarState = remember { PinnedNavBarState().also { it.load() } } + val pinnedNavBarState = remember { PinnedNavBarState(workspaceManager).also { it.loadFromWorkspace() } } // Always reload from prefs — after key() rebuild, prefs have the latest saved settings var torSettings by remember { @@ -795,17 +795,12 @@ fun App( } LayoutMode.SINGLE_PANE -> { - // Set nav bar screens from workspace - if (ws.singlePaneScreens.isNotEmpty()) { - val screens = - ws.singlePaneScreens.mapNotNull { - DeckState.parseColumnTypeFromKey(it) - } - if (screens.isNotEmpty()) { - pinnedNavBarState.loadFromList(screens) - singlePaneState.navigate(screens.first()) - } - } + // Load nav bar from workspace + navigate to first screen + pinnedNavBarState.loadFromWorkspace() + val firstKey = + ws.singlePaneScreens.firstOrNull() ?: "home" + val type = DeckState.parseColumnTypeFromKey(firstKey) + if (type != null) singlePaneState.navigate(type) } } }, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/PinnedNavBarState.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/PinnedNavBarState.kt index 00d5f5eb9..6deb120b7 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/PinnedNavBarState.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/PinnedNavBarState.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.desktop.ui.deck -import com.vitorpamplona.amethyst.desktop.DesktopPreferences import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -28,9 +27,11 @@ import kotlinx.coroutines.flow.update /** * Manages which screens are pinned to the navigation sidebar. - * Persists to DesktopPreferences as CSV of typeKey strings. + * Pin/unpin syncs to the active workspace's singlePaneScreens. */ -class PinnedNavBarState { +class PinnedNavBarState( + private val workspaceManager: WorkspaceManager? = null, +) { private val _pinnedScreens = MutableStateFlow(DEFAULT_PINNED) val pinnedScreens: StateFlow> = _pinnedScreens.asStateFlow() @@ -40,13 +41,13 @@ class PinnedNavBarState { if (isPinned(type)) return if (!isPinnable(type)) return _pinnedScreens.update { it + type } - save() + syncToWorkspace() } fun unpin(type: DeckColumnType) { if (!isUnpinnable(type)) return _pinnedScreens.update { current -> current.filter { it.typeKey() != type.typeKey() } } - save() + syncToWorkspace() } fun move( @@ -60,34 +61,32 @@ class PinnedNavBarState { mutable.add(toIndex, item) mutable.toList() } - save() - } - - fun save() { - DesktopPreferences.pinnedNavItems = _pinnedScreens.value.joinToString(",") { it.typeKey() } + syncToWorkspace() } fun loadFromList(screens: List) { _pinnedScreens.value = screens.ifEmpty { DEFAULT_PINNED } - save() } - fun load() { - val raw = DesktopPreferences.pinnedNavItems - if (raw.isBlank()) { - _pinnedScreens.value = DEFAULT_PINNED - return + fun loadFromWorkspace() { + val ws = workspaceManager?.activeWorkspace ?: return + if (ws.singlePaneScreens.isNotEmpty()) { + val screens = + ws.singlePaneScreens.mapNotNull { key -> + PINNABLE_SCREENS.find { it.typeKey() == key } + } + _pinnedScreens.value = screens.ifEmpty { DEFAULT_PINNED } } - val keys = raw.split(",").filter { it.isNotBlank() } - val screens = - keys.mapNotNull { key -> - PINNABLE_SCREENS.find { it.typeKey() == key } - } - _pinnedScreens.value = screens.ifEmpty { DEFAULT_PINNED } + } + + private fun syncToWorkspace() { + val wm = workspaceManager ?: return + val ws = wm.activeWorkspace + val updated = ws.copy(singlePaneScreens = _pinnedScreens.value.map { it.typeKey() }) + wm.updateWorkspace(updated) } companion object { - // Only object types are pinnable (no parameterized types like Hashtag, Editor) val PINNABLE_SCREENS: List = LAUNCHABLE_SCREENS.filter { !it.requiresInput() && it !is DeckColumnType.Editor } @@ -106,7 +105,6 @@ class PinnedNavBarState { DeckColumnType.Settings, ) - // These screens cannot be unpinned private val ALWAYS_PINNED = setOf("home", "settings") fun isPinnable(type: DeckColumnType): Boolean = PINNABLE_SCREENS.any { it.typeKey() == type.typeKey() } From 486ff2e8cf3da7594666d90a46164b00b5456858 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Sun, 19 Apr 2026 05:52:02 +0300 Subject: [PATCH 26/48] =?UTF-8?q?fix(desktop):=20UI=20polish=20=E2=80=94?= =?UTF-8?q?=20single-line=20layout=20toggle,=20checkmark=20margin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Shorten "Single Pane" to "Single" in SegmentedButton to prevent wrapping - Add 8dp left margin before checkmark in workspace card Co-Authored-By: Claude Opus 4.6 (1M context) --- .../com/vitorpamplona/amethyst/desktop/ui/deck/AppDrawer.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AppDrawer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AppDrawer.kt index 0adca7e11..44807eff5 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AppDrawer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AppDrawer.kt @@ -817,6 +817,7 @@ private fun WorkspaceCard( } // Active indicator if (isActive) { + Spacer(Modifier.width(8.dp)) Icon( Icons.Default.Check, "Active", @@ -929,7 +930,7 @@ private fun WorkspaceEditorDialog( onClick = { layoutMode = LayoutMode.SINGLE_PANE }, shape = SegmentedButtonDefaults.itemShape(0, 2), ) { - Text("Single Pane") + Text("Single", maxLines = 1) } SegmentedButton( selected = layoutMode == LayoutMode.DECK, From cb306b52191be31481108466bdf4b383d8cb0958 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Sun, 19 Apr 2026 05:55:09 +0300 Subject: [PATCH 27/48] fix(desktop): icon picker shows selected state with background highlight Replace tint-only selection with Surface background + primaryContainer color so the selected icon is clearly visible in the workspace editor. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../amethyst/desktop/ui/deck/AppDrawer.kt | 41 +++++++++++++------ 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AppDrawer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AppDrawer.kt index 44807eff5..f98466bd4 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AppDrawer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AppDrawer.kt @@ -904,19 +904,36 @@ private fun WorkspaceEditorDialog( ) // Icon picker Text("Icon", style = MaterialTheme.typography.labelMedium) - FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + val isSelected = { iName: String -> iName == iconName } WorkspaceIcons.availableNames.forEach { iName -> - IconButton(onClick = { iconName = iName }) { - Icon( - WorkspaceIcons.resolve(iName), - iName, - tint = - if (iName == iconName) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - ) + Surface( + modifier = Modifier.size(40.dp).clickable { iconName = iName }, + shape = RoundedCornerShape(8.dp), + color = + if (isSelected(iName)) { + MaterialTheme.colorScheme.primaryContainer + } else { + Color.Transparent + }, + tonalElevation = if (isSelected(iName)) 4.dp else 0.dp, + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + WorkspaceIcons.resolve(iName), + iName, + modifier = Modifier.size(24.dp), + tint = + if (isSelected(iName)) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } } } } From d8b313088da73dca93554fbe30c15292b30b7537 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 14:34:07 +0000 Subject: [PATCH 28/48] fix(dvm-favorites): star click is a no-op + star missing from DVM detail top bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two distinct bugs with the same symptom ("clicking the star doesn't seem to do anything"): 1. LocalCache didn't know how to route kind 10090. FavoriteDvmListEvent was missing from LocalCache's event-type dispatch, so after Account.followFavoriteDvm signs and publishes the event, the cache silently dropped it — never updated favoriteDvmListNote, so account.favoriteDvmList.flow never re-emitted, so the star's filled/outlined state (and the spinner chip) never flipped. Add `is FavoriteDvmListEvent -> consumeBaseReplaceable(...)`. 2. DvmTopBar's toggle never rendered. The Home feed passes the DVM's hex event id via Route.ContentDiscovery, so LoadNote(baseNoteHex) returns a plain Note, not the AddressableNote the toggle needs (the `is AddressableNote` guard was always false). Derive the AddressableNote from the loaded AppDefinitionEvent.address() so the toggle shows up with the correct backing object. --- .../amethyst/model/LocalCache.kt | 2 ++ .../ui/screen/loggedIn/dvms/DvmTopBar.kt | 26 ++++++++++++++----- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 347df0b52..339747d75 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -149,6 +149,7 @@ import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent import com.vitorpamplona.quartz.nip51Lists.PinListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.favoriteDvmList.FavoriteDvmListEvent import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent @@ -2635,6 +2636,7 @@ object LocalCache : ILocalCache, ICacheProvider { is LiveChessGameEndEvent -> consumeBaseReplaceable(event, relay, wasVerified) is LiveChessDrawOfferEvent -> consumeBaseReplaceable(event, relay, wasVerified) is HashtagListEvent -> consumeBaseReplaceable(event, relay, wasVerified) + is FavoriteDvmListEvent -> consumeBaseReplaceable(event, relay, wasVerified) is HighlightEvent -> consumeRegularEvent(event, relay, wasVerified) is IndexerRelayListEvent -> consumeBaseReplaceable(event, relay, wasVerified) is InteractiveStoryPrologueEvent -> consumeBaseReplaceable(event, relay, wasVerified) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmTopBar.kt index d3dcecc66..4ae8cefe5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmTopBar.kt @@ -24,10 +24,12 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.material3.IconButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow -import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteAndMap import com.vitorpamplona.amethyst.ui.components.LoadNote import com.vitorpamplona.amethyst.ui.components.MyAsyncImage import com.vitorpamplona.amethyst.ui.navigation.navs.INav @@ -37,6 +39,7 @@ import com.vitorpamplona.amethyst.ui.note.elements.BannerImage import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer import com.vitorpamplona.amethyst.ui.theme.SimpleImage35Modifier +import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent @Composable fun DvmTopBar( @@ -87,12 +90,23 @@ fun DvmTopBar( }, navigationIcon = { IconButton(onClick = nav::popBack) { ArrowBackIcon() } }, actions = { + // The route passes the event's hex id, so LoadNote returns a plain Note, + // not the AddressableNote the toggle needs. Derive the AddressableNote + // from the loaded AppDefinitionEvent's address() once the event exists. LoadNote(baseNoteHex = appDefinitionId, accountViewModel = accountViewModel) { appDefinitionNote -> - if (appDefinitionNote is AddressableNote) { - FavoriteDvmToggle( - appDefinitionNote = appDefinitionNote, - accountViewModel = accountViewModel, - ) + if (appDefinitionNote != null) { + val addressableNote by + observeNoteAndMap(appDefinitionNote, accountViewModel) { note -> + (note.event as? AppDefinitionEvent)?.let { + LocalCache.getOrCreateAddressableNote(it.address()) + } + } + addressableNote?.let { target -> + FavoriteDvmToggle( + appDefinitionNote = target, + accountViewModel = accountViewModel, + ) + } } } }, From dcaa6607ea0ef7e6d343e23d133f778271892258 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 14:47:20 +0000 Subject: [PATCH 29/48] perf(feeds): remember isSensitive and content warning reasons per note These derivations iterate every tag and parse every iMeta, so running them on each recomposition churns through feed scrolls. Cache them in remember(note) alongside the other per-note derivations. --- .../amethyst/ui/note/types/PictureDisplay.kt | 6 +++--- .../amethyst/ui/note/types/VideoDisplay.kt | 6 +++--- .../ui/screen/loggedIn/pictures/PictureCardCompose.kt | 6 +++--- .../ui/screen/loggedIn/shorts/VideoCardCompose.kt | 6 +++--- .../ui/screen/loggedIn/video/FileHeaderCardCompose.kt | 10 +++++----- 5 files changed, 17 insertions(+), 17 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PictureDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PictureDisplay.kt index 466764b6a..6730e72c9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PictureDisplay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PictureDisplay.kt @@ -67,9 +67,9 @@ fun PictureDisplay( nav: INav, ) { val event = (note.event as? PictureEvent) ?: return - val uri = note.toNostrUri() - val isSensitive = event.isSensitiveOrNSFW() - val reasons = collectContentWarningReasons(event) + val uri = remember(note) { note.toNostrUri() } + val isSensitive = remember(note) { event.isSensitiveOrNSFW() } + val reasons = remember(note) { collectContentWarningReasons(event) } val images by remember(note) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VideoDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VideoDisplay.kt index 50041196f..63cb577eb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VideoDisplay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VideoDisplay.kt @@ -53,9 +53,9 @@ fun JustVideoDisplay( val event = (videoEvent as? Event) ?: return val imeta = videoEvent.imetaTags().getOrNull(0) ?: return - val isSensitive = event.isSensitiveOrNSFW() - val reasons = collectContentWarningReasons(event) - val isImage = imeta.mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(imeta.url) + val isSensitive = remember(note) { event.isSensitiveOrNSFW() } + val reasons = remember(note) { collectContentWarningReasons(event) } + val isImage = remember(note) { imeta.mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(imeta.url) } val content by remember(note) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureCardCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureCardCompose.kt index d6c210eda..d7aa97489 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureCardCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureCardCompose.kt @@ -97,9 +97,9 @@ private fun PictureCardImage( backgroundColor: MutableState, accountViewModel: AccountViewModel, ) { - val uri = note.toNostrUri() - val isSensitive = event.isSensitiveOrNSFW() - val reasons = collectContentWarningReasons(event) + val uri = remember(note) { note.toNostrUri() } + val isSensitive = remember(note) { event.isSensitiveOrNSFW() } + val reasons = remember(note) { collectContentWarningReasons(event) } val images by remember(note) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/VideoCardCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/VideoCardCompose.kt index 5b0cace57..11385dd60 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/VideoCardCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/VideoCardCompose.kt @@ -103,9 +103,9 @@ private fun VideoCardImage( val event = (event as? Event) ?: return val imeta = videoEvent.imetaTags().getOrNull(0) ?: return - val isSensitive = event.isSensitiveOrNSFW() - val reasons = collectContentWarningReasons(event) - val isImage = imeta.mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(imeta.url) + val isSensitive = remember(note) { event.isSensitiveOrNSFW() } + val reasons = remember(note) { collectContentWarningReasons(event) } + val isImage = remember(note) { imeta.mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(imeta.url) } val content by remember(note) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/FileHeaderCardCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/FileHeaderCardCompose.kt index 8d90d907d..2efc3243a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/FileHeaderCardCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/FileHeaderCardCompose.kt @@ -100,11 +100,11 @@ private fun FileHeaderCardImage( ) { val fullUrl = event.url() ?: return - val isSensitive = event.isSensitiveOrNSFW() - val reasons = collectContentWarningReasons(event) - val isImage = event.mimeType()?.startsWith("image/") == true || RichTextParser.isImageUrl(fullUrl) - val blurHash = event.blurhash() - val dimensions = event.dimensions() + val isSensitive = remember(note) { event.isSensitiveOrNSFW() } + val reasons = remember(note) { collectContentWarningReasons(event) } + val isImage = remember(note) { event.mimeType()?.startsWith("image/") == true || RichTextParser.isImageUrl(fullUrl) } + val blurHash = remember(note) { event.blurhash() } + val dimensions = remember(note) { event.dimensions() } val content by remember(note) { val hash = event.hash() From 876513ad58c3b55c6634fec5049c5529723531af Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 14:55:32 +0000 Subject: [PATCH 30/48] fix(pdf): capture page count eagerly to survive PagerState teardown PagerState's saveable reads the pageCount lambda during composition teardown, which runs *after* DisposableEffect.onDispose has already closed the PdfRenderer. That triggered IllegalStateException("Document already closed") the first time the dialog was dismissed. Store pageCount as a stored val sampled at handle construction instead of re-querying the renderer. Also add a @Volatile closed flag so the PdfPageView render coroutine bails out if it wakes up after close(); existing catch clause still swallows any narrow race. --- .../ui/components/pdf/PdfViewerDialog.kt | 27 +++++++++++++------ 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfViewerDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfViewerDialog.kt index eb7402988..2062ee7c1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfViewerDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfViewerDialog.kt @@ -89,10 +89,17 @@ private class PdfDocumentHandle( val pfd: ParcelFileDescriptor, val renderer: PdfRenderer, ) { - val pageCount: Int get() = renderer.pageCount + // Snapshot eagerly. Compose's saveable PagerState reads pageCount during + // teardown, which can run *after* close() — so we can't query the renderer + // lazily without tripping IllegalStateException("Document already closed"). + val pageCount: Int = renderer.pageCount val mutex: Mutex = Mutex() + @Volatile var closed: Boolean = false + private set + fun close() { + closed = true runCatching { renderer.close() }.onFailure { Log.w("PdfViewerDialog", "renderer close failed", it) } runCatching { pfd.close() }.onFailure { Log.w("PdfViewerDialog", "pfd close failed", it) } runCatching { snapshot.close() }.onFailure { Log.w("PdfViewerDialog", "snapshot close failed", it) } @@ -259,13 +266,17 @@ private fun PdfPageView( val rendered = try { handle.mutex.withLock { - withContext(Dispatchers.IO) { - handle.renderer.openPage(pageIndex).use { page -> - val (width, height) = cappedRenderSize(page.width, page.height, VIEWER_MAX_DIM_PX) - val bmp = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) - bmp.eraseColor(android.graphics.Color.WHITE) - page.render(bmp, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY) - bmp + if (handle.closed) { + null + } else { + withContext(Dispatchers.IO) { + handle.renderer.openPage(pageIndex).use { page -> + val (width, height) = cappedRenderSize(page.width, page.height, VIEWER_MAX_DIM_PX) + val bmp = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + bmp.eraseColor(android.graphics.Color.WHITE) + page.render(bmp, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY) + bmp + } } } } From c2101c16dcaa1411a9500e45bdae52e593e44936 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 15:02:46 +0000 Subject: [PATCH 31/48] fix(pdf): avoid closing renderer via delegated read in DisposableEffect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DisposableEffect(handleState) captured handleState as a property delegate, so onDispose read the *current* delegated value at dispose time. When the async load transitioned handleState from null to the new handle, the previous DisposableEffect(null) was forgotten and its onDispose fired — reading the freshly-created handle via the delegate and closing it immediately. That left the dialog stuck on the loading spinner (page renders bailed out due to the closed flag) and double- closed the renderer on dismiss. Capture the handle as a local val before DisposableEffect so the lambda closes the specific handle that was current at effect creation. --- .../amethyst/ui/components/pdf/PdfViewerDialog.kt | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfViewerDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfViewerDialog.kt index 2062ee7c1..5320f9ec1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfViewerDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfViewerDialog.kt @@ -161,9 +161,15 @@ private fun PdfViewerContent( } } - DisposableEffect(handleState) { + // Capture the handle as a local val so the onDispose lambda closes *this* handle, + // not whatever the delegated property reads at dispose time. Without this, the + // DisposableEffect keyed on handleState runs its onDispose when handleState + // transitions from null -> handle, and `handleState?.close()` reads the new handle + // and closes it right after it was created. + val handleForDispose = handleState + DisposableEffect(handleForDispose) { onDispose { - handleState?.close() + handleForDispose?.close() } } From 013c211e6ad7cfd77cdd26b5e260c8013672bbfe Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 15:21:01 +0000 Subject: [PATCH 32/48] fix(pdf): top-align viewer controls and bump render resolution - Wrap pager + controls Row in a Box(fillMaxSize) and align the Row to TopCenter so the back/share buttons sit at the top of the screen, matching ZoomableContentDialog's layout. They were previously vertically centered. - Raise VIEWER_MAX_DIM_PX from 2048 to 3072 so pinch-zoomed pages stay legible. A4-sized pages now render at ~26 MB each (ARGB_8888). - Replace the unbounded mutableStateMapOf page cache with a tiny LinkedHashMap-based LRU (PAGE_CACHE_SIZE = 3) to keep total bitmap memory around 80 MB regardless of PDF length. The cache only feeds produceState's initial value, so it doesn't need to be a snapshot state. --- .../ui/components/pdf/PdfViewerDialog.kt | 137 +++++++++++------- 1 file changed, 82 insertions(+), 55 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfViewerDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfViewerDialog.kt index 5320f9ec1..8f3ef0ef4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfViewerDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfViewerDialog.kt @@ -50,7 +50,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember @@ -81,8 +80,33 @@ import kotlinx.coroutines.withContext import net.engawapg.lib.zoomable.rememberZoomState import net.engawapg.lib.zoomable.zoomable -// Hard ceiling on each rendered page bitmap, in pixels. Prevents OOM on very large pages. -private const val VIEWER_MAX_DIM_PX = 2048 +// Hard ceiling on each rendered page bitmap, in pixels. Higher = sharper when the +// user pinch-zooms inside the dialog, but each page costs ~maxDim^2 * 4 bytes of +// RAM (ARGB_8888). 3072 gives ~26 MB per A4-shaped page. +private const val VIEWER_MAX_DIM_PX = 3072 + +// How many recently-rendered pages to keep around. Pager already pre-composes the +// current page plus one neighbor; this just speeds up small back/forward swipes. +// At VIEWER_MAX_DIM_PX = 3072 this caps memory at ~80 MB worth of page bitmaps. +private const val PAGE_CACHE_SIZE = 3 + +private class PageBitmapCache( + private val maxSize: Int, +) { + private val cache = + object : java.util.LinkedHashMap(maxSize, 0.75f, true) { + override fun removeEldestEntry(eldest: Map.Entry): Boolean = size > maxSize + } + + @Synchronized fun get(key: Int): Bitmap? = cache[key] + + @Synchronized fun put( + key: Int, + value: Bitmap, + ) { + cache[key] = value + } +} private class PdfDocumentHandle( val snapshot: DiskCache.Snapshot, @@ -196,63 +220,66 @@ private fun PdfViewerContent( } } else { val pagerState = rememberPagerState { handle.pageCount } - val pageCache = remember(handle) { mutableStateMapOf() } + val pageCache = remember(handle) { PageBitmapCache(PAGE_CACHE_SIZE) } - HorizontalPager( - state = pagerState, - modifier = Modifier.fillMaxSize(), - ) { pageIndex -> - PdfPageView( - handle = handle, - pageIndex = pageIndex, - cache = pageCache, - ) - } - - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = Size15dp, vertical = Size10dp) - .statusBarsPadding() - .systemBarsPadding(), - horizontalArrangement = spacedBy(Size10dp), - verticalAlignment = Alignment.CenterVertically, - ) { - OutlinedButton( - onClick = onDismiss, - contentPadding = PaddingValues(horizontal = Size5dp), - colors = ButtonDefaults.outlinedButtonColors().copy(containerColor = MaterialTheme.colorScheme.background), - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringRes(R.string.back), + Box(modifier = Modifier.fillMaxSize()) { + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxSize(), + ) { pageIndex -> + PdfPageView( + handle = handle, + pageIndex = pageIndex, + cache = pageCache, ) } - Spacer(modifier = Modifier.weight(1f)) - - Text( - text = "${pagerState.currentPage + 1} / ${handle.pageCount}", - color = Color.White, + Row( modifier = Modifier - .background(Color.Black.copy(alpha = 0.4f), shape = MaterialTheme.shapes.small) - .padding(horizontal = Size10dp, vertical = Size5dp), - ) - - Spacer(modifier = Modifier.weight(1f)) - - OutlinedButton( - onClick = { sharePopupExpanded.value = true }, - contentPadding = PaddingValues(horizontal = Size5dp), - colors = ButtonDefaults.outlinedButtonColors().copy(containerColor = MaterialTheme.colorScheme.background), + .align(Alignment.TopCenter) + .fillMaxWidth() + .statusBarsPadding() + .systemBarsPadding() + .padding(horizontal = Size15dp, vertical = Size10dp), + horizontalArrangement = spacedBy(Size10dp), + verticalAlignment = Alignment.CenterVertically, ) { - Icon( - imageVector = Icons.Default.Share, - modifier = Size20Modifier, - contentDescription = stringRes(R.string.quick_action_share), + OutlinedButton( + onClick = onDismiss, + contentPadding = PaddingValues(horizontal = Size5dp), + colors = ButtonDefaults.outlinedButtonColors().copy(containerColor = MaterialTheme.colorScheme.background), + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringRes(R.string.back), + ) + } + + Spacer(modifier = Modifier.weight(1f)) + + Text( + text = "${pagerState.currentPage + 1} / ${handle.pageCount}", + color = Color.White, + modifier = + Modifier + .background(Color.Black.copy(alpha = 0.4f), shape = MaterialTheme.shapes.small) + .padding(horizontal = Size10dp, vertical = Size5dp), ) + + Spacer(modifier = Modifier.weight(1f)) + + OutlinedButton( + onClick = { sharePopupExpanded.value = true }, + contentPadding = PaddingValues(horizontal = Size5dp), + colors = ButtonDefaults.outlinedButtonColors().copy(containerColor = MaterialTheme.colorScheme.background), + ) { + Icon( + imageVector = Icons.Default.Share, + modifier = Size20Modifier, + contentDescription = stringRes(R.string.quick_action_share), + ) + } } } } @@ -262,9 +289,9 @@ private fun PdfViewerContent( private fun PdfPageView( handle: PdfDocumentHandle, pageIndex: Int, - cache: MutableMap, + cache: PageBitmapCache, ) { - val cached = cache[pageIndex] + val cached = cache.get(pageIndex) @Suppress("ProduceStateDoesNotAssignValue") val bitmap by produceState(initialValue = cached, key1 = handle, key2 = pageIndex) { @@ -292,7 +319,7 @@ private fun PdfPageView( null } - rendered?.let { cache[pageIndex] = it } + rendered?.let { cache.put(pageIndex, it) } value = rendered } From dc41432c5bdf94873e88844060091b760601fa79 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 15:37:07 +0000 Subject: [PATCH 33/48] fix(topnav): long filter names no longer wrap + hide expand icon Selecting a DVM with a long name (or any long-named filter) made the Text in the top-nav spinner wrap to two lines, pushing the ExpandMore icon out of the visible area. The Column holding the Text had no width constraint and the Text itself had no maxLines. - Constrain the Column with `Modifier.weight(1f, fill = false)` so it reserves space for the icon instead of consuming it. - Add `maxLines = 1, overflow = TextOverflow.Ellipsis` to every text path in the spinner (primary label, geohash city-loading, AroundMe location labels). Single-line + ellipsis for all filter types, icon always visible. --- .../navigation/topbars/FeedFilterSpinner.kt | 36 ++++++++++++++++--- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt index c08cf8ba4..b4cf9a719 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt @@ -68,6 +68,7 @@ import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog @@ -153,23 +154,40 @@ fun FeedFilterSpinner( Row(verticalAlignment = Alignment.CenterVertically) { Spacer(modifier = Size20Modifier) - Column(horizontalAlignment = Alignment.CenterHorizontally) { + // Bound the Column so long filter names (e.g. DVM titles) get truncated + // instead of wrapping to multiple lines and shoving the expand icon out. + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.weight(1f, fill = false), + ) { val filter = selected?.code if (filter is TopFilter.Geohash) { LoadCityName( geohashStr = filter.tag, onLoading = { Row { - Text(filter.tag) + Text( + text = filter.tag, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) Spacer(modifier = StdHorzSpacer) LoadingAnimation(indicatorSize = 12.dp, circleWidth = 2.dp) } }, ) { cityName -> - Text(cityName) + Text( + text = cityName, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) } } else { - Text(currentText) + Text( + text = currentText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) } if (filter is TopFilter.AroundMe) { @@ -181,6 +199,8 @@ fun FeedFilterSpinner( text = stringRes(R.string.lack_location_permissions), fontSize = Font12SP, lineHeight = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } else { val location by Amethyst.instance.locationManager.geohashStateFlow @@ -196,6 +216,8 @@ fun FeedFilterSpinner( text = "(${myLocation.geoHash})", fontSize = Font12SP, lineHeight = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) Spacer(modifier = StdHorzSpacer) LoadingAnimation(indicatorSize = 12.dp, circleWidth = 2.dp) @@ -206,6 +228,8 @@ fun FeedFilterSpinner( text = "($cityName)", fontSize = Font12SP, lineHeight = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } } @@ -215,6 +239,8 @@ fun FeedFilterSpinner( text = stringRes(R.string.lack_location_permissions), fontSize = Font12SP, lineHeight = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } @@ -223,6 +249,8 @@ fun FeedFilterSpinner( text = stringRes(R.string.loading_location), fontSize = Font12SP, lineHeight = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } } From e244271bd0a067c451eb8bc7d73f8567546f6ecd Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 15:46:25 +0000 Subject: [PATCH 34/48] feat(pdf): zoom-aware hi-res re-render for crisp pinch-zoom Base bitmap stays at 3072 px for the initial render. When the user zooms past 1.2x and scale settles for 200 ms, asynchronously re-render the current page at (VIEWER_MAX_DIM_PX * scale) capped at 6144 px (~100 MB peak for an A4 page). Swap the hi-res bitmap in while zoomed; revert to base when scale drops back under threshold or the page leaves composition. Only the currently-focused page gets the hi-res treatment. Also apply FilterQuality.High to the Image composable in both the viewer and the preview card so any residual GPU upscaling uses bicubic-ish sampling instead of bilinear. Factored the render path into a shared renderPageCatching() helper. --- .../ui/components/pdf/PdfPreviewCard.kt | 2 + .../ui/components/pdf/PdfViewerDialog.kt | 106 +++++++++++++----- 2 files changed, 80 insertions(+), 28 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfPreviewCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfPreviewCard.kt index 5d01fcb21..277bddafe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfPreviewCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfPreviewCard.kt @@ -45,6 +45,7 @@ import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.FilterQuality import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalConfiguration @@ -170,6 +171,7 @@ private fun LoadedPdfPreviewCard( bitmap = current.preview.thumbnail.asImageBitmap(), contentDescription = content.description ?: filename, contentScale = ContentScale.FillWidth, + filterQuality = FilterQuality.High, modifier = Modifier .fillMaxWidth() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfViewerDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfViewerDialog.kt index 8f3ef0ef4..c3e0bda95 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfViewerDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfViewerDialog.kt @@ -49,14 +49,17 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.FilterQuality import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.window.Dialog @@ -74,17 +77,29 @@ import com.vitorpamplona.amethyst.ui.theme.Size5dp import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import net.engawapg.lib.zoomable.rememberZoomState import net.engawapg.lib.zoomable.zoomable -// Hard ceiling on each rendered page bitmap, in pixels. Higher = sharper when the -// user pinch-zooms inside the dialog, but each page costs ~maxDim^2 * 4 bytes of -// RAM (ARGB_8888). 3072 gives ~26 MB per A4-shaped page. +// Hard ceiling on each base-rendered page bitmap, in pixels. Higher = sharper when +// the user pinch-zooms inside the dialog, but each page costs ~maxDim^2 * 4 bytes +// of RAM (ARGB_8888). 3072 gives ~26 MB per A4-shaped page. private const val VIEWER_MAX_DIM_PX = 3072 +// Hard ceiling for the per-page zoom-aware detail render. When the user zooms in +// past HI_RES_ZOOM_THRESHOLD we re-render the current page at +// (VIEWER_MAX_DIM_PX * scale) capped at this value. 6144 allows up to 2x sharper +// than the base render at peak cost of ~100 MB for one page (ARGB_8888, A4 shape). +private const val HI_RES_MAX_DIM_PX = 6144 +private const val HI_RES_ZOOM_THRESHOLD = 1.2f +private const val HI_RES_DEBOUNCE_MS = 200L + // How many recently-rendered pages to keep around. Pager already pre-composes the // current page plus one neighbor; this just speeds up small back/forward swipes. // At VIEWER_MAX_DIM_PX = 3072 this caps memory at ~80 MB worth of page bitmaps. @@ -285,6 +300,7 @@ private fun PdfViewerContent( } } +@OptIn(FlowPreview::class) @Composable private fun PdfPageView( handle: PdfDocumentHandle, @@ -294,43 +310,50 @@ private fun PdfPageView( val cached = cache.get(pageIndex) @Suppress("ProduceStateDoesNotAssignValue") - val bitmap by produceState(initialValue = cached, key1 = handle, key2 = pageIndex) { + val baseBitmap by produceState(initialValue = cached, key1 = handle, key2 = pageIndex) { if (value != null) return@produceState - val rendered = - try { - handle.mutex.withLock { - if (handle.closed) { - null - } else { - withContext(Dispatchers.IO) { - handle.renderer.openPage(pageIndex).use { page -> - val (width, height) = cappedRenderSize(page.width, page.height, VIEWER_MAX_DIM_PX) - val bmp = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) - bmp.eraseColor(android.graphics.Color.WHITE) - page.render(bmp, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY) - bmp - } - } - } - } - } catch (e: Exception) { - if (e is CancellationException) throw e - Log.w("PdfViewerDialog", "Failed to render page $pageIndex", e) - null - } - + val rendered = renderPageCatching(handle, pageIndex, VIEWER_MAX_DIM_PX) rendered?.let { cache.put(pageIndex, it) } value = rendered } val zoomState = rememberZoomState() - val current = bitmap + + // Re-render the page at a higher resolution once the user zooms in and settles, + // so pinch-zoomed text stays crisp instead of getting GPU-upscaled from the base + // bitmap. Released when zoom drops back under threshold or the page leaves view. + var hiResBitmap by remember(handle, pageIndex) { mutableStateOf(null) } + + LaunchedEffect(handle, pageIndex, baseBitmap) { + if (baseBitmap == null) return@LaunchedEffect + snapshotFlow { zoomState.scale } + .debounce(HI_RES_DEBOUNCE_MS) + .distinctUntilChanged() + .collectLatest { scale -> + if (scale < HI_RES_ZOOM_THRESHOLD) { + hiResBitmap = null + } else { + val target = (VIEWER_MAX_DIM_PX * scale).toInt().coerceAtMost(HI_RES_MAX_DIM_PX) + // Skip if the hi-res render wouldn't beat what we already have. + val base = baseBitmap ?: return@collectLatest + val baseLongest = maxOf(base.width, base.height) + if (target <= baseLongest) { + hiResBitmap = null + } else { + hiResBitmap = renderPageCatching(handle, pageIndex, target) + } + } + } + } + + val current = hiResBitmap ?: baseBitmap Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { if (current != null) { Image( bitmap = current.asImageBitmap(), contentDescription = null, contentScale = ContentScale.Fit, + filterQuality = FilterQuality.High, modifier = Modifier .fillMaxSize() @@ -341,3 +364,30 @@ private fun PdfPageView( } } } + +private suspend fun renderPageCatching( + handle: PdfDocumentHandle, + pageIndex: Int, + maxDim: Int, +): Bitmap? = + try { + handle.mutex.withLock { + if (handle.closed) { + null + } else { + withContext(Dispatchers.IO) { + handle.renderer.openPage(pageIndex).use { page -> + val (width, height) = cappedRenderSize(page.width, page.height, maxDim) + val bmp = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + bmp.eraseColor(android.graphics.Color.WHITE) + page.render(bmp, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY) + bmp + } + } + } + } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.w("PdfViewerDialog", "Failed to render page $pageIndex at $maxDim px", e) + null + } From b0f75d4dd464821a9e328c18a89ebac74cafd0d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 15:53:23 +0000 Subject: [PATCH 35/48] fix(home-banner): float DVM status banner over the feed instead of in the top bar Putting HomeDvmStatusBanner inside the top-bar Column meant the SecondaryTabRow shifted up/down every time the banner appeared or disappeared (filter selection, refresh, response arrival). Annoying. Move the banner into a Box that wraps the HorizontalPager and align it to TopCenter so it floats over the feed: - topBar Column is back to just HomeTopBar + SecondaryTabRow; tabs no longer reflow when the banner toggles. - BannerCard takes a Modifier and gets tonalElevation + shadowElevation + a slightly stronger surface tint so it reads as a floating overlay rather than part of the scaffold chrome. --- .../screen/loggedIn/home/DvmStatusBanner.kt | 29 ++++++++--- .../ui/screen/loggedIn/home/HomeScreen.kt | 50 ++++++++++++------- 2 files changed, 54 insertions(+), 25 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/DvmStatusBanner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/DvmStatusBanner.kt index de3335425..723eea88a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/DvmStatusBanner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/DvmStatusBanner.kt @@ -58,13 +58,14 @@ import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent fun HomeDvmStatusBanner( accountViewModel: AccountViewModel, nav: INav, + modifier: Modifier = Modifier, ) { val topFilter by accountViewModel.account.settings.defaultHomeFollowList .collectAsStateWithLifecycle() when (val filter = topFilter) { - is TopFilter.FavoriteDvm -> SingleDvmBanner(filter, accountViewModel, nav) - is TopFilter.AllFavoriteDvms -> AllFavoriteDvmsBanner(accountViewModel) + is TopFilter.FavoriteDvm -> SingleDvmBanner(filter, accountViewModel, nav, modifier) + is TopFilter.AllFavoriteDvms -> AllFavoriteDvmsBanner(accountViewModel, modifier) else -> Unit } } @@ -74,6 +75,7 @@ private fun SingleDvmBanner( favDvm: TopFilter.FavoriteDvm, accountViewModel: AccountViewModel, nav: INav, + modifier: Modifier = Modifier, ) { val snapshot by accountViewModel.account.favoriteDvmOrchestrator .observe(favDvm.address) @@ -95,7 +97,7 @@ private fun SingleDvmBanner( ?: "" } - BannerCard { + BannerCard(modifier) { val status = snapshot.latestStatus?.status() when { @@ -167,7 +169,10 @@ private fun SingleDvmBanner( } @Composable -private fun AllFavoriteDvmsBanner(accountViewModel: AccountViewModel) { +private fun AllFavoriteDvmsBanner( + accountViewModel: AccountViewModel, + modifier: Modifier = Modifier, +) { val addresses by accountViewModel.account.favoriteDvmList.flow .collectAsStateWithLifecycle() @@ -189,7 +194,7 @@ private fun AllFavoriteDvmsBanner(accountViewModel: AccountViewModel) { val allErrored = snapshots.all { it.errorMessage != null || it.latestStatus?.status()?.code == "error" } - BannerCard { + BannerCard(modifier) { if (allErrored) { BannerMessageRow( message = stringRes(R.string.dvm_home_status_error), @@ -209,14 +214,22 @@ private fun AllFavoriteDvmsBanner(accountViewModel: AccountViewModel) { } @Composable -private fun BannerCard(content: @Composable () -> Unit) { +private fun BannerCard( + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + // Sits above the feed instead of in the topBar Column, so adding/removing + // the banner doesn't shift the tabs/filter up and down. tonalElevation + // gives it a faint surface tint so it reads as a floating overlay. Surface( modifier = - Modifier + modifier .fillMaxWidth() .padding(horizontal = 12.dp, vertical = 6.dp), shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.surfaceContainerLow, + color = MaterialTheme.colorScheme.surfaceContainerHigh, + tonalElevation = 4.dp, + shadowElevation = 4.dp, ) { Column(modifier = Modifier.padding(12.dp)) { content() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt index 76b574544..d3f676146 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt @@ -23,11 +23,14 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues 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.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyRow @@ -52,6 +55,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R @@ -179,7 +183,6 @@ private fun HomePages( topBar = { Column { HomeTopBar(accountViewModel, nav) - HomeDvmStatusBanner(accountViewModel, nav) SecondaryTabRow( containerColor = Color.Transparent, contentColor = MaterialTheme.colorScheme.onBackground, @@ -210,24 +213,37 @@ private fun HomePages( HomeScreenFloatingButton(accountViewModel, nav) }, accountViewModel = accountViewModel, - ) { - HorizontalPager( - contentPadding = it, - state = pagerState, - userScrollEnabled = true, - modifier = - Modifier.zonedDrawerSwipe( - pagerState = pagerState, - openDrawer = nav::openDrawer, - ), - ) { page -> - HomeFeeds( - feedState = tabs[page].feedState, - routeForLastRead = tabs[page].routeForLastRead, - scrollStateKey = tabs[page].scrollStateKey, - liveSection = tabs[page].liveSection, + ) { paddingValues -> + // Wrap pager + banner in a Box so the banner can float over the feed + // (anchored top-center) instead of living in the topBar Column where + // it would push the tabs down every time it appears or disappears. + Box( + modifier = Modifier.fillMaxSize().padding(paddingValues), + ) { + HorizontalPager( + contentPadding = PaddingValues(0.dp), + state = pagerState, + userScrollEnabled = true, + modifier = + Modifier.zonedDrawerSwipe( + pagerState = pagerState, + openDrawer = nav::openDrawer, + ), + ) { page -> + HomeFeeds( + feedState = tabs[page].feedState, + routeForLastRead = tabs[page].routeForLastRead, + scrollStateKey = tabs[page].scrollStateKey, + liveSection = tabs[page].liveSection, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + HomeDvmStatusBanner( accountViewModel = accountViewModel, nav = nav, + modifier = Modifier.align(Alignment.TopCenter), ) } } From e5a87a83aec82983aca549738b02a6e755fedb9a Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 19 Apr 2026 12:09:35 -0400 Subject: [PATCH 36/48] Adds a quick exit to the address parser --- .../com/vitorpamplona/quartz/nip01Core/core/AddressSerializer.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/AddressSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/AddressSerializer.kt index ec77fae66..80ca91929 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/AddressSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/AddressSerializer.kt @@ -40,6 +40,7 @@ class AddressSerializer { } fun parse(addressId: String): Address? { + if (addressId.length < 66) return null if (addressId.isBlank()) return null return try { val parts = addressId.split(":", limit = 3) From a9fba25acd510a9554f90fcef4efda93fc782b89 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 19 Apr 2026 12:09:53 -0400 Subject: [PATCH 37/48] ignores the state when running marmot tests --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 387265dae..4af0d3c2f 100644 --- a/.gitignore +++ b/.gitignore @@ -173,3 +173,5 @@ packaging/appimage/squashfs-root/ .worktrees/ .claude/worktrees/ benchmark/src/main/jniLibs/ + +/tools/marmot-interop/state From 0c88b83c3f886dc9b686db2b9e4635c53c7cd44f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 16:14:43 +0000 Subject: [PATCH 38/48] feat(pdf): wire double-tap to toggle zoom in viewer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The zoomable library's onDoubleTap callback is not wired by default, so double-tapping the PDF page did nothing — no scale change, no hi-res re-render. Pass an onDoubleTap handler that calls zoomState.toggleScale(2.5x, tapPosition). The scale animation runs through the same Animatable snapshotFlow already observes, so the debounced hi-res render kicks in automatically once the animation settles. --- .../amethyst/ui/components/pdf/PdfViewerDialog.kt | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfViewerDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfViewerDialog.kt index c3e0bda95..523c48d81 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfViewerDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfViewerDialog.kt @@ -85,6 +85,7 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import net.engawapg.lib.zoomable.rememberZoomState +import net.engawapg.lib.zoomable.toggleScale import net.engawapg.lib.zoomable.zoomable // Hard ceiling on each base-rendered page bitmap, in pixels. Higher = sharper when @@ -100,6 +101,10 @@ private const val HI_RES_MAX_DIM_PX = 6144 private const val HI_RES_ZOOM_THRESHOLD = 1.2f private const val HI_RES_DEBOUNCE_MS = 200L +// Zoom level the viewer animates to when the user double-taps. Matches the +// threshold region where we swap in the hi-res bitmap. +private const val DOUBLE_TAP_ZOOM_SCALE = 2.5f + // How many recently-rendered pages to keep around. Pager already pre-composes the // current page plus one neighbor; this just speeds up small back/forward swipes. // At VIEWER_MAX_DIM_PX = 3072 this caps memory at ~80 MB worth of page bitmaps. @@ -357,7 +362,12 @@ private fun PdfPageView( modifier = Modifier .fillMaxSize() - .zoomable(zoomState), + .zoomable( + zoomState = zoomState, + onDoubleTap = { position -> + zoomState.toggleScale(targetScale = DOUBLE_TAP_ZOOM_SCALE, position = position) + }, + ), ) } else { CircularProgressIndicator(color = Color.White) From 2e4a985b3661b1053576e62f4aba3257f6009d02 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 16:32:34 +0000 Subject: [PATCH 39/48] feat(images): load full-resolution source in the image dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SubcomposeAsyncImage was being called with just the URL (or local File), so Coil auto-sized the decode target to the composable's layout bounds. In the feed that's desirable — a small thumbnail decode — but in the zoomable dialog the composable is fillMaxWidth (~screen width), and pinch-zooming then GPU-upscales the already downsampled bitmap, producing the same blurriness the PDF viewer had before. Add a fullResolution: Boolean = false flag to UrlImageView and LocalImageView. When true, swap the model for an ImageRequest with size(Size.ORIGINAL) so Coil decodes at the image's native dimensions. Feed call sites keep the default (fast, sampled). Both dialog call sites in ZoomableContentDialog now pass fullResolution = true, so pinch-zoom shows native pixels. The existing blurhash/aspect-ratio placeholder path already covers the brief high-res load window in the dialog. --- .../ui/components/ZoomableContentDialog.kt | 2 ++ .../ui/components/ZoomableContentView.kt | 35 +++++++++++++++++-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt index c6331c9f0..e4bed1081 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt @@ -535,6 +535,7 @@ private fun RenderImageOrVideo( controllerVisible = controllerVisible, accountViewModel = accountViewModel, alwayShowImage = true, + fullResolution = true, ) } @@ -593,6 +594,7 @@ private fun RenderImageOrVideo( controllerVisible = controllerVisible, accountViewModel = accountViewModel, alwayShowImage = true, + fullResolution = true, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index 469afe4ff..2bc9d3361 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -77,6 +77,8 @@ import coil3.compose.AsyncImage import coil3.compose.AsyncImagePainter import coil3.compose.SubcomposeAsyncImage import coil3.compose.SubcomposeAsyncImageContent +import coil3.request.ImageRequest +import coil3.size.Size import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R @@ -276,6 +278,7 @@ fun LocalImageView( controllerVisible: MutableState, accountViewModel: AccountViewModel, alwayShowImage: Boolean = false, + fullResolution: Boolean = false, ) { if (content.localFileExists()) { val showImage = @@ -286,10 +289,23 @@ fun LocalImageView( } val ratio = remember(content) { content.dim?.aspectRatio() ?: MediaAspectRatioCache.get(content.localFile.toString()) } + val context = LocalContext.current + val imageModel = + if (fullResolution) { + remember(content.localFile, context) { + ImageRequest + .Builder(context) + .data(content.localFile) + .size(Size.ORIGINAL) + .build() + } + } else { + content.localFile + } CrossfadeIfEnabled(targetState = showImage.value, contentAlignment = Alignment.Center, accountViewModel = accountViewModel) { imageVisible -> if (imageVisible) { SubcomposeAsyncImage( - model = content.localFile, + model = imageModel, contentDescription = content.description, contentScale = contentScale, modifier = mainImageModifier, @@ -391,6 +407,7 @@ fun UrlImageView( controllerVisible: MutableState, accountViewModel: AccountViewModel, alwayShowImage: Boolean = false, + fullResolution: Boolean = false, ) { val ratio = content.dim?.aspectRatio() ?: MediaAspectRatioCache.get(content.url) @@ -401,10 +418,24 @@ fun UrlImageView( ) } + val context = LocalContext.current + val imageModel = + if (fullResolution) { + remember(content.url, context) { + ImageRequest + .Builder(context) + .data(content.url) + .size(Size.ORIGINAL) + .build() + } + } else { + content.url + } + CrossfadeIfEnabled(targetState = showImage.value, contentAlignment = Alignment.Center, accountViewModel = accountViewModel) { if (it) { SubcomposeAsyncImage( - model = content.url, + model = imageModel, contentDescription = content.description, contentScale = contentScale, modifier = mainImageModifier, From fc284e3cfe48b90e7eb8974016f619ca0636ce2a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 16:43:46 +0000 Subject: [PATCH 40/48] feat(dvm-favorites): rename user-facing to "Favorite Feed Algorithms" + fix cold-start race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - User-facing rename. All user-visible strings switch from "favourite" to "favorite" (American spelling) and from "DVM(s)" to "Feed Algorithm(s)": settings entry, spinner group label, empty state, banner messages, menu accessibility labels. Code identifiers still reference NIP-90's "DVM" protocol name since that's the wire spec. - Cold-start race. When the app relaunches with a persisted TopFilter.FavoriteDvm, the AppDefinitionEvent may not be in cache yet. mergeInterests was filtering out any favorite whose AppDefinitionEvent didn't yet advertise kind 5300, so the spinner didn't include a chip matching the persisted selection — it showed "Select an option" while the orchestrator quietly fired the RPC with no UI to ground it. Always include every favorite; the 5300 check at add time (in FavoriteDvmToggle) is enough. --- .../model/dvms/FavoriteDvmOrchestrator.kt | 4 ++-- .../favoriteDvm/AllFavoriteDvmsFeedFlow.kt | 4 ++-- .../AllFavoriteDvmsTopNavFilter.kt | 2 +- .../favoriteDvm/FavoriteDvmTopNavFilter.kt | 2 +- .../FavoriteDvmTopNavPerRelayFilterSet.kt | 4 ++-- .../amethyst/ui/screen/TopNavFilterState.kt | 21 ++++++++----------- .../nip90Dvms/FilterHomePostsByDvmIds.kt | 2 +- amethyst/src/main/res/values/strings.xml | 18 ++++++++-------- 8 files changed, 27 insertions(+), 30 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/dvms/FavoriteDvmOrchestrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/dvms/FavoriteDvmOrchestrator.kt index ddf24813a..860cdfd0e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/dvms/FavoriteDvmOrchestrator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/dvms/FavoriteDvmOrchestrator.kt @@ -45,7 +45,7 @@ import kotlinx.coroutines.sync.withLock private const val RESPONSE_TIMEOUT_MS = 20_000L /** - * Immutable snapshot of a favourite DVM's current request/response state. + * Immutable snapshot of a favorite DVM's current request/response state. * * - [requestId] is the id of the most recently published kind-5300 request. * - [responseRelays] is the relay set the kind-5300 was sent to — the same set on @@ -65,7 +65,7 @@ data class FavoriteDvmSnapshot( ) /** - * Manages the NIP-90 content-discovery RPC cycle for each favourite DVM the user + * Manages the NIP-90 content-discovery RPC cycle for each favorite DVM the user * pins to the top-nav. * * The orchestrator is lazy: it starts a request/response cycle the first time any diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/AllFavoriteDvmsFeedFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/AllFavoriteDvmsFeedFlow.kt index 8f880a4b1..c35c2ba72 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/AllFavoriteDvmsFeedFlow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/AllFavoriteDvmsFeedFlow.kt @@ -34,9 +34,9 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest /** - * Feed flow that merges snapshots from every currently-favourited DVM into a + * Feed flow that merges snapshots from every currently-favorited DVM into a * single [AllFavoriteDvmsTopNavFilter]. Re-wires subscriptions whenever the - * favourite set changes. + * favorite set changes. */ class AllFavoriteDvmsFeedFlow( val favoriteDvmAddresses: StateFlow>, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/AllFavoriteDvmsTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/AllFavoriteDvmsTopNavFilter.kt index f686bec2b..36e7e6b6f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/AllFavoriteDvmsTopNavFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/AllFavoriteDvmsTopNavFilter.kt @@ -32,7 +32,7 @@ import kotlinx.coroutines.flow.MutableStateFlow /** * Top-nav filter that unions the latest kind-6300 responses from every currently - * favourited DVM. Behaves like [FavoriteDvmTopNavFilter] (pure membership check + * favorited DVM. Behaves like [FavoriteDvmTopNavFilter] (pure membership check * against a snapshot), but the accepted set is the union across N DVMs and the * request-id list carries one entry per DVM for the relay-listen subscription. */ diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilter.kt index 88924a2a4..9534bc1c8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilter.kt @@ -32,7 +32,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow /** - * Top-nav filter backed by the latest kind-6300 response from a favourite DVM. + * Top-nav filter backed by the latest kind-6300 response from a favorite DVM. * * The filter is a pure immutable membership check: [match] accepts a note only if the * DVM's latest response included it. When a new response arrives, a new instance is diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilterSet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilterSet.kt index 399d2ab31..318840384 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilterSet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilterSet.kt @@ -32,8 +32,8 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl * - [listenRelays] — the union of DVM publish relays across all active DVMs * (where they will deliver future kind 6300 / 7000 events for their requests). * - [requestIds] — the set of currently-active kind-5300 request ids to listen - * for. A single-DVM filter carries one; the merged "All favourite DVMs" - * filter carries one per favourite DVM. + * for. A single-DVM filter carries one; the merged "All favorite DVMs" + * filter carries one per favorite DVM. */ class FavoriteDvmTopNavPerRelayFilterSet( val contentFetches: Map, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt index 7d2003e85..7b65a43e1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt @@ -35,7 +35,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent -import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.NIP90ContentDiscoveryRequestEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -182,24 +181,22 @@ class TopNavFilterState( ) } - // Only DVMs that advertise NIP-90 content discovery (kind 5300) can produce a - // home feed. Hide entries whose AppDefinitionEvent isn't loaded yet OR doesn't - // include kind 5300 so the chip doesn't appear for image-generation, translation, - // search, or other DVM kinds that would never reply. + // Favorites can only be added through FavoriteDvmToggle, which itself checks + // that the AppDefinitionEvent advertises kind 5300. Don't re-check here: on + // cold start the AppDefinitionEvent may not be in cache yet, and dropping + // the entry means the persisted TopFilter.FavoriteDvm can't find its chip + // in the spinner (user sees "Select an option" while the banner fires the + // RPC — the bug we had before this change). val favoriteDvms = - favoriteDvmList.mapNotNull { dvmNote -> - val supports5300 = - (dvmNote.event as? AppDefinitionEvent) - ?.includeKind(NIP90ContentDiscoveryRequestEvent.KIND) == true - if (!supports5300) return@mapNotNull null + favoriteDvmList.map { dvmNote -> FeedDefinition( TopFilter.FavoriteDvm(dvmNote.address), FavoriteDvmName(dvmNote), ) } - // Only show the "All favourite DVMs" meta-chip when there is at least one - // real favourite to merge; otherwise the chip opens to an empty feed. + // Only show the "All favorite DVMs" meta-chip when there is at least one + // real favorite to merge; otherwise the chip opens to an empty feed. val allFavorites = if (favoriteDvms.isNotEmpty()) listOf(allFavoriteDvmsFollow) else emptyList() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90Dvms/FilterHomePostsByDvmIds.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90Dvms/FilterHomePostsByDvmIds.kt index 57afdd401..c042199a6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90Dvms/FilterHomePostsByDvmIds.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90Dvms/FilterHomePostsByDvmIds.kt @@ -31,7 +31,7 @@ import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentD import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent /** - * Builds relay REQ filters for a favourite-DVM home feed. + * Builds relay REQ filters for a favorite-DVM home feed. * * Two distinct subscription kinds with two distinct relay sets: * diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index cbd3e0154..9a6ce629f 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1728,20 +1728,20 @@ Locations Communities Lists - DVMs - All favourite DVMs + Feed Algorithms + All favorite feed algorithms Relays - Add DVM to favorites + Add feed algorithm to favorites Remove from favorites - Favourite DVMs - Content-discovery DVMs you starred here appear as filter chips on the Home feed. Open Discover to add more. - No favourite DVMs yet. Open Discover, tap a content-discovery DVM, and star it to add it here. + Favorite Feed Algorithms + Feed algorithms you starred here appear as filter chips on the Home feed. Open Discover to add more. + No favorite feed algorithms yet. Open Discover, tap one, and star it to add it here. Asking %1$s for a feed… - Asking your favourite DVMs for feeds… + Asking your favorite feed algorithms for feeds… Processing your feed… - This DVM requires payment - DVM returned an error + This feed algorithm requires payment + The feed algorithm returned an error Retry Log off on device lock From 640848a2f6f6fb70ac229630d031b25afe458818 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 16:45:17 +0000 Subject: [PATCH 41/48] fix(pdf): always scale render to target, never keep 72-DPI native size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PdfRenderer.Page.getWidth()/getHeight() return the page size in PostScript points (1/72"), so for a standard A4 that's 595 x 842 "pixels" passed to cappedRenderSize. The old early-return kept native dimensions whenever longest <= targetDim (which was always), so the base bitmap was 595 x 842 regardless of what we asked for — effectively 72 DPI. Zoom-aware re-renders hit the same early return and also stayed tiny, which is why double-tap produced no visible improvement. Remove the early return: since PDFs are vector, always rescale the point dimensions so the longest side equals targetDim. Base renders now produce ~3072 px bitmaps and the hi-res path actually goes to 6144 px when zoomed. --- .../amethyst/ui/components/pdf/PdfPreviewCard.kt | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfPreviewCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfPreviewCard.kt index 277bddafe..10525869f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfPreviewCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfPreviewCard.kt @@ -271,18 +271,20 @@ private fun renderFirstPage( } /** - * Scales the page's native point size to fit within [maxDim] on the longest side, preserving - * aspect ratio. Falls back to native size if it's already smaller. + * Returns the bitmap dimensions to render a PDF page at, scaled so the longest side equals + * [targetDim] while preserving aspect ratio. Always scales, never returns native size: a PDF + * page's native width/height are in PostScript points (1/72"), which is far below any useful + * display resolution. Since PDFs are vector, rendering at a larger target is essentially free + * and avoids a 72-DPI-blurry bitmap. */ internal fun cappedRenderSize( pageWidth: Int, pageHeight: Int, - maxDim: Int, + targetDim: Int, ): Pair { if (pageWidth <= 0 || pageHeight <= 0) return 1 to 1 val longest = maxOf(pageWidth, pageHeight) - if (longest <= maxDim) return pageWidth to pageHeight - val scale = maxDim.toFloat() / longest + val scale = targetDim.toFloat() / longest val w = (pageWidth * scale).toInt().coerceAtLeast(1) val h = (pageHeight * scale).toInt().coerceAtLeast(1) return w to h From 49e913bec4c12ac9ee64e785e98b8da5a02205c0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 16:59:23 +0000 Subject: [PATCH 42/48] perf(pdf): reduce pan/zoom jitter in viewer dialog The main cost during a live pan/zoom is the GPU re-sampling the page bitmap every frame under the zoomable modifier's transform. Three changes bring that cost down substantially: - Bitmap.Config.RGB_565 instead of ARGB_8888. PDFs are opaque, so the alpha channel is unused. Halves texture memory and GPU upload bandwidth; visually identical. - HI_RES_MAX_DIM_PX lowered from 6144 to 4096. 4096 stays within the common GPU texture-size limit, keeping rendering hardware-accelerated on mid-range devices. Peak bitmap is now ~24 MB (A4, RGB_565) instead of ~107 MB (ARGB_8888, 6144). - FilterQuality.Medium (bilinear) instead of High (bicubic/Mitchell). High is recomputed per frame during pan/zoom and is the main source of jitter on multi-megapixel sources. At 3072-4096 px base resolution bilinear is effectively indistinguishable. - HI_RES_ZOOM_THRESHOLD raised from 1.2 to 1.5 so we don't trigger a costly re-render for marginal zoom levels where the base is fine. - Cache the ImageBitmap wrapper via remember to avoid per-recomposition allocations. --- .../ui/components/pdf/PdfPreviewCard.kt | 4 ++- .../ui/components/pdf/PdfViewerDialog.kt | 33 +++++++++++++------ 2 files changed, 26 insertions(+), 11 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfPreviewCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfPreviewCard.kt index 10525869f..2e652ed39 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfPreviewCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfPreviewCard.kt @@ -256,7 +256,9 @@ private fun renderFirstPage( renderer.openPage(0).use { page -> val (renderW, renderH) = cappedRenderSize(page.width, page.height, targetWidthPx) - val bitmap = Bitmap.createBitmap(renderW, renderH, Bitmap.Config.ARGB_8888) + // RGB_565 halves memory vs ARGB_8888 and is visually identical for + // opaque PDF renders. + val bitmap = Bitmap.createBitmap(renderW, renderH, Bitmap.Config.RGB_565) bitmap.eraseColor(android.graphics.Color.WHITE) page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY) PdfLoadState.Ready( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfViewerDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfViewerDialog.kt index 523c48d81..e9b21d50b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfViewerDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfViewerDialog.kt @@ -89,16 +89,18 @@ import net.engawapg.lib.zoomable.toggleScale import net.engawapg.lib.zoomable.zoomable // Hard ceiling on each base-rendered page bitmap, in pixels. Higher = sharper when -// the user pinch-zooms inside the dialog, but each page costs ~maxDim^2 * 4 bytes -// of RAM (ARGB_8888). 3072 gives ~26 MB per A4-shaped page. +// the user pinch-zooms inside the dialog, but each page costs ~maxDim^2 * 2 bytes +// of RAM (RGB_565). 3072 gives ~13 MB per A4-shaped page. private const val VIEWER_MAX_DIM_PX = 3072 // Hard ceiling for the per-page zoom-aware detail render. When the user zooms in // past HI_RES_ZOOM_THRESHOLD we re-render the current page at -// (VIEWER_MAX_DIM_PX * scale) capped at this value. 6144 allows up to 2x sharper -// than the base render at peak cost of ~100 MB for one page (ARGB_8888, A4 shape). -private const val HI_RES_MAX_DIM_PX = 6144 -private const val HI_RES_ZOOM_THRESHOLD = 1.2f +// (VIEWER_MAX_DIM_PX * scale) capped at this value. 4096 keeps the bitmap within +// common GPU texture limits (so drawing stays hardware-accelerated) and caps +// memory at ~24 MB for an A4-shaped page (RGB_565). Above 4096 most mid-range +// GPUs fall back to software rendering, which is what caused the pan/zoom jitter. +private const val HI_RES_MAX_DIM_PX = 4096 +private const val HI_RES_ZOOM_THRESHOLD = 1.5f private const val HI_RES_DEBOUNCE_MS = 200L // Zoom level the viewer animates to when the user double-taps. Matches the @@ -352,13 +354,21 @@ private fun PdfPageView( } val current = hiResBitmap ?: baseBitmap + // Cache the ImageBitmap wrapper so each recomposition doesn't allocate a new + // one and push Compose into thinking the texture changed. + val imageBitmap = remember(current) { current?.asImageBitmap() } + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - if (current != null) { + if (imageBitmap != null) { Image( - bitmap = current.asImageBitmap(), + bitmap = imageBitmap, contentDescription = null, contentScale = ContentScale.Fit, - filterQuality = FilterQuality.High, + // Medium = bilinear. High is bicubic/Mitchell and gets recomputed on every + // frame during pan/zoom, which is the main source of jitter when the + // source bitmap is several megapixels. Bilinear on a 3072-4096 px source + // looks effectively identical on-screen. + filterQuality = FilterQuality.Medium, modifier = Modifier .fillMaxSize() @@ -388,7 +398,10 @@ private suspend fun renderPageCatching( withContext(Dispatchers.IO) { handle.renderer.openPage(pageIndex).use { page -> val (width, height) = cappedRenderSize(page.width, page.height, maxDim) - val bmp = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + // RGB_565 uses half the memory of ARGB_8888 and is indistinguishable + // for PDFs (opaque white background, no alpha). Halves GPU texture + // upload size and sampling cost during pan/zoom. + val bmp = Bitmap.createBitmap(width, height, Bitmap.Config.RGB_565) bmp.eraseColor(android.graphics.Color.WHITE) page.render(bmp, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY) bmp From 496cc9876f19b387254a0db46cbdb55979164ea8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 17:19:36 +0000 Subject: [PATCH 43/48] =?UTF-8?q?fix(pdf):=20revert=20RGB=5F565=20?= =?UTF-8?q?=E2=80=94=20PdfRenderer=20requires=20ARGB=5F8888?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PdfRenderer.Page.render() only accepts ARGB_8888 bitmaps; RGB_565 is silently rejected (the renderer produces no output), which is why the preview card and viewer both stopped showing anything. Go back to ARGB_8888 and update the memory estimates in the comments. The other perf wins from 49e913b (FilterQuality.Medium, 4096 hi-res cap, 1.5x threshold, remembered ImageBitmap wrapper) are unaffected and stay. --- .../amethyst/ui/components/pdf/PdfPreviewCard.kt | 5 ++--- .../amethyst/ui/components/pdf/PdfViewerDialog.kt | 15 +++++++-------- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfPreviewCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfPreviewCard.kt index 2e652ed39..0712e95d1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfPreviewCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfPreviewCard.kt @@ -256,9 +256,8 @@ private fun renderFirstPage( renderer.openPage(0).use { page -> val (renderW, renderH) = cappedRenderSize(page.width, page.height, targetWidthPx) - // RGB_565 halves memory vs ARGB_8888 and is visually identical for - // opaque PDF renders. - val bitmap = Bitmap.createBitmap(renderW, renderH, Bitmap.Config.RGB_565) + // PdfRenderer requires ARGB_8888; RGB_565 silently produces blank output. + val bitmap = Bitmap.createBitmap(renderW, renderH, Bitmap.Config.ARGB_8888) bitmap.eraseColor(android.graphics.Color.WHITE) page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY) PdfLoadState.Ready( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfViewerDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfViewerDialog.kt index e9b21d50b..57279441c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfViewerDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfViewerDialog.kt @@ -89,16 +89,16 @@ import net.engawapg.lib.zoomable.toggleScale import net.engawapg.lib.zoomable.zoomable // Hard ceiling on each base-rendered page bitmap, in pixels. Higher = sharper when -// the user pinch-zooms inside the dialog, but each page costs ~maxDim^2 * 2 bytes -// of RAM (RGB_565). 3072 gives ~13 MB per A4-shaped page. +// the user pinch-zooms inside the dialog, but each page costs ~maxDim^2 * 4 bytes +// of RAM (PdfRenderer requires ARGB_8888). 3072 gives ~26 MB per A4-shaped page. private const val VIEWER_MAX_DIM_PX = 3072 // Hard ceiling for the per-page zoom-aware detail render. When the user zooms in // past HI_RES_ZOOM_THRESHOLD we re-render the current page at // (VIEWER_MAX_DIM_PX * scale) capped at this value. 4096 keeps the bitmap within // common GPU texture limits (so drawing stays hardware-accelerated) and caps -// memory at ~24 MB for an A4-shaped page (RGB_565). Above 4096 most mid-range -// GPUs fall back to software rendering, which is what caused the pan/zoom jitter. +// memory at ~48 MB for an A4-shaped page. Above 4096 most mid-range GPUs fall +// back to software rendering, which is what caused the pan/zoom jitter. private const val HI_RES_MAX_DIM_PX = 4096 private const val HI_RES_ZOOM_THRESHOLD = 1.5f private const val HI_RES_DEBOUNCE_MS = 200L @@ -398,10 +398,9 @@ private suspend fun renderPageCatching( withContext(Dispatchers.IO) { handle.renderer.openPage(pageIndex).use { page -> val (width, height) = cappedRenderSize(page.width, page.height, maxDim) - // RGB_565 uses half the memory of ARGB_8888 and is indistinguishable - // for PDFs (opaque white background, no alpha). Halves GPU texture - // upload size and sampling cost during pan/zoom. - val bmp = Bitmap.createBitmap(width, height, Bitmap.Config.RGB_565) + // PdfRenderer requires ARGB_8888 bitmaps — RGB_565 is silently + // rejected and produces blank output. + val bmp = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) bmp.eraseColor(android.graphics.Color.WHITE) page.render(bmp, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY) bmp From 63ef6dae6cc84e3dc118b4fd44290f45777bb7ca Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Sun, 19 Apr 2026 17:27:09 +0000 Subject: [PATCH 44/48] New Crowdin translations by GitHub Action --- .../src/main/res/values-hi-rIN/strings.xml | 20 ++++++++ .../src/main/res/values-hu-rHU/strings.xml | 3 ++ .../src/main/res/values-pl-rPL/strings.xml | 1 + .../src/main/res/values-sl-rSI/strings.xml | 50 +++++++++++++++++++ .../src/main/res/values-zh-rCN/strings.xml | 1 + 5 files changed, 75 insertions(+) diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index 100418540..568400a75 100644 --- a/amethyst/src/main/res/values-hi-rIN/strings.xml +++ b/amethyst/src/main/res/values-hi-rIN/strings.xml @@ -1350,6 +1350,7 @@ सीधा संदेश आगतपेटिका पुनःप्रसारक प्रयोक्ता इन पुनःप्रसारकों पर सीधा सन्देश प्राप्त कर रहा है आपके निजी आगतपेटिका के रूप में १ - ३ पुनःप्रसारकों को जोडें। अन्य लोग इनका उपयोग करेंगे आपको सी॰सं॰ भेजने के लिए। सी॰सं॰ आगतपेटिका पुनःप्रसारकों को किसी से भी सन्देश स्वीकारना चाहिए पर उनका अवरोहण अनुमति केवल आपको देना चाहिए। ये अच्छे विकल्प हैं :\n - inbox.nostr.wine (सशुल्क)\n - auth.nostr1.com (शुल्करहित)\n - you.nostr1.com (व्यक्तिगत पुनःप्रसारक - सशुल्क) + कुंचिकापोटलियाँ कुंचिकापोटली पुनःप्रसारक पुनःप्रसारक जहाँ आपके एमएलएस॰ कुंचिकापोटलियाँ प्रकाशित होंगे (मिप॰००)। अन्य प्रयोक्ता इन कुंचिकापोटलियों को प्राप्त करेंगे आपको आमन्त्रित करने के लिए मार्मोट॰ समूह चर्चाओं में। १ - ३ पुनःप्रसारकों को जोडें जो आप से कुंचिकापोटली घटनाएँ स्वीकारते हों तथा सार्वजनिक पठन की अनुमती देते हों। निजी पुनःप्रसारक @@ -1851,6 +1852,25 @@ स्रोत सुलझाव : %1$dघ्न%2$d उत्पाद्य : %1$s (%1$s त्यक्त - स्रोत ऊपर) + %1$d सहस्रांकप्रतिसेकण्ड + स्रोत ऊपर - त्यक्तव्य + उच्छनिरूपण दृश्याभिलेख प्रकाशित करें + प्रकाशन चालू \"%1$s\"… + संकुचनान्तरण चालू %1$s + आरोहण + आरोहण चालू %2$d में से %1$d + आरोहण चालू %1$s (%3$d में से %2$d) + आरोहण कृत %2$d में से %1$d + घटना प्रकाशन चालू… + दृश्याभिलेख प्रकाशित + आपका उच्छनिरूपण दृश्याभिलेख नोस्टर पर चलन्त है। + कुछ गडबड हुआ + टीका देखें + हो गया + पुनः प्रयास करें + टीका सम्पादन आरोहण पश्चात + टीका सम्पादक खोलें शीर्षक विवरण तथा दृश्याभिलेख योजक जानकारी से पूर्वयुक्त जिसे आप शोधन कर सकते है प्रकाशन पूर्व। + टीका सम्पादन पोटली कार्य सूची कार्य स्मर्त्तव्यचिह्न कार्य diff --git a/amethyst/src/main/res/values-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml index df8ce9048..510791544 100644 --- a/amethyst/src/main/res/values-hu-rHU/strings.xml +++ b/amethyst/src/main/res/values-hu-rHU/strings.xml @@ -1350,6 +1350,9 @@ Bejövő közvetlen üzenet-átjátszók A felhasználó ezeken az átjátszókon keresztül fogadja a közvetlen üzeneteket Adjon hozzá 1–3 átjátszót, hogy privát postafiókként szolgáljon. Mások ezeket az átjátszókat használják, hogy Önnek privát üzeneteket küldjenek. A bejövő privát üzenetek átjátszóinak bárkitől el kell fogadniuk minden üzenetet, de azok letöltését csak Ön engedélyezheti. Jó választási lehetőségek:\n - inbox.nostr.wine (fizetős)\n - auth.nostr1.com (ingyenes)\n - you.nostr1.com (személyes átjátszók - fizetős) + Kulcscsomagok + Kulcscsomag-átjátszók + Azok az átjátszók, ahol az Ön MLS-kulcscsomagjai közzétételre kerülnek (MIP-00). Más felhasználók ezeket a kulcscsomagokat lekérve tudják Önt meghívni a Marmot csoportos beszélgetésekbe. Adjon meg 1-3 olyan átjátszót, amely fogadja az Ön kulcscsomag-eseményeit, és lehetővé teszi azok nyilvános olvasását. Privát saját átjátszók Adjon hozzá 1–3 átjátszót, hogy olyan eseményeket tároljanak, amelyeket senki más nem láthat, például a piszkozatait és/vagy az alkalmazásbeállításait. Ideális esetben ezek az átjátszók vagy helyi szintűek, vagy hitelesítést igényelnek az egyes felhasználói tartalmak letöltése előtt. Általános átjátszók diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index 9c980c163..473b78618 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -1346,6 +1346,7 @@ Odbiorcze transmitery DM Użytkownik otrzymuje DM na tych transmiterach Wstaw od 1 do 3 transmiterów, które będą służyć jako Twoja prywatna skrzynka odbiorcza. Inni będą używać tych transmiterów do wysyłania wiadomości DM do Ciebie. Transmitery odbiorcze DM powinny akceptować dowolne wiadomości od każdego, ale pozwalać tylko na ich pobieranie. Dobre opcje to:\n - inbox.nostr.wine (płatny)\n - you.nostr1.com (transmitery osobiste - płatny) + Pakiety kluczy Transmitery pakietu kluczy Transmitery, na których publikowane są Twoje pakiety kluczy MLS (MIP-00). Inni użytkownicy pobierają te pakiety kluczy, aby zaprosić Cię do czatów grupowych w aplikacji Marmot. Wprowadź od 1 do 3 transmiterów, które akceptują zdarzenia związane z pakietami kluczy od Ciebie i zezwalają na publiczny dostęp do tych danych. Transmitery Prywatne diff --git a/amethyst/src/main/res/values-sl-rSI/strings.xml b/amethyst/src/main/res/values-sl-rSI/strings.xml index c5d5e3161..575e988fd 100644 --- a/amethyst/src/main/res/values-sl-rSI/strings.xml +++ b/amethyst/src/main/res/values-sl-rSI/strings.xml @@ -281,6 +281,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem So odlične za odprte skupnosti, povezane s specifičnimi temami. Nekatere od teh skupin so kratkotrajne zato sporočila v klepetu sčasoma izginejo Javni pogovor + MLS skupina Metapodatki Javnega Klepeta Javni klepeti so vidni vsem na Nostru in vsakdo se lahko pridruži. So odlični za odprte skupnosti o specifičnih temah. @@ -402,6 +403,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Fotografije Kratki posnetki Videoposnetki + Članki Privatni zaznamki Javni zaznamki Dodaj v privatne zaznamke @@ -409,8 +411,11 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Odstrani iz privatnih zaznamkov Odstrani iz javnih zaznamkov Pripeti zapiski + Vaši pripeti zapiski Pripni k profilu Odpni od profila + Odstrani iz seznama + Prekliči Seznam zaznamkov Ikona za seznam zaznamkov Nov seznam zaznamkov @@ -727,6 +732,8 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Sprejem klica ni uspel Vzpostavitev klicne seje ni uspela Klicne nastavitve + Vklop glasovnih in video klicev + Če funkcijo izklopite, bodo gumbi za klice skriti, vsi dohodni klici pa se bodo tiho zavrnili. Kakovost videa Najvišja bitna hitrost videa Strežniki TURN / STUN @@ -952,6 +959,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem To bodo videli samo sledilci lokacije. Vaši splošni sledilci tega ne bodo videli. Ekskluzivna objava ključnika Vidno bo le sledilcem tega ključnika. Tvoji splošni sledilci tega ne bojo videli. + %1$d min branja Nalaganje lokacije Brez dovoljenj za lokacijo Doda opozorilo o občutljivi vsebini pred prikazom vaše vsebine. To je primerno za vsebino NSFW ali vsebino, ki jo nekateri lahko smatrajo za žaljivo ali vznemirjajočo @@ -960,6 +968,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Za aktivacijo tega načina mora Amethyst poslati sporočilo NIP-17 (GiftWrapped, šifrirana neposredna in skupinska sporočila). NIP-17 je nov in večina Nostr odjemalcev ga še ni implementirala. Prepričajte se, da prejemnik uporablja združljiv Nostr odjemalec. Aktiviraj Javno + Skupina Nova javna ali zasebna skupina Rele Zasebno @@ -1355,6 +1364,9 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Releji predala zasebnih sporočil Uporabnik prejema ZS prek teh relejev Vnesite 1–3 releje, ki bodo služili kot vaš predal za zasebna sporočila. Drugi bodo te releje uporabljali za pošiljanje zasebnih sporočil vam. Releji za zasebna sporočila bi morali sprejeti katero koli sporočilo od kogar koli, vendar samo vam dovoliti njihov prenos. Dobre možnosti so:\n - inbox.nostr.wine (plačljiv)\n - auth.nostr1.com (brezplačen)\n - you.nostr1.com (osebni releji - plačljivi) + KeyPackage-i + KeyPackage Releji + Releji, kjer so objavljeni vaši MLS KeyPackage-i (MIP-00). Drugi uporabniki jih pridobijo, da vas lahko povabijo v skupinske klepete Marmot. Vstavite od 1 do 3 releje, ki sprejemajo vaše KeyPackage dogodke in omogočajo javno branje. Zasebni domači releji Vnesite 1–3 releje za shranjevanje dogodkov, ki jih nihče drug ne more videti, kot so vaši osnutki in/ali nastavitve aplikacije. Idealno je, da so ti releji bodisi lokalni ali zahtevajo preverjanje pristnosti pred prenosom vsebine posameznega uporabnika. Splošni releji @@ -1834,7 +1846,45 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Možnosti profila Možnosti predstavnosti Predvajaj + Samodejno + Naloži HLS + Objava HLS v več ločljivostih na medijski strežnik + Izberi video + Vaš video bo pripravljen v več ločljivostih, da bo predvajanje teklo gladko na vsaki povezavi. + Sprememba + Naslov + Dodajte naslov videa + Opis + Kaj je vsebina tega videa? + Razlog (neobvezno) + Kodek + H.265 (boljše stiskanje) + H.264 + Naprava ne podpira H.265 — izbran bo H.264. + Izvedbe + Resolucija vira: %1$d×%2$d + Ustvarilo bo: %1$s + (%1$s preskočen — zgornji vir) + %1$d kbps + Zgornji vir — bo preskočen + Objavite HD video + Objavljam “%1$s”… + Priprava videa: %1$s + Naložite + Nalagam %1$d of %2$d + Nalagam %1$s (%2$d of %3$d) + Naloženo %1$d of %2$d + Dogodek se objavlja… + Video je objavljen + Vaš HD-video je dostopen na Nostru. + Prišlo je do napake + Poglej zapisek + Končano + Poskusi znova + Osnutek zapiska po nalaganju + Odpri urejevalnik z že izpolnjenim naslovom, opisom in povezavo do videa, da ga lahko pred objavo še dodelaš. + Osnutek zapiska Možnosti paketa Možnosti seznama Možnosti zaznamkov diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index 2c4e4d7a6..9f11a256a 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -1350,6 +1350,7 @@ 私信收件箱中继 用户接收到这些中继的私信 设置 1 ~ 3 个中继作为您的私人收件箱。其他人将使用这些中继向您发送私信。需要确保这些收件箱中继能够接受来自任何人的任何私信消息,但只允许您读取这些消息。示例:\n - inbox.nostr.wine(付费)\n - you.nostr1.com(个人专用中继 - 付费) + 密钥包 密钥包中继 发布你的 MLS 密钥包发布的中继(MIP-00)。其他用户获取这些密钥包来邀请你加入 Marmot 群聊。插入1~3个接受来自你的密钥包事件的中继并允许公开读取。 私人中继 From 72598026a3328a11986fb98b2ca80eedd8c2f4c0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 17:37:55 +0000 Subject: [PATCH 45/48] refactor(quartz): rename FavoriteDvmListEvent -> FavoriteAlgoFeedsListEvent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The list event only stores content-discovery feeds (kind-5300 DVMs), not every DVM, so the old name was misleading. Rename the wire-level type to reflect what's actually in it. - Quartz: package nip51Lists.favoriteDvmList -> favoriteAlgoFeedsList; class FavoriteDvmListEvent -> FavoriteAlgoFeedsListEvent. - DSL helpers renamed: favoriteDvm/favoriteDvms builder extensions -> favoriteAlgoFeed/favoriteAlgoFeeds; TagArray.favoriteDvmList/Set -> favoriteAlgoFeedsList/Set. - create/add/remove parameter names dvm -> feed, publicDvms/privateDvms -> publicFeeds/privateFeeds; public/private accessors publicFavoriteDvms/privateFavoriteDvms -> publicFavoriteAlgoFeeds/ privateFavoriteAlgoFeeds. ALT string updated. - EventFactory + LocalCache dispatch branches + AccountSettings backup field type + FavoriteDvmListState + FavoriteDvmListDecryptionCache all import the new type. Internal amethyst-side classes (FavoriteDvmListState, FavoriteDvmListDecryptionCache), the top-nav filter classes (FavoriteDvm*, AllFavoriteDvms*) and the orchestrator keep their names — they still deal with content-discovery DVMs specifically, and the narrower rename here is scoped to the Nostr wire format the user was asking about. - Quartz test renamed + rewired. Build + tests green on both modules. --- .../amethyst/model/AccountSettings.kt | 6 +- .../amethyst/model/LocalCache.kt | 4 +- .../FavoriteDvmListDecryptionCache.kt | 10 +-- .../favoriteDvmLists/FavoriteDvmListState.kt | 20 +++--- .../FavoriteAlgoFeedsListEvent.kt} | 62 +++++++++---------- .../TagArrayBuilderExt.kt | 6 +- .../TagArrayExt.kt | 6 +- .../quartz/utils/EventFactory.kt | 4 +- .../FavoriteAlgoFeedsListEventTest.kt} | 54 ++++++++-------- 9 files changed, 86 insertions(+), 86 deletions(-) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/{favoriteDvmList/FavoriteDvmListEvent.kt => favoriteAlgoFeedsList/FavoriteAlgoFeedsListEvent.kt} (75%) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/{favoriteDvmList => favoriteAlgoFeedsList}/TagArrayBuilderExt.kt (80%) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/{favoriteDvmList => favoriteAlgoFeedsList}/TagArrayExt.kt (83%) rename quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/{favoriteDvmList/FavoriteDvmListEventTest.kt => favoriteAlgoFeedsList/FavoriteAlgoFeedsListEventTest.kt} (75%) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index 15332850e..2a5f6b681 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -43,7 +43,7 @@ import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayList import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent -import com.vitorpamplona.quartz.nip51Lists.favoriteDvmList.FavoriteDvmListEvent +import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListEvent import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent @@ -203,7 +203,7 @@ class AccountSettings( var backupChannelList: ChannelListEvent? = null, var backupCommunityList: CommunityListEvent? = null, var backupHashtagList: HashtagListEvent? = null, - var backupFavoriteDvmList: FavoriteDvmListEvent? = null, + var backupFavoriteDvmList: FavoriteAlgoFeedsListEvent? = null, var backupGeohashList: GeohashListEvent? = null, var backupEphemeralChatList: EphemeralChatListEvent? = null, var backupTrustProviderList: TrustProviderListEvent? = null, @@ -727,7 +727,7 @@ class AccountSettings( } } - fun updateFavoriteDvmListTo(newFavoriteDvmList: FavoriteDvmListEvent?) { + fun updateFavoriteDvmListTo(newFavoriteDvmList: FavoriteAlgoFeedsListEvent?) { if (newFavoriteDvmList == null || newFavoriteDvmList.tags.isEmpty()) return // Events might be different objects, we have to compare their ids. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 339747d75..aed8edf69 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -149,7 +149,7 @@ import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent import com.vitorpamplona.quartz.nip51Lists.PinListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent -import com.vitorpamplona.quartz.nip51Lists.favoriteDvmList.FavoriteDvmListEvent +import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListEvent import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent @@ -2636,7 +2636,7 @@ object LocalCache : ILocalCache, ICacheProvider { is LiveChessGameEndEvent -> consumeBaseReplaceable(event, relay, wasVerified) is LiveChessDrawOfferEvent -> consumeBaseReplaceable(event, relay, wasVerified) is HashtagListEvent -> consumeBaseReplaceable(event, relay, wasVerified) - is FavoriteDvmListEvent -> consumeBaseReplaceable(event, relay, wasVerified) + is FavoriteAlgoFeedsListEvent -> consumeBaseReplaceable(event, relay, wasVerified) is HighlightEvent -> consumeRegularEvent(event, relay, wasVerified) is IndexerRelayListEvent -> consumeBaseReplaceable(event, relay, wasVerified) is InteractiveStoryPrologueEvent -> consumeBaseReplaceable(event, relay, wasVerified) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteDvmLists/FavoriteDvmListDecryptionCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteDvmLists/FavoriteDvmListDecryptionCache.kt index 8af3bfa97..fef666385 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteDvmLists/FavoriteDvmListDecryptionCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteDvmLists/FavoriteDvmListDecryptionCache.kt @@ -22,15 +22,15 @@ package com.vitorpamplona.amethyst.model.nip51Lists.favoriteDvmLists import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEventCache -import com.vitorpamplona.quartz.nip51Lists.favoriteDvmList.FavoriteDvmListEvent -import com.vitorpamplona.quartz.nip51Lists.favoriteDvmList.favoriteDvmSet +import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListEvent +import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.favoriteAlgoFeedsSet class FavoriteDvmListDecryptionCache( val signer: NostrSigner, ) { - val cachedPrivateLists = PrivateTagArrayEventCache(signer) + val cachedPrivateLists = PrivateTagArrayEventCache(signer) - fun cachedFavoriteDvms(event: FavoriteDvmListEvent) = cachedPrivateLists.mergeTagListPrecached(event).favoriteDvmSet() + fun cachedFavoriteDvms(event: FavoriteAlgoFeedsListEvent) = cachedPrivateLists.mergeTagListPrecached(event).favoriteAlgoFeedsSet() - suspend fun favoriteDvms(event: FavoriteDvmListEvent) = cachedPrivateLists.mergeTagList(event).favoriteDvmSet() + suspend fun favoriteDvms(event: FavoriteAlgoFeedsListEvent) = cachedPrivateLists.mergeTagList(event).favoriteAlgoFeedsSet() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteDvmLists/FavoriteDvmListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteDvmLists/FavoriteDvmListState.kt index db41026d5..6181d8b02 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteDvmLists/FavoriteDvmListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteDvmLists/FavoriteDvmListState.kt @@ -28,7 +28,7 @@ import com.vitorpamplona.amethyst.model.NoteState import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark -import com.vitorpamplona.quartz.nip51Lists.favoriteDvmList.FavoriteDvmListEvent +import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi @@ -53,14 +53,14 @@ class FavoriteDvmListState( // Creates a long-term reference for this note so that the GC doesn't collect the note itself val favoriteDvmListNote = cache.getOrCreateAddressableNote(getFavoriteDvmListAddress()) - fun getFavoriteDvmListAddress() = FavoriteDvmListEvent.createAddress(signer.pubKey) + fun getFavoriteDvmListAddress() = FavoriteAlgoFeedsListEvent.createAddress(signer.pubKey) fun getFavoriteDvmListFlow(): StateFlow = favoriteDvmListNote.flow().metadata.stateFlow - fun getFavoriteDvmList(): FavoriteDvmListEvent? = favoriteDvmListNote.event as? FavoriteDvmListEvent + fun getFavoriteDvmList(): FavoriteAlgoFeedsListEvent? = favoriteDvmListNote.event as? FavoriteAlgoFeedsListEvent suspend fun favoriteDvmListWithBackup(note: Note): Set
{ - val event = note.event as? FavoriteDvmListEvent ?: settings.backupFavoriteDvmList + val event = note.event as? FavoriteAlgoFeedsListEvent ?: settings.backupFavoriteDvmList return event?.let { decryptionCache.favoriteDvms(it) } ?: emptySet() } @@ -92,18 +92,18 @@ class FavoriteDvmListState( emptyList(), ) - suspend fun follow(dvm: AddressBookmark): FavoriteDvmListEvent { + suspend fun follow(dvm: AddressBookmark): FavoriteAlgoFeedsListEvent { val list = getFavoriteDvmList() return if (list == null) { - FavoriteDvmListEvent.create(dvm, false, signer) + FavoriteAlgoFeedsListEvent.create(dvm, false, signer) } else { - FavoriteDvmListEvent.add(list, dvm, false, signer) + FavoriteAlgoFeedsListEvent.add(list, dvm, false, signer) } } - suspend fun unfollow(dvm: Address): FavoriteDvmListEvent? { + suspend fun unfollow(dvm: Address): FavoriteAlgoFeedsListEvent? { val list = getFavoriteDvmList() ?: return null - return FavoriteDvmListEvent.remove(list, dvm, signer) + return FavoriteAlgoFeedsListEvent.remove(list, dvm, signer) } init { @@ -119,7 +119,7 @@ class FavoriteDvmListState( Log.d("AccountRegisterObservers", "Favorite DVM List Collector Start") getFavoriteDvmListFlow().collect { Log.d("AccountRegisterObservers") { "Favorite DVM List for ${signer.pubKey}" } - (it.note.event as? FavoriteDvmListEvent)?.let { + (it.note.event as? FavoriteAlgoFeedsListEvent)?.let { settings.updateFavoriteDvmListTo(it) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/FavoriteDvmListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteAlgoFeedsList/FavoriteAlgoFeedsListEvent.kt similarity index 75% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/FavoriteDvmListEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteAlgoFeedsList/FavoriteAlgoFeedsListEvent.kt index 34f28bf7c..d4b673582 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/FavoriteDvmListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteAlgoFeedsList/FavoriteAlgoFeedsListEvent.kt @@ -18,7 +18,7 @@ * 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.quartz.nip51Lists.favoriteDvmList +package com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Address @@ -38,7 +38,7 @@ import com.vitorpamplona.quartz.nip51Lists.remove import com.vitorpamplona.quartz.utils.TimeUtils @Immutable -class FavoriteDvmListEvent( +class FavoriteAlgoFeedsListEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -46,72 +46,72 @@ class FavoriteDvmListEvent( content: String, sig: HexKey, ) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - fun publicFavoriteDvms(): List = tags.mapNotNull(AddressBookmark::parse) + fun publicFavoriteAlgoFeeds(): List = tags.mapNotNull(AddressBookmark::parse) - suspend fun privateFavoriteDvms(signer: NostrSigner): List? = privateTags(signer)?.mapNotNull(AddressBookmark::parse) + suspend fun privateFavoriteAlgoFeeds(signer: NostrSigner): List? = privateTags(signer)?.mapNotNull(AddressBookmark::parse) companion object { const val KIND = 10090 - const val ALT = "Favorite DVM list" + const val ALT = "Favorite algo-feeds list" const val FIXED_D_TAG = "" fun createAddress(pubKey: HexKey) = Address(KIND, pubKey, FIXED_D_TAG) suspend fun create( - dvm: AddressBookmark, + feed: AddressBookmark, isPrivate: Boolean, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - ): FavoriteDvmListEvent = + ): FavoriteAlgoFeedsListEvent = if (isPrivate) { create( - publicDvms = emptyList(), - privateDvms = listOf(dvm), + publicFeeds = emptyList(), + privateFeeds = listOf(feed), signer = signer, createdAt = createdAt, ) } else { create( - publicDvms = listOf(dvm), - privateDvms = emptyList(), + publicFeeds = listOf(feed), + privateFeeds = emptyList(), signer = signer, createdAt = createdAt, ) } suspend fun add( - earlierVersion: FavoriteDvmListEvent, - dvm: AddressBookmark, + earlierVersion: FavoriteAlgoFeedsListEvent, + feed: AddressBookmark, isPrivate: Boolean, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - ): FavoriteDvmListEvent = + ): FavoriteAlgoFeedsListEvent = if (isPrivate) { val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() resign( tags = earlierVersion.tags, - privateTags = privateTags.remove(dvm.toTagIdOnly()) + dvm.toTagArray(), + privateTags = privateTags.remove(feed.toTagIdOnly()) + feed.toTagArray(), signer = signer, createdAt = createdAt, ) } else { resign( content = earlierVersion.content, - tags = earlierVersion.tags.remove(dvm.toTagIdOnly()) + dvm.toTagArray(), + tags = earlierVersion.tags.remove(feed.toTagIdOnly()) + feed.toTagArray(), signer = signer, createdAt = createdAt, ) } suspend fun remove( - earlierVersion: FavoriteDvmListEvent, - dvm: Address, + earlierVersion: FavoriteAlgoFeedsListEvent, + feed: Address, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - ): FavoriteDvmListEvent { - val idOnly = AddressBookmark.assemble(dvm, null) + ): FavoriteAlgoFeedsListEvent { + val idOnly = AddressBookmark.assemble(feed, null) val privateTags = earlierVersion.privateTags(signer) return if (privateTags != null) { resign( @@ -147,7 +147,7 @@ class FavoriteDvmListEvent( tags: TagArray, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - ): FavoriteDvmListEvent { + ): FavoriteAlgoFeedsListEvent { val newTags = if (tags.fastAny(AltTag::match)) { tags @@ -159,32 +159,32 @@ class FavoriteDvmListEvent( } suspend fun create( - publicDvms: List = emptyList(), - privateDvms: List = emptyList(), + publicFeeds: List = emptyList(), + privateFeeds: List = emptyList(), signer: NostrSigner, createdAt: Long = TimeUtils.now(), - ): FavoriteDvmListEvent { - val template = build(publicDvms, privateDvms, signer, createdAt) + ): FavoriteAlgoFeedsListEvent { + val template = build(publicFeeds, privateFeeds, signer, createdAt) return signer.sign(template) } suspend fun build( - publicDvms: List = emptyList(), - privateDvms: List = emptyList(), + publicFeeds: List = emptyList(), + privateFeeds: List = emptyList(), signer: NostrSigner, createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, - ) = eventTemplate( + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate( kind = KIND, description = PrivateTagsInContent.encryptNip44( - privateDvms.map { it.toTagArray() }.toTypedArray(), + privateFeeds.map { it.toTagArray() }.toTypedArray(), signer, ), createdAt = createdAt, ) { alt(ALT) - favoriteDvms(publicDvms) + favoriteAlgoFeeds(publicFeeds) initializer() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteAlgoFeedsList/TagArrayBuilderExt.kt similarity index 80% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/TagArrayBuilderExt.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteAlgoFeedsList/TagArrayBuilderExt.kt index 6f49fd98b..4214d7627 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteAlgoFeedsList/TagArrayBuilderExt.kt @@ -18,11 +18,11 @@ * 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.quartz.nip51Lists.favoriteDvmList +package com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark -fun TagArrayBuilder.favoriteDvm(app: AddressBookmark) = add(app.toTagArray()) +fun TagArrayBuilder.favoriteAlgoFeed(app: AddressBookmark) = add(app.toTagArray()) -fun TagArrayBuilder.favoriteDvms(apps: List) = addAll(apps.map { it.toTagArray() }) +fun TagArrayBuilder.favoriteAlgoFeeds(apps: List) = addAll(apps.map { it.toTagArray() }) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteAlgoFeedsList/TagArrayExt.kt similarity index 83% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/TagArrayExt.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteAlgoFeedsList/TagArrayExt.kt index 0f2d3501c..5024bb91e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteAlgoFeedsList/TagArrayExt.kt @@ -18,11 +18,11 @@ * 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.quartz.nip51Lists.favoriteDvmList +package com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList import com.vitorpamplona.quartz.nip01Core.core.TagArray import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark -fun TagArray.favoriteDvmList() = mapNotNull(AddressBookmark::parseAddress) +fun TagArray.favoriteAlgoFeedsList() = mapNotNull(AddressBookmark::parseAddress) -fun TagArray.favoriteDvmSet() = mapNotNullTo(mutableSetOf(), AddressBookmark::parseAddress) +fun TagArray.favoriteAlgoFeedsSet() = mapNotNullTo(mutableSetOf(), AddressBookmark::parseAddress) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index 1e525e148..8c46307f2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -134,7 +134,7 @@ import com.vitorpamplona.quartz.nip51Lists.appCurationSet.AppCurationSetEvent import com.vitorpamplona.quartz.nip51Lists.articleCurationSet.ArticleCurationSetEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent -import com.vitorpamplona.quartz.nip51Lists.favoriteDvmList.FavoriteDvmListEvent +import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListEvent import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent import com.vitorpamplona.quartz.nip51Lists.gitAuthorList.GitAuthorListEvent @@ -423,7 +423,7 @@ class EventFactory { GoodWikiAuthorListEvent.KIND -> GoodWikiAuthorListEvent(id, pubKey, createdAt, tags, content, sig) GoodWikiRelayListEvent.KIND -> GoodWikiRelayListEvent(id, pubKey, createdAt, tags, content, sig) GoalEvent.KIND -> GoalEvent(id, pubKey, createdAt, tags, content, sig) - FavoriteDvmListEvent.KIND -> FavoriteDvmListEvent(id, pubKey, createdAt, tags, content, sig) + FavoriteAlgoFeedsListEvent.KIND -> FavoriteAlgoFeedsListEvent(id, pubKey, createdAt, tags, content, sig) HashtagListEvent.KIND -> HashtagListEvent(id, pubKey, createdAt, tags, content, sig) HighlightEvent.KIND -> HighlightEvent(id, pubKey, createdAt, tags, content, sig) HTTPAuthorizationEvent.KIND -> HTTPAuthorizationEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/FavoriteDvmListEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteAlgoFeedsList/FavoriteAlgoFeedsListEventTest.kt similarity index 75% rename from quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/FavoriteDvmListEventTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteAlgoFeedsList/FavoriteAlgoFeedsListEventTest.kt index 88c36c6e4..f3ac5ff24 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/FavoriteDvmListEventTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteAlgoFeedsList/FavoriteAlgoFeedsListEventTest.kt @@ -18,7 +18,7 @@ * 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.quartz.nip51Lists.favoriteDvmList +package com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal @@ -30,7 +30,7 @@ import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue -class FavoriteDvmListEventTest { +class FavoriteAlgoFeedsListEventTest { private val signer = NostrSignerInternal("nsec10g0wheggqn9dawlc0yuv6adnat6n09anr7eyykevw2dm8xa5fffs0wsdsr".nsecToKeyPair()) private fun dvm( @@ -40,12 +40,12 @@ class FavoriteDvmListEventTest { @Test fun kindMatchesSpec() { - assertEquals(10090, FavoriteDvmListEvent.KIND) + assertEquals(10090, FavoriteAlgoFeedsListEvent.KIND) } @Test fun addressesAreReplaceableWithFixedDTag() { - val address = FavoriteDvmListEvent.createAddress("a".repeat(64)) + val address = FavoriteAlgoFeedsListEvent.createAddress("a".repeat(64)) assertEquals(10090, address.kind) assertEquals("", address.dTag) } @@ -53,11 +53,11 @@ class FavoriteDvmListEventTest { @Test fun createStoresDvmAsATag() = runTest { - val dvm = dvm("a".repeat(64)) + val aFeed = dvm("a".repeat(64)) val event = - FavoriteDvmListEvent.create( - dvm = dvm, + FavoriteAlgoFeedsListEvent.create( + feed = aFeed, isPrivate = false, signer = signer, createdAt = 1740669816, @@ -65,31 +65,31 @@ class FavoriteDvmListEventTest { assertEquals(10090, event.kind) assertTrue( - event.tags.any { it.size >= 2 && it[0] == "a" && it[1] == dvm.address.toValue() }, + event.tags.any { it.size >= 2 && it[0] == "a" && it[1] == aFeed.address.toValue() }, "public a tag for the favourited DVM should be present", ) - val favorites = event.publicFavoriteDvms() + val favorites = event.publicFavoriteAlgoFeeds() assertEquals(1, favorites.size) - assertEquals(dvm.address, favorites.first().address) + assertEquals(aFeed.address, favorites.first().address) } @Test fun addAppendsWithoutDuplicatingExistingEntry() = runTest { - val dvm = dvm("a".repeat(64)) + val aFeed = dvm("a".repeat(64)) val initial = - FavoriteDvmListEvent.create( - dvm = dvm, + FavoriteAlgoFeedsListEvent.create( + feed = aFeed, isPrivate = false, signer = signer, createdAt = 1740669816, ) val afterDupeAdd = - FavoriteDvmListEvent.add( + FavoriteAlgoFeedsListEvent.add( earlierVersion = initial, - dvm = dvm, + feed = aFeed, isPrivate = false, signer = signer, createdAt = 1740669817, @@ -97,7 +97,7 @@ class FavoriteDvmListEventTest { assertEquals( 1, - afterDupeAdd.publicFavoriteDvms().count { it.address == dvm.address }, + afterDupeAdd.publicFavoriteAlgoFeeds().count { it.address == aFeed.address }, "re-adding the same DVM must not produce a duplicate tag", ) } @@ -109,23 +109,23 @@ class FavoriteDvmListEventTest { val second = dvm("b".repeat(64)) val initial = - FavoriteDvmListEvent.create( - dvm = first, + FavoriteAlgoFeedsListEvent.create( + feed = first, isPrivate = false, signer = signer, createdAt = 1740669816, ) val after = - FavoriteDvmListEvent.add( + FavoriteAlgoFeedsListEvent.add( earlierVersion = initial, - dvm = second, + feed = second, isPrivate = false, signer = signer, createdAt = 1740669817, ) - val addresses = after.publicFavoriteDvms().map { it.address }.toSet() + val addresses = after.publicFavoriteAlgoFeeds().map { it.address }.toSet() assertTrue(first.address in addresses) assertTrue(second.address in addresses) } @@ -137,22 +137,22 @@ class FavoriteDvmListEventTest { val second = dvm("b".repeat(64)) val initial = - FavoriteDvmListEvent.create( - publicDvms = listOf(first, second), - privateDvms = emptyList(), + FavoriteAlgoFeedsListEvent.create( + publicFeeds = listOf(first, second), + privateFeeds = emptyList(), signer = signer, createdAt = 1740669816, ) val after = - FavoriteDvmListEvent.remove( + FavoriteAlgoFeedsListEvent.remove( earlierVersion = initial, - dvm = first.address, + feed = first.address, signer = signer, createdAt = 1740669817, ) - val addresses = after.publicFavoriteDvms().map { it.address }.toSet() + val addresses = after.publicFavoriteAlgoFeeds().map { it.address }.toSet() assertFalse(first.address in addresses, "removed DVM should not survive") assertTrue(second.address in addresses, "other DVMs should be preserved") } From f99fc4c3334d70136ae8e47af22d2a7c878277cb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 17:56:16 +0000 Subject: [PATCH 46/48] refactor: rename FavoriteDvm* -> FavoriteAlgoFeed* throughout amethyst MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wire-level class was renamed to FavoriteAlgoFeedsListEvent in a prior commit. Finish the rename through the rest of the feature so the internal vocabulary (packages, classes, properties, routes, DSL helpers, TopFilter variants, composables) matches what users see. Packages: - model.nip51Lists.favoriteDvmLists -> favoriteAlgoFeedsLists - model.dvms -> algoFeeds - model.topNavFeeds.favoriteDvm -> favoriteAlgoFeeds - home.datasource.nip90Dvms -> nip90AlgoFeeds Classes/composables renamed (selected): - FavoriteDvmListState/DecryptionCache -> FavoriteAlgoFeedsListState/DecryptionCache - FavoriteDvmOrchestrator / FavoriteDvmSnapshot -> FavoriteAlgoFeedsOrchestrator / FavoriteAlgoFeedsSnapshot - FavoriteDvmTopNavFilter{,PerRelayFilter{,Set}}, FavoriteDvmFeedFlow -> FavoriteAlgoFeedTopNavFilter{,PerRelayFilter{,Set}}, FavoriteAlgoFeedFlow - AllFavoriteDvmsTopNavFilter / AllFavoriteDvmsFeedFlow / AllFavoriteDvmsBanner -> AllFavoriteAlgoFeedsTopNavFilter / AllFavoriteAlgoFeedsFlow / AllFavoriteAlgoFeedsBanner - FavoriteDvmName -> FavoriteAlgoFeedName - FavoriteDvmToggle -> FavoriteAlgoFeedToggle - FavoriteDvmListScreen -> FavoriteAlgoFeedsListScreen - HomeDvmStatusBanner / SingleDvmBanner / DvmStatusBanner -> HomeAlgoFeedStatusBanner / SingleAlgoFeedBanner / AlgoFeedStatusBanner - filterHomePostsByDvmIds -> filterHomePostsByAlgoFeedIds - TopFilter.FavoriteDvm / TopFilter.AllFavoriteDvms -> TopFilter.FavoriteAlgoFeed / TopFilter.AllFavoriteAlgoFeeds - Route.EditFavoriteDvms -> Route.EditFavoriteAlgoFeeds Account / AccountSettings / AccountViewModel properties & mutators renamed consistently: favoriteDvmList -> favoriteAlgoFeedsList, backupFavoriteDvmList -> backupFavoriteAlgoFeedsList, follow/unfollow/ isFavoriteDvm -> follow/unfollow/isFavoriteAlgoFeed, refreshFavoriteDvm -> refreshFavoriteAlgoFeed, dvmAddress kwargs -> feedAddress, dvmNote locals -> feedNote, etc. Persisted TopFilter.FavoriteAlgoFeed/AllFavoriteAlgoFeeds `code` strings changed (old "FavoriteDvm/…" / " All Favourite DVMs " values don't round-trip anymore) — kotlinx-serialization polymorphism keys the sealed class by class name, so cold-start of pre-existing installs will fall back to AllFollows for this filter. Acceptable since this is a new feature only on this branch. Kept unchanged: ui/screen/loggedIn/dvms/ folder (hosts generic DvmContentDiscoveryScreen / DvmTopBar / DvmPaymentActions), quartz nip90Dvms package (NIP-90 wire-protocol naming). XML string resource keys (favorite_dvms_title, dvm_home_* etc) kept stable; only their values were updated previously. Build + tests green on both modules. --- .../vitorpamplona/amethyst/model/Account.kt | 22 ++++----- .../amethyst/model/AccountSettings.kt | 14 +++--- .../FavoriteAlgoFeedsOrchestrator.kt} | 48 +++++++++---------- .../FavoriteAlgoFeedsListDecryptionCache.kt} | 8 ++-- .../FavoriteAlgoFeedsListState.kt} | 36 +++++++------- .../topNavFeeds/FeedTopNavFilterState.kt | 26 +++++----- .../AllFavoriteAlgoFeedsFlow.kt} | 32 ++++++------- .../AllFavoriteAlgoFeedsTopNavFilter.kt} | 14 +++--- .../FavoriteAlgoFeedFlow.kt} | 22 ++++----- .../FavoriteAlgoFeedTopNavFilter.kt} | 18 +++---- .../FavoriteAlgoFeedTopNavPerRelayFilter.kt} | 4 +- ...avoriteAlgoFeedTopNavPerRelayFilterSet.kt} | 10 ++-- .../amethyst/ui/navigation/AppNavigation.kt | 4 +- .../amethyst/ui/navigation/routes/Routes.kt | 2 +- .../navigation/topbars/FeedFilterSpinner.kt | 14 +++--- .../amethyst/ui/screen/TopNavFilterState.kt | 30 ++++++------ .../ui/screen/loggedIn/AccountViewModel.kt | 6 +-- .../loggedIn/discover/nip90DVMs/DVMCard.kt | 4 +- .../ui/screen/loggedIn/dvms/DvmTopBar.kt | 2 +- ...DvmToggle.kt => FavoriteAlgoFeedToggle.kt} | 8 ++-- ...reen.kt => FavoriteAlgoFeedsListScreen.kt} | 32 ++++++------- ...tatusBanner.kt => AlgoFeedStatusBanner.kt} | 36 +++++++------- .../ui/screen/loggedIn/home/HomeScreen.kt | 8 ++-- .../HomeOutboxEventsEoseManager.kt | 6 +-- .../FilterHomePostsByAlgoFeedIds.kt} | 12 ++--- .../loggedIn/settings/AllSettingsScreen.kt | 2 +- .../FavoriteAlgoFeedTopNavFilterTest.kt} | 26 +++++----- .../FilterHomePostsByAlgoFeedIdsTest.kt} | 32 ++++++------- 28 files changed, 239 insertions(+), 239 deletions(-) rename amethyst/src/main/java/com/vitorpamplona/amethyst/model/{dvms/FavoriteDvmOrchestrator.kt => algoFeeds/FavoriteAlgoFeedsOrchestrator.kt} (86%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/{favoriteDvmLists/FavoriteDvmListDecryptionCache.kt => favoriteAlgoFeedsLists/FavoriteAlgoFeedsListDecryptionCache.kt} (79%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/{favoriteDvmLists/FavoriteDvmListState.kt => favoriteAlgoFeedsLists/FavoriteAlgoFeedsListState.kt} (77%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/{favoriteDvm/AllFavoriteDvmsFeedFlow.kt => favoriteAlgoFeeds/AllFavoriteAlgoFeedsFlow.kt} (80%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/{favoriteDvm/AllFavoriteDvmsTopNavFilter.kt => favoriteAlgoFeeds/AllFavoriteAlgoFeedsTopNavFilter.kt} (85%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/{favoriteDvm/FavoriteDvmFeedFlow.kt => favoriteAlgoFeeds/FavoriteAlgoFeedFlow.kt} (79%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/{favoriteDvm/FavoriteDvmTopNavFilter.kt => favoriteAlgoFeeds/FavoriteAlgoFeedTopNavFilter.kt} (84%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/{favoriteDvm/FavoriteDvmTopNavPerRelayFilter.kt => favoriteAlgoFeeds/FavoriteAlgoFeedTopNavPerRelayFilter.kt} (92%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/{favoriteDvm/FavoriteDvmTopNavPerRelayFilterSet.kt => favoriteAlgoFeeds/FavoriteAlgoFeedTopNavPerRelayFilterSet.kt} (85%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/{FavoriteDvmToggle.kt => FavoriteAlgoFeedToggle.kt} (94%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/favorites/{FavoriteDvmListScreen.kt => FavoriteAlgoFeedsListScreen.kt} (90%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/{DvmStatusBanner.kt => AlgoFeedStatusBanner.kt} (89%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/{nip90Dvms/FilterHomePostsByDvmIds.kt => nip90AlgoFeeds/FilterHomePostsByAlgoFeedIds.kt} (91%) rename amethyst/src/test/java/com/vitorpamplona/amethyst/model/topNavFeeds/{favoriteDvm/FavoriteDvmTopNavFilterTest.kt => favoriteAlgoFeeds/FavoriteAlgoFeedTopNavFilterTest.kt} (87%) rename amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/{nip90Dvms/FilterHomePostsByDvmIdsTest.kt => nip90AlgoFeeds/FilterHomePostsByAlgoFeedIdsTest.kt} (78%) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 4f52e43d7..fe977c64b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -38,7 +38,7 @@ import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.UserStatusActi import com.vitorpamplona.amethyst.commons.model.nip56Reports.ReportAction import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.logTime -import com.vitorpamplona.amethyst.model.dvms.FavoriteDvmOrchestrator +import com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsOrchestrator import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListDecryptionCache import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListState import com.vitorpamplona.amethyst.model.localRelays.ForwardKind0ToLocalRelayState @@ -67,8 +67,8 @@ import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayLis import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayListState import com.vitorpamplona.amethyst.model.nip51Lists.broadcastRelays.BroadcastRelayListDecryptionCache import com.vitorpamplona.amethyst.model.nip51Lists.broadcastRelays.BroadcastRelayListState -import com.vitorpamplona.amethyst.model.nip51Lists.favoriteDvmLists.FavoriteDvmListDecryptionCache -import com.vitorpamplona.amethyst.model.nip51Lists.favoriteDvmLists.FavoriteDvmListState +import com.vitorpamplona.amethyst.model.nip51Lists.favoriteAlgoFeedsLists.FavoriteAlgoFeedsListDecryptionCache +import com.vitorpamplona.amethyst.model.nip51Lists.favoriteAlgoFeedsLists.FavoriteAlgoFeedsListState import com.vitorpamplona.amethyst.model.nip51Lists.geohashLists.GeohashListDecryptionCache import com.vitorpamplona.amethyst.model.nip51Lists.geohashLists.GeohashListState import com.vitorpamplona.amethyst.model.nip51Lists.hashtagLists.HashtagListDecryptionCache @@ -327,9 +327,9 @@ class Account( val hashtagListDecryptionCache = HashtagListDecryptionCache(signer) val hashtagList = HashtagListState(signer, cache, hashtagListDecryptionCache, scope, settings) - val favoriteDvmListDecryptionCache = FavoriteDvmListDecryptionCache(signer) - val favoriteDvmList = FavoriteDvmListState(signer, cache, favoriteDvmListDecryptionCache, scope, settings) - val favoriteDvmOrchestrator = FavoriteDvmOrchestrator(this, scope) + val favoriteAlgoFeedsListDecryptionCache = FavoriteAlgoFeedsListDecryptionCache(signer) + val favoriteAlgoFeedsList = FavoriteAlgoFeedsListState(signer, cache, favoriteAlgoFeedsListDecryptionCache, scope, settings) + val favoriteAlgoFeedsOrchestrator = FavoriteAlgoFeedsOrchestrator(this, scope) val geohashListDecryptionCache = GeohashListDecryptionCache(signer) val geohashList = GeohashListState(signer, cache, geohashListDecryptionCache, scope, settings) @@ -426,8 +426,8 @@ class Account( caches = feedDecryptionCaches, signer = signer, scope = scope, - favoriteDvmOrchestrator = favoriteDvmOrchestrator, - favoriteDvmAddresses = favoriteDvmList.flow, + favoriteAlgoFeedsOrchestrator = favoriteAlgoFeedsOrchestrator, + favoriteAlgoFeedAddresses = favoriteAlgoFeedsList.flow, ).flow // App-ready Feeds @@ -1023,11 +1023,11 @@ class Account( suspend fun unfollowHashtag(tag: String) = sendMyPublicAndPrivateOutbox(hashtagList.unfollow(tag)) - suspend fun followFavoriteDvm(dvm: AddressBookmark) = sendMyPublicAndPrivateOutbox(favoriteDvmList.follow(dvm)) + suspend fun followFavoriteAlgoFeed(dvm: AddressBookmark) = sendMyPublicAndPrivateOutbox(favoriteAlgoFeedsList.follow(dvm)) - suspend fun unfollowFavoriteDvm(dvm: Address) = sendMyPublicAndPrivateOutbox(favoriteDvmList.unfollow(dvm)) + suspend fun unfollowFavoriteAlgoFeed(dvm: Address) = sendMyPublicAndPrivateOutbox(favoriteAlgoFeedsList.unfollow(dvm)) - fun isFavoriteDvm(dvm: Address): Boolean = favoriteDvmList.flow.value.contains(dvm) + fun isFavoriteAlgoFeed(dvm: Address): Boolean = favoriteAlgoFeedsList.flow.value.contains(dvm) suspend fun followGeohash(geohash: String) = sendMyPublicAndPrivateOutbox(geohashList.follow(geohash)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index 2a5f6b681..4b7319d04 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -157,11 +157,11 @@ sealed class TopFilter( ) : TopFilter("Relay/$url") @Serializable - class FavoriteDvm( + class FavoriteAlgoFeed( val address: Address, - ) : TopFilter("FavoriteDvm/${address.toValue()}") + ) : TopFilter("FavoriteAlgoFeed/${address.toValue()}") - @Serializable object AllFavoriteDvms : TopFilter(" All Favourite DVMs ") + @Serializable object AllFavoriteAlgoFeeds : TopFilter(" All Favourite DVMs ") } @Stable @@ -203,7 +203,7 @@ class AccountSettings( var backupChannelList: ChannelListEvent? = null, var backupCommunityList: CommunityListEvent? = null, var backupHashtagList: HashtagListEvent? = null, - var backupFavoriteDvmList: FavoriteAlgoFeedsListEvent? = null, + var backupFavoriteAlgoFeedsList: FavoriteAlgoFeedsListEvent? = null, var backupGeohashList: GeohashListEvent? = null, var backupEphemeralChatList: EphemeralChatListEvent? = null, var backupTrustProviderList: TrustProviderListEvent? = null, @@ -727,12 +727,12 @@ class AccountSettings( } } - fun updateFavoriteDvmListTo(newFavoriteDvmList: FavoriteAlgoFeedsListEvent?) { + fun updateFavoriteAlgoFeedsListTo(newFavoriteDvmList: FavoriteAlgoFeedsListEvent?) { if (newFavoriteDvmList == null || newFavoriteDvmList.tags.isEmpty()) return // Events might be different objects, we have to compare their ids. - if (backupFavoriteDvmList?.id != newFavoriteDvmList.id) { - backupFavoriteDvmList = newFavoriteDvmList + if (backupFavoriteAlgoFeedsList?.id != newFavoriteDvmList.id) { + backupFavoriteAlgoFeedsList = newFavoriteDvmList saveAccountSettings() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/dvms/FavoriteDvmOrchestrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/algoFeeds/FavoriteAlgoFeedsOrchestrator.kt similarity index 86% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/dvms/FavoriteDvmOrchestrator.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/model/algoFeeds/FavoriteAlgoFeedsOrchestrator.kt index 860cdfd0e..0496ad62d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/dvms/FavoriteDvmOrchestrator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/algoFeeds/FavoriteAlgoFeedsOrchestrator.kt @@ -18,7 +18,7 @@ * 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.model.dvms +package com.vitorpamplona.amethyst.model.algoFeeds import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.quartz.nip01Core.core.Address @@ -45,7 +45,7 @@ import kotlinx.coroutines.sync.withLock private const val RESPONSE_TIMEOUT_MS = 20_000L /** - * Immutable snapshot of a favorite DVM's current request/response state. + * Immutable snapshot of a favorite algo feed's current request/response state. * * - [requestId] is the id of the most recently published kind-5300 request. * - [responseRelays] is the relay set the kind-5300 was sent to — the same set on @@ -55,7 +55,7 @@ private const val RESPONSE_TIMEOUT_MS = 20_000L * - [latestStatus] is the latest kind-7000 status event (processing, payment-required, error, …). * - [errorMessage] captures any client-side failure while publishing the request. */ -data class FavoriteDvmSnapshot( +data class FavoriteAlgoFeedsSnapshot( val requestId: HexKey? = null, val responseRelays: Set = emptySet(), val ids: Set = emptySet(), @@ -65,7 +65,7 @@ data class FavoriteDvmSnapshot( ) /** - * Manages the NIP-90 content-discovery RPC cycle for each favorite DVM the user + * Manages the NIP-90 content-discovery RPC cycle for each favorite algo feed the user * pins to the top-nav. * * The orchestrator is lazy: it starts a request/response cycle the first time any @@ -75,50 +75,50 @@ data class FavoriteDvmSnapshot( * * This class does not own the relay subscriptions that fetch DVM responses and * matching notes. Those are issued by `HomeOutboxEventsEoseManager` while the - * user has a `TopFilter.FavoriteDvm` selected. The orchestrator merely observes + * user has a `TopFilter.FavoriteAlgoFeed` selected. The orchestrator merely observes * what the relays deliver into `LocalCache`. */ -class FavoriteDvmOrchestrator( +class FavoriteAlgoFeedsOrchestrator( val account: Account, val scope: CoroutineScope, ) { - private val flows = mutableMapOf>() + private val flows = mutableMapOf>() private val jobs = mutableMapOf() private val mutex = Mutex() - fun observe(dvmAddress: Address): StateFlow { - flows[dvmAddress]?.let { return it.asStateFlow() } + fun observe(feedAddress: Address): StateFlow { + flows[feedAddress]?.let { return it.asStateFlow() } - val seed = MutableStateFlow(FavoriteDvmSnapshot()) - flows[dvmAddress] = seed - scope.launch { startFor(dvmAddress, seed) } + val seed = MutableStateFlow(FavoriteAlgoFeedsSnapshot()) + flows[feedAddress] = seed + scope.launch { startFor(feedAddress, seed) } return seed.asStateFlow() } - fun refresh(dvmAddress: Address) { - val seed = flows[dvmAddress] ?: return + fun refresh(feedAddress: Address) { + val seed = flows[feedAddress] ?: return scope.launch { mutex.withLock { - jobs.remove(dvmAddress)?.cancel() + jobs.remove(feedAddress)?.cancel() } - startFor(dvmAddress, seed) + startFor(feedAddress, seed) } } - fun stop(dvmAddress: Address) { + fun stop(feedAddress: Address) { scope.launch { mutex.withLock { - jobs.remove(dvmAddress)?.cancel() - flows.remove(dvmAddress) + jobs.remove(feedAddress)?.cancel() + flows.remove(feedAddress) } } } private suspend fun startFor( - dvmAddress: Address, - seed: MutableStateFlow, + feedAddress: Address, + seed: MutableStateFlow, ) { - val user = account.cache.checkGetOrCreateUser(dvmAddress.pubKeyHex) ?: return + val user = account.cache.checkGetOrCreateUser(feedAddress.pubKeyHex) ?: return val job = scope.launch(Dispatchers.IO) { try { @@ -188,12 +188,12 @@ class FavoriteDvmOrchestrator( } } catch (e: Exception) { if (e is CancellationException) throw e - Log.w("FavoriteDvmOrchestrator", "Failed to start DVM request: ${e.message}", e) + Log.w("FavoriteAlgoFeedsOrchestrator", "Failed to start DVM request: ${e.message}", e) seed.update { it.copy(errorMessage = e.message ?: "Unknown error") } } } - mutex.withLock { jobs[dvmAddress] = job } + mutex.withLock { jobs[feedAddress] = job } } private fun splitInnerTags(innerTags: List): Pair, Set> { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteDvmLists/FavoriteDvmListDecryptionCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteAlgoFeedsLists/FavoriteAlgoFeedsListDecryptionCache.kt similarity index 79% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteDvmLists/FavoriteDvmListDecryptionCache.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteAlgoFeedsLists/FavoriteAlgoFeedsListDecryptionCache.kt index fef666385..03b3f8536 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteDvmLists/FavoriteDvmListDecryptionCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteAlgoFeedsLists/FavoriteAlgoFeedsListDecryptionCache.kt @@ -18,19 +18,19 @@ * 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.model.nip51Lists.favoriteDvmLists +package com.vitorpamplona.amethyst.model.nip51Lists.favoriteAlgoFeedsLists import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEventCache import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListEvent import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.favoriteAlgoFeedsSet -class FavoriteDvmListDecryptionCache( +class FavoriteAlgoFeedsListDecryptionCache( val signer: NostrSigner, ) { val cachedPrivateLists = PrivateTagArrayEventCache(signer) - fun cachedFavoriteDvms(event: FavoriteAlgoFeedsListEvent) = cachedPrivateLists.mergeTagListPrecached(event).favoriteAlgoFeedsSet() + fun cachedFavoriteAlgoFeeds(event: FavoriteAlgoFeedsListEvent) = cachedPrivateLists.mergeTagListPrecached(event).favoriteAlgoFeedsSet() - suspend fun favoriteDvms(event: FavoriteAlgoFeedsListEvent) = cachedPrivateLists.mergeTagList(event).favoriteAlgoFeedsSet() + suspend fun favoriteAlgoFeeds(event: FavoriteAlgoFeedsListEvent) = cachedPrivateLists.mergeTagList(event).favoriteAlgoFeedsSet() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteDvmLists/FavoriteDvmListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteAlgoFeedsLists/FavoriteAlgoFeedsListState.kt similarity index 77% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteDvmLists/FavoriteDvmListState.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteAlgoFeedsLists/FavoriteAlgoFeedsListState.kt index 6181d8b02..b39d78e80 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteDvmLists/FavoriteDvmListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteAlgoFeedsLists/FavoriteAlgoFeedsListState.kt @@ -18,7 +18,7 @@ * 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.model.nip51Lists.favoriteDvmLists +package com.vitorpamplona.amethyst.model.nip51Lists.favoriteAlgoFeedsLists import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.model.AddressableNote @@ -43,34 +43,34 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.launch -class FavoriteDvmListState( +class FavoriteAlgoFeedsListState( val signer: NostrSigner, val cache: LocalCache, - val decryptionCache: FavoriteDvmListDecryptionCache, + val decryptionCache: FavoriteAlgoFeedsListDecryptionCache, val scope: CoroutineScope, val settings: AccountSettings, ) { // Creates a long-term reference for this note so that the GC doesn't collect the note itself - val favoriteDvmListNote = cache.getOrCreateAddressableNote(getFavoriteDvmListAddress()) + val favoriteAlgoFeedsListNote = cache.getOrCreateAddressableNote(getFavoriteAlgoFeedsListAddress()) - fun getFavoriteDvmListAddress() = FavoriteAlgoFeedsListEvent.createAddress(signer.pubKey) + fun getFavoriteAlgoFeedsListAddress() = FavoriteAlgoFeedsListEvent.createAddress(signer.pubKey) - fun getFavoriteDvmListFlow(): StateFlow = favoriteDvmListNote.flow().metadata.stateFlow + fun getFavoriteAlgoFeedsListFlow(): StateFlow = favoriteAlgoFeedsListNote.flow().metadata.stateFlow - fun getFavoriteDvmList(): FavoriteAlgoFeedsListEvent? = favoriteDvmListNote.event as? FavoriteAlgoFeedsListEvent + fun getFavoriteAlgoFeedsList(): FavoriteAlgoFeedsListEvent? = favoriteAlgoFeedsListNote.event as? FavoriteAlgoFeedsListEvent - suspend fun favoriteDvmListWithBackup(note: Note): Set
{ - val event = note.event as? FavoriteAlgoFeedsListEvent ?: settings.backupFavoriteDvmList - return event?.let { decryptionCache.favoriteDvms(it) } ?: emptySet() + suspend fun favoriteAlgoFeedsListWithBackup(note: Note): Set
{ + val event = note.event as? FavoriteAlgoFeedsListEvent ?: settings.backupFavoriteAlgoFeedsList + return event?.let { decryptionCache.favoriteAlgoFeeds(it) } ?: emptySet() } @OptIn(ExperimentalCoroutinesApi::class) val flow: StateFlow> = - getFavoriteDvmListFlow() + getFavoriteAlgoFeedsListFlow() .transformLatest { noteState -> - emit(favoriteDvmListWithBackup(noteState.note)) + emit(favoriteAlgoFeedsListWithBackup(noteState.note)) }.onStart { - emit(favoriteDvmListWithBackup(favoriteDvmListNote)) + emit(favoriteAlgoFeedsListWithBackup(favoriteAlgoFeedsListNote)) }.flowOn(Dispatchers.IO) .stateIn( scope, @@ -93,7 +93,7 @@ class FavoriteDvmListState( ) suspend fun follow(dvm: AddressBookmark): FavoriteAlgoFeedsListEvent { - val list = getFavoriteDvmList() + val list = getFavoriteAlgoFeedsList() return if (list == null) { FavoriteAlgoFeedsListEvent.create(dvm, false, signer) } else { @@ -102,12 +102,12 @@ class FavoriteDvmListState( } suspend fun unfollow(dvm: Address): FavoriteAlgoFeedsListEvent? { - val list = getFavoriteDvmList() ?: return null + val list = getFavoriteAlgoFeedsList() ?: return null return FavoriteAlgoFeedsListEvent.remove(list, dvm, signer) } init { - settings.backupFavoriteDvmList?.let { event -> + settings.backupFavoriteAlgoFeedsList?.let { event -> Log.d("AccountRegisterObservers") { "Loading saved Favorite DVM list ${event.toJson()}" } @OptIn(DelicateCoroutinesApi::class) scope.launch(Dispatchers.IO) { @@ -117,10 +117,10 @@ class FavoriteDvmListState( scope.launch(Dispatchers.IO) { Log.d("AccountRegisterObservers", "Favorite DVM List Collector Start") - getFavoriteDvmListFlow().collect { + getFavoriteAlgoFeedsListFlow().collect { Log.d("AccountRegisterObservers") { "Favorite DVM List for ${signer.pubKey}" } (it.note.event as? FavoriteAlgoFeedsListEvent)?.let { - settings.updateFavoriteDvmListTo(it) + settings.updateFavoriteAlgoFeedsListTo(it) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt index be72d1b26..d8269aa43 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.model.topNavFeeds import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.TopFilter -import com.vitorpamplona.amethyst.model.dvms.FavoriteDvmOrchestrator +import com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsOrchestrator import com.vitorpamplona.amethyst.model.nip02FollowLists.Kind3FollowListState import com.vitorpamplona.amethyst.model.serverList.MergedFollowListsState import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsFeedFlow @@ -31,8 +31,8 @@ import com.vitorpamplona.amethyst.model.topNavFeeds.allUserFollows.Kind3UserFoll import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.AroundMeFeedFlow import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.GeohashFeedFlow import com.vitorpamplona.amethyst.model.topNavFeeds.chess.ChessFeedFlow -import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteDvm.AllFavoriteDvmsFeedFlow -import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteDvm.FavoriteDvmFeedFlow +import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds.AllFavoriteAlgoFeedsFlow +import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds.FavoriteAlgoFeedFlow import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalFeedFlow import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagFeedFlow import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.NoteFeedFlow @@ -66,8 +66,8 @@ class FeedTopNavFilterState( val caches: FeedDecryptionCaches, val signer: NostrSigner, val scope: CoroutineScope, - val favoriteDvmOrchestrator: FavoriteDvmOrchestrator, - val favoriteDvmAddresses: StateFlow>, + val favoriteAlgoFeedsOrchestrator: FavoriteAlgoFeedsOrchestrator, + val favoriteAlgoFeedAddresses: StateFlow>, ) { fun loadFlowsFor(listName: TopFilter): IFeedFlowsType = when (listName) { @@ -149,19 +149,19 @@ class FeedTopNavFilterState( RelayFeedFlow(listName.url.normalizeRelayUrl()) } - is TopFilter.FavoriteDvm -> { - FavoriteDvmFeedFlow( - dvmAddress = listName.address, - orchestrator = favoriteDvmOrchestrator, + is TopFilter.FavoriteAlgoFeed -> { + FavoriteAlgoFeedFlow( + feedAddress = listName.address, + orchestrator = favoriteAlgoFeedsOrchestrator, outboxRelays = followsRelays, proxyRelays = proxyRelays, ) } - TopFilter.AllFavoriteDvms -> { - AllFavoriteDvmsFeedFlow( - favoriteDvmAddresses = favoriteDvmAddresses, - orchestrator = favoriteDvmOrchestrator, + TopFilter.AllFavoriteAlgoFeeds -> { + AllFavoriteAlgoFeedsFlow( + favoriteAlgoFeedAddresses = favoriteAlgoFeedAddresses, + orchestrator = favoriteAlgoFeedsOrchestrator, outboxRelays = followsRelays, proxyRelays = proxyRelays, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/AllFavoriteDvmsFeedFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/AllFavoriteAlgoFeedsFlow.kt similarity index 80% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/AllFavoriteDvmsFeedFlow.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/AllFavoriteAlgoFeedsFlow.kt index c35c2ba72..f23930722 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/AllFavoriteDvmsFeedFlow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/AllFavoriteAlgoFeedsFlow.kt @@ -18,10 +18,10 @@ * 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.model.topNavFeeds.favoriteDvm +package com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds -import com.vitorpamplona.amethyst.model.dvms.FavoriteDvmOrchestrator -import com.vitorpamplona.amethyst.model.dvms.FavoriteDvmSnapshot +import com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsOrchestrator +import com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsSnapshot import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedFlowsType import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter import com.vitorpamplona.quartz.nip01Core.core.Address @@ -35,12 +35,12 @@ import kotlinx.coroutines.flow.flatMapLatest /** * Feed flow that merges snapshots from every currently-favorited DVM into a - * single [AllFavoriteDvmsTopNavFilter]. Re-wires subscriptions whenever the + * single [AllFavoriteAlgoFeedsTopNavFilter]. Re-wires subscriptions whenever the * favorite set changes. */ -class AllFavoriteDvmsFeedFlow( - val favoriteDvmAddresses: StateFlow>, - val orchestrator: FavoriteDvmOrchestrator, +class AllFavoriteAlgoFeedsFlow( + val favoriteAlgoFeedAddresses: StateFlow>, + val orchestrator: FavoriteAlgoFeedsOrchestrator, val outboxRelays: StateFlow>, val proxyRelays: StateFlow>, ) : IFeedFlowsType { @@ -50,9 +50,9 @@ class AllFavoriteDvmsFeedFlow( ): Set = if (proxy.isNotEmpty()) proxy else outbox private fun merge( - snapshots: List, + snapshots: List, contentRelays: Set, - ): AllFavoriteDvmsTopNavFilter { + ): AllFavoriteAlgoFeedsTopNavFilter { val ids = mutableSetOf() val addresses = mutableSetOf() val listen = mutableSetOf() @@ -63,7 +63,7 @@ class AllFavoriteDvmsFeedFlow( listen += snap.responseRelays snap.requestId?.let { requestIds += it } } - return AllFavoriteDvmsTopNavFilter( + return AllFavoriteAlgoFeedsTopNavFilter( acceptedIds = ids, acceptedAddresses = addresses, contentRelays = contentRelays, @@ -72,8 +72,8 @@ class AllFavoriteDvmsFeedFlow( ) } - private fun emptyFilter(contentRelays: Set): AllFavoriteDvmsTopNavFilter = - AllFavoriteDvmsTopNavFilter( + private fun emptyFilter(contentRelays: Set): AllFavoriteAlgoFeedsTopNavFilter = + AllFavoriteAlgoFeedsTopNavFilter( acceptedIds = emptySet(), acceptedAddresses = emptySet(), contentRelays = contentRelays, @@ -83,13 +83,13 @@ class AllFavoriteDvmsFeedFlow( @OptIn(ExperimentalCoroutinesApi::class) override fun flow(): Flow = - favoriteDvmAddresses.flatMapLatest { addresses -> + favoriteAlgoFeedAddresses.flatMapLatest { addresses -> if (addresses.isEmpty()) { combine(outboxRelays, proxyRelays) { outbox, proxy -> emptyFilter(resolveContentRelays(outbox, proxy)) } } else { - val snapshotFlows: List> = addresses.map { orchestrator.observe(it) } + val snapshotFlows: List> = addresses.map { orchestrator.observe(it) } combine(snapshotFlows) { it.toList() } .let { merged -> combine(merged, outboxRelays, proxyRelays) { snaps, outbox, proxy -> @@ -99,9 +99,9 @@ class AllFavoriteDvmsFeedFlow( } } - override fun startValue(): AllFavoriteDvmsTopNavFilter { + override fun startValue(): AllFavoriteAlgoFeedsTopNavFilter { val contentRelays = resolveContentRelays(outboxRelays.value, proxyRelays.value) - val addresses = favoriteDvmAddresses.value + val addresses = favoriteAlgoFeedAddresses.value return if (addresses.isEmpty()) { emptyFilter(contentRelays) } else { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/AllFavoriteDvmsTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/AllFavoriteAlgoFeedsTopNavFilter.kt similarity index 85% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/AllFavoriteDvmsTopNavFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/AllFavoriteAlgoFeedsTopNavFilter.kt index 36e7e6b6f..eba50b6bc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/AllFavoriteDvmsTopNavFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/AllFavoriteAlgoFeedsTopNavFilter.kt @@ -18,7 +18,7 @@ * 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.model.topNavFeeds.favoriteDvm +package com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds import androidx.compose.runtime.Immutable import com.vitorpamplona.amethyst.model.LocalCache @@ -32,12 +32,12 @@ import kotlinx.coroutines.flow.MutableStateFlow /** * Top-nav filter that unions the latest kind-6300 responses from every currently - * favorited DVM. Behaves like [FavoriteDvmTopNavFilter] (pure membership check + * favorited DVM. Behaves like [FavoriteAlgoFeedTopNavFilter] (pure membership check * against a snapshot), but the accepted set is the union across N DVMs and the * request-id list carries one entry per DVM for the relay-listen subscription. */ @Immutable -class AllFavoriteDvmsTopNavFilter( +class AllFavoriteAlgoFeedsTopNavFilter( val acceptedIds: Set, val acceptedAddresses: Set, val contentRelays: Set, @@ -50,13 +50,13 @@ class AllFavoriteDvmsTopNavFilter( noteEvent.id in acceptedIds || (noteEvent is AddressableEvent && noteEvent.addressTag() in acceptedAddresses) - override fun toPerRelayFlow(cache: LocalCache): Flow = MutableStateFlow(startValue(cache)) + override fun toPerRelayFlow(cache: LocalCache): Flow = MutableStateFlow(startValue(cache)) - override fun startValue(cache: LocalCache): FavoriteDvmTopNavPerRelayFilterSet = - FavoriteDvmTopNavPerRelayFilterSet( + override fun startValue(cache: LocalCache): FavoriteAlgoFeedTopNavPerRelayFilterSet = + FavoriteAlgoFeedTopNavPerRelayFilterSet( contentFetches = contentRelays.associateWith { - FavoriteDvmTopNavPerRelayFilter( + FavoriteAlgoFeedTopNavPerRelayFilter( ids = acceptedIds, addresses = acceptedAddresses, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmFeedFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedFlow.kt similarity index 79% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmFeedFlow.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedFlow.kt index 04d35e0d6..e17accd43 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmFeedFlow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedFlow.kt @@ -18,9 +18,9 @@ * 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.model.topNavFeeds.favoriteDvm +package com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds -import com.vitorpamplona.amethyst.model.dvms.FavoriteDvmOrchestrator +import com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsOrchestrator import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedFlowsType import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter import com.vitorpamplona.quartz.nip01Core.core.Address @@ -30,9 +30,9 @@ import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine -class FavoriteDvmFeedFlow( - val dvmAddress: Address, - val orchestrator: FavoriteDvmOrchestrator, +class FavoriteAlgoFeedFlow( + val feedAddress: Address, + val orchestrator: FavoriteAlgoFeedsOrchestrator, val outboxRelays: StateFlow>, val proxyRelays: StateFlow>, ) : IFeedFlowsType { @@ -42,10 +42,10 @@ class FavoriteDvmFeedFlow( ): Set = if (proxy.isNotEmpty()) proxy else outbox private fun buildFilter( - snapshot: com.vitorpamplona.amethyst.model.dvms.FavoriteDvmSnapshot, + snapshot: com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsSnapshot, contentRelays: Set, - ) = FavoriteDvmTopNavFilter( - dvmAddress = dvmAddress, + ) = FavoriteAlgoFeedTopNavFilter( + feedAddress = feedAddress, acceptedIds = snapshot.ids, acceptedAddresses = snapshot.addresses, contentRelays = contentRelays, @@ -54,13 +54,13 @@ class FavoriteDvmFeedFlow( ) override fun flow(): Flow = - combine(orchestrator.observe(dvmAddress), outboxRelays, proxyRelays) { snap, outbox, proxy -> + combine(orchestrator.observe(feedAddress), outboxRelays, proxyRelays) { snap, outbox, proxy -> buildFilter(snap, resolveRelays(outbox, proxy)) } - override fun startValue(): FavoriteDvmTopNavFilter = + override fun startValue(): FavoriteAlgoFeedTopNavFilter = buildFilter( - snapshot = orchestrator.observe(dvmAddress).value, + snapshot = orchestrator.observe(feedAddress).value, contentRelays = resolveRelays(outboxRelays.value, proxyRelays.value), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavFilter.kt similarity index 84% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavFilter.kt index 9534bc1c8..6cd70135c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavFilter.kt @@ -18,7 +18,7 @@ * 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.model.topNavFeeds.favoriteDvm +package com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds import androidx.compose.runtime.Immutable import com.vitorpamplona.amethyst.model.LocalCache @@ -32,15 +32,15 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow /** - * Top-nav filter backed by the latest kind-6300 response from a favorite DVM. + * Top-nav filter backed by the latest kind-6300 response from a favorite algo feed. * * The filter is a pure immutable membership check: [match] accepts a note only if the * DVM's latest response included it. When a new response arrives, a new instance is - * emitted through [FavoriteDvmFeedFlow] and replaces the active filter. + * emitted through [FavoriteAlgoFeedFlow] and replaces the active filter. */ @Immutable -class FavoriteDvmTopNavFilter( - val dvmAddress: Address, +class FavoriteAlgoFeedTopNavFilter( + val feedAddress: Address, val acceptedIds: Set, val acceptedAddresses: Set, val contentRelays: Set, @@ -53,13 +53,13 @@ class FavoriteDvmTopNavFilter( noteEvent.id in acceptedIds || (noteEvent is AddressableEvent && noteEvent.addressTag() in acceptedAddresses) - override fun toPerRelayFlow(cache: LocalCache): Flow = MutableStateFlow(startValue(cache)) + override fun toPerRelayFlow(cache: LocalCache): Flow = MutableStateFlow(startValue(cache)) - override fun startValue(cache: LocalCache): FavoriteDvmTopNavPerRelayFilterSet = - FavoriteDvmTopNavPerRelayFilterSet( + override fun startValue(cache: LocalCache): FavoriteAlgoFeedTopNavPerRelayFilterSet = + FavoriteAlgoFeedTopNavPerRelayFilterSet( contentFetches = contentRelays.associateWith { - FavoriteDvmTopNavPerRelayFilter( + FavoriteAlgoFeedTopNavPerRelayFilter( ids = acceptedIds, addresses = acceptedAddresses, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavPerRelayFilter.kt similarity index 92% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavPerRelayFilter.kt index 4dcbeddaa..374f5ecf0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavPerRelayFilter.kt @@ -18,14 +18,14 @@ * 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.model.topNavFeeds.favoriteDvm +package com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds import androidx.compose.runtime.Immutable import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilter import com.vitorpamplona.quartz.nip01Core.core.HexKey @Immutable -class FavoriteDvmTopNavPerRelayFilter( +class FavoriteAlgoFeedTopNavPerRelayFilter( val ids: Set, val addresses: Set, ) : IFeedTopNavPerRelayFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilterSet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavPerRelayFilterSet.kt similarity index 85% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilterSet.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavPerRelayFilterSet.kt index 318840384..a111c9361 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilterSet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavPerRelayFilterSet.kt @@ -18,7 +18,7 @@ * 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.model.topNavFeeds.favoriteDvm +package com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -32,11 +32,11 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl * - [listenRelays] — the union of DVM publish relays across all active DVMs * (where they will deliver future kind 6300 / 7000 events for their requests). * - [requestIds] — the set of currently-active kind-5300 request ids to listen - * for. A single-DVM filter carries one; the merged "All favorite DVMs" - * filter carries one per favorite DVM. + * for. A single feed carries one; the merged "All favorite algo feeds" + * filter carries one per favorite algo feed. */ -class FavoriteDvmTopNavPerRelayFilterSet( - val contentFetches: Map, +class FavoriteAlgoFeedTopNavPerRelayFilterSet( + val contentFetches: Map, val listenRelays: Set, val requestIds: Set, ) : IFeedTopNavPerRelayFilterSet diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 972befbcd..3ba6d22b3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -95,7 +95,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.Long import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.NewProductScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.drafts.DraftListScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.DvmContentDiscoveryScreen -import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.favorites.FavoriteDvmListScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.favorites.FavoriteAlgoFeedsListScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.FollowPackFeedScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.GeoHashPostScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.GeoHashScreen @@ -278,7 +278,7 @@ fun BuildNavigation( composableFromEnd { RequestToVanishScreen(accountViewModel, nav) } composableFromEnd { VanishEventsScreen(accountViewModel, nav) } composableFromEndArgs { AllMediaServersScreen(accountViewModel, nav) } - composableFromEnd { FavoriteDvmListScreen(accountViewModel, nav) } + composableFromEnd { FavoriteAlgoFeedsListScreen(accountViewModel, nav) } composableFromEnd { PaymentTargetsScreen(accountViewModel, nav) } composableFromEndArgs { UpdateReactionTypeScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 057a24397..70a12a0dd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -171,7 +171,7 @@ sealed class Route { @Serializable object EditMediaServers : Route() - @Serializable object EditFavoriteDvms : Route() + @Serializable object EditFavoriteAlgoFeeds : Route() @Serializable object EditPaymentTargets : Route() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt index b4cf9a719..9d01c8a12 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt @@ -86,7 +86,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNo import com.vitorpamplona.amethyst.ui.components.LoadingAnimation import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName import com.vitorpamplona.amethyst.ui.screen.CommunityName -import com.vitorpamplona.amethyst.ui.screen.FavoriteDvmName +import com.vitorpamplona.amethyst.ui.screen.FavoriteAlgoFeedName import com.vitorpamplona.amethyst.ui.screen.FeedDefinition import com.vitorpamplona.amethyst.ui.screen.GeoHashName import com.vitorpamplona.amethyst.ui.screen.HashtagName @@ -361,7 +361,7 @@ fun RenderOption( ) } - is FavoriteDvmName -> { + is FavoriteAlgoFeedName -> { val noteState by observeNote(option.note, accountViewModel) val name = (noteState.note.event as? AppDefinitionEvent) @@ -419,7 +419,7 @@ private fun groupFeedDefinitions(options: ImmutableList): Map { + is FavoriteAlgoFeedName -> { FeedGroup.DVMS } @@ -427,7 +427,7 @@ private fun groupFeedDefinitions(options: ImmutableList): Map FeedGroup.LOCATIONS is TopFilter.Global -> FeedGroup.RELAYS - is TopFilter.AllFavoriteDvms -> FeedGroup.DVMS + is TopFilter.AllFavoriteAlgoFeeds -> FeedGroup.DVMS else -> FeedGroup.FEEDS } } @@ -592,11 +592,11 @@ private fun FeedIcon( Icons.AutoMirrored.Outlined.ViewList } - is TopFilter.FavoriteDvm -> { + is TopFilter.FavoriteAlgoFeed -> { Icons.Outlined.AutoAwesome } - is TopFilter.AllFavoriteDvms -> { + is TopFilter.AllFavoriteAlgoFeeds -> { Icons.Outlined.AutoAwesome } @@ -606,7 +606,7 @@ private fun FeedIcon( is RelayName -> Icons.Outlined.Storage is CommunityName -> Icons.Outlined.Groups is PeopleListName -> Icons.AutoMirrored.Outlined.ViewList - is FavoriteDvmName -> Icons.Outlined.AutoAwesome + is FavoriteAlgoFeedName -> Icons.Outlined.AutoAwesome else -> Icons.Outlined.Person } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt index 7b65a43e1..69060103f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt @@ -97,9 +97,9 @@ class TopNavFilterState( name = ResourceName(R.string.follow_list_chess), ) - val allFavoriteDvmsFollow = + val allFavoriteAlgoFeedsFollow = FeedDefinition( - code = TopFilter.AllFavoriteDvms, + code = TopFilter.AllFavoriteAlgoFeeds, name = ResourceName(R.string.follow_list_all_favorite_dvms), ) @@ -147,7 +147,7 @@ class TopNavFilterState( geotagList: Set, communityList: List, relayList: Set, - favoriteDvmList: List, + favoriteAlgoFeedsList: List, ): List { val hashtags = hashtagList.map { @@ -181,26 +181,26 @@ class TopNavFilterState( ) } - // Favorites can only be added through FavoriteDvmToggle, which itself checks + // Favorites can only be added through FavoriteAlgoFeedToggle, which itself checks // that the AppDefinitionEvent advertises kind 5300. Don't re-check here: on // cold start the AppDefinitionEvent may not be in cache yet, and dropping - // the entry means the persisted TopFilter.FavoriteDvm can't find its chip + // the entry means the persisted TopFilter.FavoriteAlgoFeed can't find its chip // in the spinner (user sees "Select an option" while the banner fires the // RPC — the bug we had before this change). - val favoriteDvms = - favoriteDvmList.map { dvmNote -> + val favoriteAlgoFeeds = + favoriteAlgoFeedsList.map { feedNote -> FeedDefinition( - TopFilter.FavoriteDvm(dvmNote.address), - FavoriteDvmName(dvmNote), + TopFilter.FavoriteAlgoFeed(feedNote.address), + FavoriteAlgoFeedName(feedNote), ) } - // Only show the "All favorite DVMs" meta-chip when there is at least one + // Only show the "All favorite algo feeds" meta-chip when there is at least one // real favorite to merge; otherwise the chip opens to an empty feed. val allFavorites = - if (favoriteDvms.isNotEmpty()) listOf(allFavoriteDvmsFollow) else emptyList() + if (favoriteAlgoFeeds.isNotEmpty()) listOf(allFavoriteAlgoFeedsFollow) else emptyList() - return (communities + hashtags + geotags + relays + allFavorites + favoriteDvms).sortedBy { it.name.name() } + return (communities + hashtags + geotags + relays + allFavorites + favoriteAlgoFeeds).sortedBy { it.name.name() } } @OptIn(ExperimentalCoroutinesApi::class) @@ -210,7 +210,7 @@ class TopNavFilterState( account.geohashList.flow, account.communityList.flowNotes, account.relayFeedsList.flow, - account.favoriteDvmList.flowNotes, + account.favoriteAlgoFeedsList.flowNotes, ::mergeInterests, ).onStart { emit( @@ -219,7 +219,7 @@ class TopNavFilterState( account.geohashList.flow.value, account.communityList.flowNotes.value, account.relayFeedsList.flow.value, - account.favoriteDvmList.flowNotes.value, + account.favoriteAlgoFeedsList.flowNotes.value, ), ) } @@ -328,7 +328,7 @@ class CommunityName( } @Stable -class FavoriteDvmName( +class FavoriteAlgoFeedName( val note: AddressableNote, ) : Name() { override fun name(): String = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 97b01c336..e08814ee4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -1089,11 +1089,11 @@ class AccountViewModel( fun unfollowHashtag(tag: String) = launchSigner { account.unfollowHashtag(tag) } - fun followFavoriteDvm(dvm: AddressBookmark) = launchSigner { account.followFavoriteDvm(dvm) } + fun followFavoriteAlgoFeed(dvm: AddressBookmark) = launchSigner { account.followFavoriteAlgoFeed(dvm) } - fun unfollowFavoriteDvm(dvm: Address) = launchSigner { account.unfollowFavoriteDvm(dvm) } + fun unfollowFavoriteAlgoFeed(dvm: Address) = launchSigner { account.unfollowFavoriteAlgoFeed(dvm) } - fun refreshFavoriteDvm(dvm: Address) = account.favoriteDvmOrchestrator.refresh(dvm) + fun refreshFavoriteAlgoFeed(dvm: Address) = account.favoriteAlgoFeedsOrchestrator.refresh(dvm) fun followRelayFeed(url: NormalizedRelayUrl) = launchSigner { account.followRelayFeed(url) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DVMCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DVMCard.kt index 0ca21209b..329444e2f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DVMCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DVMCard.kt @@ -49,7 +49,7 @@ import com.vitorpamplona.amethyst.ui.note.LikeReaction import com.vitorpamplona.amethyst.ui.note.ZapReaction import com.vitorpamplona.amethyst.ui.note.elements.BannerImage import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.FavoriteDvmToggle +import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.FavoriteAlgoFeedToggle import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.observeAppDefinition import com.vitorpamplona.amethyst.ui.theme.HalfTopPadding import com.vitorpamplona.amethyst.ui.theme.RowColSpacing5dp @@ -117,7 +117,7 @@ fun RenderContentDVMThumb( modifier = Modifier.weight(1f), ) if (baseNote is AddressableNote) { - FavoriteDvmToggle( + FavoriteAlgoFeedToggle( appDefinitionNote = baseNote, accountViewModel = accountViewModel, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmTopBar.kt index 4ae8cefe5..78e65eed3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmTopBar.kt @@ -102,7 +102,7 @@ fun DvmTopBar( } } addressableNote?.let { target -> - FavoriteDvmToggle( + FavoriteAlgoFeedToggle( appDefinitionNote = target, accountViewModel = accountViewModel, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/FavoriteDvmToggle.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/FavoriteAlgoFeedToggle.kt similarity index 94% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/FavoriteDvmToggle.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/FavoriteAlgoFeedToggle.kt index 1bed004b7..3838bef64 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/FavoriteDvmToggle.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/FavoriteAlgoFeedToggle.kt @@ -52,7 +52,7 @@ import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.NIP90ContentDi * a 6300 reply that never comes. */ @Composable -fun FavoriteDvmToggle( +fun FavoriteAlgoFeedToggle( appDefinitionNote: AddressableNote, accountViewModel: AccountViewModel, modifier: Modifier = Modifier, @@ -65,7 +65,7 @@ fun FavoriteDvmToggle( if (!supportsContentDiscovery) return - val favorites by accountViewModel.account.favoriteDvmList.flow + val favorites by accountViewModel.account.favoriteAlgoFeedsList.flow .collectAsStateWithLifecycle() val isFavorite = favorites.contains(appDefinitionNote.address) @@ -74,9 +74,9 @@ fun FavoriteDvmToggle( modifier = modifier, onClick = { if (isFavorite) { - accountViewModel.unfollowFavoriteDvm(appDefinitionNote.address) + accountViewModel.unfollowFavoriteAlgoFeed(appDefinitionNote.address) } else { - accountViewModel.followFavoriteDvm( + accountViewModel.followFavoriteAlgoFeed( AddressBookmark( address = appDefinitionNote.address, relayHint = appDefinitionNote.relayHintUrl(), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/favorites/FavoriteDvmListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/favorites/FavoriteAlgoFeedsListScreen.kt similarity index 90% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/favorites/FavoriteDvmListScreen.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/favorites/FavoriteAlgoFeedsListScreen.kt index 0f22ae4ec..8d71292bc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/favorites/FavoriteDvmListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/favorites/FavoriteAlgoFeedsListScreen.kt @@ -70,7 +70,7 @@ import com.vitorpamplona.amethyst.ui.theme.grayText @OptIn(ExperimentalMaterial3Api::class) @Composable -fun FavoriteDvmListScreen( +fun FavoriteAlgoFeedsListScreen( accountViewModel: AccountViewModel, nav: INav, ) { @@ -101,17 +101,17 @@ fun FavoriteDvmListScreen( color = MaterialTheme.colorScheme.grayText, ) - FavoriteDvmList(accountViewModel, nav) + FavoriteAlgoFeedList(accountViewModel, nav) } } } @Composable -private fun FavoriteDvmList( +private fun FavoriteAlgoFeedList( accountViewModel: AccountViewModel, nav: INav, ) { - val favorites by accountViewModel.account.favoriteDvmList.flowNotes + val favorites by accountViewModel.account.favoriteAlgoFeedsList.flowNotes .collectAsStateWithLifecycle() if (favorites.isEmpty()) { @@ -136,12 +136,12 @@ private fun FavoriteDvmList( items( items = favorites, key = { it.address.toValue() }, - ) { dvmNote -> - FavoriteDvmRow( - dvmNote = dvmNote, + ) { feedNote -> + FavoriteAlgoFeedRow( + feedNote = feedNote, accountViewModel = accountViewModel, - onOpen = { nav.nav(Route.ContentDiscovery(dvmNote.idHex)) }, - onRemove = { accountViewModel.unfollowFavoriteDvm(dvmNote.address) }, + onOpen = { nav.nav(Route.ContentDiscovery(feedNote.idHex)) }, + onRemove = { accountViewModel.unfollowFavoriteAlgoFeed(feedNote.address) }, ) } } @@ -149,13 +149,13 @@ private fun FavoriteDvmList( @OptIn(ExperimentalFoundationApi::class) @Composable -private fun FavoriteDvmRow( - dvmNote: AddressableNote, +private fun FavoriteAlgoFeedRow( + feedNote: AddressableNote, accountViewModel: AccountViewModel, onOpen: () -> Unit, onRemove: () -> Unit, ) { - val card = observeAppDefinition(dvmNote, accountViewModel) + val card = observeAppDefinition(feedNote, accountViewModel) Row( modifier = @@ -175,19 +175,19 @@ private fun FavoriteDvmRow( loadedImageModifier = SimpleImage35Modifier, accountViewModel = accountViewModel, onLoadingBackground = { - dvmNote.author?.let { author -> + feedNote.author?.let { author -> BannerImage(author, SimpleImage35Modifier, accountViewModel) } }, onError = { - dvmNote.author?.let { author -> + feedNote.author?.let { author -> BannerImage(author, SimpleImage35Modifier, accountViewModel) } }, ) } } ?: run { - dvmNote.author?.let { author -> + feedNote.author?.let { author -> BannerImage(author, SimpleImage35Modifier, accountViewModel) } } @@ -198,7 +198,7 @@ private fun FavoriteDvmRow( modifier = Modifier.weight(1f), ) { Text( - text = card.name.ifBlank { dvmNote.dTag() }, + text = card.name.ifBlank { feedNote.dTag() }, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/DvmStatusBanner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/AlgoFeedStatusBanner.kt similarity index 89% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/DvmStatusBanner.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/AlgoFeedStatusBanner.kt index 723eea88a..22d10c444 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/DvmStatusBanner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/AlgoFeedStatusBanner.kt @@ -42,7 +42,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.TopFilter -import com.vitorpamplona.amethyst.model.dvms.FavoriteDvmSnapshot +import com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsSnapshot import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteAndMap import com.vitorpamplona.amethyst.ui.components.LoadNote import com.vitorpamplona.amethyst.ui.components.LoadingAnimation @@ -55,7 +55,7 @@ import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent @Composable -fun HomeDvmStatusBanner( +fun HomeAlgoFeedStatusBanner( accountViewModel: AccountViewModel, nav: INav, modifier: Modifier = Modifier, @@ -64,31 +64,31 @@ fun HomeDvmStatusBanner( .collectAsStateWithLifecycle() when (val filter = topFilter) { - is TopFilter.FavoriteDvm -> SingleDvmBanner(filter, accountViewModel, nav, modifier) - is TopFilter.AllFavoriteDvms -> AllFavoriteDvmsBanner(accountViewModel, modifier) + is TopFilter.FavoriteAlgoFeed -> SingleAlgoFeedBanner(filter, accountViewModel, nav, modifier) + is TopFilter.AllFavoriteAlgoFeeds -> AllFavoriteAlgoFeedsBanner(accountViewModel, modifier) else -> Unit } } @Composable -private fun SingleDvmBanner( - favDvm: TopFilter.FavoriteDvm, +private fun SingleAlgoFeedBanner( + favFeed: TopFilter.FavoriteAlgoFeed, accountViewModel: AccountViewModel, nav: INav, modifier: Modifier = Modifier, ) { - val snapshot by accountViewModel.account.favoriteDvmOrchestrator - .observe(favDvm.address) + val snapshot by accountViewModel.account.favoriteAlgoFeedsOrchestrator + .observe(favFeed.address) .collectAsStateWithLifecycle() // Hide the banner when the feed is already populated. if (snapshot.ids.isNotEmpty() || snapshot.addresses.isNotEmpty()) return - val dvmAddressValue = favDvm.address.toValue() + val feedAddressValue = favFeed.address.toValue() - LoadNote(baseNoteHex = dvmAddressValue, accountViewModel = accountViewModel) { dvmNote -> + LoadNote(baseNoteHex = feedAddressValue, accountViewModel = accountViewModel) { feedNote -> val resolvedName by - observeNoteAndMap(dvmNote ?: return@LoadNote, accountViewModel) { note -> + observeNoteAndMap(feedNote ?: return@LoadNote, accountViewModel) { note -> (note.event as? AppDefinitionEvent) ?.appMetaData() ?.name @@ -107,7 +107,7 @@ private fun SingleDvmBanner( showSpinner = false, ) Spacer(modifier = StdVertSpacer) - RetryButton { accountViewModel.refreshFavoriteDvm(favDvm.address) } + RetryButton { accountViewModel.refreshFavoriteAlgoFeed(favFeed.address) } } status?.code == "payment-required" -> { @@ -144,7 +144,7 @@ private fun SingleDvmBanner( showSpinner = false, ) Spacer(modifier = StdVertSpacer) - RetryButton { accountViewModel.refreshFavoriteDvm(favDvm.address) } + RetryButton { accountViewModel.refreshFavoriteAlgoFeed(favFeed.address) } } status?.code == "processing" -> { @@ -169,11 +169,11 @@ private fun SingleDvmBanner( } @Composable -private fun AllFavoriteDvmsBanner( +private fun AllFavoriteAlgoFeedsBanner( accountViewModel: AccountViewModel, modifier: Modifier = Modifier, ) { - val addresses by accountViewModel.account.favoriteDvmList.flow + val addresses by accountViewModel.account.favoriteAlgoFeedsList.flow .collectAsStateWithLifecycle() if (addresses.isEmpty()) return @@ -181,9 +181,9 @@ private fun AllFavoriteDvmsBanner( // Observe each DVM's snapshot so we can decide whether to hide the banner // based on the aggregate state. Hide it as soon as any DVM has produced a // feed; only error out when every one of them has errored. - val snapshots: List = + val snapshots: List = addresses.map { address -> - val snap by accountViewModel.account.favoriteDvmOrchestrator + val snap by accountViewModel.account.favoriteAlgoFeedsOrchestrator .observe(address) .collectAsStateWithLifecycle() snap @@ -202,7 +202,7 @@ private fun AllFavoriteDvmsBanner( ) Spacer(modifier = StdVertSpacer) RetryButton { - addresses.forEach { accountViewModel.refreshFavoriteDvm(it) } + addresses.forEach { accountViewModel.refreshFavoriteAlgoFeed(it) } } } else { BannerMessageRow( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt index d3f676146..fb2ee8914 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt @@ -240,7 +240,7 @@ private fun HomePages( ) } - HomeDvmStatusBanner( + HomeAlgoFeedStatusBanner( accountViewModel = accountViewModel, nav = nav, modifier = Modifier.align(Alignment.TopCenter), @@ -300,7 +300,7 @@ fun HomeFeeds( ) { val activeFilter by accountViewModel.account.settings.defaultHomeFollowList .collectAsStateWithLifecycle() - val favoriteDvmAddresses by accountViewModel.account.favoriteDvmList.flow + val favoriteAlgoFeedAddresses by accountViewModel.account.favoriteAlgoFeedsList.flow .collectAsStateWithLifecycle() val onRefresh: () -> Unit = { @@ -308,8 +308,8 @@ fun HomeFeeds( // Swiping down on Home should also re-issue the kind-5300 request(s) so the // DVM(s) produce fresh feeds, not just re-render whatever's cached. when (val filter = activeFilter) { - is TopFilter.FavoriteDvm -> accountViewModel.refreshFavoriteDvm(filter.address) - is TopFilter.AllFavoriteDvms -> favoriteDvmAddresses.forEach { accountViewModel.refreshFavoriteDvm(it) } + is TopFilter.FavoriteAlgoFeed -> accountViewModel.refreshFavoriteAlgoFeed(filter.address) + is TopFilter.AllFavoriteAlgoFeeds -> favoriteAlgoFeedAddresses.forEach { accountViewModel.refreshFavoriteAlgoFeed(it) } else -> Unit } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/HomeOutboxEventsEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/HomeOutboxEventsEoseManager.kt index 29abc2ae6..09488ead9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/HomeOutboxEventsEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/HomeOutboxEventsEoseManager.kt @@ -25,7 +25,7 @@ import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.chess.ChessTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteDvm.FavoriteDvmTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds.FavoriteAlgoFeedTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet @@ -43,7 +43,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip01Core.f import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip64Chess.filterHomePostsByChess import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip72Communities.filterHomePostsByAllCommunities import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip72Communities.filterHomePostsByCommunity -import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip90Dvms.filterHomePostsByDvmIds +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip90AlgoFeeds.filterHomePostsByAlgoFeedIds import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription @@ -76,7 +76,7 @@ class HomeOutboxEventsEoseManager( is MutedAuthorsTopNavPerRelayFilterSet -> filterHomePostsByAuthors(feedSettings, since, newThreadSince, repliesSince) is RelayTopNavPerRelayFilterSet -> filterHomePostsByRelay(feedSettings, since, newThreadSince, repliesSince) is SingleCommunityTopNavPerRelayFilterSet -> filterHomePostsByCommunity(feedSettings, since, newThreadSince) - is FavoriteDvmTopNavPerRelayFilterSet -> filterHomePostsByDvmIds(feedSettings, since, newThreadSince) + is FavoriteAlgoFeedTopNavPerRelayFilterSet -> filterHomePostsByAlgoFeedIds(feedSettings, since, newThreadSince) else -> emptyList() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90Dvms/FilterHomePostsByDvmIds.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90AlgoFeeds/FilterHomePostsByAlgoFeedIds.kt similarity index 91% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90Dvms/FilterHomePostsByDvmIds.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90AlgoFeeds/FilterHomePostsByAlgoFeedIds.kt index c042199a6..029546417 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90Dvms/FilterHomePostsByDvmIds.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90AlgoFeeds/FilterHomePostsByAlgoFeedIds.kt @@ -18,10 +18,10 @@ * 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.ui.screen.loggedIn.home.datasource.nip90Dvms +package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip90AlgoFeeds -import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteDvm.FavoriteDvmTopNavPerRelayFilter -import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteDvm.FavoriteDvmTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds.FavoriteAlgoFeedTopNavPerRelayFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds.FavoriteAlgoFeedTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter @@ -45,8 +45,8 @@ import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent * never publishes responses on the user's outbox, so listening anywhere else * would silently miss them. */ -fun filterHomePostsByDvmIds( - set: FavoriteDvmTopNavPerRelayFilterSet, +fun filterHomePostsByAlgoFeedIds( + set: FavoriteAlgoFeedTopNavPerRelayFilterSet, @Suppress("UNUSED_PARAMETER") since: SincePerRelayMap?, @Suppress("UNUSED_PARAMETER") defaultSince: Long?, ): List { @@ -68,7 +68,7 @@ fun filterHomePostsByDvmIds( private fun contentFetchFilters( relay: NormalizedRelayUrl, - filter: FavoriteDvmTopNavPerRelayFilter, + filter: FavoriteAlgoFeedTopNavPerRelayFilter, ): List { val out = mutableListOf() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt index 69207a03b..6175115ac 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt @@ -127,7 +127,7 @@ fun AllSettingsScreen( title = R.string.favorite_dvms_title, icon = Icons.Outlined.AutoAwesome, tint = tint, - onClick = { nav.nav(Route.EditFavoriteDvms) }, + onClick = { nav.nav(Route.EditFavoriteAlgoFeeds) }, ) HorizontalDivider() SettingsNavigationRow( diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilterTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavFilterTest.kt similarity index 87% rename from amethyst/src/test/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilterTest.kt rename to amethyst/src/test/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavFilterTest.kt index c55e3927e..0e8594af8 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilterTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavFilterTest.kt @@ -18,7 +18,7 @@ * 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.model.topNavFeeds.favoriteDvm +package com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent @@ -27,7 +27,7 @@ import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test -class FavoriteDvmTopNavFilterTest { +class FavoriteAlgoFeedTopNavFilterTest { private fun textNote(id: String) = TextNoteEvent(id = id, pubKey = "a".repeat(64), createdAt = 1, tags = emptyArray(), content = "", sig = "x".repeat(128)) private fun longFormNote( @@ -47,8 +47,8 @@ class FavoriteDvmTopNavFilterTest { @Test fun matchesNoteWhoseIdIsInAcceptedSet() { val filter = - FavoriteDvmTopNavFilter( - dvmAddress = dvmAddress, + FavoriteAlgoFeedTopNavFilter( + feedAddress = dvmAddress, acceptedIds = setOf("1".repeat(64)), acceptedAddresses = emptySet(), contentRelays = emptySet(), @@ -62,8 +62,8 @@ class FavoriteDvmTopNavFilterTest { @Test fun rejectsNoteNotInAcceptedSet() { val filter = - FavoriteDvmTopNavFilter( - dvmAddress = dvmAddress, + FavoriteAlgoFeedTopNavFilter( + feedAddress = dvmAddress, acceptedIds = setOf("1".repeat(64)), acceptedAddresses = emptySet(), contentRelays = emptySet(), @@ -81,8 +81,8 @@ class FavoriteDvmTopNavFilterTest { val articleAddress = "30023:$articleAuthor:$articleDTag" val filter = - FavoriteDvmTopNavFilter( - dvmAddress = dvmAddress, + FavoriteAlgoFeedTopNavFilter( + feedAddress = dvmAddress, acceptedIds = emptySet(), acceptedAddresses = setOf(articleAddress), contentRelays = emptySet(), @@ -96,8 +96,8 @@ class FavoriteDvmTopNavFilterTest { @Test fun nullRequestIdCollapsesToEmptyRequestIdsInFilterSet() { val filter = - FavoriteDvmTopNavFilter( - dvmAddress = dvmAddress, + FavoriteAlgoFeedTopNavFilter( + feedAddress = dvmAddress, acceptedIds = emptySet(), acceptedAddresses = emptySet(), contentRelays = emptySet(), @@ -106,7 +106,7 @@ class FavoriteDvmTopNavFilterTest { ) // passing a LocalCache is only needed because the method demands it; - // FavoriteDvmTopNavFilter.startValue doesn't actually consult it. + // FavoriteAlgoFeedTopNavFilter.startValue doesn't actually consult it. val set = filter.startValue(com.vitorpamplona.amethyst.model.LocalCache) assertTrue(set.requestIds.isEmpty()) } @@ -114,8 +114,8 @@ class FavoriteDvmTopNavFilterTest { @Test fun nonNullRequestIdProducesSingletonInFilterSet() { val filter = - FavoriteDvmTopNavFilter( - dvmAddress = dvmAddress, + FavoriteAlgoFeedTopNavFilter( + feedAddress = dvmAddress, acceptedIds = emptySet(), acceptedAddresses = emptySet(), contentRelays = emptySet(), diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90Dvms/FilterHomePostsByDvmIdsTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90AlgoFeeds/FilterHomePostsByAlgoFeedIdsTest.kt similarity index 78% rename from amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90Dvms/FilterHomePostsByDvmIdsTest.kt rename to amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90AlgoFeeds/FilterHomePostsByAlgoFeedIdsTest.kt index 62086f8b3..5ae5d27a1 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90Dvms/FilterHomePostsByDvmIdsTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90AlgoFeeds/FilterHomePostsByAlgoFeedIdsTest.kt @@ -18,10 +18,10 @@ * 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.ui.screen.loggedIn.home.datasource.nip90Dvms +package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip90AlgoFeeds -import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteDvm.FavoriteDvmTopNavPerRelayFilter -import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteDvm.FavoriteDvmTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds.FavoriteAlgoFeedTopNavPerRelayFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds.FavoriteAlgoFeedTopNavPerRelayFilterSet import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent @@ -30,34 +30,34 @@ import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue import org.junit.Test -class FilterHomePostsByDvmIdsTest { +class FilterHomePostsByAlgoFeedIdsTest { private val userRelay = RelayUrlNormalizer.normalizeOrNull("wss://user.example/")!! private val dvmRelay = RelayUrlNormalizer.normalizeOrNull("wss://dvm.example/")!! @Test fun emptyFilterSetProducesNoRequests() { val set = - FavoriteDvmTopNavPerRelayFilterSet( + FavoriteAlgoFeedTopNavPerRelayFilterSet( contentFetches = emptyMap(), listenRelays = emptySet(), requestIds = emptySet(), ) - assertTrue(filterHomePostsByDvmIds(set, since = null, defaultSince = null).isEmpty()) + assertTrue(filterHomePostsByAlgoFeedIds(set, since = null, defaultSince = null).isEmpty()) } @Test fun contentFetchIssuedOnUserRelayWithIdsFilter() { val ids = setOf("a".repeat(64), "b".repeat(64)) val set = - FavoriteDvmTopNavPerRelayFilterSet( + FavoriteAlgoFeedTopNavPerRelayFilterSet( contentFetches = - mapOf(userRelay to FavoriteDvmTopNavPerRelayFilter(ids = ids, addresses = emptySet())), + mapOf(userRelay to FavoriteAlgoFeedTopNavPerRelayFilter(ids = ids, addresses = emptySet())), listenRelays = emptySet(), requestIds = emptySet(), ) - val filters = filterHomePostsByDvmIds(set, since = null, defaultSince = null) + val filters = filterHomePostsByAlgoFeedIds(set, since = null, defaultSince = null) assertEquals(1, filters.size) val single = filters.single() @@ -71,13 +71,13 @@ class FilterHomePostsByDvmIdsTest { fun listenFilterIssuedOnDvmRelayWithKinds6300And7000() { val requestId = "9".repeat(64) val set = - FavoriteDvmTopNavPerRelayFilterSet( + FavoriteAlgoFeedTopNavPerRelayFilterSet( contentFetches = emptyMap(), listenRelays = setOf(dvmRelay), requestIds = setOf(requestId), ) - val filters = filterHomePostsByDvmIds(set, since = null, defaultSince = null) + val filters = filterHomePostsByAlgoFeedIds(set, since = null, defaultSince = null) assertEquals(1, filters.size) val listen = filters.single() @@ -96,13 +96,13 @@ class FilterHomePostsByDvmIdsTest { val req1 = "1".repeat(64) val req2 = "2".repeat(64) val set = - FavoriteDvmTopNavPerRelayFilterSet( + FavoriteAlgoFeedTopNavPerRelayFilterSet( contentFetches = emptyMap(), listenRelays = setOf(dvmRelay), requestIds = setOf(req1, req2), ) - val filters = filterHomePostsByDvmIds(set, since = null, defaultSince = null) + val filters = filterHomePostsByAlgoFeedIds(set, since = null, defaultSince = null) assertEquals(1, filters.size) val eTag = @@ -120,14 +120,14 @@ class FilterHomePostsByDvmIdsTest { val ids = setOf("a".repeat(64)) val requestId = "9".repeat(64) val set = - FavoriteDvmTopNavPerRelayFilterSet( + FavoriteAlgoFeedTopNavPerRelayFilterSet( contentFetches = - mapOf(userRelay to FavoriteDvmTopNavPerRelayFilter(ids = ids, addresses = emptySet())), + mapOf(userRelay to FavoriteAlgoFeedTopNavPerRelayFilter(ids = ids, addresses = emptySet())), listenRelays = setOf(dvmRelay), requestIds = setOf(requestId), ) - val filters = filterHomePostsByDvmIds(set, since = null, defaultSince = null) + val filters = filterHomePostsByAlgoFeedIds(set, since = null, defaultSince = null) assertEquals(2, filters.size) assertTrue(filters.any { it.relay == userRelay && it.filter.ids != null }) From f15e28086240d3cbd11ada8de591d8f8b28d5926 Mon Sep 17 00:00:00 2001 From: davotoula Date: Sun, 19 Apr 2026 21:31:56 +0200 Subject: [PATCH 47/48] updated cz,sv,pt,de --- .../src/main/res/values-cs-rCZ/strings.xml | 63 +++++++++++++++++++ .../src/main/res/values-de-rDE/strings.xml | 63 +++++++++++++++++++ .../src/main/res/values-pt-rBR/strings.xml | 63 +++++++++++++++++++ .../src/main/res/values-sv-rSE/strings.xml | 63 +++++++++++++++++++ 4 files changed, 252 insertions(+) diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index 6101c6c71..74eb0f1f3 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -1991,4 +1991,67 @@ Přímější Úderný + Emoji + Přidat algoritmus zdroje k oblíbeným + Články + Povolit hlasové a video hovory + Když je vypnuto, tlačítka hovoru jsou skryta z chatovacích obrazovek a všechny příchozí hovory jsou tiše ignorovány. + Zavřít + Odebrat ze seznamu + %1$d položek v tomto seznamu bylo smazáno autorem. + Zkusit znovu + Algoritmus zdroje vrátil chybu + Tento algoritmus zdroje vyžaduje platbu + Zpracování zdroje… + Žádost %1$s o zdroj… + Žádost oblíbených algoritmů o zdroje… + Zatím nemáte žádné oblíbené algoritmy zdrojů. Otevřete Objevovat, klepněte na nějaký a označte ho hvězdičkou. + Algoritmy zdrojů, které zde označíte hvězdičkou, se zobrazí jako filtry na hlavním zdroji. Otevřete Objevovat pro přidání dalších. + Oblíbené algoritmy zdrojů + Algoritmy zdrojů + Všechny oblíbené algoritmy zdrojů + Změnit + H.265 není na tomto zařízení dostupný — používá se H.264. + H.264 + H.265 (lepší komprese) + Kodek + Důvod (nepovinné) + Popis + O čem je toto video? + Hotovo + Vytvořit poznámku po nahrání + Otevře editor poznámky předvyplněný titulkem, popisem a odkazem na video, abyste ho mohli upravit před odesláním. + Vytvořit poznámku + Vaše video bude přepsáno do více rozlišení, aby si diváci užili plynulé přehrávání na jakémkoli připojení. + Vyberte video + Publikovat HD video + Publikování „%1$s“… + nad zdrojem — bude přeskočeno + %1$d kbps + Rozlišení + Bude vytvořeno: %1$s + (%1$s přeskočeno — nad zdrojem) + Zdrojové rozlišení: %1$d×%2$d + Něco se pokazilo + Publikování události… + Vaše HD video je publikováno na Nostr. + Video publikováno + Překódování %1$s + Nahráno %1$d z %2$d + Nahrávání %1$d z %2$d + Nahrát + Nahrávání %1$s (%2$d z %3$d) + Titulek + Zadejte titulek videa + Zkusit znovu + Zobrazit poznámku + KeyPackage relays + Relays, kde jsou publikovány vaše MLS KeyPackages (MIP-00). Ostatní uživatelé je stahují, aby vás mohli pozvat do Marmot skupinových chatů. Vložte 1–3 relays, které přijímají KeyPackage události od vás a umožňují veřejné čtení. + KeyPackages + %1$d min čtení + MLS skupina + Skupina + Vaše připnuté poznámky + Odebrat z oblíbených + HLS nahrávání + Publikujte multi-rozlišení HLS na váš media server diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index 98878b100..581f61078 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -1996,4 +1996,67 @@ anz der Bedingungen ist erforderlich Direkter Prägnant + Emoji + Feed-Algorithmus zu Favoriten hinzufügen + Artikel + Sprach- und Videoanrufe aktivieren + Wenn deaktiviert, werden Anruf-Schaltflächen in Chat-Bildschirmen ausgeblendet und alle eingehenden Anrufe stillschweigend ignoriert. + Verwerfen + Aus Liste entfernen + %1$d Element(e) in dieser Liste wurden von ihren Autoren gelöscht. + Wiederholen + Der Feed-Algorithmus hat einen Fehler zurückgegeben + Dieser Feed-Algorithmus erfordert eine Zahlung + Feed wird verarbeitet… + Frage %1$s nach einem Feed… + Frage deine bevorzugten Feed-Algorithmen nach Feeds… + Noch keine bevorzugten Feed-Algorithmen. Öffne Entdecken, tippe einen an und markiere ihn mit einem Stern, um ihn hier hinzuzufügen. + Feed-Algorithmen, die du mit einem Stern markierst, erscheinen als Filter-Chips im Home-Feed. Öffne Entdecken, um weitere hinzuzufügen. + Bevorzugte Feed-Algorithmen + Feed-Algorithmen + Alle bevorzugten Feed-Algorithmen + Ändern + H.265 ist auf diesem Gerät nicht verfügbar — wechsle zu H.264. + H.264 + H.265 (bessere Komprimierung) + Codec + Grund (optional) + Beschreibung + Worum geht es in diesem Video? + Fertig + Notiz nach Upload entwerfen + Öffnet den Notiz-Editor mit Titel, Beschreibung und Video-Link vorausgefüllt, damit du sie vor dem Posten anpassen kannst. + Notiz entwerfen + Dein Video wird in mehrere Auflösungen transkodiert, damit Zuschauer eine reibungslose Wiedergabe auf jeder Verbindung erhalten. + Video auswählen + HD-Video veröffentlichen + Veröffentliche „%1$s“… + über Quelle — wird übersprungen + %1$d kbps + Versionen + Wird erstellt: %1$s + (%1$s übersprungen — über Quelle) + Quellauflösung: %1$d×%2$d + Etwas ist schiefgelaufen + Veröffentliche Ereignis… + Dein HD-Video ist auf Nostr live. + Video veröffentlicht + Transkodiere %1$s + %1$d von %2$d hochgeladen + Lade %1$d von %2$d hoch + Hochladen + Lade %1$s hoch (%2$d von %3$d) + Titel + Gib deinem Video einen Titel + Erneut versuchen + Notiz anzeigen + KeyPackage-Relays + Relays, auf denen deine MLS KeyPackages veröffentlicht werden (MIP-00). Andere Nutzer rufen diese KeyPackages ab, um dich zu Marmot-Gruppenchats einzuladen. Füge 1–3 Relays hinzu, die KeyPackage-Ereignisse von dir akzeptieren und öffentliches Lesen erlauben. + KeyPackages + %1$d Min. Lesezeit + MLS-Gruppe + Gruppe + Deine angepinnten Notizen + Aus Favoriten entfernen + HLS-Upload + Veröffentliche HLS in mehreren Auflösungen auf deinem Medienserver diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 956ccfc2d..dafb089b4 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -1991,4 +1991,67 @@ Mais direto Impactante + Emoji + Adicionar algoritmo de feed aos favoritos + Artigos + Ativar chamadas de voz e vídeo + Quando desativado, os botões de chamada ficam ocultos nas telas de conversa e todas as chamadas recebidas são silenciosamente ignoradas. + Dispensar + Remover da lista + %1$d item(ns) desta lista foram excluídos pelos seus autores. + Tentar novamente + O algoritmo de feed retornou um erro + Este algoritmo de feed requer pagamento + Processando seu feed… + Solicitando feed a %1$s… + Solicitando feeds aos seus algoritmos favoritos… + Ainda não há algoritmos favoritos. Abra Descobrir, toque em um e marque com estrela para adicioná-lo aqui. + Os algoritmos de feed marcados com estrela aparecem como chips de filtro no feed Início. Abra Descobrir para adicionar mais. + Algoritmos de feed favoritos + Algoritmos de feed + Todos os algoritmos de feed favoritos + Trocar + H.265 não disponível neste dispositivo — usando H.264. + H.264 + H.265 (melhor compressão) + Codec + Motivo (opcional) + Descrição + Sobre o que é este vídeo? + Concluído + Rascunho de nota após upload + Abre o compositor de nota pré-preenchido com o título, descrição e link do vídeo para que você possa ajustá-lo antes de publicar. + Rascunhar nota + Seu vídeo será transcodificado em múltiplas resoluções para que os espectadores tenham reprodução suave em qualquer conexão. + Escolher um vídeo + Publicar vídeo HD + Publicando “%1$s”… + acima da origem — será ignorado + %1$d kbps + Versões + Será gerado: %1$s + (%1$s ignorado — acima da origem) + Resolução da origem: %1$d×%2$d + Algo deu errado + Publicando evento… + Seu vídeo HD está publicado no Nostr. + Vídeo publicado + Transcodificando %1$s + Enviado %1$d de %2$d + Enviando %1$d de %2$d + Enviar + Enviando %1$s (%2$d de %3$d) + Título + Dê um título ao seu vídeo + Tentar novamente + Ver nota + Relays de KeyPackage + Relays onde seus MLS KeyPackages são publicados (MIP-00). Outros usuários os buscam para convidá-lo para chats em grupo Marmot. Insira de 1 a 3 relays que aceitem eventos KeyPackage seus e permitam leitura pública. + KeyPackages + %1$d min de leitura + Grupo MLS + Grupo + Suas notas fixadas + Remover dos favoritos + Upload HLS + Publique HLS multi-resolução em seu servidor de mídia diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index 5c425a64b..2d494b4d5 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -1990,4 +1990,67 @@ Mer direkt Slagkraftig + Emoji + Lägg till flödesalgoritm i favoriter + Artiklar + Aktivera röst- och videosamtal + När det är inaktiverat döljs samtalsknapparna från chattskärmar och alla inkommande samtal ignoreras tyst. + Avvisa + Ta bort från lista + %1$d objekt i den här listan har raderats av sina författare. + Försök igen + Flödesalgoritmen returnerade ett fel + Den här flödesalgoritmen kräver betalning + Bearbetar ditt flöde… + Frågar %1$s om ett flöde… + Frågar dina favorit-flödesalgoritmer om flöden… + Inga favorit-flödesalgoritmer än. Öppna Upptäck, tryck på en och stjärnmarkera för att lägga till den här. + Flödesalgoritmer du stjärnmarkerar visas som filterchips på Hem-flödet. Öppna Upptäck för att lägga till fler. + Favorit-flödesalgoritmer + Flödesalgoritmer + Alla favorit-flödesalgoritmer + Ändra + H.265 inte tillgängligt på den här enheten — växlar till H.264. + H.264 + H.265 (bättre komprimering) + Codec + Anledning (valfritt) + Beskrivning + Vad handlar den här videon om? + Klar + Utkast till anteckning efter uppladdning + Öppnar anteckningskompositören förifylld med titel, beskrivning och videolänk så att du kan justera den innan publicering. + Utkast till anteckning + Din video transkodas till flera upplösningar så att tittare får mjuk uppspelning på alla anslutningar. + Välj en video + Publicera HD-video + Publicerar ”%1$s”… + över källa — kommer att hoppas över + %1$d kbps + Versioner + Kommer att skapa: %1$s + (%1$s hoppas över — över källan) + Källupplösning: %1$d×%2$d + Något gick fel + Publicerar händelse… + Din HD-video är live på Nostr. + Video publicerad + Transkodar %1$s + Uppladdade %1$d av %2$d + Laddar upp %1$d av %2$d + Ladda upp + Laddar upp %1$s (%2$d av %3$d) + Titel + Ge din video en titel + Försök igen + Visa anteckning + KeyPackage-relays + Relays där dina MLS KeyPackages publiceras (MIP-00). Andra användare hämtar dessa KeyPackages för att bjuda in dig till Marmot-gruppchatter. Lägg till mellan 1–3 relays som accepterar KeyPackage-händelser från dig och tillåter offentlig läsning. + KeyPackages + %1$d min läsning + MLS-grupp + Grupp + Dina fastnålade anteckningar + Ta bort från favoriter + HLS-uppladdning + Publicera HLS i flera upplösningar till din mediaserver From af27ad4673b40172e3709bd47a130ba969de496b Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Sun, 19 Apr 2026 19:37:16 +0000 Subject: [PATCH 48/48] New Crowdin translations by GitHub Action --- .../src/main/res/values-cs-rCZ/strings.xml | 123 +++++++++--------- .../src/main/res/values-de-rDE/strings.xml | 122 +++++++++-------- .../src/main/res/values-pt-rBR/strings.xml | 122 +++++++++-------- .../src/main/res/values-sv-rSE/strings.xml | 122 +++++++++-------- 4 files changed, 237 insertions(+), 252 deletions(-) diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index 74eb0f1f3..e445b58bc 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -270,6 +270,7 @@ Jsou skvělé pro otevřené komunity kolem konkrétních témat. Některé z těchto skupin jsou efemérní a proto zprávy časem mizí Veřejný chat + MLS skupina Metadata veřejného chatu Veřejné konverzace jsou viditelné pro všechny na Nostru a kdokoli se na nich může podílet. Jsou skvělé pro otevřené komunity kolem konkrétních témat. @@ -389,6 +390,7 @@ Obrázky Krátká videa Videa + Články Soukromé záložky Veřejné záložky Přidat do soukromých záložek @@ -396,8 +398,12 @@ Odebrat ze soukromých záložek Odebrat z veřejných záložek Připnuté poznámky + Vaše připnuté poznámky Připnout na profil Odepnout z profilu + %1$d položek v tomto seznamu bylo smazáno autorem. + Odebrat ze seznamu + Zavřít Seznamy záložek Ikona seznamu záložek Nový seznam záložek @@ -712,6 +718,8 @@ Nepodařilo se přijmout hovor Nepodařilo se vytvořit relaci hovoru Nastavení hovorů + Povolit hlasové a video hovory + Když je vypnuto, tlačítka hovoru jsou skryta z chatovacích obrazovek a všechny příchozí hovory jsou tiše ignorovány. Kvalita videa Maximální datový tok videa TURN / STUN servery @@ -937,6 +945,7 @@ Uvidí to pouze následovníci umístění. Tvoji obecní následovníci to neuvidí. Hashtag-exkluzivní příspěvek Uvidí to pouze následovníci hashtagu. Tvůj všeobecní následovníci to neuvidí. + %1$d min čtení Načítání umístění Žádná lokace oprávnění Přidat varování o citlivém obsahu před zobrazením vašeho obsahu. Toto je ideální pro obsah NSFW (nebezpečné pro práci) nebo obsah, který někteří lidé mohou považovat za urážlivý nebo znepokojující @@ -945,6 +954,7 @@ Aktivace tohoto režimu vyžaduje od Amethystu odeslání zprávy NIP-17 (GiftWrapped, Zapečetěné přímé a skupinové zprávy). NIP-17 je nový a většina klientů ho zatím neimplementovala. Ujistěte se, že příjemce používá kompatibilního klienta. Aktivovat Veřejné + Skupina Nová veřejná nebo soukromá skupina Relé Soukromé @@ -1338,6 +1348,8 @@ DM schránka relé Uživatel přijímá soukromé zprávy na těchto přenašečích Vložte mezi 1–3 relé, která budou sloužit jako vaše soukromá schránka. Ostatní použijí tato relé k posílání DM zpráv vám. DM schránka relé by měla přijímat jakékoli zprávy od kohokoli, ale pouze vám umožnit jejich stahování. Dobré možnosti jsou:\n - inbox.nostr.wine (placené)\n - you.nostr1.com (osobní relé - placené) + KeyPackage relays + Relays, kde jsou publikovány vaše MLS KeyPackages (MIP-00). Ostatní uživatelé je stahují, aby vás mohli pozvat do Marmot skupinových chatů. Vložte 1–3 relays, které přijímají KeyPackage události od vás a umožňují veřejné čtení. Soukromá relé Vložte mezi 1–3 relé pro ukládání událostí nikoho jiného, jako jsou koncepty a/nebo nastavení aplikace. V ideálním případě jsou tato relé buď lokální, nebo vyžadují autentizaci před stažením obsahu každého uživatele. Obecná relé @@ -1475,7 +1487,20 @@ Lokace Komunity Seznamy + Algoritmy zdrojů + Všechny oblíbené algoritmy zdrojů Relé + Přidat algoritmus zdroje k oblíbeným + Odebrat z oblíbených + Oblíbené algoritmy zdrojů + Algoritmy zdrojů, které zde označíte hvězdičkou, se zobrazí jako filtry na hlavním zdroji. Otevřete Objevovat pro přidání dalších. + Zatím nemáte žádné oblíbené algoritmy zdrojů. Otevřete Objevovat, klepněte na nějaký a označte ho hvězdičkou. + Žádost %1$s o zdroj… + Žádost oblíbených algoritmů o zdroje… + Zpracování zdroje… + Tento algoritmus zdroje vyžaduje platbu + Algoritmus zdroje vrátil chybu + Zkusit znovu Odhlásit se na zámek zařízení Soukromá zpráva Veřejná zpráva @@ -1819,6 +1844,41 @@ Přehrávání Auto + HLS nahrávání + Publikujte multi-rozlišení HLS na váš media server + Vyberte video + Vaše video bude přepsáno do více rozlišení, aby si diváci užili plynulé přehrávání na jakémkoli připojení. + Změnit + Titulek + Zadejte titulek videa + Popis + O čem je toto video? + Důvod (nepovinné) + Kodek + H.265 (lepší komprese) + H.265 není na tomto zařízení dostupný — používá se H.264. + Rozlišení + Zdrojové rozlišení: %1$d×%2$d + Bude vytvořeno: %1$s + (%1$s přeskočeno — nad zdrojem) + nad zdrojem — bude přeskočeno + Publikovat HD video + Publikování „%1$s“… + Překódování %1$s + Nahrát + Nahrávání %1$d z %2$d + Nahrávání %1$s (%2$d z %3$d) + Nahráno %1$d z %2$d + Publikování události… + Video publikováno + Vaše HD video je publikováno na Nostr. + Něco se pokazilo + Zobrazit poznámku + Hotovo + Zkusit znovu + Vytvořit poznámku po nahrání + Otevře editor poznámky předvyplněný titulkem, popisem a odkazem na video, abyste ho mohli upravit před odesláním. + Vytvořit poznámku Akce balíčku Akce seznamu Akce záložky @@ -1991,67 +2051,4 @@ Přímější Úderný + Emoji - Přidat algoritmus zdroje k oblíbeným - Články - Povolit hlasové a video hovory - Když je vypnuto, tlačítka hovoru jsou skryta z chatovacích obrazovek a všechny příchozí hovory jsou tiše ignorovány. - Zavřít - Odebrat ze seznamu - %1$d položek v tomto seznamu bylo smazáno autorem. - Zkusit znovu - Algoritmus zdroje vrátil chybu - Tento algoritmus zdroje vyžaduje platbu - Zpracování zdroje… - Žádost %1$s o zdroj… - Žádost oblíbených algoritmů o zdroje… - Zatím nemáte žádné oblíbené algoritmy zdrojů. Otevřete Objevovat, klepněte na nějaký a označte ho hvězdičkou. - Algoritmy zdrojů, které zde označíte hvězdičkou, se zobrazí jako filtry na hlavním zdroji. Otevřete Objevovat pro přidání dalších. - Oblíbené algoritmy zdrojů - Algoritmy zdrojů - Všechny oblíbené algoritmy zdrojů - Změnit - H.265 není na tomto zařízení dostupný — používá se H.264. - H.264 - H.265 (lepší komprese) - Kodek - Důvod (nepovinné) - Popis - O čem je toto video? - Hotovo - Vytvořit poznámku po nahrání - Otevře editor poznámky předvyplněný titulkem, popisem a odkazem na video, abyste ho mohli upravit před odesláním. - Vytvořit poznámku - Vaše video bude přepsáno do více rozlišení, aby si diváci užili plynulé přehrávání na jakémkoli připojení. - Vyberte video - Publikovat HD video - Publikování „%1$s“… - nad zdrojem — bude přeskočeno - %1$d kbps - Rozlišení - Bude vytvořeno: %1$s - (%1$s přeskočeno — nad zdrojem) - Zdrojové rozlišení: %1$d×%2$d - Něco se pokazilo - Publikování události… - Vaše HD video je publikováno na Nostr. - Video publikováno - Překódování %1$s - Nahráno %1$d z %2$d - Nahrávání %1$d z %2$d - Nahrát - Nahrávání %1$s (%2$d z %3$d) - Titulek - Zadejte titulek videa - Zkusit znovu - Zobrazit poznámku - KeyPackage relays - Relays, kde jsou publikovány vaše MLS KeyPackages (MIP-00). Ostatní uživatelé je stahují, aby vás mohli pozvat do Marmot skupinových chatů. Vložte 1–3 relays, které přijímají KeyPackage události od vás a umožňují veřejné čtení. - KeyPackages - %1$d min čtení - MLS skupina - Skupina - Vaše připnuté poznámky - Odebrat z oblíbených - HLS nahrávání - Publikujte multi-rozlišení HLS na váš media server diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index 581f61078..78b9b9c61 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -274,6 +274,7 @@ anz der Bedingungen ist erforderlich Sie eignen sich hervorragend für offene Communities rund um bestimmte Themen. Einige dieser Gruppen sind kurzsichtig und daher verschwinden Chat-Nachrichten im Laufe der Zeit Öffentlicher Chat + MLS-Gruppe Öffentliche Chat Metadaten Öffentliche Chats sind für jeden auf Nostr sichtbar und jeder kann daran teilnehmen. Sie eignen sich hervorragend für offene Gemeinschaften rund um bestimmte Themen. @@ -395,6 +396,7 @@ anz der Bedingungen ist erforderlich Bilder Kurzvideos Videos + Artikel Private Lesezeichen Öffentliche Lesezeichen Zu den privaten Lesezeichen hinzufügen @@ -402,8 +404,12 @@ anz der Bedingungen ist erforderlich Aus den privaten Lesezeichen entfernen Aus den öffentlichen Lesezeichen entfernen Angeheftete Notizen + Deine angepinnten Notizen An Profil anheften Von Profil lösen + %1$d Element(e) in dieser Liste wurden von ihren Autoren gelöscht. + Aus Liste entfernen + Verwerfen Lesezeichenlisten Symbol für Lesezeichenliste Neue Lesezeichenliste @@ -717,6 +723,8 @@ anz der Bedingungen ist erforderlich Anruf konnte nicht angenommen werden Anrufsitzung konnte nicht erstellt werden Anrufeinstellungen + Sprach- und Videoanrufe aktivieren + Wenn deaktiviert, werden Anruf-Schaltflächen in Chat-Bildschirmen ausgeblendet und alle eingehenden Anrufe stillschweigend ignoriert. Videoqualität Maximale Video-Bitrate TURN- / STUN-Server @@ -942,6 +950,7 @@ anz der Bedingungen ist erforderlich Nur Anhänger des Ortes werden es sehen. Deine allgemeinen Anhänger werden es nicht sehen. Hashtag-exklusive Beitrag Nur die Anhänger des Hashtags werden ihn sehen. Deine allgemeinen Follower werden ihn nicht sehen. + %1$d Min. Lesezeit Standort wird geladen Keine Standortberechtigungen Fügt eine Warnung für sensiblen Inhalt hinzu, bevor Ihr Inhalt angezeigt wird. Dies ist ideal für NSFW-Inhalte (nicht sicher für die Arbeit) oder Inhalte, die manche Menschen als anstößig oder verstörend empfinden könnten @@ -950,6 +959,7 @@ anz der Bedingungen ist erforderlich Um diesen Modus zu aktivieren, muss Amethyst eine NIP-17-Nachricht senden (GiftWrapped, Versiegelte Direkt- und Gruppennachrichten). NIP-17 ist neu und die meisten Clients haben es noch nicht implementiert. Stellen Sie sicher, dass der Empfänger einen kompatiblen Client verwendet. Aktivieren Öffentlich + Gruppe Neue öffentliche oder private Gruppe Relais Privat @@ -1343,6 +1353,8 @@ anz der Bedingungen ist erforderlich DM-Posteingangsrelais Der Benutzer empfängt Direktnachrichten auf diesen Relays Fügen Sie 1–3 Relais ein, die als Ihr privater Posteingang dienen sollen. Andere werden diese Relais verwenden, um Ihnen DMs zu senden. DM-Posteingangsrelais sollten Nachrichten von jedem akzeptieren, aber nur Ihnen erlauben, sie herunterzuladen. Gute Optionen sind:\n - inbox.nostr.wine (bezahlt)\n - you.nostr1.com (persönliche Relais - bezahlt) + KeyPackage-Relays + Relays, auf denen deine MLS KeyPackages veröffentlicht werden (MIP-00). Andere Nutzer rufen diese KeyPackages ab, um dich zu Marmot-Gruppenchats einzuladen. Füge 1–3 Relays hinzu, die KeyPackage-Ereignisse von dir akzeptieren und öffentliches Lesen erlauben. Private Relais Fügen Sie zwischen 1–3 Relais ein, um Ereignisse zu speichern, die niemand anders sehen kann, wie Ihre Entwürfe und/oder App-Einstellungen. Idealerweise sind diese Relais entweder lokal oder erfordern eine Authentifizierung, bevor Sie die Inhalte eines jeden Benutzers herunterladen. Allgemeine Relais @@ -1480,7 +1492,20 @@ anz der Bedingungen ist erforderlich Standorte Gemeinschaften Listen + Feed-Algorithmen + Alle bevorzugten Feed-Algorithmen Relais + Feed-Algorithmus zu Favoriten hinzufügen + Aus Favoriten entfernen + Bevorzugte Feed-Algorithmen + Feed-Algorithmen, die du mit einem Stern markierst, erscheinen als Filter-Chips im Home-Feed. Öffne Entdecken, um weitere hinzuzufügen. + Noch keine bevorzugten Feed-Algorithmen. Öffne Entdecken, tippe einen an und markiere ihn mit einem Stern, um ihn hier hinzuzufügen. + Frage %1$s nach einem Feed… + Frage deine bevorzugten Feed-Algorithmen nach Feeds… + Feed wird verarbeitet… + Dieser Feed-Algorithmus erfordert eine Zahlung + Der Feed-Algorithmus hat einen Fehler zurückgegeben + Wiederholen Beim Sperren des Geräts abmelden Private Nachricht Öffentliche Nachricht @@ -1824,6 +1849,40 @@ anz der Bedingungen ist erforderlich Wiedergabe Auto + HLS-Upload + Veröffentliche HLS in mehreren Auflösungen auf deinem Medienserver + Video auswählen + Dein Video wird in mehrere Auflösungen transkodiert, damit Zuschauer eine reibungslose Wiedergabe auf jeder Verbindung erhalten. + Ändern + Titel + Gib deinem Video einen Titel + Beschreibung + Worum geht es in diesem Video? + Grund (optional) + H.265 (bessere Komprimierung) + H.265 ist auf diesem Gerät nicht verfügbar — wechsle zu H.264. + Versionen + Quellauflösung: %1$d×%2$d + Wird erstellt: %1$s + (%1$s übersprungen — über Quelle) + über Quelle — wird übersprungen + HD-Video veröffentlichen + Veröffentliche „%1$s“… + Transkodiere %1$s + Hochladen + Lade %1$d von %2$d hoch + Lade %1$s hoch (%2$d von %3$d) + %1$d von %2$d hochgeladen + Veröffentliche Ereignis… + Video veröffentlicht + Dein HD-Video ist auf Nostr live. + Etwas ist schiefgelaufen + Notiz anzeigen + Fertig + Erneut versuchen + Notiz nach Upload entwerfen + Öffnet den Notiz-Editor mit Titel, Beschreibung und Video-Link vorausgefüllt, damit du sie vor dem Posten anpassen kannst. + Notiz entwerfen Paket-Aktionen Listenaktionen Lesezeichen-Aktionen @@ -1996,67 +2055,4 @@ anz der Bedingungen ist erforderlich Direkter Prägnant + Emoji - Feed-Algorithmus zu Favoriten hinzufügen - Artikel - Sprach- und Videoanrufe aktivieren - Wenn deaktiviert, werden Anruf-Schaltflächen in Chat-Bildschirmen ausgeblendet und alle eingehenden Anrufe stillschweigend ignoriert. - Verwerfen - Aus Liste entfernen - %1$d Element(e) in dieser Liste wurden von ihren Autoren gelöscht. - Wiederholen - Der Feed-Algorithmus hat einen Fehler zurückgegeben - Dieser Feed-Algorithmus erfordert eine Zahlung - Feed wird verarbeitet… - Frage %1$s nach einem Feed… - Frage deine bevorzugten Feed-Algorithmen nach Feeds… - Noch keine bevorzugten Feed-Algorithmen. Öffne Entdecken, tippe einen an und markiere ihn mit einem Stern, um ihn hier hinzuzufügen. - Feed-Algorithmen, die du mit einem Stern markierst, erscheinen als Filter-Chips im Home-Feed. Öffne Entdecken, um weitere hinzuzufügen. - Bevorzugte Feed-Algorithmen - Feed-Algorithmen - Alle bevorzugten Feed-Algorithmen - Ändern - H.265 ist auf diesem Gerät nicht verfügbar — wechsle zu H.264. - H.264 - H.265 (bessere Komprimierung) - Codec - Grund (optional) - Beschreibung - Worum geht es in diesem Video? - Fertig - Notiz nach Upload entwerfen - Öffnet den Notiz-Editor mit Titel, Beschreibung und Video-Link vorausgefüllt, damit du sie vor dem Posten anpassen kannst. - Notiz entwerfen - Dein Video wird in mehrere Auflösungen transkodiert, damit Zuschauer eine reibungslose Wiedergabe auf jeder Verbindung erhalten. - Video auswählen - HD-Video veröffentlichen - Veröffentliche „%1$s“… - über Quelle — wird übersprungen - %1$d kbps - Versionen - Wird erstellt: %1$s - (%1$s übersprungen — über Quelle) - Quellauflösung: %1$d×%2$d - Etwas ist schiefgelaufen - Veröffentliche Ereignis… - Dein HD-Video ist auf Nostr live. - Video veröffentlicht - Transkodiere %1$s - %1$d von %2$d hochgeladen - Lade %1$d von %2$d hoch - Hochladen - Lade %1$s hoch (%2$d von %3$d) - Titel - Gib deinem Video einen Titel - Erneut versuchen - Notiz anzeigen - KeyPackage-Relays - Relays, auf denen deine MLS KeyPackages veröffentlicht werden (MIP-00). Andere Nutzer rufen diese KeyPackages ab, um dich zu Marmot-Gruppenchats einzuladen. Füge 1–3 Relays hinzu, die KeyPackage-Ereignisse von dir akzeptieren und öffentliches Lesen erlauben. - KeyPackages - %1$d Min. Lesezeit - MLS-Gruppe - Gruppe - Deine angepinnten Notizen - Aus Favoriten entfernen - HLS-Upload - Veröffentliche HLS in mehreren Auflösungen auf deinem Medienserver diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index dafb089b4..53ffc5eee 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -270,6 +270,7 @@ São ótimos para comunidades abertas em torno de tópicos específicos. Alguns desses grupos são efêmeros, portanto, as mensagens desaparecem com o tempo Chat público + Grupo MLS Metadados do Chat Público Os chats públicos são visíveis para todos no Nostr, e qualquer pessoa pode participar deles. Eles são ótimos para comunidades abertas em torno de tópicos específicos. @@ -389,6 +390,7 @@ Imagens Curtas Vídeos + Artigos Itens Salvos Privados Itens Salvos Públicos Adicionar aos Itens Salvos Privados @@ -396,8 +398,12 @@ Remover dos Itens Salvos Privados Remover dos Itens Salvos Públicos Notas Fixadas + Suas notas fixadas Fixar no Perfil Desafixar do Perfil + %1$d item(ns) desta lista foram excluídos pelos seus autores. + Remover da lista + Dispensar Listas de favoritos Ícone da lista de favoritos Nova lista de favoritos @@ -712,6 +718,8 @@ Falha ao aceitar chamada Falha ao criar sessão de chamada Configurações de Chamada + Ativar chamadas de voz e vídeo + Quando desativado, os botões de chamada ficam ocultos nas telas de conversa e todas as chamadas recebidas são silenciosamente ignoradas. Qualidade do Vídeo Taxa de Bits Máxima de Vídeo Servidores TURN / STUN @@ -937,6 +945,7 @@ Somente seguidores da localização verão isso. Seus seguidores gerais não verão isso. Postagem exclusiva de Hashtag Somente seguidores da hashtag verão isso. Seus seguidores gerais não verão isso. + %1$d min de leitura Carregando localização Sem permissões para localização Adiciona aviso de conteúdo sensível antes de mostrar seu conteúdo. Isso é ideal para qualquer conteúdo NSFW ou conteúdo que algumas pessoas possam considerar ofensivo ou perturbador @@ -945,6 +954,7 @@ Ativando este modo requer o Amethyst para enviar uma mensagem de NIP-17 (GiftWrapped, Sealed Direct and Group Messages). NIP-17 é novo e a maioria dos clientes ainda não o implementaram. Certifique-se de que o destinatário está usando um cliente compatível. Ativar Público + Grupo Novo Grupo Público ou Privado Relé Privado @@ -1338,6 +1348,8 @@ Relés de Caixa de Entrada de DM O usuário recebe mensagens diretas (DMs) nesses relays Insira entre 1–3 relés para servir como sua caixa de entrada privada. Outros usarão esses relés para enviar DMs para você. Relés de Caixa de Entrada de DM devem aceitar qualquer mensagem de qualquer pessoa, mas permitir apenas você baixá-las. Boas opções são:\n - inbox.nostr.wine (pago)\n - you.nostr1.com (relés pessoais - pago) + Relays de KeyPackage + Relays onde seus MLS KeyPackages são publicados (MIP-00). Outros usuários os buscam para convidá-lo para chats em grupo Marmot. Insira de 1 a 3 relays que aceitem eventos KeyPackage seus e permitam leitura pública. Relés privados Insira entre 1–3 retransmissores para armazenar eventos que ninguém mais possa ver, como seus rascunhos e/ou configurações de aplicativo. Idealmente, esses relés são locais ou requerem autenticação antes de baixar o conteúdo de cada usuário. Relés Gerais @@ -1475,7 +1487,20 @@ Localizações Comunidades Listas + Algoritmos de feed + Todos os algoritmos de feed favoritos Relés + Adicionar algoritmo de feed aos favoritos + Remover dos favoritos + Algoritmos de feed favoritos + Os algoritmos de feed marcados com estrela aparecem como chips de filtro no feed Início. Abra Descobrir para adicionar mais. + Ainda não há algoritmos favoritos. Abra Descobrir, toque em um e marque com estrela para adicioná-lo aqui. + Solicitando feed a %1$s… + Solicitando feeds aos seus algoritmos favoritos… + Processando seu feed… + Este algoritmo de feed requer pagamento + O algoritmo de feed retornou um erro + Tentar novamente Terminar sessão no bloqueio do dispositivo Mensagem Privada Mensagem pública @@ -1819,6 +1844,40 @@ Reprodução Auto + Upload HLS + Publique HLS multi-resolução em seu servidor de mídia + Escolher um vídeo + Seu vídeo será transcodificado em múltiplas resoluções para que os espectadores tenham reprodução suave em qualquer conexão. + Trocar + Título + Dê um título ao seu vídeo + Descrição + Sobre o que é este vídeo? + Motivo (opcional) + H.265 (melhor compressão) + H.265 não disponível neste dispositivo — usando H.264. + Versões + Resolução da origem: %1$d×%2$d + Será gerado: %1$s + (%1$s ignorado — acima da origem) + acima da origem — será ignorado + Publicar vídeo HD + Publicando “%1$s”… + Transcodificando %1$s + Enviar + Enviando %1$d de %2$d + Enviando %1$s (%2$d de %3$d) + Enviado %1$d de %2$d + Publicando evento… + Vídeo publicado + Seu vídeo HD está publicado no Nostr. + Algo deu errado + Ver nota + Concluído + Tentar novamente + Rascunho de nota após upload + Abre o compositor de nota pré-preenchido com o título, descrição e link do vídeo para que você possa ajustá-lo antes de publicar. + Rascunhar nota Ações do pacote Ações da lista Ações de favorito @@ -1991,67 +2050,4 @@ Mais direto Impactante + Emoji - Adicionar algoritmo de feed aos favoritos - Artigos - Ativar chamadas de voz e vídeo - Quando desativado, os botões de chamada ficam ocultos nas telas de conversa e todas as chamadas recebidas são silenciosamente ignoradas. - Dispensar - Remover da lista - %1$d item(ns) desta lista foram excluídos pelos seus autores. - Tentar novamente - O algoritmo de feed retornou um erro - Este algoritmo de feed requer pagamento - Processando seu feed… - Solicitando feed a %1$s… - Solicitando feeds aos seus algoritmos favoritos… - Ainda não há algoritmos favoritos. Abra Descobrir, toque em um e marque com estrela para adicioná-lo aqui. - Os algoritmos de feed marcados com estrela aparecem como chips de filtro no feed Início. Abra Descobrir para adicionar mais. - Algoritmos de feed favoritos - Algoritmos de feed - Todos os algoritmos de feed favoritos - Trocar - H.265 não disponível neste dispositivo — usando H.264. - H.264 - H.265 (melhor compressão) - Codec - Motivo (opcional) - Descrição - Sobre o que é este vídeo? - Concluído - Rascunho de nota após upload - Abre o compositor de nota pré-preenchido com o título, descrição e link do vídeo para que você possa ajustá-lo antes de publicar. - Rascunhar nota - Seu vídeo será transcodificado em múltiplas resoluções para que os espectadores tenham reprodução suave em qualquer conexão. - Escolher um vídeo - Publicar vídeo HD - Publicando “%1$s”… - acima da origem — será ignorado - %1$d kbps - Versões - Será gerado: %1$s - (%1$s ignorado — acima da origem) - Resolução da origem: %1$d×%2$d - Algo deu errado - Publicando evento… - Seu vídeo HD está publicado no Nostr. - Vídeo publicado - Transcodificando %1$s - Enviado %1$d de %2$d - Enviando %1$d de %2$d - Enviar - Enviando %1$s (%2$d de %3$d) - Título - Dê um título ao seu vídeo - Tentar novamente - Ver nota - Relays de KeyPackage - Relays onde seus MLS KeyPackages são publicados (MIP-00). Outros usuários os buscam para convidá-lo para chats em grupo Marmot. Insira de 1 a 3 relays que aceitem eventos KeyPackage seus e permitam leitura pública. - KeyPackages - %1$d min de leitura - Grupo MLS - Grupo - Suas notas fixadas - Remover dos favoritos - Upload HLS - Publique HLS multi-resolução em seu servidor de mídia diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index 2d494b4d5..ed7eba1dc 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -270,6 +270,7 @@ De är bra för öppna gemenskaper kring specifika ämnen. Några av dessa grupper är kortlivade och därmed försvinner chattmeddelanden över tiden Publik Chat + MLS-grupp Metadata för offentlig chatt Offentliga chattar är synliga för alla på Nostr och alla kan delta på dem. De är bra för öppna samhällen kring specifika ämnen. @@ -389,6 +390,7 @@ Bilder Kortfilmer Videor + Artiklar Privata Bokmärken Publika Bokmärken Lägg till i Privata Bokmärken @@ -396,8 +398,12 @@ Ta bort från Privata Bokmärken Ta bort från Publika Bokmärken Fästa anteckningar + Dina fastnålade anteckningar Fäst på profil Ta bort från profil + %1$d objekt i den här listan har raderats av sina författare. + Ta bort från lista + Avvisa Bokmärkeslistor Ikon för bokmärkeslista Ny bokmärkeslista @@ -711,6 +717,8 @@ Kunde inte ta emot samtal Kunde inte skapa samtalssession Samtalsinställningar + Aktivera röst- och videosamtal + När det är inaktiverat döljs samtalsknapparna från chattskärmar och alla inkommande samtal ignoreras tyst. Videokvalitet Maximal videobitfrekvens TURN- / STUN-servrar @@ -936,6 +944,7 @@ Endast anhängare av platsen kommer att se den. Dina allmänna anhängare kommer inte att se den. Hashtag-exklusivt inlägg Endast anhängare av hashtaggen kommer att se den. Dina generella följare kommer inte att se den. + %1$d min läsning Laddar position Inga platsbehörigheter Lägger till en varning för känsligt innehåll innan ditt innehåll visas. Detta är idealiskt för NSFW-innehåll (inte säkert för arbete) eller innehåll som vissa personer kan uppleva som stötande eller störande @@ -944,6 +953,7 @@ För att aktivera denna funktion kräver det att Amethyst skickar ett NIP-17 meddelande (GiftWrapped, Förseglade Direkta och Gruppmeddelanden). NIP-17 är nytt och de flesta klienter har ännu inte implementerat det. Se till att mottagaren använder en kompatibel klient. Aktivera Publik + Grupp Ny offentlig eller privat grupp Relä Privat @@ -1337,6 +1347,8 @@ DM inkorgsreläer Användaren tar emot DM:s på dessa reläer Sätt in mellan 1–3 reläer som ska fungera som din privata inkorg. Andra kommer att använda dessa reläer för att skicka DM till dig. DM inkorgsreläer bör acceptera alla meddelanden från vem som helst, men endast tillåta dig att ladda ner dem. Bra alternativ är:\n - inbox.nostr.wine (betald)\n - you.nostr1.com (personliga reläer - betald) + KeyPackage-relays + Relays där dina MLS KeyPackages publiceras (MIP-00). Andra användare hämtar dessa KeyPackages för att bjuda in dig till Marmot-gruppchatter. Lägg till mellan 1–3 relays som accepterar KeyPackage-händelser från dig och tillåter offentlig läsning. Privata reläer Infoga mellan 1–3 reläer för att lagra händelser som ingen annan kan se, som dina Utkast och/eller appinställningar. Helst är dessa reläer antingen lokala eller kräver autentisering innan du laddar ner varje användares innehåll. Allmänna reläer @@ -1474,7 +1486,20 @@ Platser Gemenskaper Listor + Flödesalgoritmer + Alla favorit-flödesalgoritmer Reläer + Lägg till flödesalgoritm i favoriter + Ta bort från favoriter + Favorit-flödesalgoritmer + Flödesalgoritmer du stjärnmarkerar visas som filterchips på Hem-flödet. Öppna Upptäck för att lägga till fler. + Inga favorit-flödesalgoritmer än. Öppna Upptäck, tryck på en och stjärnmarkera för att lägga till den här. + Frågar %1$s om ett flöde… + Frågar dina favorit-flödesalgoritmer om flöden… + Bearbetar ditt flöde… + Den här flödesalgoritmen kräver betalning + Flödesalgoritmen returnerade ett fel + Försök igen Logga ut när enheten låses Privat meddelande Offentligt meddelande @@ -1818,6 +1843,40 @@ Uppspelning Auto + HLS-uppladdning + Publicera HLS i flera upplösningar till din mediaserver + Välj en video + Din video transkodas till flera upplösningar så att tittare får mjuk uppspelning på alla anslutningar. + Ändra + Titel + Ge din video en titel + Beskrivning + Vad handlar den här videon om? + Anledning (valfritt) + H.265 (bättre komprimering) + H.265 inte tillgängligt på den här enheten — växlar till H.264. + Versioner + Källupplösning: %1$d×%2$d + Kommer att skapa: %1$s + (%1$s hoppas över — över källan) + över källa — kommer att hoppas över + Publicera HD-video + Publicerar ”%1$s”… + Transkodar %1$s + Ladda upp + Laddar upp %1$d av %2$d + Laddar upp %1$s (%2$d av %3$d) + Uppladdade %1$d av %2$d + Publicerar händelse… + Video publicerad + Din HD-video är live på Nostr. + Något gick fel + Visa anteckning + Klar + Försök igen + Utkast till anteckning efter uppladdning + Öppnar anteckningskompositören förifylld med titel, beskrivning och videolänk så att du kan justera den innan publicering. + Utkast till anteckning Paketåtgärder Liståtgärder Bokmärkesåtgärder @@ -1990,67 +2049,4 @@ Mer direkt Slagkraftig + Emoji - Lägg till flödesalgoritm i favoriter - Artiklar - Aktivera röst- och videosamtal - När det är inaktiverat döljs samtalsknapparna från chattskärmar och alla inkommande samtal ignoreras tyst. - Avvisa - Ta bort från lista - %1$d objekt i den här listan har raderats av sina författare. - Försök igen - Flödesalgoritmen returnerade ett fel - Den här flödesalgoritmen kräver betalning - Bearbetar ditt flöde… - Frågar %1$s om ett flöde… - Frågar dina favorit-flödesalgoritmer om flöden… - Inga favorit-flödesalgoritmer än. Öppna Upptäck, tryck på en och stjärnmarkera för att lägga till den här. - Flödesalgoritmer du stjärnmarkerar visas som filterchips på Hem-flödet. Öppna Upptäck för att lägga till fler. - Favorit-flödesalgoritmer - Flödesalgoritmer - Alla favorit-flödesalgoritmer - Ändra - H.265 inte tillgängligt på den här enheten — växlar till H.264. - H.264 - H.265 (bättre komprimering) - Codec - Anledning (valfritt) - Beskrivning - Vad handlar den här videon om? - Klar - Utkast till anteckning efter uppladdning - Öppnar anteckningskompositören förifylld med titel, beskrivning och videolänk så att du kan justera den innan publicering. - Utkast till anteckning - Din video transkodas till flera upplösningar så att tittare får mjuk uppspelning på alla anslutningar. - Välj en video - Publicera HD-video - Publicerar ”%1$s”… - över källa — kommer att hoppas över - %1$d kbps - Versioner - Kommer att skapa: %1$s - (%1$s hoppas över — över källan) - Källupplösning: %1$d×%2$d - Något gick fel - Publicerar händelse… - Din HD-video är live på Nostr. - Video publicerad - Transkodar %1$s - Uppladdade %1$d av %2$d - Laddar upp %1$d av %2$d - Ladda upp - Laddar upp %1$s (%2$d av %3$d) - Titel - Ge din video en titel - Försök igen - Visa anteckning - KeyPackage-relays - Relays där dina MLS KeyPackages publiceras (MIP-00). Andra användare hämtar dessa KeyPackages för att bjuda in dig till Marmot-gruppchatter. Lägg till mellan 1–3 relays som accepterar KeyPackage-händelser från dig och tillåter offentlig läsning. - KeyPackages - %1$d min läsning - MLS-grupp - Grupp - Dina fastnålade anteckningar - Ta bort från favoriter - HLS-uppladdning - Publicera HLS i flera upplösningar till din mediaserver