From f206b0bb46ab540036a89e1119359473088558ec Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 17:07:18 +0000 Subject: [PATCH 01/18] feat: suggest a default list of Nostr web apps in the empty browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a "Discover web apps" section to the browser launcher home, shown after the Recent block, with a hardcoded list of popular Nostr web apps drawn from the nostrapps.com directory. Gives new users (whose Favorites and Recent are empty) somewhere to start instead of a bare empty screen. - New DefaultWebClients in commons (URL + label entries), grouped by category; extensions/signer-only tools are excluded and every URL is a confirmed canonical domain. No remote icons are loaded on the idle screen — favicons are captured the normal way once a site is opened. - Render the list via a new suggestedAppItems grid (long-press offers "Add to favorites"); already-favorited apps are filtered out. - FavoriteAppCell now takes a menu slot so the favorites grid and the suggestions grid can offer different long-press actions. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0151Uczec41LhTogxkgoAhKa --- .../screen/loggedIn/browser/BrowserScreen.kt | 22 ++-- .../loggedIn/favorites/FavoriteAppsScreen.kt | 51 ++++++-- amethyst/src/main/res/values/strings.xml | 1 + .../commons/browser/DefaultWebClients.kt | 114 ++++++++++++++++++ 4 files changed, 168 insertions(+), 20 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/DefaultWebClients.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/BrowserScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/BrowserScreen.kt index 060d5a4be1..666fea3b81 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/BrowserScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/BrowserScreen.kt @@ -71,6 +71,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.browser.DefaultWebClients import com.vitorpamplona.amethyst.commons.browser.OmniboxInput import com.vitorpamplona.amethyst.commons.browser.OmniboxSuggestions import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp @@ -89,6 +90,7 @@ import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.favorites.favoriteAppItems +import com.vitorpamplona.amethyst.ui.screen.loggedIn.favorites.suggestedAppItems import com.vitorpamplona.amethyst.commons.R as CommonsR /** How many of the most recent history entries the idle browser home surfaces under "Recent". */ @@ -209,25 +211,19 @@ private fun BrowserLauncher( onOpen = { open(it.url) }, modifier = contentModifier, ) - apps.isEmpty() && history.isEmpty() -> - Box( - contentModifier.padding(32.dp), - contentAlignment = Alignment.Center, - ) { - Text( - stringResource(R.string.favorite_apps_empty), - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } else -> { val favoriteUrls = remember(apps) { apps.filterIsInstance().mapTo(HashSet()) { it.url } } + // Hardcoded starter web apps, minus any the user already pinned (those show under Favorites). + val suggested = remember(favoriteUrls) { DefaultWebClients.list.filter { it.url !in favoriteUrls } } BrowserHome( apps = apps, history = history, iconKeys = iconKeys, favoriteUrls = favoriteUrls, + suggested = suggested, onOpenApp = { FavoriteAppLauncher.launch(context, it) }, onRemoveApp = { FavoriteAppsRegistry.remove(it.id) }, + onAddApp = { FavoriteAppsRegistry.add(it) }, onOpenUrl = { open(it) }, onToggleRecentFavorite = { entry -> val id = "url:" + entry.url @@ -373,8 +369,10 @@ private fun BrowserHome( history: List, iconKeys: Set, favoriteUrls: Set, + suggested: List, onOpenApp: (FavoriteApp) -> Unit, onRemoveApp: (FavoriteApp) -> Unit, + onAddApp: (FavoriteApp) -> Unit, onOpenUrl: (String) -> Unit, onToggleRecentFavorite: (BrowserHistoryEntry) -> Unit, onRemoveRecent: (String) -> Unit, @@ -405,6 +403,10 @@ private fun BrowserHome( ) } } + if (suggested.isNotEmpty()) { + item(span = { GridItemSpan(maxLineSpan) }, key = "h-sug") { SectionHeader(stringResource(R.string.browser_suggested)) } + suggestedAppItems(suggested, onOpenApp, onAddApp) + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/FavoriteAppsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/FavoriteAppsScreen.kt index 1fc3bbac78..90eda7dbe3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/FavoriteAppsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/FavoriteAppsScreen.kt @@ -167,7 +167,45 @@ fun LazyGridScope.favoriteAppItems( FavoriteAppCell( app = app, onOpen = { onOpen(app) }, - onRemove = { onRemove(app) }, + menu = { dismiss -> + DropdownMenuItem( + text = { Text(stringResource(R.string.favorite_app_remove)) }, + leadingIcon = { Icon(MaterialSymbols.Delete, contentDescription = null) }, + onClick = { + dismiss() + onRemove(app) + }, + ) + }, + ) + } +} + +/** + * Emits the same launch cells for a list of *suggested* web apps (the hardcoded defaults the browser + * home offers under "Discover web apps"). Identical to [favoriteAppItems] except the long-press menu offers + * "Add to favorites" instead of "Remove" — these aren't pinned yet, so tapping star is what the user + * would want next. + */ +fun LazyGridScope.suggestedAppItems( + apps: List, + onOpen: (FavoriteApp) -> Unit, + onAddFavorite: (FavoriteApp) -> Unit, +) { + items(apps, key = { "suggested:" + it.id }) { app -> + FavoriteAppCell( + app = app, + onOpen = { onOpen(app) }, + menu = { dismiss -> + DropdownMenuItem( + text = { Text(stringResource(R.string.favorite_app_add)) }, + leadingIcon = { Icon(MaterialSymbols.StarBorder, contentDescription = null) }, + onClick = { + dismiss() + onAddFavorite(app) + }, + ) + }, ) } } @@ -177,7 +215,7 @@ fun LazyGridScope.favoriteAppItems( internal fun FavoriteAppCell( app: FavoriteApp, onOpen: () -> Unit, - onRemove: () -> Unit, + menu: @Composable (dismiss: () -> Unit) -> Unit, ) { var menuOpen by remember { mutableStateOf(false) } @@ -230,14 +268,7 @@ internal fun FavoriteAppCell( ) DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { - DropdownMenuItem( - text = { Text(stringResource(R.string.favorite_app_remove)) }, - leadingIcon = { Icon(MaterialSymbols.Delete, contentDescription = null) }, - onClick = { - menuOpen = false - onRemove() - }, - ) + menu { menuOpen = false } } } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 55d233659b..2f2342e959 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -675,6 +675,7 @@ Open Clear Favorites + Discover web apps Options Remove from history Web apps diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/DefaultWebClients.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/DefaultWebClients.kt new file mode 100644 index 0000000000..67cf31bbca --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/DefaultWebClients.kt @@ -0,0 +1,114 @@ +/* + * 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.browser + +import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp + +/** + * The Nostr **web apps** offered as starting points in the browser launcher when the user hasn't + * pinned or visited anything of their own yet. They are shown under a "Discover" section below Recent, + * so they're discoverable without ever getting in the way of the user's own favorites/history. + * + * The list is drawn from the [nostrapps.com](https://nostrapps.com) directory — every entry that ships + * a browser-openable web version — grouped here by what it does. Entries that are browser *extensions* + * or signer-only tools (nos2x, Nostrame, …) are intentionally left out: they aren't something you open + * in an in-app browser. URLs are the apps' own canonical domains; an entry is only included when its + * URL could be confirmed, so a stale/guessed link never ships. + * + * These are plain [FavoriteApp.WebApp] entries (URL + label), so tapping one opens it like any other + * web favorite and the user can star it to pin it for real. They are **not** persisted as favorites: + * the list is hardcoded, device-local, and never becomes account state. + * + * No remote icon is set ([FavoriteApp.iconUrl] is null on purpose): the idle launcher must not phone + * home to dozens of third-party servers before the user has chosen anything. Each site's real favicon + * is captured the normal way once the user actually opens it; until then the entry shows the globe glyph. + */ +object DefaultWebClients { + val list: List = + buildList { + // Social / microblogging clients + webApp("Primal", "https://primal.net") + webApp("Coracle", "https://coracle.social") + webApp("Snort", "https://snort.social") + webApp("noStrudel", "https://nostrudel.ninja") + webApp("Iris", "https://iris.to") + webApp("Nostter", "https://nostter.app") + webApp("Jumble", "https://jumble.social") + webApp("Nostria", "https://nostria.app") + webApp("Nosotros", "https://nosotros.app") + webApp("lumilumi", "https://lumilumi.app") + webApp("Phoenix", "https://phoenix.social") + webApp("Shosho", "https://shosho.live") + webApp("ants", "https://ants.sh") + webApp("YakiHonne", "https://yakihonne.com") + webApp("Ditto", "https://ditto.pub") + + // Reading / long-form / feeds + webApp("Habla", "https://habla.news") + webApp("Highlighter", "https://highlighter.com") + webApp("Boris", "https://readwithboris.com") + webApp("Noflux", "https://noflux.nostr.technology") + + // Communities / chat + webApp("Flotilla", "https://flotilla.social") + webApp("Chachi", "https://chachi.chat") + webApp("NostrChat", "https://www.nostrchat.io") + + // Media — video, photo, audio, files + webApp("zap.stream", "https://zap.stream") + webApp("Olas", "https://olas.app") + webApp("Slidestr", "https://slidestr.net") + webApp("Bouquet", "https://bouquet.slidestr.net") + webApp("YakBak", "https://yakbak.app") + webApp("Nests", "https://nostrnests.com") + + // Knowledge / wiki + webApp("Wikifreedia", "https://wikifreedia.xyz") + webApp("Wikistr", "https://wikistr.com") + + // Marketplace + webApp("Shopstr", "https://shopstr.store") + webApp("Plebeian Market", "https://plebeian.market") + + // Tools / utilities + webApp("Emojito", "https://emojito.meme") + webApp("Formstr", "https://formstr.app") + webApp("Nostree", "https://nostree.me") + webApp("Badges", "https://badges.page") + webApp("Nstart", "https://nstart.me") + webApp("alphaama", "https://alphaama.com") + webApp("Treasures", "https://treasures.to") + webApp("Yondar", "https://yondar.me") + webApp("DTAN", "https://dtan.xyz") + webApp("Nostrocket", "https://nostrocket.org") + webApp("MAKIMONO", "https://makimono.lumilumi.app") + webApp("Primal Studio", "https://studio.primal.net") + } + + // addedAt is 0L: these are hardcoded suggestions, not user-added favorites, so they never need a + // real "added" timestamp for ordering — the curated order in `list` is what matters. + private fun MutableList.webApp( + label: String, + url: String, + ) { + add(FavoriteApp.WebApp(url = url, label = label, addedAt = 0L)) + } +} From 5029accceea7a132e8a312cd6bea10b56d9b43e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 18:12:18 +0000 Subject: [PATCH 02/18] feat: expand suggested web apps list with icons and reachability check Grow the browser "Discover web apps" list to the full set of browser-openable Nostr web apps from the nostrapps.com directory plus several requested additions, and give each entry its own logo. - Each suggestion now carries iconUrl set to the app's own declared apple-touch-icon / icon (PNG or SVG, individually verified to return an image), so the grid matches the favicon look of Favorites/Recent without any third-party favicon service. Apps whose only icon is an ICO (Coil has no ICO decoder) or that couldn't be resolved stay icon-less and fall back to the globe glyph until their favicon is captured on first visit. - Added: nymchat, nostr.build, nostrcheck, zap.cooking, x21, divine.video, brainstorm, zappix, plektos, zaptrax, zaplytics, podstr, ghostr, mutable, metadata, plebsvszombies, blobbi. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0151Uczec41LhTogxkgoAhKa --- .../commons/browser/DefaultWebClients.kt | 110 +++++++++++------- 1 file changed, 66 insertions(+), 44 deletions(-) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/DefaultWebClients.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/DefaultWebClients.kt index 67cf31bbca..b2459d69cb 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/DefaultWebClients.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/DefaultWebClients.kt @@ -30,85 +30,107 @@ import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp * The list is drawn from the [nostrapps.com](https://nostrapps.com) directory — every entry that ships * a browser-openable web version — grouped here by what it does. Entries that are browser *extensions* * or signer-only tools (nos2x, Nostrame, …) are intentionally left out: they aren't something you open - * in an in-app browser. URLs are the apps' own canonical domains; an entry is only included when its - * URL could be confirmed, so a stale/guessed link never ships. + * in an in-app browser. URLs are the apps' own canonical domains. * - * These are plain [FavoriteApp.WebApp] entries (URL + label), so tapping one opens it like any other - * web favorite and the user can star it to pin it for real. They are **not** persisted as favorites: - * the list is hardcoded, device-local, and never becomes account state. + * Each [icon] is the app's **own** logo (the PNG/SVG declared in its `` / + * ``), so the grid matches the favicon look of the Favorites/Recent rows without + * routing through any third-party favicon service. Only PNG/SVG are used — Coil has no ICO decoder, so + * an apps whose only icon is a `.ico` (and a few that couldn't be resolved) are left [icon]-less and + * fall back to the globe glyph until their real favicon is captured the normal way on first visit. * - * No remote icon is set ([FavoriteApp.iconUrl] is null on purpose): the idle launcher must not phone - * home to dozens of third-party servers before the user has chosen anything. Each site's real favicon - * is captured the normal way once the user actually opens it; until then the entry shows the globe glyph. + * These are plain [FavoriteApp.WebApp] entries, so tapping one opens it like any other web favorite and + * the user can star it to pin it for real. They are **not** persisted as favorites: the list is + * hardcoded, device-local, and never becomes account state. */ object DefaultWebClients { val list: List = buildList { // Social / microblogging clients - webApp("Primal", "https://primal.net") - webApp("Coracle", "https://coracle.social") - webApp("Snort", "https://snort.social") - webApp("noStrudel", "https://nostrudel.ninja") - webApp("Iris", "https://iris.to") - webApp("Nostter", "https://nostter.app") - webApp("Jumble", "https://jumble.social") - webApp("Nostria", "https://nostria.app") - webApp("Nosotros", "https://nosotros.app") - webApp("lumilumi", "https://lumilumi.app") - webApp("Phoenix", "https://phoenix.social") - webApp("Shosho", "https://shosho.live") - webApp("ants", "https://ants.sh") + webApp("Primal", "https://primal.net", "https://primal.net/assets/apple-touch-icon-a536f430.png") + webApp("Coracle", "https://coracle.social", "https://coracle.social/icons/apple-touch-icon-76x76.png") + webApp("Snort", "https://snort.social", "https://snort.social/img/apple-touch-icon.png") + webApp("noStrudel", "https://nostrudel.ninja", "https://nostrudel.ninja/apple-touch-icon.png") + webApp("Iris", "https://iris.to", "https://iris.to/img/apple-touch-icon.png") + webApp("Nostter", "https://nostter.app", "https://nostter.app/apple-touch-icon.png") + webApp("Jumble", "https://jumble.social", "https://jumble.social/favicon.svg") + webApp("Nostria", "https://nostria.app", "https://nostria.app/icons/icon-192x192-maskable.png") + webApp("Nosotros", "https://nosotros.app", "https://nosotros.app/apple-touch-icon-144x144.png") + webApp("lumilumi", "https://lumilumi.app", "https://lumilumi.app/apple-touch-icon-180x180.png") + webApp("Phoenix", "https://phoenix.social", "https://phoenix.social/img/apple-touch-icon.png") + webApp("Shosho", "https://shosho.live", "https://shosho.live/apple-touch-icon.png") + webApp("ants", "https://ants.sh", "https://ants.sh/apple-touch-icon.png") webApp("YakiHonne", "https://yakihonne.com") - webApp("Ditto", "https://ditto.pub") + webApp("Ditto", "https://ditto.pub", "https://ditto.pub/apple-touch-icon.png") + webApp("x21", "https://x21.social", "https://x21.social/apple-touch-icon.png?v=4") + webApp("Ghostr", "https://ghostr.org", "https://ghostr.org/favicon/apple-touch-icon.png") + webApp("Mutable", "https://mutable.top", "https://mutable.top/mutable_logo.svg") // Reading / long-form / feeds webApp("Habla", "https://habla.news") - webApp("Highlighter", "https://highlighter.com") - webApp("Boris", "https://readwithboris.com") + webApp("Highlighter", "https://highlighter.com", "https://highlighter.com/apple-touch-icon-180x180.png") + webApp("Boris", "https://readwithboris.com", "https://readwithboris.com/apple-touch-icon.png") webApp("Noflux", "https://noflux.nostr.technology") // Communities / chat - webApp("Flotilla", "https://flotilla.social") + webApp("Flotilla", "https://flotilla.social", "https://framerusercontent.com/images/8UjnVxSvRkmvY2lEYU5z8OMOw0M.png") webApp("Chachi", "https://chachi.chat") - webApp("NostrChat", "https://www.nostrchat.io") + webApp("NostrChat", "https://www.nostrchat.io", "https://www.nostrchat.io/logo192.png") + webApp("NymChat", "https://www.nymchat.com") - // Media — video, photo, audio, files - webApp("zap.stream", "https://zap.stream") - webApp("Olas", "https://olas.app") - webApp("Slidestr", "https://slidestr.net") - webApp("Bouquet", "https://bouquet.slidestr.net") - webApp("YakBak", "https://yakbak.app") - webApp("Nests", "https://nostrnests.com") + // Media — video, photo, audio, podcasts, files + webApp("zap.stream", "https://zap.stream", "https://zap.stream/logo.png") + webApp("Divine Video", "https://divine.video", "https://divine.video/app_icon.png") + webApp("Olas", "https://olas.app", "https://olas.app/favicon.png") + webApp("Zappix", "https://zappix.app", "https://zappix.app/icon-192.png") + webApp("Slidestr", "https://slidestr.net", "https://slidestr.net/slidestr.svg") + webApp("Bouquet", "https://bouquet.slidestr.net", "https://bouquet.slidestr.net/bouquet.png") + webApp("YakBak", "https://yakbak.app", "https://yakbak.app/yakbak-logo.png") + webApp("ZapTrax", "https://zaptrax.app", "https://zaptrax.app/icon-192.png") + webApp("Podstr", "https://podstr.org", "https://podstr.org/favicon.svg") + webApp("Nests", "https://nostrnests.com", "https://nostrnests.com/apple-touch-icon.png") // Knowledge / wiki - webApp("Wikifreedia", "https://wikifreedia.xyz") - webApp("Wikistr", "https://wikistr.com") + webApp("Wikifreedia", "https://wikifreedia.xyz", "https://wikifreedia.xyz/favicon.svg") + webApp("Wikistr", "https://wikistr.com", "https://wikistr.com/favicon.png") - // Marketplace + // Marketplace / food webApp("Shopstr", "https://shopstr.store") - webApp("Plebeian Market", "https://plebeian.market") + webApp("Plebeian Market", "https://plebeian.market", "https://plebeian.market/logo-st5zpap9.svg") + webApp("Zap Cooking", "https://zap.cooking", "https://zap.cooking/favicon.svg") // Tools / utilities webApp("Emojito", "https://emojito.meme") - webApp("Formstr", "https://formstr.app") + webApp("Formstr", "https://formstr.app", "https://formstr.app/logo192.png") webApp("Nostree", "https://nostree.me") webApp("Badges", "https://badges.page") - webApp("Nstart", "https://nstart.me") + webApp("Nstart", "https://nstart.me", "https://nstart.me/favicon.png") webApp("alphaama", "https://alphaama.com") - webApp("Treasures", "https://treasures.to") - webApp("Yondar", "https://yondar.me") + webApp("Treasures", "https://treasures.to", "https://treasures.to/apple-touch-icon.png") + webApp("Yondar", "https://yondar.me", "https://yondar.me/apple-touch-icon.png") webApp("DTAN", "https://dtan.xyz") webApp("Nostrocket", "https://nostrocket.org") - webApp("MAKIMONO", "https://makimono.lumilumi.app") + webApp("Plektos", "https://plektos.app", "https://plektos.app/icon-180.png") + webApp("Zaplytics", "https://zaplytics.app") + webApp("Brainstorm", "https://brainstorm.world", "https://brainstorm.world/brainstorm.svg") + webApp("MAKIMONO", "https://makimono.lumilumi.app", "https://makimono.lumilumi.app/favicon3.png") webApp("Primal Studio", "https://studio.primal.net") + webApp("nostr.build", "https://nostr.build", "https://nostr.build/apple-touch-icon.png") + webApp("nostrcheck", "https://nostrcheck.me", "https://nostrcheck.me/apple-touch-icon.png") + webApp("Metadata", "https://metadata.nostr.com") + + // Games + webApp("Plebs vs Zombies", "https://www.plebsvszombies.cc", "https://www.plebsvszombies.cc/favicon.svg") + webApp("Blobbi", "https://www.blobbi.pet", "https://www.blobbi.pet/icons/apple-touch-icon.png") } // addedAt is 0L: these are hardcoded suggestions, not user-added favorites, so they never need a - // real "added" timestamp for ordering — the curated order in `list` is what matters. + // real "added" timestamp for ordering — the curated order in `list` is what matters. `icon` is the + // app's own logo URL, or null to fall back to the globe glyph until a favicon is captured on visit. private fun MutableList.webApp( label: String, url: String, + icon: String? = null, ) { - add(FavoriteApp.WebApp(url = url, label = label, addedAt = 0L)) + add(FavoriteApp.WebApp(url = url, label = label, addedAt = 0L, iconUrl = icon)) } } From 2d6221fd10d1a60f26edc73bc78c276f78a434cd Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 18:34:53 +0000 Subject: [PATCH 03/18] feat: show curated descriptions for discover web apps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Render the browser "Discover" section as full-width rows (icon + name + one-line description) instead of bare icon cells, matching the Recent row layout which already carries a subtitle. Each suggestion now has a short curated description (trimmed from the app's own meta description) so unfamiliar apps explain themselves; tapping a row opens the app, and a trailing star pins it to favorites. Auto-pulling page /description was rejected: many of these apps are client-rendered SPAs that serve an empty <title>, and several titles are long marketing strings — curated short names read better in the list. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0151Uczec41LhTogxkgoAhKa --- .../screen/loggedIn/browser/BrowserScreen.kt | 62 ++++++- .../loggedIn/favorites/FavoriteAppsScreen.kt | 51 ++---- .../commons/browser/DefaultWebClients.kt | 166 ++++++++++-------- 3 files changed, 158 insertions(+), 121 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/BrowserScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/BrowserScreen.kt index 666fea3b81..eea38b3ca2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/BrowserScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/BrowserScreen.kt @@ -74,7 +74,9 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.browser.DefaultWebClients import com.vitorpamplona.amethyst.commons.browser.OmniboxInput import com.vitorpamplona.amethyst.commons.browser.OmniboxSuggestions +import com.vitorpamplona.amethyst.commons.browser.SuggestedWebApp import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp +import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.icons.symbols.rememberMaterialSymbolPainter @@ -90,7 +92,6 @@ import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.favorites.favoriteAppItems -import com.vitorpamplona.amethyst.ui.screen.loggedIn.favorites.suggestedAppItems import com.vitorpamplona.amethyst.commons.R as CommonsR /** How many of the most recent history entries the idle browser home surfaces under "Recent". */ @@ -214,7 +215,7 @@ private fun BrowserLauncher( else -> { val favoriteUrls = remember(apps) { apps.filterIsInstance<FavoriteApp.WebApp>().mapTo(HashSet()) { it.url } } // Hardcoded starter web apps, minus any the user already pinned (those show under Favorites). - val suggested = remember(favoriteUrls) { DefaultWebClients.list.filter { it.url !in favoriteUrls } } + val suggested = remember(favoriteUrls) { DefaultWebClients.list.filter { it.app.url !in favoriteUrls } } BrowserHome( apps = apps, history = history, @@ -369,7 +370,7 @@ private fun BrowserHome( history: List<BrowserHistoryEntry>, iconKeys: Set<String>, favoriteUrls: Set<String>, - suggested: List<FavoriteApp.WebApp>, + suggested: List<SuggestedWebApp>, onOpenApp: (FavoriteApp) -> Unit, onRemoveApp: (FavoriteApp) -> Unit, onAddApp: (FavoriteApp) -> Unit, @@ -405,7 +406,60 @@ private fun BrowserHome( } if (suggested.isNotEmpty()) { item(span = { GridItemSpan(maxLineSpan) }, key = "h-sug") { SectionHeader(stringResource(R.string.browser_suggested)) } - suggestedAppItems(suggested, onOpenApp, onAddApp) + items(suggested, span = { GridItemSpan(maxLineSpan) }, key = { "s:" + it.app.url }) { entry -> + SuggestedRow( + entry = entry, + iconKeys = iconKeys, + onClick = { onOpenApp(entry.app) }, + onAddFavorite = { onAddApp(entry.app) }, + ) + } + } + } +} + +/** A Discover row: the app's own icon, its name, and a one-line description, with a star to pin it. */ +@Composable +private fun SuggestedRow( + entry: SuggestedWebApp, + iconKeys: Set<String>, + onClick: () -> Unit, + onAddFavorite: () -> Unit, +) { + val iconModel = remember(entry, iconKeys) { OmniboxInput.hostOf(entry.app.url)?.let(BrowserIconRegistry::iconModelFor) } + Row( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable(onClick = onClick) + .padding(start = 8.dp, top = 4.dp, bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + FavoriteAppIcon( + app = entry.app, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(28.dp), + iconModel = iconModel, + ) + Spacer(Modifier.width(16.dp)) + Column(Modifier.weight(1f)) { + Text( + entry.app.label, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + entry.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + IconButton(onClick = onAddFavorite) { + Icon(MaterialSymbols.StarBorder, contentDescription = stringResource(R.string.favorite_app_add)) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/FavoriteAppsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/FavoriteAppsScreen.kt index 90eda7dbe3..1fc3bbac78 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/FavoriteAppsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/FavoriteAppsScreen.kt @@ -167,45 +167,7 @@ fun LazyGridScope.favoriteAppItems( FavoriteAppCell( app = app, onOpen = { onOpen(app) }, - menu = { dismiss -> - DropdownMenuItem( - text = { Text(stringResource(R.string.favorite_app_remove)) }, - leadingIcon = { Icon(MaterialSymbols.Delete, contentDescription = null) }, - onClick = { - dismiss() - onRemove(app) - }, - ) - }, - ) - } -} - -/** - * Emits the same launch cells for a list of *suggested* web apps (the hardcoded defaults the browser - * home offers under "Discover web apps"). Identical to [favoriteAppItems] except the long-press menu offers - * "Add to favorites" instead of "Remove" — these aren't pinned yet, so tapping star is what the user - * would want next. - */ -fun LazyGridScope.suggestedAppItems( - apps: List<FavoriteApp>, - onOpen: (FavoriteApp) -> Unit, - onAddFavorite: (FavoriteApp) -> Unit, -) { - items(apps, key = { "suggested:" + it.id }) { app -> - FavoriteAppCell( - app = app, - onOpen = { onOpen(app) }, - menu = { dismiss -> - DropdownMenuItem( - text = { Text(stringResource(R.string.favorite_app_add)) }, - leadingIcon = { Icon(MaterialSymbols.StarBorder, contentDescription = null) }, - onClick = { - dismiss() - onAddFavorite(app) - }, - ) - }, + onRemove = { onRemove(app) }, ) } } @@ -215,7 +177,7 @@ fun LazyGridScope.suggestedAppItems( internal fun FavoriteAppCell( app: FavoriteApp, onOpen: () -> Unit, - menu: @Composable (dismiss: () -> Unit) -> Unit, + onRemove: () -> Unit, ) { var menuOpen by remember { mutableStateOf(false) } @@ -268,7 +230,14 @@ internal fun FavoriteAppCell( ) DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { - menu { menuOpen = false } + DropdownMenuItem( + text = { Text(stringResource(R.string.favorite_app_remove)) }, + leadingIcon = { Icon(MaterialSymbols.Delete, contentDescription = null) }, + onClick = { + menuOpen = false + onRemove() + }, + ) } } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/DefaultWebClients.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/DefaultWebClients.kt index b2459d69cb..c80acc53ae 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/DefaultWebClients.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/DefaultWebClients.kt @@ -20,117 +20,131 @@ */ package com.vitorpamplona.amethyst.commons.browser +import androidx.compose.runtime.Immutable import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp +/** One suggested web app: the launchable [app] plus a short [description] of what it does. */ +@Immutable +data class SuggestedWebApp( + val app: FavoriteApp.WebApp, + val description: String, +) + /** * The Nostr **web apps** offered as starting points in the browser launcher when the user hasn't * pinned or visited anything of their own yet. They are shown under a "Discover" section below Recent, * so they're discoverable without ever getting in the way of the user's own favorites/history. * - * The list is drawn from the [nostrapps.com](https://nostrapps.com) directory — every entry that ships - * a browser-openable web version — grouped here by what it does. Entries that are browser *extensions* - * or signer-only tools (nos2x, Nostrame, …) are intentionally left out: they aren't something you open - * in an in-app browser. URLs are the apps' own canonical domains. + * The list is drawn from the [nostrapps.com](https://nostrapps.com) directory (cross-checked against + * [awesome-nostr](https://github.com/aljazceru/awesome-nostr)) — every entry that ships a + * browser-openable web version — grouped here by what it does. Entries that are browser *extensions* or + * signer-only tools (nos2x, Nostrame, …) are intentionally left out: they aren't something you open in + * an in-app browser. URLs are the apps' own canonical domains. * - * Each [icon] is the app's **own** logo (the PNG/SVG declared in its `<link rel="apple-touch-icon">` / - * `<link rel="icon">`), so the grid matches the favicon look of the Favorites/Recent rows without - * routing through any third-party favicon service. Only PNG/SVG are used — Coil has no ICO decoder, so - * an apps whose only icon is a `.ico` (and a few that couldn't be resolved) are left [icon]-less and - * fall back to the globe glyph until their real favicon is captured the normal way on first visit. + * Each entry carries: + * - a short curated [SuggestedWebApp.description] (a trimmed version of the app's own meta description) + * shown as the row subtitle, since these are apps the user likely hasn't seen before; + * - the app's **own** logo as [FavoriteApp.iconUrl] (the PNG/SVG declared in its + * `<link rel="apple-touch-icon">` / `<link rel="icon">`), so it matches the favicon look of the + * Favorites/Recent rows without routing through any third-party favicon service. Only PNG/SVG are + * used — Coil has no ICO decoder — so apps whose only icon is a `.ico` (and a few that couldn't be + * resolved) are left icon-less and fall back to the globe glyph until their real favicon is captured + * the normal way on first visit. * - * These are plain [FavoriteApp.WebApp] entries, so tapping one opens it like any other web favorite and - * the user can star it to pin it for real. They are **not** persisted as favorites: the list is - * hardcoded, device-local, and never becomes account state. + * The [app]s are plain [FavoriteApp.WebApp] entries, so tapping one opens it like any other web + * favorite and the user can star it to pin it for real. They are **not** persisted as favorites: the + * list is hardcoded, device-local, and never becomes account state. */ object DefaultWebClients { - val list: List<FavoriteApp.WebApp> = + val list: List<SuggestedWebApp> = buildList { // Social / microblogging clients - webApp("Primal", "https://primal.net", "https://primal.net/assets/apple-touch-icon-a536f430.png") - webApp("Coracle", "https://coracle.social", "https://coracle.social/icons/apple-touch-icon-76x76.png") - webApp("Snort", "https://snort.social", "https://snort.social/img/apple-touch-icon.png") - webApp("noStrudel", "https://nostrudel.ninja", "https://nostrudel.ninja/apple-touch-icon.png") - webApp("Iris", "https://iris.to", "https://iris.to/img/apple-touch-icon.png") - webApp("Nostter", "https://nostter.app", "https://nostter.app/apple-touch-icon.png") - webApp("Jumble", "https://jumble.social", "https://jumble.social/favicon.svg") - webApp("Nostria", "https://nostria.app", "https://nostria.app/icons/icon-192x192-maskable.png") - webApp("Nosotros", "https://nosotros.app", "https://nosotros.app/apple-touch-icon-144x144.png") - webApp("lumilumi", "https://lumilumi.app", "https://lumilumi.app/apple-touch-icon-180x180.png") - webApp("Phoenix", "https://phoenix.social", "https://phoenix.social/img/apple-touch-icon.png") - webApp("Shosho", "https://shosho.live", "https://shosho.live/apple-touch-icon.png") - webApp("ants", "https://ants.sh", "https://ants.sh/apple-touch-icon.png") - webApp("YakiHonne", "https://yakihonne.com") - webApp("Ditto", "https://ditto.pub", "https://ditto.pub/apple-touch-icon.png") - webApp("x21", "https://x21.social", "https://x21.social/apple-touch-icon.png?v=4") - webApp("Ghostr", "https://ghostr.org", "https://ghostr.org/favicon/apple-touch-icon.png") - webApp("Mutable", "https://mutable.top", "https://mutable.top/mutable_logo.svg") + webApp("Primal", "https://primal.net", "All-in-one client with a built-in wallet", "https://primal.net/assets/apple-touch-icon-a536f430.png") + webApp("Coracle", "https://coracle.social", "Relay-savvy client for regular people", "https://coracle.social/icons/apple-touch-icon-76x76.png") + webApp("Snort", "https://snort.social", "Fast, feature-packed social client", "https://snort.social/img/apple-touch-icon.png") + webApp("noStrudel", "https://nostrudel.ninja", "Power-user client for exploring Nostr", "https://nostrudel.ninja/apple-touch-icon.png") + webApp("Iris", "https://iris.to", "Simple, fast social client", "https://iris.to/img/apple-touch-icon.png") + webApp("Nostter", "https://nostter.app", "Lightweight web social client", "https://nostter.app/apple-touch-icon.png") + webApp("Jumble", "https://jumble.social", "Explore feeds relay by relay", "https://jumble.social/favicon.svg") + webApp("Nostria", "https://nostria.app", "Social without the noise", "https://nostria.app/icons/icon-192x192-maskable.png") + webApp("Nosotros", "https://nosotros.app", "A weirdly fast social client", "https://nosotros.app/apple-touch-icon-144x144.png") + webApp("lumilumi", "https://lumilumi.app", "Lightweight Nostr client", "https://lumilumi.app/apple-touch-icon-180x180.png") + webApp("Phoenix", "https://phoenix.social", "Snort-based social client", "https://phoenix.social/img/apple-touch-icon.png") + webApp("Shosho", "https://shosho.live", "Live-streaming marketplace", "https://shosho.live/apple-touch-icon.png") + webApp("ants", "https://ants.sh", "Advanced Nostr text search", "https://ants.sh/apple-touch-icon.png") + webApp("YakiHonne", "https://yakihonne.com", "Decentralized media & long-form") + webApp("Ditto", "https://ditto.pub", "Your content, your vibe, your rules", "https://ditto.pub/apple-touch-icon.png") + webApp("x21", "https://x21.social", "Relay feed explorer", "https://x21.social/apple-touch-icon.png?v=4") + webApp("Ghostr", "https://ghostr.org", "Draft & delegated publishing", "https://ghostr.org/favicon/apple-touch-icon.png") + webApp("Mutable", "https://mutable.top", "Your mute list manager", "https://mutable.top/mutable_logo.svg") // Reading / long-form / feeds - webApp("Habla", "https://habla.news") - webApp("Highlighter", "https://highlighter.com", "https://highlighter.com/apple-touch-icon-180x180.png") - webApp("Boris", "https://readwithboris.com", "https://readwithboris.com/apple-touch-icon.png") - webApp("Noflux", "https://noflux.nostr.technology") + webApp("Habla", "https://habla.news", "Long-form articles & blogs") + webApp("Highlighter", "https://highlighter.com", "Articles, highlights & communities", "https://highlighter.com/apple-touch-icon-180x180.png") + webApp("Boris", "https://readwithboris.com", "Distraction-free reading & highlights", "https://readwithboris.com/apple-touch-icon.png") + webApp("Noflux", "https://noflux.nostr.technology", "RSS-style feed reader") // Communities / chat - webApp("Flotilla", "https://flotilla.social", "https://framerusercontent.com/images/8UjnVxSvRkmvY2lEYU5z8OMOw0M.png") - webApp("Chachi", "https://chachi.chat") - webApp("NostrChat", "https://www.nostrchat.io", "https://www.nostrchat.io/logo192.png") - webApp("NymChat", "https://www.nymchat.com") + webApp("Flotilla", "https://flotilla.social", "Community spaces & chat", "https://framerusercontent.com/images/8UjnVxSvRkmvY2lEYU5z8OMOw0M.png") + webApp("Chachi", "https://chachi.chat", "Group chat & communities") + webApp("NostrChat", "https://www.nostrchat.io", "Decentralized chat", "https://www.nostrchat.io/logo192.png") + webApp("NymChat", "https://www.nymchat.com", "Anonymous, ephemeral chat") // Media — video, photo, audio, podcasts, files - webApp("zap.stream", "https://zap.stream", "https://zap.stream/logo.png") - webApp("Divine Video", "https://divine.video", "https://divine.video/app_icon.png") - webApp("Olas", "https://olas.app", "https://olas.app/favicon.png") - webApp("Zappix", "https://zappix.app", "https://zappix.app/icon-192.png") - webApp("Slidestr", "https://slidestr.net", "https://slidestr.net/slidestr.svg") - webApp("Bouquet", "https://bouquet.slidestr.net", "https://bouquet.slidestr.net/bouquet.png") - webApp("YakBak", "https://yakbak.app", "https://yakbak.app/yakbak-logo.png") - webApp("ZapTrax", "https://zaptrax.app", "https://zaptrax.app/icon-192.png") - webApp("Podstr", "https://podstr.org", "https://podstr.org/favicon.svg") - webApp("Nests", "https://nostrnests.com", "https://nostrnests.com/apple-touch-icon.png") + webApp("zap.stream", "https://zap.stream", "Live streaming with Lightning", "https://zap.stream/logo.png") + webApp("Divine Video", "https://divine.video", "6-second looping videos", "https://divine.video/app_icon.png") + webApp("Olas", "https://olas.app", "Photo & media sharing", "https://olas.app/favicon.png") + webApp("Zappix", "https://zappix.app", "Share & discover images", "https://zappix.app/icon-192.png") + webApp("Slidestr", "https://slidestr.net", "Media slideshow viewer", "https://slidestr.net/slidestr.svg") + webApp("Bouquet", "https://bouquet.slidestr.net", "Blossom media manager", "https://bouquet.slidestr.net/bouquet.png") + webApp("YakBak", "https://yakbak.app", "Voice messages", "https://yakbak.app/yakbak-logo.png") + webApp("ZapTrax", "https://zaptrax.app", "Music streaming with Wavlake", "https://zaptrax.app/icon-192.png") + webApp("Podstr", "https://podstr.org", "Podcasts on Nostr", "https://podstr.org/favicon.svg") + webApp("Nests", "https://nostrnests.com", "Live audio rooms", "https://nostrnests.com/apple-touch-icon.png") // Knowledge / wiki - webApp("Wikifreedia", "https://wikifreedia.xyz", "https://wikifreedia.xyz/favicon.svg") - webApp("Wikistr", "https://wikistr.com", "https://wikistr.com/favicon.png") + webApp("Wikifreedia", "https://wikifreedia.xyz", "Decentralized encyclopedia", "https://wikifreedia.xyz/favicon.svg") + webApp("Wikistr", "https://wikistr.com", "A wiki built on Nostr", "https://wikistr.com/favicon.png") // Marketplace / food - webApp("Shopstr", "https://shopstr.store") - webApp("Plebeian Market", "https://plebeian.market", "https://plebeian.market/logo-st5zpap9.svg") - webApp("Zap Cooking", "https://zap.cooking", "https://zap.cooking/favicon.svg") + webApp("Shopstr", "https://shopstr.store", "Bitcoin-native marketplace") + webApp("Plebeian Market", "https://plebeian.market", "Decentralized marketplace", "https://plebeian.market/logo-st5zpap9.svg") + webApp("Zap Cooking", "https://zap.cooking", "Recipes & food culture", "https://zap.cooking/favicon.svg") // Tools / utilities - webApp("Emojito", "https://emojito.meme") - webApp("Formstr", "https://formstr.app", "https://formstr.app/logo192.png") - webApp("Nostree", "https://nostree.me") - webApp("Badges", "https://badges.page") - webApp("Nstart", "https://nstart.me", "https://nstart.me/favicon.png") - webApp("alphaama", "https://alphaama.com") - webApp("Treasures", "https://treasures.to", "https://treasures.to/apple-touch-icon.png") - webApp("Yondar", "https://yondar.me", "https://yondar.me/apple-touch-icon.png") - webApp("DTAN", "https://dtan.xyz") - webApp("Nostrocket", "https://nostrocket.org") - webApp("Plektos", "https://plektos.app", "https://plektos.app/icon-180.png") - webApp("Zaplytics", "https://zaplytics.app") - webApp("Brainstorm", "https://brainstorm.world", "https://brainstorm.world/brainstorm.svg") - webApp("MAKIMONO", "https://makimono.lumilumi.app", "https://makimono.lumilumi.app/favicon3.png") - webApp("Primal Studio", "https://studio.primal.net") - webApp("nostr.build", "https://nostr.build", "https://nostr.build/apple-touch-icon.png") - webApp("nostrcheck", "https://nostrcheck.me", "https://nostrcheck.me/apple-touch-icon.png") - webApp("Metadata", "https://metadata.nostr.com") + webApp("Emojito", "https://emojito.meme", "Custom emoji sets") + webApp("Formstr", "https://formstr.app", "Decentralized forms", "https://formstr.app/logo192.png") + webApp("Nostree", "https://nostree.me", "Link-in-bio pages") + webApp("Badges", "https://badges.page", "Create & award badges") + webApp("Nstart", "https://nstart.me", "Guided account onboarding", "https://nstart.me/favicon.png") + webApp("alphaama", "https://alphaama.com", "Nostr tools & experiments") + webApp("Treasures", "https://treasures.to", "Geocaching on Nostr", "https://treasures.to/apple-touch-icon.png") + webApp("Yondar", "https://yondar.me", "Places & maps", "https://yondar.me/apple-touch-icon.png") + webApp("DTAN", "https://dtan.xyz", "Torrents on Nostr") + webApp("Nostrocket", "https://nostrocket.org", "Project coordination") + webApp("Plektos", "https://plektos.app", "Decentralized meetup events", "https://plektos.app/icon-180.png") + webApp("Zaplytics", "https://zaplytics.app", "Zap analytics for creators") + webApp("Brainstorm", "https://brainstorm.world", "Web-of-trust explorer", "https://brainstorm.world/brainstorm.svg") + webApp("MAKIMONO", "https://makimono.lumilumi.app", "Long-form article editor", "https://makimono.lumilumi.app/favicon3.png") + webApp("Primal Studio", "https://studio.primal.net", "Schedule & publish content") + webApp("nostr.build", "https://nostr.build", "Media & image uploads", "https://nostr.build/apple-touch-icon.png") + webApp("nostrcheck", "https://nostrcheck.me", "Media hosting & NIP-05", "https://nostrcheck.me/apple-touch-icon.png") + webApp("Metadata", "https://metadata.nostr.com", "Edit your profile metadata") // Games - webApp("Plebs vs Zombies", "https://www.plebsvszombies.cc", "https://www.plebsvszombies.cc/favicon.svg") - webApp("Blobbi", "https://www.blobbi.pet", "https://www.blobbi.pet/icons/apple-touch-icon.png") + webApp("Plebs vs Zombies", "https://www.plebsvszombies.cc", "Clean up your follow list", "https://www.plebsvszombies.cc/favicon.svg") + webApp("Blobbi", "https://www.blobbi.pet", "A virtual pet game", "https://www.blobbi.pet/icons/apple-touch-icon.png") } // addedAt is 0L: these are hardcoded suggestions, not user-added favorites, so they never need a // real "added" timestamp for ordering — the curated order in `list` is what matters. `icon` is the // app's own logo URL, or null to fall back to the globe glyph until a favicon is captured on visit. - private fun MutableList<FavoriteApp.WebApp>.webApp( + private fun MutableList<SuggestedWebApp>.webApp( label: String, url: String, + description: String, icon: String? = null, ) { - add(FavoriteApp.WebApp(url = url, label = label, addedAt = 0L, iconUrl = icon)) + add(SuggestedWebApp(FavoriteApp.WebApp(url = url, label = label, addedAt = 0L, iconUrl = icon), description)) } } From 152f81602c68ee8b70b798b2c2ee17ef31cbe4e1 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 27 Jun 2026 18:38:20 +0000 Subject: [PATCH 04/18] fix: align cashu nutzap icon with reaction icons in notifications The nutzap gallery in MultiSetCompose used WidthAuthorPictureModifier (55dp, flush-right) for its cashu icon column, while the like/boost reaction galleries above it use NotificationIconModifier (55dp with a 5dp end inset). That left the cashu glyph sitting ~5dp further right than the reactions it stacks under. Switch the cashu Box to the same NotificationIconModifier so the icons line up. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011qQdaD3DHRQNDKe2BUnEiM --- .../com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt index 5cd99c5e0f..36ef874ded 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt @@ -451,7 +451,10 @@ fun RenderNutzapGallery( Row(Modifier.fillMaxWidth()) { Box( - modifier = WidthAuthorPictureModifier, + // Reuse the reaction galleries' icon column (55dp wide with a 5dp end + // inset) so the cashu glyph lines up with the like/boost icons above + // it, instead of sitting flush-right like the lightning ZappedIcon. + modifier = NotificationIconModifier, ) { Icon( imageVector = CustomHashTagIcons.Cashu, From 756648aef10d930dfa01544f4c90730a76a859e5 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 27 Jun 2026 18:44:50 +0000 Subject: [PATCH 05/18] feat: add HiveTalk to discover web apps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HiveTalk (hivetalk.org) — Nostr-native, Lightning-powered video conferencing. Reachability and own apple-touch-icon verified. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0151Uczec41LhTogxkgoAhKa --- .../vitorpamplona/amethyst/commons/browser/DefaultWebClients.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/DefaultWebClients.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/DefaultWebClients.kt index c80acc53ae..34ee0b0bbb 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/DefaultWebClients.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/DefaultWebClients.kt @@ -89,6 +89,7 @@ object DefaultWebClients { webApp("Chachi", "https://chachi.chat", "Group chat & communities") webApp("NostrChat", "https://www.nostrchat.io", "Decentralized chat", "https://www.nostrchat.io/logo192.png") webApp("NymChat", "https://www.nymchat.com", "Anonymous, ephemeral chat") + webApp("HiveTalk", "https://hivetalk.org", "Lightning-powered video conferencing", "https://hivetalk.org/_astro/apple-touch-icon.BAevOzwc.png") // Media — video, photo, audio, podcasts, files webApp("zap.stream", "https://zap.stream", "Live streaming with Lightning", "https://zap.stream/logo.png") From 64abf2d5448ca5dd639546b7b442d351d8eec582 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 27 Jun 2026 18:51:22 +0000 Subject: [PATCH 06/18] feat: search the discover list in the omnibox + add 3-dot menu to results The omnibox now ranks the hardcoded Discover web apps alongside favorites and history, so typing finds a suggested app even before its first visit (the ranker dedupes by host, and favorites/history outscore a plain default, so an already-pinned/visited app never doubles up). Search results are split into Favorites / Recent / Discover groups using the visited-URL set so the headers stay accurate, and every result row now carries the same 3-dot menu as the idle Recent cards: pin/unpin to favorites, plus remove-from-history for visited sites. The favorite toggle is shared with the Recent rows via one helper. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0151Uczec41LhTogxkgoAhKa --- .../screen/loggedIn/browser/BrowserScreen.kt | 118 ++++++++++++++---- 1 file changed, 95 insertions(+), 23 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/BrowserScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/BrowserScreen.kt index eea38b3ca2..b832a60e6c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/BrowserScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/BrowserScreen.kt @@ -36,6 +36,7 @@ import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyGridScope import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.shape.RoundedCornerShape @@ -135,7 +136,10 @@ private fun BrowserLauncher( var field by remember { mutableStateOf(TextFieldValue("")) } - // Favorites + visit history flattened into the neutral candidate shape the ranker consumes. + // Favorites + visit history + the hardcoded Discover apps, flattened into the neutral candidate shape + // the ranker consumes — so typing the omnibox finds a suggested app even before its first visit. The + // ranker dedupes by host, and favorites/history outscore a plain default, so a default that the user + // already pinned or visited collapses into that higher-ranked row instead of showing twice. val candidates = remember(apps, history) { buildList { @@ -151,19 +155,37 @@ private fun BrowserLauncher( ), ) } + DefaultWebClients.list.forEach { add(OmniboxSuggestions.Candidate(it.app.url, it.app.label, isFavorite = false)) } } } + // Visited URLs, so the suggestion list can tell a Recent result from a Discover one (and only the + // former offers "Remove from history"). + val historyUrls = remember(history) { history.mapTo(HashSet()) { it.url } } + // What the user actually typed, excluding any selected ghost-completion suffix (selection.min is the // caret when collapsed, or the start of the highlighted suffix when a completion is showing). val typed = field.text.take(field.selection.min.coerceIn(0, field.text.length)) - val suggestions = remember(typed, candidates) { OmniboxSuggestions.rank(typed, candidates) } + val suggestions = remember(typed, candidates) { OmniboxSuggestions.rank(typed, candidates, limit = 12) } fun open(text: String) { val target = OmniboxInput.resolve(text) ?: return FavoriteAppLauncher.launchUrl(context, target.url, target.forceTor) } + // Pin/unpin a plain web URL by its favorite id. Shared by the suggestion list and the Recent rows. + fun toggleFavorite( + url: String, + label: String, + ) { + val id = "url:$url" + if (FavoriteAppsRegistry.isFavorite(id)) { + FavoriteAppsRegistry.remove(id) + } else { + FavoriteAppsRegistry.add(FavoriteApp.WebApp(url, label.ifBlank { OmniboxInput.hostOf(url) ?: url }, System.currentTimeMillis())) + } + } + // Inline autocomplete: when the user appends a character, offer the top host as selected ghost text so // the next keystroke replaces it. On deletion or mid-string edits, leave the value untouched. fun onValueChange(new: TextFieldValue) { @@ -209,7 +231,10 @@ private fun BrowserLauncher( SuggestionGrid( suggestions = suggestions, iconKeys = iconKeys, + historyUrls = historyUrls, onOpen = { open(it.url) }, + onToggleFavorite = { toggleFavorite(it.url, it.label) }, + onRemoveFromHistory = { BrowserHistoryRegistry.remove(it) }, modifier = contentModifier, ) else -> { @@ -226,16 +251,7 @@ private fun BrowserLauncher( onRemoveApp = { FavoriteAppsRegistry.remove(it.id) }, onAddApp = { FavoriteAppsRegistry.add(it) }, onOpenUrl = { open(it) }, - onToggleRecentFavorite = { entry -> - val id = "url:" + entry.url - if (FavoriteAppsRegistry.isFavorite(id)) { - FavoriteAppsRegistry.remove(id) - } else { - FavoriteAppsRegistry.add( - FavoriteApp.WebApp(entry.url, entry.title.ifBlank { entry.host }, System.currentTimeMillis()), - ) - } - }, + onToggleRecentFavorite = { entry -> toggleFavorite(entry.url, entry.title.ifBlank { entry.host }) }, onRemoveRecent = { BrowserHistoryRegistry.remove(it) }, modifier = contentModifier, ) @@ -302,25 +318,50 @@ private fun OmniBar( } } -/** The typed-state body: ranked suggestions split into a highlighted Favorites group then Recent. */ +/** + * The typed-state body: ranked suggestions split into a highlighted Favorites group, then Recent (visited + * sites), then Discover (the hardcoded web apps the user hasn't pinned or visited yet). Every row carries + * the same 3-dot menu as the idle Recent cards (pin/unpin; plus remove-from-history for visited sites). + */ @Composable private fun SuggestionGrid( suggestions: List<OmniboxSuggestions.Suggestion>, iconKeys: Set<String>, + historyUrls: Set<String>, onOpen: (OmniboxSuggestions.Suggestion) -> Unit, + onToggleFavorite: (OmniboxSuggestions.Suggestion) -> Unit, + onRemoveFromHistory: (String) -> Unit, modifier: Modifier = Modifier, ) { val favorites = suggestions.filter { it.isFavorite } - val others = suggestions.filterNot { it.isFavorite } + val recent = suggestions.filter { !it.isFavorite && it.url in historyUrls } + val discover = suggestions.filter { !it.isFavorite && it.url !in historyUrls } + + fun LazyGridScope.section( + keyPrefix: String, + title: Int, + rows: List<OmniboxSuggestions.Suggestion>, + highlighted: Boolean, + ) { + if (rows.isEmpty()) return + item(key = "h-$keyPrefix") { SectionHeader(stringResource(title)) } + items(rows, key = { "$keyPrefix:" + it.url }) { suggestion -> + SuggestionRow( + suggestion = suggestion, + iconKeys = iconKeys, + highlighted = highlighted, + removableFromHistory = suggestion.url in historyUrls, + onClick = { onOpen(suggestion) }, + onToggleFavorite = { onToggleFavorite(suggestion) }, + onRemoveFromHistory = { onRemoveFromHistory(suggestion.url) }, + ) + } + } + LazyVerticalGrid(columns = GridCells.Fixed(1), modifier = modifier) { - if (favorites.isNotEmpty()) { - item(key = "h-fav") { SectionHeader(stringResource(R.string.browser_favorites)) } - items(favorites, key = { "f:" + it.url }) { SuggestionRow(it, iconKeys, highlighted = true) { onOpen(it) } } - } - if (others.isNotEmpty()) { - item(key = "h-rec") { SectionHeader(stringResource(R.string.favorite_app_recent)) } - items(others, key = { "o:" + it.url }) { SuggestionRow(it, iconKeys, highlighted = false) { onOpen(it) } } - } + section("f", R.string.browser_favorites, favorites, highlighted = true) + section("o", R.string.favorite_app_recent, recent, highlighted = false) + section("s", R.string.browser_suggested, discover, highlighted = false) } } @@ -329,15 +370,19 @@ private fun SuggestionRow( suggestion: OmniboxSuggestions.Suggestion, iconKeys: Set<String>, highlighted: Boolean, + removableFromHistory: Boolean, onClick: () -> Unit, + onToggleFavorite: () -> Unit, + onRemoveFromHistory: () -> Unit, ) { + var menuOpen by remember { mutableStateOf(false) } Row( modifier = Modifier .fillMaxWidth() .clickable(onClick = onClick) .background(if (highlighted) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.25f) else Color.Transparent) - .padding(horizontal = 16.dp, vertical = 12.dp), + .padding(start = 16.dp, top = 12.dp, bottom = 12.dp), verticalAlignment = Alignment.CenterVertically, ) { SiteIcon(suggestion.host, suggestion.isFavorite, iconKeys, Modifier.size(24.dp)) @@ -360,6 +405,33 @@ private fun SuggestionRow( ) } } + Box { + IconButton(onClick = { menuOpen = true }) { + Icon(MaterialSymbols.MoreVert, contentDescription = stringResource(R.string.browser_recent_options)) + } + DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + DropdownMenuItem( + text = { Text(stringResource(if (suggestion.isFavorite) R.string.favorite_app_remove else R.string.favorite_app_add)) }, + leadingIcon = { + Icon(if (suggestion.isFavorite) MaterialSymbols.Star else MaterialSymbols.StarBorder, contentDescription = null) + }, + onClick = { + menuOpen = false + onToggleFavorite() + }, + ) + if (removableFromHistory) { + DropdownMenuItem( + text = { Text(stringResource(R.string.browser_recent_remove)) }, + leadingIcon = { Icon(MaterialSymbols.Delete, contentDescription = null) }, + onClick = { + menuOpen = false + onRemoveFromHistory() + }, + ) + } + } + } } } From 217f4ad9ca6742d8343b0839fe4a3250018ba6be Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 27 Jun 2026 19:04:59 +0000 Subject: [PATCH 07/18] fix: drop confirm dialog on nsite Tor network switch Tapping the "loads over Tor" row on an nSite's pull-down sheet popped a confirm dialog explaining the routing change. Users already know what Tor is, so toggle the routing directly on tap (still relaunches the session to rebuild the proxy + content server) and remove the now-unused strings. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0177rhf2L93YRcq6NrWkkM4Q --- .../napplethost/NappletHostActivity.kt | 24 +++---------------- nappletHost/src/main/res/values/strings.xml | 6 ----- 2 files changed, 3 insertions(+), 27 deletions(-) diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt index cbd602b5b7..868182907d 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt @@ -736,31 +736,13 @@ class NappletHostActivity : ComponentActivity() { title = barTitle(), isSandbox = true, onReload = { if (this::webView.isInitialized) webView.reload() }, - // Website-mode nSites can re-route over Tor; switching rebuilds the session via a confirm - // dialog, so the row taps through rather than toggling inline. + // Website-mode nSites can re-route over Tor; switching rebuilds the session, so the row taps + // through to a full relaunch rather than toggling inline. torInitiallyOn = if (profile.exposesNetwork && proxyPort > 0) useTor else null, - onNetworkTap = if (profile.exposesNetwork && proxyPort > 0) ({ showNetworkDialog() }) else null, + onNetworkTap = if (profile.exposesNetwork && proxyPort > 0) ({ setNetworkMode(!useTor) }) else null, onInfo = { showAccessDialog() }, ) - /** - * Explains the site's current network routing and offers to switch it. Switching persists the - * per-site choice (via the broker, which owns the preference) and relaunches this screen so the new - * routing applies cleanly from [onCreate] — the proxy and content server are rebuilt for the new mode. - */ - private fun showNetworkDialog() { - val titleRes = if (useTor) R.string.napplet_net_tor_title else R.string.napplet_net_open_title - val messageRes = if (useTor) R.string.napplet_net_tor_message else R.string.napplet_net_open_message - val switchRes = if (useTor) R.string.napplet_net_switch_open else R.string.napplet_net_switch_tor - AlertDialog - .Builder(this) - .setTitle(getString(titleRes, barTitle())) - .setMessage(getString(messageRes)) - .setPositiveButton(getString(switchRes)) { _, _ -> setNetworkMode(!useTor) } - .setNegativeButton(android.R.string.cancel, null) - .show() - } - /** Persists the new routing choice in the main process, then relaunches this screen to apply it. */ private fun setNetworkMode(newUseTor: Boolean) { val msg = diff --git a/nappletHost/src/main/res/values/strings.xml b/nappletHost/src/main/res/values/strings.xml index ddd4b168d4..74b715186f 100644 --- a/nappletHost/src/main/res/values/strings.xml +++ b/nappletHost/src/main/res/values/strings.xml @@ -19,12 +19,6 @@ <!-- nSite network routing (Tor vs open web) --> <string name="napplet_net_tor_desc">This site loads over Tor. Tap to change.</string> <string name="napplet_net_open_desc">This site loads over the open web. Tap to change.</string> - <string name="napplet_net_tor_title">“%1$s” loads over Tor</string> - <string name="napplet_net_tor_message">This site\'s traffic is routed through Tor, so it can\'t see your IP address. Some sites are slow or broken over Tor — you can switch this site to the open web. Your choice is remembered for this site.</string> - <string name="napplet_net_open_title">“%1$s” loads over the open web</string> - <string name="napplet_net_open_message">This site loads directly, so it (and the servers it contacts) can see your IP address. Switch it back to Tor to keep your IP private. Your choice is remembered for this site.</string> - <string name="napplet_net_switch_open">Use open web</string> - <string name="napplet_net_switch_tor">Use Tor</string> <!-- Short labels for the pull-down sheet's network row --> <string name="napplet_net_tor_label">Loads over Tor</string> <string name="napplet_net_open_label">Loads over the open web</string> From fb76705f71bb2c74e4b80b2172d7ce88f570be28 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 27 Jun 2026 19:10:57 +0000 Subject: [PATCH 08/18] feat: discover followed nsites & napplets in the browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add two Discover sections to the browser launcher home that surface the NIP-5A sites and NIP-5D apps published by the people the user follows, reusing the same feed + follow-list filter as the dedicated nSites and nApplets screens (set those to All Follows for a pure follows list). The launcher subscribes the nsite/napplet assemblers while open, observes the addressable manifest store, keeps the followed authors' (or own, in the Mine case), drops ones already pinned, and caps each section. Rows mirror the web Discover row — manifest icon, title, description, and a star to pin — launching Nostr-natively through the sandboxed host. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0151Uczec41LhTogxkgoAhKa --- .../screen/loggedIn/browser/BrowserScreen.kt | 165 ++++++++++++++++++ amethyst/src/main/res/values/strings.xml | 2 + 2 files changed, 167 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/BrowserScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/BrowserScreen.kt index b832a60e6c..6d7fb4dbcd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/BrowserScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/BrowserScreen.kt @@ -71,6 +71,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.browser.DefaultWebClients import com.vitorpamplona.amethyst.commons.browser.OmniboxInput @@ -87,12 +88,22 @@ import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry import com.vitorpamplona.amethyst.favorites.PreloadFavoriteNostrApps +import com.vitorpamplona.amethyst.favorites.rememberNappletIconModel +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.TopFilter import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.favorites.favoriteAppItems +import com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.datasource.NappletsFilterAssemblerSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.nsites.datasource.NsitesFilterAssemblerSubscription +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent +import com.vitorpamplona.quartz.nip5aStaticWebsites.RootSiteEvent +import com.vitorpamplona.quartz.nip5dNapplets.NamedNappletEvent +import com.vitorpamplona.quartz.nip5dNapplets.RootNappletEvent import com.vitorpamplona.amethyst.commons.R as CommonsR /** How many of the most recent history entries the idle browser home surfaces under "Recent". */ @@ -163,6 +174,39 @@ private fun BrowserLauncher( // former offers "Remove from history"). val historyUrls = remember(history) { history.mapTo(HashSet()) { it.url } } + // Discover nsites & napplets — the NIP-5A sites and NIP-5D apps published by the people the user + // follows. Same feed + follow-list filter the dedicated nSites/nApplets screens use (set those to + // "All Follows" for a pure follows list): the subscriptions pull manifests into LocalCache while the + // Browser tab is open, and we observe the addressable store and keep the matching authors'. + NsitesFilterAssemblerSubscription(accountViewModel) + NappletsFilterAssemblerSubscription(accountViewModel) + + val nsiteNotes by remember { + Amethyst.instance.cache.observeNotes(Filter(kinds = listOf(RootSiteEvent.KIND, NamedSiteEvent.KIND))) + }.collectAsStateWithLifecycle(emptyList()) + val nappletNotes by remember { + Amethyst.instance.cache.observeNotes(Filter(kinds = listOf(RootNappletEvent.KIND, NamedNappletEvent.KIND))) + }.collectAsStateWithLifecycle(emptyList()) + + val nsiteFollows by accountViewModel.account.liveNsitesFollowLists.collectAsStateWithLifecycle() + val nsiteListName by accountViewModel.account.settings.defaultNsitesFollowList + .collectAsStateWithLifecycle() + val nappletFollows by accountViewModel.account.liveNappletsFollowLists.collectAsStateWithLifecycle() + val nappletListName by accountViewModel.account.settings.defaultNappletsFollowList + .collectAsStateWithLifecycle() + val myPubkey = accountViewModel.account.userProfile().pubkeyHex + + // Drop ones already pinned — they show under Favorites, not twice. + val favoriteCoordinates = remember(apps) { apps.filterIsInstance<FavoriteApp.NostrApp>().mapTo(HashSet()) { it.coordinate } } + val followedNsites = + remember(nsiteNotes, nsiteFollows, nsiteListName, myPubkey, favoriteCoordinates) { + nsiteNotes.toDiscoverApps(nsiteListName == TopFilter.Mine, myPubkey, nsiteFollows::matchAuthor, favoriteCoordinates) + } + val followedNapplets = + remember(nappletNotes, nappletFollows, nappletListName, myPubkey, favoriteCoordinates) { + nappletNotes.toDiscoverApps(nappletListName == TopFilter.Mine, myPubkey, nappletFollows::matchAuthor, favoriteCoordinates) + } + // What the user actually typed, excluding any selected ghost-completion suffix (selection.min is the // caret when collapsed, or the start of the highlighted suffix when a completion is showing). val typed = field.text.take(field.selection.min.coerceIn(0, field.text.length)) @@ -247,6 +291,8 @@ private fun BrowserLauncher( iconKeys = iconKeys, favoriteUrls = favoriteUrls, suggested = suggested, + nsites = followedNsites, + napplets = followedNapplets, onOpenApp = { FavoriteAppLauncher.launch(context, it) }, onRemoveApp = { FavoriteAppsRegistry.remove(it.id) }, onAddApp = { FavoriteAppsRegistry.add(it) }, @@ -443,6 +489,8 @@ private fun BrowserHome( iconKeys: Set<String>, favoriteUrls: Set<String>, suggested: List<SuggestedWebApp>, + nsites: List<DiscoverNostrApp>, + napplets: List<DiscoverNostrApp>, onOpenApp: (FavoriteApp) -> Unit, onRemoveApp: (FavoriteApp) -> Unit, onAddApp: (FavoriteApp) -> Unit, @@ -476,6 +524,18 @@ private fun BrowserHome( ) } } + if (nsites.isNotEmpty()) { + item(span = { GridItemSpan(maxLineSpan) }, key = "h-nsite") { SectionHeader(stringResource(R.string.browser_discover_nsites)) } + items(nsites, span = { GridItemSpan(maxLineSpan) }, key = { "ns:" + it.app.coordinate }) { entry -> + NostrAppRow(entry, onClick = { onOpenApp(entry.app) }, onAddFavorite = { onAddApp(entry.app) }) + } + } + if (napplets.isNotEmpty()) { + item(span = { GridItemSpan(maxLineSpan) }, key = "h-napp") { SectionHeader(stringResource(R.string.browser_discover_napplets)) } + items(napplets, span = { GridItemSpan(maxLineSpan) }, key = { "np:" + it.app.coordinate }) { entry -> + NostrAppRow(entry, onClick = { onOpenApp(entry.app) }, onAddFavorite = { onAddApp(entry.app) }) + } + } if (suggested.isNotEmpty()) { item(span = { GridItemSpan(maxLineSpan) }, key = "h-sug") { SectionHeader(stringResource(R.string.browser_suggested)) } items(suggested, span = { GridItemSpan(maxLineSpan) }, key = { "s:" + it.app.url }) { entry -> @@ -536,6 +596,111 @@ private fun SuggestedRow( } } +/** A followed nsite/napplet, resolved into its launchable [app] plus the manifest [description]. */ +private data class DiscoverNostrApp( + val app: FavoriteApp.NostrApp, + val description: String?, +) + +/** How many followed nsites/napplets each Discover section surfaces, so the launcher stays tidy. */ +private const val DISCOVER_NOSTR_LIMIT = 12 + +/** + * Keeps the [Note]s authored by the followed set (or by the user, in the "Mine" case — the shared + * matcher resolves Mine to all-follows, so it can't serve that case), drops ones already pinned, maps + * each to its launchable [DiscoverNostrApp], and caps the result. + */ +private fun List<Note>.toDiscoverApps( + mine: Boolean, + myPubkey: String, + matchAuthor: (String) -> Boolean, + excludeCoordinates: Set<String>, +): List<DiscoverNostrApp> = + asSequence() + .filter { note -> + val author = note.event?.pubKey ?: return@filter false + if (mine) author == myPubkey else matchAuthor(author) + }.mapNotNull { it.toDiscoverNostrApp() } + .filter { it.app.coordinate !in excludeCoordinates } + .take(DISCOVER_NOSTR_LIMIT) + .toList() + +/** Resolve a cached nsite/napplet manifest note into a launchable favorite + its description, or null. */ +private fun Note.toDiscoverNostrApp(): DiscoverNostrApp? { + val event = event ?: return null + val coordinate = FavoriteAppLauncher.coordinateOf(event) + val label: String + val description: String? + when (event) { + is RootSiteEvent -> { + label = event.title()?.ifBlank { null } ?: "Website" + description = event.description() + } + is NamedSiteEvent -> { + label = event.title()?.ifBlank { null } ?: event.identifier() + description = event.description() + } + is RootNappletEvent -> { + label = event.title()?.ifBlank { null } ?: "App" + description = event.description() + } + is NamedNappletEvent -> { + label = event.title()?.ifBlank { null } ?: event.identifier() + description = event.description() + } + else -> return null + } + return DiscoverNostrApp(FavoriteApp.NostrApp(coordinate, label, 0L), description) +} + +/** A Discover row for a followed nsite/napplet: its manifest icon, name, and description, with a pin star. */ +@Composable +private fun NostrAppRow( + discover: DiscoverNostrApp, + onClick: () -> Unit, + onAddFavorite: () -> Unit, +) { + val app = discover.app + val iconModel = rememberNappletIconModel(app.coordinate) + Row( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable(onClick = onClick) + .padding(start = 8.dp, top = 4.dp, bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + FavoriteAppIcon( + app = app, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(28.dp), + iconModel = iconModel, + ) + Spacer(Modifier.width(16.dp)) + Column(Modifier.weight(1f)) { + Text( + app.label, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (!discover.description.isNullOrBlank()) { + Text( + discover.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + IconButton(onClick = onAddFavorite) { + Icon(MaterialSymbols.StarBorder, contentDescription = stringResource(R.string.favorite_app_add)) + } + } +} + @Composable private fun RecentRow( entry: BrowserHistoryEntry, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 2f2342e959..23a220fe44 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -676,6 +676,8 @@ <string name="browser_clear">Clear</string> <string name="browser_favorites">Favorites</string> <string name="browser_suggested">Discover web apps</string> + <string name="browser_discover_nsites">Sites from people you follow</string> + <string name="browser_discover_napplets">Apps from people you follow</string> <string name="browser_recent_options">Options</string> <string name="browser_recent_remove">Remove from history</string> <string name="favorite_apps">Web apps</string> From b01e1eb6bc603c0e351db861aec8cea713cb88a8 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 27 Jun 2026 19:20:54 +0000 Subject: [PATCH 09/18] feat(napplet): show top loading bar, keep splash until first paint, log load errors to console MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The napplet/nsite host (NappletHostActivity) removed its loading splash the moment the index probe succeeded and then mounted a WebView with no progress tracking at all, so during the seconds the shell + bundle take to load (notably over Tor) the user saw only the WebView's dark colorBackground — a black screen with no sign anything was happening, especially in dark theme. - Add a thin browser-style determinate progress bar pinned to the top edge, driven by WebChromeClient.onProgressChanged and hidden at 100%, to both the napplet/nsite host and the URL browser (NappletBrowserActivity). - Mount the WebView under the loading splash and keep the splash (now opaque) until first paint (onPageCommitVisible) instead of removing it on mount, so there is never a blank/dark gap between probe-success and the shell's first frame. This mirrors the pattern the URL browser already used. - Add a developer console (NappletConsolePanel) to the napplet/nsite host, wired through the existing onConsole hook in NappletControlSheet, and forward the page's console.* output to it. - Surface failed resource fetches (onReceivedError / onReceivedHttpError) as ERROR lines in the console on both hosts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D4iYA4Qf5guWZyKexkcmhb --- .../napplethost/NappletBrowserActivity.kt | 59 +++++++- .../napplethost/NappletHostActivity.kt | 130 +++++++++++++++++- nappletHost/src/main/res/values/strings.xml | 4 + 3 files changed, 188 insertions(+), 5 deletions(-) diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt index bfc3c5e812..5eef258f37 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt @@ -24,6 +24,7 @@ import android.content.ComponentName import android.content.Context import android.content.Intent import android.content.ServiceConnection +import android.content.res.ColorStateList import android.graphics.Bitmap import android.net.Uri import android.os.Bundle @@ -40,6 +41,7 @@ import android.webkit.ConsoleMessage import android.webkit.WebChromeClient import android.webkit.WebResourceError import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse import android.webkit.WebSettings import android.webkit.WebView import android.webkit.WebViewClient @@ -94,6 +96,10 @@ class NappletBrowserActivity : ComponentActivity() { private var controlSheet: NappletControlSheet? = null private var consolePanel: NappletConsolePanel? = null + // A thin determinate progress bar pinned to the top edge (browser-style), driven by the chrome + // client's onProgressChanged; hidden at 100%. + private val topProgressBar by lazy { buildTopProgressBar() } + // Visit-history gating: only a clean main-frame load (no error) is recorded, so a misspelled/ // unresolved address never enters history. Reset on each main-frame page start. private var pendingMainFrameUrl: String? = null @@ -200,6 +206,8 @@ class NappletBrowserActivity : ComponentActivity() { Gravity.BOTTOM, ), ) + // Added last so the thin loading bar paints above the content (and over the grabber's top edge). + addView(topProgressBar) } setContentView(root) // Pad by the system bars + cutout, but NOT the IME — windowSoftInputMode=adjustResize shrinks the @@ -315,8 +323,15 @@ class NappletBrowserActivity : ComponentActivity() { wv.webChromeClient = BrowserChromeClient() } - /** Captures favicon and console output; the only source of both is the WebChromeClient. */ + /** Captures favicon and console output, and drives the top loading bar; all come from the WebChromeClient. */ private inner class BrowserChromeClient : WebChromeClient() { + override fun onProgressChanged( + view: WebView, + newProgress: Int, + ) { + updateLoadProgress(newProgress) + } + override fun onReceivedIcon( view: WebView, icon: Bitmap?, @@ -377,6 +392,15 @@ class NappletBrowserActivity : ComponentActivity() { // A main-frame failure (DNS miss on a misspelled host, no connection, …) disqualifies this // navigation from history. Sub-resource errors are irrelevant to whether the page opened. if (request.isForMainFrame) mainFrameLoadFailed = true + logConsoleError(request, getString(R.string.napplet_console_load_error, error.errorCode, error.description?.toString().orEmpty())) + } + + override fun onReceivedHttpError( + view: WebView, + request: WebResourceRequest, + errorResponse: WebResourceResponse, + ) { + logConsoleError(request, getString(R.string.napplet_console_http_error, errorResponse.statusCode, errorResponse.reasonPhrase.orEmpty())) } override fun onPageCommitVisible( @@ -658,6 +682,39 @@ class NappletBrowserActivity : ComponentActivity() { addView(ProgressBar(this@NappletBrowserActivity)) } + /** + * A thin determinate progress bar pinned to the top edge, like a browser's. Driven by + * [BrowserChromeClient.onProgressChanged]: visible while the page loads and gone at 100%. + */ + private fun buildTopProgressBar(): ProgressBar = + ProgressBar(this, null, android.R.attr.progressBarStyleHorizontal).apply { + max = 100 + isIndeterminate = false + visibility = View.GONE + progressTintList = ColorStateList.valueOf(resolveThemeColor(android.R.attr.colorPrimary)) + layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, dp(3), Gravity.TOP) + } + + /** Shows the thin top bar at [progress]% while loading, hiding it once the page is fully loaded. */ + private fun updateLoadProgress(progress: Int) { + if (progress >= 100) { + topProgressBar.visibility = View.GONE + } else { + topProgressBar.progress = progress + topProgressBar.visibility = View.VISIBLE + } + } + + /** Appends a single ERROR line to the console panel and refreshes the chrome's unread count. */ + private fun logConsoleError( + request: WebResourceRequest, + message: String, + ) { + val panel = consolePanel ?: return + panel.appendLog(ConsoleMessage.MessageLevel.ERROR, message, request.url?.toString().orEmpty(), 0) + controlSheet?.updateConsoleCount(panel.entryCount) + } + private fun resolveThemeColor(attr: Int): Int { val tv = android.util.TypedValue() theme.resolveAttribute(attr, tv, true) diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt index cbd602b5b7..d47f9974e7 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt @@ -24,6 +24,7 @@ import android.app.AlertDialog import android.content.ComponentName import android.content.Intent import android.content.ServiceConnection +import android.content.res.ColorStateList import android.net.Uri import android.os.Bundle import android.os.Handler @@ -37,6 +38,9 @@ import android.view.Gravity import android.view.KeyEvent import android.view.View import android.view.ViewGroup +import android.webkit.ConsoleMessage +import android.webkit.WebChromeClient +import android.webkit.WebResourceError import android.webkit.WebResourceRequest import android.webkit.WebResourceResponse import android.webkit.WebSettings @@ -147,6 +151,18 @@ class NappletHostActivity : ComponentActivity() { private val contentFrame by lazy { FrameLayout(this) } private val uiScope = CoroutineScope(Dispatchers.Main + SupervisorJob()) + // The loading splash (monogram + spinner). Kept on top of the mounted WebView and removed only on + // first paint, so there's never a blank/dark gap between the index probe and the shell's first frame. + private var loadingView: View? = null + + // A thin determinate progress bar pinned to the top edge (browser-style), driven by the + // WebChromeClient's onProgressChanged; hidden at 100%. + private val topProgressBar by lazy { buildTopProgressBar() } + + // Bottom pull-up developer console: the page's console.log/warn/error plus any resource load errors. + private var consolePanel: NappletConsolePanel? = null + private var controlSheet: NappletControlSheet? = null + // Set once the WebView has begun loading the shell, so a retry doesn't reload it. private var started = false @@ -268,6 +284,18 @@ class NappletHostActivity : ComponentActivity() { Gravity.TOP, ), ) + addView( + buildConsolePanel(), + FrameLayout + .LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.WRAP_CONTENT, + Gravity.BOTTOM, + ), + ) + // Added last so the thin loading bar paints above the content (and over the grabber's top + // edge); it's GONE except while loading, so it never obscures the trusted chrome. + addView(topProgressBar) } setContentView(root) // Activities are edge-to-edge by default on recent Android; pad by the system bar and @@ -295,22 +323,26 @@ class NappletHostActivity : ComponentActivity() { */ private fun probeAndMount() { contentFrame.removeAllViews() - contentFrame.addView(buildLoadingView()) + loadingView = buildLoadingView().also { contentFrame.addView(it) } uiScope.launch { val available = withContext(Dispatchers.IO) { contentServer.resolve("/") is StaticSiteResolution.Resolved } if (available) { mountWebView() } else { contentFrame.removeAllViews() + loadingView = null contentFrame.addView(buildErrorView { probeAndMount() }) } } } private fun mountWebView() { - contentFrame.removeAllViews() (webView.parent as? ViewGroup)?.removeView(webView) - contentFrame.addView(webView, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)) + // Mount the WebView UNDER the loading splash (index 0) instead of replacing it: the shell + applet + // bundle still take time to paint (seconds over Tor), and the WebView shows only its dark + // colorBackground until then. The splash stays until the first frame paints (onPageCommitVisible), + // so the user never sees a blank/black screen with no sign that anything is loading. + contentFrame.addView(webView, 0, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)) if (!started) { started = true webView.loadUrl(NappletWebContract.SHELL_URL) @@ -484,6 +516,7 @@ class NappletHostActivity : ComponentActivity() { webView.overScrollMode = View.OVER_SCROLL_NEVER WebView.setWebContentsDebuggingEnabled(false) webView.webViewClient = NappletWebViewClient() + webView.webChromeClient = NappletWebChromeClient() } /** @@ -529,6 +562,34 @@ class NappletHostActivity : ComponentActivity() { syncBackState() } + // The shell has painted its first frame — drop the loading splash so the running app shows + // through. Null-safe so a later in-app navigation/reload (splash already gone) is a no-op. + override fun onPageCommitVisible( + view: WebView, + url: String, + ) { + loadingView?.let { contentFrame.removeView(it) } + loadingView = null + } + + // Surface failed resource fetches (a missing blob, a verify miss, an off-origin request the + // default-deny CSP blocked) in the console so an nsite/napplet developer can see what broke. + override fun onReceivedError( + view: WebView, + request: WebResourceRequest, + error: WebResourceError, + ) { + logConsoleError(request, getString(R.string.napplet_console_load_error, error.errorCode, error.description?.toString().orEmpty())) + } + + override fun onReceivedHttpError( + view: WebView, + request: WebResourceRequest, + errorResponse: WebResourceResponse, + ) { + logConsoleError(request, getString(R.string.napplet_console_http_error, errorResponse.statusCode, errorResponse.reasonPhrase.orEmpty())) + } + override fun shouldOverrideUrlLoading( view: WebView, request: WebResourceRequest, @@ -550,6 +611,43 @@ class NappletHostActivity : ComponentActivity() { } } + /** Drives the top loading bar and forwards the applet/site's `console.*` output to the console panel. */ + private inner class NappletWebChromeClient : WebChromeClient() { + override fun onProgressChanged( + view: WebView, + newProgress: Int, + ) { + updateLoadProgress(newProgress) + } + + override fun onConsoleMessage(consoleMessage: ConsoleMessage): Boolean { + val panel = consolePanel ?: return false + panel.appendLog(consoleMessage.messageLevel(), consoleMessage.message(), consoleMessage.sourceId(), consoleMessage.lineNumber()) + controlSheet?.updateConsoleCount(panel.entryCount) + return true + } + } + + /** Shows the thin top bar at [progress]% while loading, hiding it once the page is fully loaded. */ + private fun updateLoadProgress(progress: Int) { + if (progress >= 100) { + topProgressBar.visibility = View.GONE + } else { + topProgressBar.progress = progress + topProgressBar.visibility = View.VISIBLE + } + } + + /** Appends a single ERROR line to the console panel and refreshes the chrome's unread count. */ + private fun logConsoleError( + request: WebResourceRequest, + message: String, + ) { + val panel = consolePanel ?: return + panel.appendLog(ConsoleMessage.MessageLevel.ERROR, message, request.url?.toString().orEmpty(), 0) + controlSheet?.updateConsoleCount(panel.entryCount) + } + // ---- bridge: shell <-> native ---- private fun onShellMessage( @@ -663,6 +761,9 @@ class NappletHostActivity : ComponentActivity() { orientation = LinearLayout.VERTICAL gravity = Gravity.CENTER setPadding(dp(32), dp(32), dp(32), dp(32)) + // Opaque so the splash/error screen fully covers the WebView it now overlays (mounted beneath + // it until first paint) instead of letting the dark, not-yet-painted page show through. + setBackgroundColor(resolveThemeColor(android.R.attr.colorBackground)) layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT) } @@ -741,7 +842,28 @@ class NappletHostActivity : ComponentActivity() { torInitiallyOn = if (profile.exposesNetwork && proxyPort > 0) useTor else null, onNetworkTap = if (profile.exposesNetwork && proxyPort > 0) ({ showNetworkDialog() }) else null, onInfo = { showAccessDialog() }, - ) + onConsole = { consolePanel?.toggle() }, + ).also { controlSheet = it } + + private fun buildConsolePanel(): View = + NappletConsolePanel(this).also { + it.onClearCallback = { controlSheet?.updateConsoleCount(0) } + consolePanel = it + } + + /** + * A thin determinate progress bar pinned to the top edge, like a browser's. Driven by + * [NappletWebChromeClient.onProgressChanged]: visible while the shell + verified blobs load and gone + * at 100%, so a slow load (e.g. a large bundle over Tor) shows progress instead of a blank dark WebView. + */ + private fun buildTopProgressBar(): ProgressBar = + ProgressBar(this, null, android.R.attr.progressBarStyleHorizontal).apply { + max = 100 + isIndeterminate = false + visibility = View.GONE + progressTintList = ColorStateList.valueOf(resolveThemeColor(android.R.attr.colorPrimary)) + layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, dp(3), Gravity.TOP) + } /** * Explains the site's current network routing and offers to switch it. Switching persists the diff --git a/nappletHost/src/main/res/values/strings.xml b/nappletHost/src/main/res/values/strings.xml index ddd4b168d4..e0f8dceaae 100644 --- a/nappletHost/src/main/res/values/strings.xml +++ b/nappletHost/src/main/res/values/strings.xml @@ -37,4 +37,8 @@ <string name="napplet_unavailable_title">Couldn\'t load “%1$s”</string> <string name="napplet_unavailable_subtitle">The publisher\'s servers may be offline, or you\'re not connected. You can try again.</string> <string name="napplet_unavailable_retry">Try again</string> + + <!-- Developer console: page-load failures surfaced as console errors --> + <string name="napplet_console_load_error">Failed to load (%1$d): %2$s</string> + <string name="napplet_console_http_error">HTTP %1$d %2$s</string> </resources> From bdacc2866368cb839f8d6b6e2824686e13226cb8 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 27 Jun 2026 19:28:18 +0000 Subject: [PATCH 10/18] feat: add app UI theme customization (accent color, font, font size) Adds three new appearance settings to Application Preferences, alongside the existing Theme selector: - Accent Color: Purple (default), Blue, Green, Orange, Red, Pink. Drives the Material primary/secondary/tertiary colors so buttons, links, FABs and switches follow the chosen hue. Purple preserves the original look (purple primary + teal secondary). - Font: System Default, Sans Serif, Serif, Monospace. Applied to the full Material typography and to bare Text via LocalTextStyle. - Font Size: Small, Normal (default), Large, Huge. Scales all text through LocalDensity.fontScale without affecting dp-based layout. Plumbed through the existing UiSettings -> UiSettingsFlow -> UiSharedPreferences (DataStore) pipeline and the AmethystTheme composable. New fields default to the current behavior and are appended, so existing stored settings deserialize unchanged. ColorScheme.isLight now derives from background luminance instead of a fixed primary, so the light/dark check keeps working when a non-purple accent is selected. The primary-derived tint extensions (links, new-item background, secondary button) now compute from the live scheme so they track the accent color. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01REGsru6cnm6wUzqm12Rh2d --- .../amethyst/model/UiSettings.kt | 65 ++++++++++ .../amethyst/model/UiSettingsFlow.kt | 27 ++++ .../model/preferences/UISharedPreferences.kt | 12 ++ .../loggedIn/settings/AppSettingsScreen.kt | 77 ++++++++++++ .../vitorpamplona/amethyst/ui/theme/Color.kt | 13 ++ .../vitorpamplona/amethyst/ui/theme/Theme.kt | 118 ++++++++++++------ .../vitorpamplona/amethyst/ui/theme/Type.kt | 34 +++++ amethyst/src/main/res/values/strings.xml | 20 +++ 8 files changed, 330 insertions(+), 36 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettings.kt index 069c8a8805..e986f5f778 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettings.kt @@ -54,6 +54,9 @@ data class UiSettings( val showProfileFollowersFeed: Boolean = true, val dontShowOnchainPublicWarning: Boolean = false, val suggestWorkoutsFromHealthConnect: BooleanType = BooleanType.ALWAYS, + val accentColor: AccentColorType = AccentColorType.PURPLE, + val fontFamily: FontFamilyType = FontFamilyType.SYSTEM, + val fontSize: FontSizeType = FontSizeType.NORMAL, ) enum class ThemeType( @@ -73,6 +76,68 @@ fun parseThemeType(code: Int?): ThemeType = else -> ThemeType.SYSTEM } +enum class AccentColorType( + val screenCode: Int, + val resourceId: Int, +) { + PURPLE(0, R.string.accent_color_purple), + BLUE(1, R.string.accent_color_blue), + GREEN(2, R.string.accent_color_green), + ORANGE(3, R.string.accent_color_orange), + RED(4, R.string.accent_color_red), + PINK(5, R.string.accent_color_pink), +} + +fun parseAccentColorType(screenCode: Int): AccentColorType = + when (screenCode) { + AccentColorType.PURPLE.screenCode -> AccentColorType.PURPLE + AccentColorType.BLUE.screenCode -> AccentColorType.BLUE + AccentColorType.GREEN.screenCode -> AccentColorType.GREEN + AccentColorType.ORANGE.screenCode -> AccentColorType.ORANGE + AccentColorType.RED.screenCode -> AccentColorType.RED + AccentColorType.PINK.screenCode -> AccentColorType.PINK + else -> AccentColorType.PURPLE + } + +enum class FontFamilyType( + val screenCode: Int, + val resourceId: Int, +) { + SYSTEM(0, R.string.font_family_system), + SANS_SERIF(1, R.string.font_family_sans_serif), + SERIF(2, R.string.font_family_serif), + MONOSPACE(3, R.string.font_family_monospace), +} + +fun parseFontFamilyType(screenCode: Int): FontFamilyType = + when (screenCode) { + FontFamilyType.SYSTEM.screenCode -> FontFamilyType.SYSTEM + FontFamilyType.SANS_SERIF.screenCode -> FontFamilyType.SANS_SERIF + FontFamilyType.SERIF.screenCode -> FontFamilyType.SERIF + FontFamilyType.MONOSPACE.screenCode -> FontFamilyType.MONOSPACE + else -> FontFamilyType.SYSTEM + } + +enum class FontSizeType( + val scale: Float, + val screenCode: Int, + val resourceId: Int, +) { + SMALL(0.85f, 0, R.string.font_size_small), + NORMAL(1.0f, 1, R.string.font_size_normal), + LARGE(1.15f, 2, R.string.font_size_large), + HUGE(1.3f, 3, R.string.font_size_huge), +} + +fun parseFontSizeType(screenCode: Int): FontSizeType = + when (screenCode) { + FontSizeType.SMALL.screenCode -> FontSizeType.SMALL + FontSizeType.NORMAL.screenCode -> FontSizeType.NORMAL + FontSizeType.LARGE.screenCode -> FontSizeType.LARGE + FontSizeType.HUGE.screenCode -> FontSizeType.HUGE + else -> FontSizeType.NORMAL + } + enum class ConnectivityType( val prefCode: Boolean?, val screenCode: Int, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt index 176691dfe6..d86fc45a51 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt @@ -54,6 +54,9 @@ class UiSettingsFlow( val showProfileFollowersFeed: MutableStateFlow<Boolean> = MutableStateFlow(true), val dontShowOnchainPublicWarning: MutableStateFlow<Boolean> = MutableStateFlow(false), val suggestWorkoutsFromHealthConnect: MutableStateFlow<BooleanType> = MutableStateFlow(BooleanType.ALWAYS), + val accentColor: MutableStateFlow<AccentColorType> = MutableStateFlow(AccentColorType.PURPLE), + val fontFamily: MutableStateFlow<FontFamilyType> = MutableStateFlow(FontFamilyType.SYSTEM), + val fontSize: MutableStateFlow<FontSizeType> = MutableStateFlow(FontSizeType.NORMAL), ) { val listOfFlows: List<Flow<Any?>> = listOf<Flow<Any?>>( @@ -82,6 +85,9 @@ class UiSettingsFlow( showProfileFollowersFeed, dontShowOnchainPublicWarning, suggestWorkoutsFromHealthConnect, + accentColor, + fontFamily, + fontSize, ) // emits at every change in any of the propertyes. @@ -114,6 +120,9 @@ class UiSettingsFlow( flows[22] as Boolean, flows[23] as Boolean, flows[24] as BooleanType, + flows[25] as AccentColorType, + flows[26] as FontFamilyType, + flows[27] as FontSizeType, ) } @@ -144,6 +153,9 @@ class UiSettingsFlow( showProfileFollowersFeed.value, dontShowOnchainPublicWarning.value, suggestWorkoutsFromHealthConnect.value, + accentColor.value, + fontFamily.value, + fontSize.value, ) fun update(torSettings: UiSettings): Boolean { @@ -249,6 +261,18 @@ class UiSettingsFlow( suggestWorkoutsFromHealthConnect.tryEmit(torSettings.suggestWorkoutsFromHealthConnect) any = true } + if (accentColor.value != torSettings.accentColor) { + accentColor.tryEmit(torSettings.accentColor) + any = true + } + if (fontFamily.value != torSettings.fontFamily) { + fontFamily.tryEmit(torSettings.fontFamily) + any = true + } + if (fontSize.value != torSettings.fontSize) { + fontSize.tryEmit(torSettings.fontSize) + any = true + } return any } @@ -299,6 +323,9 @@ class UiSettingsFlow( MutableStateFlow(uiSettings.showProfileFollowersFeed), MutableStateFlow(uiSettings.dontShowOnchainPublicWarning), MutableStateFlow(uiSettings.suggestWorkoutsFromHealthConnect), + MutableStateFlow(uiSettings.accentColor), + MutableStateFlow(uiSettings.fontFamily), + MutableStateFlow(uiSettings.fontSize), ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt index af4c15f520..25f5ba21cb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt @@ -31,9 +31,12 @@ import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore import com.vitorpamplona.amethyst.LocalPreferences +import com.vitorpamplona.amethyst.model.AccentColorType import com.vitorpamplona.amethyst.model.BooleanType import com.vitorpamplona.amethyst.model.ConnectivityType import com.vitorpamplona.amethyst.model.FeatureSetType +import com.vitorpamplona.amethyst.model.FontFamilyType +import com.vitorpamplona.amethyst.model.FontSizeType import com.vitorpamplona.amethyst.model.ProfileGalleryType import com.vitorpamplona.amethyst.model.ThemeType import com.vitorpamplona.amethyst.model.UiSettings @@ -120,6 +123,9 @@ class UiSharedPreferences( val UI_SHOW_PROFILE_FOLLOWERS_FEED = booleanPreferencesKey("ui.show_profile_followers_feed") val UI_DONT_SHOW_ONCHAIN_PUBLIC_WARNING = booleanPreferencesKey("ui.dont_show_onchain_public_warning") val UI_SUGGEST_WORKOUTS_FROM_HEALTH_CONNECT = stringPreferencesKey("ui.suggest_workouts_from_health_connect") + val UI_ACCENT_COLOR = stringPreferencesKey("ui.accent_color") + val UI_FONT_FAMILY = stringPreferencesKey("ui.font_family") + val UI_FONT_SIZE = stringPreferencesKey("ui.font_size") suspend fun uiPreferences(context: Context): UiSettings? = try { @@ -157,6 +163,9 @@ class UiSharedPreferences( dontShowOnchainPublicWarning = preferences[UI_DONT_SHOW_ONCHAIN_PUBLIC_WARNING] ?: false, suggestWorkoutsFromHealthConnect = preferences[UI_SUGGEST_WORKOUTS_FROM_HEALTH_CONNECT]?.let { BooleanType.valueOf(it) } ?: BooleanType.ALWAYS, + accentColor = preferences[UI_ACCENT_COLOR]?.let { AccentColorType.valueOf(it) } ?: AccentColorType.PURPLE, + fontFamily = preferences[UI_FONT_FAMILY]?.let { FontFamilyType.valueOf(it) } ?: FontFamilyType.SYSTEM, + fontSize = preferences[UI_FONT_SIZE]?.let { FontSizeType.valueOf(it) } ?: FontSizeType.NORMAL, ) } catch (e: Exception) { if (e is CancellationException) throw e @@ -206,6 +215,9 @@ class UiSharedPreferences( preferences[UI_SHOW_PROFILE_FOLLOWERS_FEED] = sharedSettings.showProfileFollowersFeed preferences[UI_DONT_SHOW_ONCHAIN_PUBLIC_WARNING] = sharedSettings.dontShowOnchainPublicWarning preferences[UI_SUGGEST_WORKOUTS_FROM_HEALTH_CONNECT] = sharedSettings.suggestWorkoutsFromHealthConnect.name + preferences[UI_ACCENT_COLOR] = sharedSettings.accentColor.name + preferences[UI_FONT_FAMILY] = sharedSettings.fontFamily.name + preferences[UI_FONT_SIZE] = sharedSettings.fontSize.name } } catch (e: Exception) { if (e is CancellationException) throw e diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt index 0d3a39e992..608b2b0ac2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt @@ -48,14 +48,20 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.core.os.LocaleListCompat import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.AccentColorType import com.vitorpamplona.amethyst.model.ConnectivityType import com.vitorpamplona.amethyst.model.FeatureSetType +import com.vitorpamplona.amethyst.model.FontFamilyType +import com.vitorpamplona.amethyst.model.FontSizeType import com.vitorpamplona.amethyst.model.ProfileGalleryType import com.vitorpamplona.amethyst.model.ThemeType import com.vitorpamplona.amethyst.model.UiSettingsFlow +import com.vitorpamplona.amethyst.model.parseAccentColorType import com.vitorpamplona.amethyst.model.parseBooleanType import com.vitorpamplona.amethyst.model.parseConnectivityType import com.vitorpamplona.amethyst.model.parseFeatureSetType +import com.vitorpamplona.amethyst.model.parseFontFamilyType +import com.vitorpamplona.amethyst.model.parseFontSizeType import com.vitorpamplona.amethyst.model.parseGalleryType import com.vitorpamplona.amethyst.model.parseThemeType import com.vitorpamplona.amethyst.ui.components.TextSpinner @@ -113,6 +119,9 @@ fun SettingsScreen(sharedPrefs: UiSettingsFlow) { ) { ShowLanguageChoice(sharedPrefs) ShowThemeChoice(sharedPrefs) + ShowAccentColorChoice(sharedPrefs) + ShowFontFamilyChoice(sharedPrefs) + ShowFontSizeChoice(sharedPrefs) ShowImagePreviewChoice(sharedPrefs) ShowVideoPlaybackChoice(sharedPrefs) AutoplayVideosChoice(sharedPrefs) @@ -218,6 +227,74 @@ fun ShowThemeChoice(sharedPrefs: UiSettingsFlow) { } } +@Composable +fun ShowAccentColorChoice(sharedPrefs: UiSettingsFlow) { + val accentOptions = + persistentListOf( + TitleExplainer(stringRes(AccentColorType.PURPLE.resourceId)), + TitleExplainer(stringRes(AccentColorType.BLUE.resourceId)), + TitleExplainer(stringRes(AccentColorType.GREEN.resourceId)), + TitleExplainer(stringRes(AccentColorType.ORANGE.resourceId)), + TitleExplainer(stringRes(AccentColorType.RED.resourceId)), + TitleExplainer(stringRes(AccentColorType.PINK.resourceId)), + ) + + val accentIndex by sharedPrefs.accentColor.collectAsState() + + SettingsRow( + R.string.accent_color, + R.string.accent_color_description, + accentOptions, + accentIndex.screenCode, + ) { + sharedPrefs.accentColor.tryEmit(parseAccentColorType(it)) + } +} + +@Composable +fun ShowFontFamilyChoice(sharedPrefs: UiSettingsFlow) { + val fontOptions = + persistentListOf( + TitleExplainer(stringRes(FontFamilyType.SYSTEM.resourceId)), + TitleExplainer(stringRes(FontFamilyType.SANS_SERIF.resourceId)), + TitleExplainer(stringRes(FontFamilyType.SERIF.resourceId)), + TitleExplainer(stringRes(FontFamilyType.MONOSPACE.resourceId)), + ) + + val fontIndex by sharedPrefs.fontFamily.collectAsState() + + SettingsRow( + R.string.font_family, + R.string.font_family_description, + fontOptions, + fontIndex.screenCode, + ) { + sharedPrefs.fontFamily.tryEmit(parseFontFamilyType(it)) + } +} + +@Composable +fun ShowFontSizeChoice(sharedPrefs: UiSettingsFlow) { + val fontSizeOptions = + persistentListOf( + TitleExplainer(stringRes(FontSizeType.SMALL.resourceId)), + TitleExplainer(stringRes(FontSizeType.NORMAL.resourceId)), + TitleExplainer(stringRes(FontSizeType.LARGE.resourceId)), + TitleExplainer(stringRes(FontSizeType.HUGE.resourceId)), + ) + + val fontSizeIndex by sharedPrefs.fontSize.collectAsState() + + SettingsRow( + R.string.font_size, + R.string.font_size_description, + fontSizeOptions, + fontSizeIndex.screenCode, + ) { + sharedPrefs.fontSize.tryEmit(parseFontSizeType(it)) + } +} + @Composable fun ShowImagePreviewChoice(sharedPrefs: UiSettingsFlow) { val connectivityBasedOptions = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Color.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Color.kt index 98c40b26bc..6ca1574e84 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Color.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Color.kt @@ -36,6 +36,19 @@ val Purple200 = Color(0xFFBB86FC) val Purple500 = Color(0xFF6200EE) val Purple700 = Color(0xFF3700B3) val Teal200 = Color(0xFF03DAC5) + +// Accent palette options selected through Settings -> Accent Color. +// Each accent ships a brighter variant for the dark theme and a deeper variant for the light theme. +val AccentBlueDark = Color(0xFF82B1FF) +val AccentBlueLight = Color(0xFF1565C0) +val AccentGreenDark = Color(0xFF80CBC4) +val AccentGreenLight = Color(0xFF2E7D32) +val AccentOrangeDark = Color(0xFFFFB74D) +val AccentOrangeLight = Color(0xFFE65100) +val AccentRedDark = Color(0xFFEF9A9A) +val AccentRedLight = Color(0xFFC62828) +val AccentPinkDark = Color(0xFFF48FB1) +val AccentPinkLight = Color(0xFFAD1457) val BitcoinOrange = Color(0xFFF7931A) val RoyalBlue = Color(0xFF4169E1) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt index 213d04d4e6..a53fa12c30 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt @@ -31,25 +31,31 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.ColorScheme +import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.compositeOver +import androidx.compose.ui.graphics.luminance import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalView import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextLinkStyles import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.em import androidx.compose.ui.unit.sp @@ -62,48 +68,58 @@ import com.patrykandpatrick.vico.compose.common.VicoTheme import com.patrykandpatrick.vico.compose.common.VicoTheme.CandlestickCartesianLayerColors import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.icons.symbols.ProvideMaterialSymbols +import com.vitorpamplona.amethyst.model.AccentColorType +import com.vitorpamplona.amethyst.model.FontFamilyType +import com.vitorpamplona.amethyst.model.FontSizeType import com.vitorpamplona.amethyst.model.ThemeType -private val DarkColorPalette = +// The accent color (primary/secondary/tertiary) is user-selectable in Settings -> Accent Color. +// Purple keeps the original Amethyst look (purple primary + teal secondary). Every other accent +// uses its single hue across primary and secondary for a cohesive single-color theme. +private fun accentPrimary( + accent: AccentColorType, + dark: Boolean, +): Color = + when (accent) { + AccentColorType.PURPLE -> if (dark) Purple200 else Purple500 + AccentColorType.BLUE -> if (dark) AccentBlueDark else AccentBlueLight + AccentColorType.GREEN -> if (dark) AccentGreenDark else AccentGreenLight + AccentColorType.ORANGE -> if (dark) AccentOrangeDark else AccentOrangeLight + AccentColorType.RED -> if (dark) AccentRedDark else AccentRedLight + AccentColorType.PINK -> if (dark) AccentPinkDark else AccentPinkLight + } + +private fun accentSecondary( + accent: AccentColorType, + dark: Boolean, +): Color = if (accent == AccentColorType.PURPLE) Teal200 else accentPrimary(accent, dark) + +private fun darkColors(accent: AccentColorType) = darkColorScheme( - primary = Purple200, - secondary = Teal200, - tertiary = Teal200, + primary = accentPrimary(accent, dark = true), + secondary = accentSecondary(accent, dark = true), + tertiary = accentSecondary(accent, dark = true), background = Color.Black, surface = Color.Black, surfaceDim = Color.Black, surfaceVariant = Color(red = 29, green = 26, blue = 34), ) -private val LightColorPalette = +private fun lightColors(accent: AccentColorType) = lightColorScheme( - primary = Purple500, - secondary = Teal200, - tertiary = Teal200, + primary = accentPrimary(accent, dark = false), + secondary = accentSecondary(accent, dark = false), + tertiary = accentSecondary(accent, dark = false), surfaceContainerHighest = Color(red = 236, green = 230, blue = 240), surfaceVariant = Color(red = 250, green = 245, blue = 252), ) -private val DarkNewItemBackground = DarkColorPalette.primary.copy(0.12f) -private val LightNewItemBackground = LightColorPalette.primary.copy(0.12f) +private val DarkColorPalette = darkColors(AccentColorType.PURPLE) +private val LightColorPalette = lightColors(AccentColorType.PURPLE) private val DarkTransparentBackground = DarkColorPalette.background.copy(0.32f) private val LightTransparentBackground = LightColorPalette.background.copy(0.32f) -private val DarkSelectedNote = DarkNewItemBackground.compositeOver(DarkColorPalette.background) -private val LightSelectedNote = LightNewItemBackground.compositeOver(LightColorPalette.background) - -private val DarkButtonBackground = - DarkColorPalette.primary.copy(alpha = 0.32f).compositeOver(DarkColorPalette.background) -private val LightButtonBackground = - LightColorPalette.primary.copy(alpha = 0.32f).compositeOver(LightColorPalette.background) - -private val DarkLessImportantLink = DarkColorPalette.primary.copy(alpha = 0.52f) -private val LightLessImportantLink = LightColorPalette.primary.copy(alpha = 0.52f) - -private val DarkMediumImportantLink = DarkColorPalette.primary.copy(alpha = 0.32f) -private val LightMediumImportantLink = LightColorPalette.primary.copy(alpha = 0.32f) - private val DarkGrayText = DarkColorPalette.onSurface.copy(alpha = 0.52f) private val LightGrayText = LightColorPalette.onSurface.copy(alpha = 0.52f) @@ -407,26 +423,30 @@ val MarkDownStyleOnLight = ), ) +// Derived from background luminance instead of a fixed primary so the check keeps working +// when the user picks a non-purple accent color (only primary/secondary change, not background). val ColorScheme.isLight: Boolean - get() = primary == Purple500 + get() = background.luminance() > 0.5f +// The accent-derived tints below are computed from the live scheme's primary so they follow +// the selected accent color. Color is an inline value class, so these copies don't allocate. val ColorScheme.newItemBackgroundColor: Color - get() = if (isLight) LightNewItemBackground else DarkNewItemBackground + get() = primary.copy(alpha = 0.12f) val ColorScheme.transparentBackground: Color get() = if (isLight) LightTransparentBackground else DarkTransparentBackground val ColorScheme.selectedNote: Color - get() = if (isLight) LightSelectedNote else DarkSelectedNote + get() = primary.copy(alpha = 0.12f).compositeOver(background) val ColorScheme.secondaryButtonBackground: Color - get() = if (isLight) LightButtonBackground else DarkButtonBackground + get() = primary.copy(alpha = 0.32f).compositeOver(background) val ColorScheme.lessImportantLink: Color - get() = if (isLight) LightLessImportantLink else DarkLessImportantLink + get() = primary.copy(alpha = 0.52f) val ColorScheme.mediumImportanceLink: Color - get() = if (isLight) LightMediumImportantLink else DarkMediumImportantLink + get() = primary.copy(alpha = 0.32f) val ColorScheme.placeholderText: Color get() = if (isLight) LightPlaceholderText else DarkPlaceholderText @@ -562,15 +582,21 @@ val ColorScheme.chartStyle: VicoTheme @Composable fun AmethystTheme(content: @Composable () -> Unit) { - val theme by Amethyst.instance.uiPrefs.value.theme - .collectAsStateWithLifecycle() + val uiPrefs = Amethyst.instance.uiPrefs.value + val theme by uiPrefs.theme.collectAsStateWithLifecycle() + val accentColor by uiPrefs.accentColor.collectAsStateWithLifecycle() + val fontFamily by uiPrefs.fontFamily.collectAsStateWithLifecycle() + val fontSize by uiPrefs.fontSize.collectAsStateWithLifecycle() - AmethystTheme(theme, content) + AmethystTheme(theme, accentColor, fontFamily, fontSize, content) } @Composable fun AmethystTheme( prefTheme: ThemeType, + accentColor: AccentColorType = AccentColorType.PURPLE, + fontFamily: FontFamilyType = FontFamilyType.SYSTEM, + fontSize: FontSizeType = FontSizeType.NORMAL, content: @Composable () -> Unit, ) { val context = LocalContext.current @@ -592,13 +618,33 @@ fun AmethystTheme( isSystemInDarkTheme() } } - val colors = if (darkTheme) DarkColorPalette else LightColorPalette + val colors = + remember(darkTheme, accentColor) { + if (darkTheme) darkColors(accentColor) else lightColors(accentColor) + } + + val resolvedFontFamily = remember(fontFamily) { fontFamily.toFontFamily() } + val typography = remember(fontFamily) { Typography.withFontFamily(resolvedFontFamily) } + + val density = LocalDensity.current + val scaledDensity = + remember(density, fontSize) { + Density(density.density, density.fontScale * fontSize.scale) + } MaterialTheme( colorScheme = colors, - typography = Typography, + typography = typography, shapes = Shapes, - content = { ProvideMaterialSymbols(content = content) }, + content = { + ProvideMaterialSymbols { + CompositionLocalProvider( + LocalDensity provides scaledDensity, + LocalTextStyle provides LocalTextStyle.current.merge(TextStyle(fontFamily = resolvedFontFamily)), + content = content, + ) + } + }, ) val view = LocalView.current diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Type.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Type.kt index b36b940d4f..88aa8e5178 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Type.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Type.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.em import androidx.compose.ui.unit.sp import com.halilibo.richtext.ui.HeadingStyle +import com.vitorpamplona.amethyst.model.FontFamilyType // Set of Material typography styles to start with val Typography = @@ -52,6 +53,39 @@ val Typography = */ ) +// Maps the user-selected font preference to a Compose [FontFamily]. +// SYSTEM returns null so the platform default is used unchanged. +fun FontFamilyType.toFontFamily(): FontFamily? = + when (this) { + FontFamilyType.SYSTEM -> null + FontFamilyType.SANS_SERIF -> FontFamily.SansSerif + FontFamilyType.SERIF -> FontFamily.Serif + FontFamilyType.MONOSPACE -> FontFamily.Monospace + } + +// Applies the chosen [FontFamily] to every text style so Material components pick it up too. +// A null family leaves the typography untouched (platform default). +fun Typography.withFontFamily(fontFamily: FontFamily?): Typography { + if (fontFamily == null) return this + return copy( + displayLarge = displayLarge.copy(fontFamily = fontFamily), + displayMedium = displayMedium.copy(fontFamily = fontFamily), + displaySmall = displaySmall.copy(fontFamily = fontFamily), + headlineLarge = headlineLarge.copy(fontFamily = fontFamily), + headlineMedium = headlineMedium.copy(fontFamily = fontFamily), + headlineSmall = headlineSmall.copy(fontFamily = fontFamily), + titleLarge = titleLarge.copy(fontFamily = fontFamily), + titleMedium = titleMedium.copy(fontFamily = fontFamily), + titleSmall = titleSmall.copy(fontFamily = fontFamily), + bodyLarge = bodyLarge.copy(fontFamily = fontFamily), + bodyMedium = bodyMedium.copy(fontFamily = fontFamily), + bodySmall = bodySmall.copy(fontFamily = fontFamily), + labelLarge = labelLarge.copy(fontFamily = fontFamily), + labelMedium = labelMedium.copy(fontFamily = fontFamily), + labelSmall = labelSmall.copy(fontFamily = fontFamily), + ) +} + val Font4SP = 4.sp val Font6SP = 6.sp val Font8SP = 8.sp diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 013e8c069e..b0be6ff2ef 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1621,6 +1621,26 @@ <string name="wallet_connect">Wallet Connect</string> <string name="language">Language</string> <string name="theme">Theme</string> + <string name="accent_color">Accent Color</string> + <string name="accent_color_description">Main color used across buttons and links</string> + <string name="accent_color_purple">Purple</string> + <string name="accent_color_blue">Blue</string> + <string name="accent_color_green">Green</string> + <string name="accent_color_orange">Orange</string> + <string name="accent_color_red">Red</string> + <string name="accent_color_pink">Pink</string> + <string name="font_family">Font</string> + <string name="font_family_description">Typeface used throughout the app</string> + <string name="font_family_system">System Default</string> + <string name="font_family_sans_serif">Sans Serif</string> + <string name="font_family_serif">Serif</string> + <string name="font_family_monospace">Monospace</string> + <string name="font_size">Font Size</string> + <string name="font_size_description">Scale the text size across the app</string> + <string name="font_size_small">Small</string> + <string name="font_size_normal">Normal</string> + <string name="font_size_large">Large</string> + <string name="font_size_huge">Huge</string> <string name="automatically_load_images_gifs">Image Preview</string> <string name="automatically_play_videos">Video Playback</string> <string name="autoplay_videos">Autoplay Videos</string> From 1069fb0701d0c6bed8ea17158a871ded248e24f7 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 27 Jun 2026 19:50:51 +0000 Subject: [PATCH 11/18] refactor: move Profile Gallery Style setting to Profile UI settings The gallery style selector is profile-specific, so it now lives on the Profile UI settings screen alongside the other profile display toggles instead of the general Application Preferences screen. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01REGsru6cnm6wUzqm12Rh2d --- .../loggedIn/settings/AppSettingsScreen.kt | 23 -------------- .../settings/ProfileUiSettingsScreen.kt | 30 +++++++++++++++++++ 2 files changed, 30 insertions(+), 23 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt index 608b2b0ac2..9a3f6839da 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt @@ -53,7 +53,6 @@ import com.vitorpamplona.amethyst.model.ConnectivityType import com.vitorpamplona.amethyst.model.FeatureSetType import com.vitorpamplona.amethyst.model.FontFamilyType import com.vitorpamplona.amethyst.model.FontSizeType -import com.vitorpamplona.amethyst.model.ProfileGalleryType import com.vitorpamplona.amethyst.model.ThemeType import com.vitorpamplona.amethyst.model.UiSettingsFlow import com.vitorpamplona.amethyst.model.parseAccentColorType @@ -62,7 +61,6 @@ import com.vitorpamplona.amethyst.model.parseConnectivityType import com.vitorpamplona.amethyst.model.parseFeatureSetType import com.vitorpamplona.amethyst.model.parseFontFamilyType import com.vitorpamplona.amethyst.model.parseFontSizeType -import com.vitorpamplona.amethyst.model.parseGalleryType import com.vitorpamplona.amethyst.model.parseThemeType import com.vitorpamplona.amethyst.ui.components.TextSpinner import com.vitorpamplona.amethyst.ui.components.TitleExplainer @@ -129,7 +127,6 @@ fun SettingsScreen(sharedPrefs: UiSettingsFlow) { ShowProfilePictureChoice(sharedPrefs) ImmersiveScrollingChoice(sharedPrefs) FeatureSetChoice(sharedPrefs) - GalleryChoice(sharedPrefs) } } @@ -440,26 +437,6 @@ fun FeatureSetChoice(sharedPrefs: UiSettingsFlow) { } } -@Composable -fun GalleryChoice(sharedPrefs: UiSettingsFlow) { - val galleryItems = - persistentListOf( - TitleExplainer(stringRes(ProfileGalleryType.CLASSIC.resourceId)), - TitleExplainer(stringRes(ProfileGalleryType.MODERN.resourceId)), - ) - - val galleryIndex by sharedPrefs.gallerySet.collectAsState() - - SettingsRow( - R.string.gallery_style, - R.string.gallery_style_description, - galleryItems, - galleryIndex.screenCode, - ) { - sharedPrefs.gallerySet.tryEmit(parseGalleryType(it)) - } -} - @Composable fun SettingsRow( name: Int, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ProfileUiSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ProfileUiSettingsScreen.kt index 5b6ffb4fe2..c39794a696 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ProfileUiSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ProfileUiSettingsScreen.kt @@ -43,6 +43,10 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.ProfileGalleryType +import com.vitorpamplona.amethyst.model.UiSettingsFlow +import com.vitorpamplona.amethyst.model.parseGalleryType +import com.vitorpamplona.amethyst.ui.components.TitleExplainer import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton @@ -51,6 +55,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size20dp import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow +import kotlinx.collections.immutable.persistentListOf @Preview @Composable @@ -129,11 +134,36 @@ fun ProfileUiSettingsContent(accountViewModel: AccountViewModel) { checked = showFollowers, onCheckedChange = { ui.showProfileFollowersFeed.tryEmit(it) }, ) + HorizontalDivider(modifier = Modifier.padding(horizontal = Size20dp)) + + Column(modifier = Modifier.padding(vertical = 12.dp, horizontal = Size20dp)) { + GalleryChoice(ui) + } Spacer(Modifier.height(16.dp)) } } +@Composable +fun GalleryChoice(sharedPrefs: UiSettingsFlow) { + val galleryItems = + persistentListOf( + TitleExplainer(stringRes(ProfileGalleryType.CLASSIC.resourceId)), + TitleExplainer(stringRes(ProfileGalleryType.MODERN.resourceId)), + ) + + val galleryIndex by sharedPrefs.gallerySet.collectAsStateWithLifecycle() + + SettingsRow( + R.string.gallery_style, + R.string.gallery_style_description, + galleryItems, + galleryIndex.screenCode, + ) { + sharedPrefs.gallerySet.tryEmit(parseGalleryType(it)) + } +} + @Composable private fun ProfileUiSwitchRow( title: String, From 373aa7d74045feaa594699ddb98e4863b136bf7d Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 27 Jun 2026 19:56:37 +0000 Subject: [PATCH 12/18] perf: keep ColorScheme.isLight O(1) after accent-color change isLight fans out to hundreds of themed-color getters on hot note/chat/feed render paths. The accent-color work had switched it to background.luminance(), which adds per-call gamma math. Since the accent never touches background (only primary/secondary) and the dark palette's background is exactly Color.Black, a single reference comparison is just as accent-robust and restores the original constant-time cost. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01REGsru6cnm6wUzqm12Rh2d --- .../java/com/vitorpamplona/amethyst/ui/theme/Theme.kt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt index a53fa12c30..39f561d15d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt @@ -46,7 +46,6 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.compositeOver -import androidx.compose.ui.graphics.luminance import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity @@ -423,10 +422,12 @@ val MarkDownStyleOnLight = ), ) -// Derived from background luminance instead of a fixed primary so the check keeps working -// when the user picks a non-purple accent color (only primary/secondary change, not background). +// Compared against the dark palette's background instead of a fixed primary so the check keeps +// working when the user picks a non-purple accent (accent only changes primary/secondary, never +// background). Kept as a single reference comparison because this getter fans out to hundreds of +// themed-color call sites on hot rendering paths — luminance()/etc. would add real per-frame cost. val ColorScheme.isLight: Boolean - get() = background.luminance() > 0.5f + get() = background != Color.Black // The accent-derived tints below are computed from the live scheme's primary so they follow // the selected accent color. Color is an inline value class, so these copies don't allocate. From ac255d5cc66ee8f019f14f8421f7335c1ba538e5 Mon Sep 17 00:00:00 2001 From: vitorpamplona <532031+vitorpamplona@users.noreply.github.com> Date: Sat, 27 Jun 2026 19:59:26 +0000 Subject: [PATCH 13/18] chore: sync Crowdin translations and seed translator npub placeholders --- amethyst/src/main/res/values-sl-rSI/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/amethyst/src/main/res/values-sl-rSI/strings.xml b/amethyst/src/main/res/values-sl-rSI/strings.xml index 81571d3406..86866fedea 100644 --- a/amethyst/src/main/res/values-sl-rSI/strings.xml +++ b/amethyst/src/main/res/values-sl-rSI/strings.xml @@ -2342,6 +2342,7 @@ Za ohranitev zasebnosti to denarnico polni in prazni prek ne-zasebnih računov, <string name="git_repo_section_maintainers">Vzdrževalci</string> <string name="git_repo_section_topics">Teme</string> <string name="git_repo_personal_fork">Osebni fork</string> + <string name="git_repositories">Git repozitoriji</string> <string name="nsite_title">Statična spletna stran: %1$s</string> <string name="napplet_card_title">nApplet: %1$s</string> <string name="napplet_card_permissions">Dovoljenja:</string> From 393ff9ba8b3e0349f270dc1eef7bae782460d783 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 27 Jun 2026 20:47:07 +0000 Subject: [PATCH 14/18] fix: make search filter bar opaque The top bar on the Search screen (search field + filter row with the 3 scope buttons) had no background, so the feed scrolled visibly behind the segmented buttons and the gaps in the bar. Apply the theme surface color to the SearchBar Column, before statusBarsPadding() so the status-bar inset is filled too, matching the default Material3 top-app-bar container color used elsewhere. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LkfRpnNyeeo3AVEaX71oRX --- .../amethyst/ui/screen/loggedIn/search/SearchScreen.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt index cd432f0cfa..c3d39717bf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt @@ -193,7 +193,12 @@ private fun SearchBar( } } - Column(modifier = Modifier.statusBarsPadding()) { + Column( + modifier = + Modifier + .background(MaterialTheme.colorScheme.surface) + .statusBarsPadding(), + ) { SearchTextField(searchBarViewModel, Modifier) // Inline Namecoin lookup feedback for the global search field. // Mirrors the wiring in OnchainZapSendDialog: the local prefix From 9acb53d57474fe149b2d7b4ce688fcbc536d3eb4 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona <vitor@vitorpamplona.com> Date: Sat, 27 Jun 2026 16:53:34 -0400 Subject: [PATCH 15/18] fix(video): drop errored players from warm pool and crop blurhash to true aspect Two inline-video bugs surfaced by a portrait Damus post (imeta dim 720x1280, a rotated H.264 file) that showed a square blurhash in a too-tall box and flashed "Can't play this video": - Square blurhash placeholder: the placeholder bitmap is decoded at the blurhash's DCT component-grid aspect (e.g. a 5x5 grid -> a square bitmap), not the real media shape. When the true ratio is known (from imeta dim) the box is already sized correctly, so render the placeholder with ContentScale.Crop to fill it instead of letting FillWidth letterbox a square inside the taller portrait box. - "Can't play this video" flash: a warm-pooled ExoPlayer could be handed back still carrying a stale PlaybackException. releasePlayer now drops a player that errored before being pooled; acquirePlayer drops one whose decoder died asynchronously while it sat warm (surface reclaim / codec loss). Either way a clean cold/fresh player is used and the stale error never reaches a controller. Also adds playback-error lifecycle logging (PlaybackError tag with a flattened cause chain, a live-controller counter, and cold-load + acquire-time stale-error markers) that made both issues traceable from logcat. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- .../playback/composable/GetVideoController.kt | 15 ++++++ .../service/playback/composable/VideoView.kt | 7 ++- .../composable/WatchPlaybackErrors.kt | 48 ++++++++++++++++++- .../playback/playerPool/ExoPlayerPool.kt | 29 ++++++++++- .../playback/service/PlaybackServiceClient.kt | 12 ++++- 5 files changed, 104 insertions(+), 7 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/GetVideoController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/GetVideoController.kt index f1fce2f1f9..79478bdc34 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/GetVideoController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/GetVideoController.kt @@ -81,6 +81,16 @@ fun GetVideoController( ).onEach { state -> Log.d("PlaybackService") { "Controller instance: ${state.controller}" } + // A warm-pool ExoPlayer can be handed back still carrying a prior + // PlaybackException (e.g. a decoder-init failure from an earlier acquire). The + // re-prepare below clears it before WatchPlaybackErrors ever attaches, so this + // is the only place the stale error — and its decoder/codec cause chain — is + // observable. Logged so a "Can't play this video" blink that self-heals can be + // attributed to warm-pool reuse rather than a genuinely undecodable stream. + state.controller.playerError?.let { err -> + Log.w(ERROR_LOG_TAG) { "Controller arrived carrying error for ${mediaItem.item.mediaId}: ${err.describe()}" } + } + // The default ExoPlayer volume is 1f and the MediaSessionPool reset lambda // sets it to 0f when the player is acquired, so the controller arrives at 0f. // Read first and only push an IPC if the value actually needs to change — @@ -110,6 +120,11 @@ fun GetVideoController( val targetMediaId = mediaItem.item.mediaId val needsLoad = state.controller.currentMediaItem?.mediaId != targetMediaId if (needsLoad) { + // Cold load: a fresh decoder/codec instance gets allocated here. If a + // second controller for the same URI is still alive (see liveControllers + // in PlaybackServiceClient), this prepare() is where MediaCodec.start() + // can collide and fail. + Log.d("PlaybackService") { "Cold load (setMediaItem+prepare) for $targetMediaId" } state.controller.setMediaItem(mediaItem.item) state.controller.prepare() } else if (state.controller.playbackState == Player.STATE_IDLE) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt index 5df1c13240..45f72cf3fe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt @@ -193,7 +193,12 @@ fun VideoView( DisplayBlurHash( blurhash, null, - contentScale, + // The placeholder bitmap is decoded at the blurhash's DCT component-grid aspect + // (e.g. a 5x5 grid -> a square bitmap), NOT the real media shape. When `ratio` is + // known the Box is already sized to the true aspect, so crop the placeholder to + // fill it. Without this, FillWidth letterboxes the square placeholder inside the + // taller portrait box — the "square blurhash on a twice-as-tall space" bug. + if (ratio != null) ContentScale.Crop else contentScale, if (ratio != null) borderModifier.aspectRatio(ratio) else borderModifier, thumbhash = thumbhash, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/WatchPlaybackErrors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/WatchPlaybackErrors.kt index 6a8e6d7467..8141b4a80f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/WatchPlaybackErrors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/WatchPlaybackErrors.kt @@ -30,8 +30,33 @@ import androidx.media3.common.MediaItem import androidx.media3.common.PlaybackException import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi +import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.delay +// Debug tag for the playback-error lifecycle. Logs every appearance, clear, synthetic-stall raise +// and recovery so a transient decoder-init collision (which self-recovers on a later attempt) can +// be told apart from a genuinely undecodable stream in a field logcat. See WatchPlaybackErrors. +internal const val ERROR_LOG_TAG = "PlaybackError" + +/** + * Flattens a [PlaybackException] into a single line: error code, message, and the full nested + * cause chain (e.g. `DecoderInitializationException <- MediaCodec.CodecException`). The cause + * chain is what distinguishes "format truly unsupported" from "decoder failed to start while a + * second controller held the codec" — both surface as the same top-level renderer error with + * `format_supported=YES`. + * + * Internal (not private) so [GetVideoController] can log the same detail at controller-acquire + * time — a warm-pool player can arrive already in ERROR and get re-prepared (cleared) before + * this watcher ever attaches, so the acquire site is the only place that error is observable. + */ +internal fun PlaybackException.describe(): String { + val causeChain = + generateSequence(cause) { it.cause } + .joinToString(" <- ") { "${it::class.simpleName}: ${it.message}" } + .ifEmpty { "none" } + return "code=$errorCodeName($errorCode) msg=$message causes=[$causeChain]" +} + // How often the decode-stall watchdog samples the controller's position/buffer. private const val STALL_POLL_INTERVAL_MS = 1_000L @@ -69,10 +94,18 @@ fun WatchPlaybackErrors(controllerState: MediaControllerState) { // Prime from the controller's current state — a warm-pool player may already be in ERROR // when we attach, in which case onPlayerErrorChanged will not fire again until prepare(). errorState.value = controller.playerError + controller.playerError?.let { + Log.w(ERROR_LOG_TAG) { "Primed with existing error on ${controller.currentMediaItem?.mediaId}: ${it.describe()}" } + } val listener = object : Player.Listener { override fun onPlayerErrorChanged(error: PlaybackException?) { + if (error != null) { + Log.w(ERROR_LOG_TAG) { "Error raised on ${controller.currentMediaItem?.mediaId}: ${error.describe()}" } + } else if (errorState.value != null) { + Log.d(ERROR_LOG_TAG) { "Error cleared on ${controller.currentMediaItem?.mediaId}" } + } errorState.value = error } @@ -81,7 +114,10 @@ fun WatchPlaybackErrors(controllerState: MediaControllerState) { reason: Int, ) { // A new item on a pooled player starts fresh; drop any error from the old one. - if (errorState.value != null) errorState.value = null + if (errorState.value != null) { + Log.d(ERROR_LOG_TAG) { "Error dropped on media transition (reason=$reason) -> ${mediaItem?.mediaId}" } + errorState.value = null + } } override fun onPlaybackStateChanged(state: Int) { @@ -90,7 +126,10 @@ fun WatchPlaybackErrors(controllerState: MediaControllerState) { // STATE_BUFFERING: the synthetic decode-stall error below is raised *while* // buffering, and clearing on every buffering event would wipe it instantly. if (state == Player.STATE_READY) { - if (errorState.value != null) errorState.value = null + if (errorState.value != null) { + Log.d(ERROR_LOG_TAG) { "Recovered (STATE_READY) on ${controller.currentMediaItem?.mediaId} — clearing overlay" } + errorState.value = null + } } } } @@ -141,6 +180,10 @@ private suspend fun watchForDecodeStall( if (unproductiveSinceMs < 0) { unproductiveSinceMs = now } else if (now - unproductiveSinceMs >= STALL_TIMEOUT_MS && errorState.value == null) { + Log.w(ERROR_LOG_TAG) { + "Synthetic decode-stall after ${STALL_TIMEOUT_MS}ms fed-but-frozen " + + "(pos=$position buffered=${controller.bufferedPosition}) on ${controller.currentMediaItem?.mediaId}" + } errorState.value = PlaybackException( "Video decoding stalled with a full buffer — likely an unsupported codec", @@ -156,6 +199,7 @@ private suspend fun watchForDecodeStall( // drop the stall overlay. Real decoder errors leave the player IDLE with a frozen // playhead, so they never progress here and are left for the STATE_READY listener. if (progressed && errorState.value != null) { + Log.d(ERROR_LOG_TAG) { "Playhead progressed to $position — clearing stall overlay on ${controller.currentMediaItem?.mediaId}" } errorState.value = null } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerPool.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerPool.kt index c3567da5b9..e38aa51c0a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerPool.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerPool.kt @@ -117,8 +117,20 @@ class ExoPlayerPool( if (preferredMediaId != null) { val warm = takeWarm(preferredMediaId) if (warm != null) { - Log.d("PlaybackService") { "ExoPlayerPool warm hit: $preferredMediaId" } - return warm + // A warm player can error *after* it was pooled clean — its decoder dies + // asynchronously while paused (emulator surface reclaim, codec loss). releasePlayer + // can't catch that (the error appears post-release), so it's caught here at acquire: + // never hand a stale PlaybackException to a controller. Release the dead player and + // fall through to a clean cold/fresh one — a guaranteed setMediaItem+prepare ahead. + val error = warm.playerError + if (error != null) { + Log.d("PlaybackService") { "ExoPlayerPool discarding errored warm player: $preferredMediaId (${error.errorCodeName})" } + PcmTapRegistry.unregisterPlayer(warm) + warm.release() + } else { + Log.d("PlaybackService") { "ExoPlayerPool warm hit: $preferredMediaId" } + return warm + } } } return coldPool.poll() ?: builder.build(context) @@ -148,6 +160,19 @@ class ExoPlayerPool( mutex.withLock { if (player.isReleased) return@withLock + // A player that errored out (decoder-init failure, decode error) must never be + // returned to either pool. Kept warm, it hands the stale PlaybackException straight + // back to the next acquire of the same URI — the "Can't play this video" flash traced + // to warm-pool reuse. Its failed MediaCodec instance is also suspect. Drop it so the + // pool builds a clean replacement on the next miss. + val error = player.playerError + if (error != null) { + Log.d("PlaybackService") { "ExoPlayerPool dropping errored player: ${player.currentMediaItem?.mediaId} (${error.errorCodeName})" } + PcmTapRegistry.unregisterPlayer(player) + player.release() + return@withLock + } + val mediaId = player.currentMediaItem?.mediaId if (mediaId != null && warmSlotsCap > 0) { // Warm path: keep the player paused but loaded so a quick scroll-back to the diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackServiceClient.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackServiceClient.kt index d641cd8d0c..7339bce641 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackServiceClient.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackServiceClient.kt @@ -32,6 +32,7 @@ import kotlinx.coroutines.flow.callbackFlow import java.util.concurrent.ExecutorService import java.util.concurrent.Executors import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid @@ -46,6 +47,13 @@ object PlaybackServiceClient { // video, each lingering for the 60s keep-alive afterwards. val executorService: ExecutorService = Executors.newFixedThreadPool(4) + // Number of MediaControllers currently held alive (prepared and not yet released). Two + // controllers alive for the same videoUri at once is the signature of the decoder-init + // collision that surfaces as a transient "Can't play this video": the second one's + // MediaCodec.start() fails because the first still holds a codec instance. Logged on every + // prepare/release so the overlap is visible in a field logcat. + private val liveControllers = AtomicInteger(0) + fun shutdown() { executorService.shutdown() } @@ -83,7 +91,7 @@ object PlaybackServiceClient { .setConnectionHints(bundle) .buildAsync() - Log.d("PlaybackService") { "Preparing Controller $id $videoUri" } + Log.d("PlaybackService") { "Preparing Controller $id (live=${liveControllers.incrementAndGet()}) $videoUri" } controllerFuture.addListener( { @@ -108,7 +116,7 @@ object PlaybackServiceClient { ) awaitClose { - Log.d("PlaybackService") { "Releasing Controller $id $videoUri" } + Log.d("PlaybackService") { "Releasing Controller $id (live=${liveControllers.decrementAndGet()}) $videoUri" } try { MediaController.releaseFuture(controllerFuture) } catch (e: Exception) { From 9792c5c75eb8717834ce645cd53c25b9d8756c4e Mon Sep 17 00:00:00 2001 From: vitorpamplona <532031+vitorpamplona@users.noreply.github.com> Date: Sat, 27 Jun 2026 21:03:45 +0000 Subject: [PATCH 16/18] chore: sync Crowdin translations and seed translator npub placeholders --- .../src/main/res/values-pl-rPL/strings.xml | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index 559843c1a6..71030f54e7 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -659,6 +659,9 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest <string name="browser_go">Otwórz</string> <string name="browser_clear">Wyczyść</string> <string name="browser_favorites">Ulubione</string> + <string name="browser_suggested">Odkryj apki webowe</string> + <string name="browser_discover_nsites">Strony osób, które obserwujesz</string> + <string name="browser_discover_napplets">Aplikacje od osób, które obserwujesz</string> <string name="browser_recent_options">Opcje</string> <string name="browser_recent_remove">Usuń z historii</string> <string name="favorite_apps">Ulubione aplikacje</string> @@ -1481,6 +1484,26 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest <string name="wallet_connect">Podłącz portfel</string> <string name="language">Język</string> <string name="theme">Motyw</string> + <string name="accent_color">Kolor akcentujący</string> + <string name="accent_color_description">Główny kolor używany przez przyciski i linki</string> + <string name="accent_color_purple">Fioletowy</string> + <string name="accent_color_blue">Niebieski</string> + <string name="accent_color_green">Zielony</string> + <string name="accent_color_orange">Pomarańczowy</string> + <string name="accent_color_red">Czerwony</string> + <string name="accent_color_pink">Różowy</string> + <string name="font_family">Czcionka</string> + <string name="font_family_description">Czcionka używana w całej aplikacji</string> + <string name="font_family_system">Domyślna</string> + <string name="font_family_sans_serif">Sans Serif</string> + <string name="font_family_serif">Serif</string> + <string name="font_family_monospace">Monospace</string> + <string name="font_size">Rozmiar czcionki</string> + <string name="font_size_description">Dostosuj rozmiar tekstu w całej aplikacji</string> + <string name="font_size_small">Mały</string> + <string name="font_size_normal">Normalny</string> + <string name="font_size_large">Duży</string> + <string name="font_size_huge">Wielki</string> <string name="automatically_load_images_gifs">Podgląd obrazu</string> <string name="automatically_play_videos">Odtwarzanie filmów</string> <string name="autoplay_videos">Autoodtwarzanie filmów</string> @@ -1803,6 +1826,8 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest <string name="cashu_remove_mint">Usuń Minta</string> <string name="cashu_add_mint">Dodaj mint</string> <string name="cashu_history">Historia</string> + <string name="cashu_wallet_autosaves">Twój portfel zapisuje dane automatycznie w miarę dodawania lub usuwania mintów. Klucz Nutzap jest generowany automatycznie przy pierwszym dodaniu minta.</string> + <string name="cashu_wallet_saving">Zapisywanie…</string> <string name="cashu_p2pk_section">Klucz Nutzap (zaawansowany)</string> <string name="cashu_p2pk_explainer">Oddzielny klucz prywatny służący wyłącznie do odbierania NIP-61 Nutzaps. Nie jest to klucz tożsamości Nostr.</string> <string name="cashu_p2pk_autogen">Wygeneruj nowy klucz</string> @@ -2306,6 +2331,10 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest <string name="forked_from">Sklonowany z</string> <string name="git_web_address">Strona internetowa:</string> <string name="git_clone_address">Klonuj:</string> + <string name="git_branch">Gałąź</string> + <string name="git_commit">Commit</string> + <string name="git_merge_base">Scal gałąź główną</string> + <string name="git_pr_update_description">Zaktualizowano pull request, uwzględniając nowy commit.</string> <string name="git_status_open">Otwarte</string> <string name="git_status_merged">Połączone</string> <string name="git_status_closed">Zamknięte</string> @@ -2313,11 +2342,15 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest <string name="git_repo_tab_overview">Przegląd</string> <string name="git_repo_tab_issues">Problemy</string> <string name="git_repo_tab_patches">Łaty & PRs</string> + <string name="git_repo_filter_open">Otwórz</string> + <string name="git_repo_filter_closed">Zamknięty & Rozwiązany</string> + <string name="git_untitled">Bez tytułu</string> <string name="git_repo_section_about">O programie</string> <string name="git_repo_section_links">Linki</string> <string name="git_repo_section_maintainers">Opiekunowie</string> <string name="git_repo_section_topics">Tematy</string> <string name="git_repo_personal_fork">Osobisty fork</string> + <string name="git_repositories">Repozytoria Git</string> <string name="nsite_title">Statyczna Witryna: %1$s</string> <string name="napplet_card_title">nApplet: %1$s</string> <string name="napplet_card_permissions">Uprawnienia:</string> @@ -2608,6 +2641,8 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest <string name="kind_git_patch">Łatka Git</string> <string name="kind_git_repo">Repozytorium Git</string> <string name="kind_git_reply">Odpowiedź Git</string> + <string name="kind_git_pr">Wniosek o zmianę</string> + <string name="kind_git_pr_update">Aktualizacja PR</string> <string name="kind_zap_goals">Cele Zap-a</string> <string name="kind_hashtag_follows">Obserwowane hashtagi</string> <string name="kind_highlights">Najważniejsze informacje</string> From c6575a08887f1d07cf4118e6bc7c1de2b8114cf0 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona <vitor@vitorpamplona.com> Date: Sat, 27 Jun 2026 18:42:27 -0400 Subject: [PATCH 17/18] fix(napplet): drop algorithmic darkening so dark-by-default sites aren't corrupted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setAlgorithmicDarkeningAllowed(true) was added to force-darken pages that don't implement prefers-color-scheme. Combined with nightThemedContext (which forces the embed WebView's isLightTheme=false), it now also runs on pages that are ALREADY dark but don't declare CSS color-scheme support — e.g. ditto.pub, which ships <html class="dark"> by default — and algorithmically inverts their nav bars to light, leaving "dark content, light bars". prefers-color-scheme: dark is driven by isLightTheme (nightThemedContext) INDEPENDENTLY of algorithmic darkening — device-verified: embedded pages still report prefersDark=true with darkening off — so dropping it keeps real dark-aware sites dark while no longer corrupting dark-by-default ones. The trade-off (a site with no dark mode of its own renders light instead of being force-inverted) matches how a real mobile browser behaves. Removed from all four embed/host WebView configs (browser + nsite/napplet, embedded + full-screen) along with the now-unused WebSettingsCompat import. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- .../amethyst/napplethost/NappletBrowserActivity.kt | 4 ---- .../amethyst/napplethost/NappletBrowserService.kt | 4 ---- .../vitorpamplona/amethyst/napplethost/NappletHostActivity.kt | 4 ---- .../vitorpamplona/amethyst/napplethost/NappletHostService.kt | 4 ---- 4 files changed, 16 deletions(-) diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt index 5eef258f37..ef053427ea 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt @@ -60,7 +60,6 @@ import androidx.webkit.JavaScriptReplyProxy import androidx.webkit.ProxyConfig import androidx.webkit.ProxyController import androidx.webkit.WebMessageCompat -import androidx.webkit.WebSettingsCompat import androidx.webkit.WebViewCompat import androidx.webkit.WebViewFeature import com.vitorpamplona.amethyst.commons.browser.OmniboxInput @@ -315,9 +314,6 @@ class NappletBrowserActivity : ComponentActivity() { safeBrowsingEnabled = true } } - if (WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING)) { - WebSettingsCompat.setAlgorithmicDarkeningAllowed(wv.settings, true) - } WebView.setWebContentsDebuggingEnabled(false) wv.webViewClient = BrowserClient() wv.webChromeClient = BrowserChromeClient() diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt index 5f000eef66..3ba6e87554 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt @@ -48,7 +48,6 @@ import androidx.annotation.RequiresApi import androidx.privacysandbox.ui.provider.toCoreLibInfo import androidx.webkit.JavaScriptReplyProxy import androidx.webkit.WebMessageCompat -import androidx.webkit.WebSettingsCompat import androidx.webkit.WebViewCompat import androidx.webkit.WebViewFeature import com.vitorpamplona.amethyst.commons.browser.OmniboxInput @@ -313,9 +312,6 @@ class NappletBrowserService : Service() { safeBrowsingEnabled = true } } - if (WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING)) { - WebSettingsCompat.setAlgorithmicDarkeningAllowed(wv.settings, true) - } WebView.setWebContentsDebuggingEnabled(false) wv.webViewClient = BrowserClient(tab) wv.webChromeClient = BrowserChromeClient(tab) diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt index d6b3b878c5..4861ab7798 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt @@ -62,7 +62,6 @@ import androidx.webkit.JavaScriptReplyProxy import androidx.webkit.ProxyConfig import androidx.webkit.ProxyController import androidx.webkit.WebMessageCompat -import androidx.webkit.WebSettingsCompat import androidx.webkit.WebViewCompat import androidx.webkit.WebViewFeature import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract @@ -507,9 +506,6 @@ class NappletHostActivity : ComponentActivity() { safeBrowsingEnabled = true } } - if (WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING)) { - WebSettingsCompat.setAlgorithmicDarkeningAllowed(webView.settings, true) - } // Disable the overscroll stretch/glow: forcing a scroll past the content edge stretched the // WebView's output and exposed the shell document's background behind the applet iframe at the // seam (a stray white band at the bottom). The applet's own content still scrolls normally. diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt index 79de4ee87e..b2fe56b483 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt @@ -50,7 +50,6 @@ import androidx.webkit.JavaScriptReplyProxy import androidx.webkit.ProxyConfig import androidx.webkit.ProxyController import androidx.webkit.WebMessageCompat -import androidx.webkit.WebSettingsCompat import androidx.webkit.WebViewCompat import androidx.webkit.WebViewFeature import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract @@ -340,9 +339,6 @@ class NappletHostService : Service() { safeBrowsingEnabled = true } } - if (WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING)) { - WebSettingsCompat.setAlgorithmicDarkeningAllowed(wv.settings, true) - } wv.overScrollMode = View.OVER_SCROLL_NEVER WebView.setWebContentsDebuggingEnabled(false) wv.webViewClient = HostClient(tab) From 925c5e454a797c825e8e3aac3b4b5844019252cf Mon Sep 17 00:00:00 2001 From: Vitor Pamplona <vitor@vitorpamplona.com> Date: Sat, 27 Jun 2026 18:42:46 -0400 Subject: [PATCH 18/18] feat(embed): follow the app theme live in embedded web tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An embedded tab's WebView resolves its theme from the context it is built with (nightThemedContext), once, at construction — a runtime config change does not re-flip the renderer — so a live app-theme switch never reached an already-warm surface; it took a full app restart. Watch the resolved DARK/LIGHT theme and, on a real flip, rebuild the warm sessions in the new theme: - EmbeddedTabHost.themeEpoch + rebuildAllForTheme() tears down the warm controllers but keeps activeId, so the visible tab re-activates the instant its screen re-acquires (no blanked-out surface). - EmbeddedTabThemeWatcher (mounted by AppNavigation next to the tab layer) collects uiPrefs.theme + isSystemInDarkTheme() and triggers the rebuild. - WebAppScreen / NostrAppScreen key their controller on themeEpoch (NostrApp also re-mints its launch params); the preloader re-warms off-screen tabs; the tab layer keys each surface on (id, controller) so a rebuilt session gets a fresh SandboxedSdkView. The page reloads in the new theme (unavoidable — the theme is fixed at WebView construction). Device-verified Dark<->Light with no app restart. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- .../amethyst/ui/navigation/AppNavigation.kt | 4 ++ .../screen/loggedIn/browser/WebAppScreen.kt | 6 +- .../screen/loggedIn/embed/EmbeddedTabHost.kt | 22 ++++++ .../screen/loggedIn/embed/EmbeddedTabLayer.kt | 4 +- .../loggedIn/embed/EmbeddedTabPreloader.kt | 4 +- .../loggedIn/embed/EmbeddedTabThemeWatcher.kt | 67 +++++++++++++++++++ .../loggedIn/favorites/NostrAppScreen.kt | 9 +-- 7 files changed, 108 insertions(+), 8 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabThemeWatcher.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 f8419635d6..be7aaa5cc1 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 @@ -120,6 +120,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.DvmContentDiscoveryScr import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.favorites.FavoriteAlgoFeedsListScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabLayer import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabPreloader +import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabThemeWatcher import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.FavoriteAppManifestPreloader import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.browse.BrowseEmojiSetsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.display.EmojiPackScreen @@ -270,6 +271,9 @@ fun AppNavigation( EmbeddedTabLayer(bottomBarItems.favoriteIds()) // Warm every pinned tab at startup so the first tap is instant (content already local). EmbeddedTabPreloader(accountViewModel) + // Rebuild the warm surfaces in the new theme when the app's DARK/LIGHT preference flips + // (an embed WebView's theme is fixed at construction, so it can't follow a live switch). + EmbeddedTabThemeWatcher() } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/WebAppScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/WebAppScreen.kt index a3491f8831..0699770886 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/WebAppScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/WebAppScreen.kt @@ -113,8 +113,10 @@ private fun EmbeddedWebAppTab( val backgroundColor = MaterialTheme.colorScheme.background.toArgb() + // Keyed on the theme epoch too: when the app theme flips, the warm session is torn down and this + // re-acquires a freshly-themed one (the embed WebView's theme is fixed at construction). val controller = - remember(id) { + remember(id, EmbeddedTabHost.themeEpoch) { EmbeddedTabFactory.acquireWebApp(context, url, backgroundColor) } @@ -128,7 +130,7 @@ private fun EmbeddedWebAppTab( // Rebuilt only when a displayed value changes, so the tab layer isn't recomposed every frame. val chrome = - remember(currentUrl, torOn, proxyAvailable, isFavorite) { + remember(currentUrl, torOn, proxyAvailable, isFavorite, controller) { EmbeddedTabChrome( title = hostLabel(currentUrl), isSandbox = false, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabHost.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabHost.kt index a95a3aff56..bc0ed87804 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabHost.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabHost.kt @@ -56,6 +56,15 @@ object EmbeddedTabHost { var activeId by mutableStateOf<String?>(null) private set + /** + * Bumped whenever the app's resolved DARK/LIGHT theme flips (see [rebuildAllForTheme]). The embed + * WebView's theme is locked in at construction (`nightThemedContext`), so following a theme change + * means rebuilding the surface — the favorite screens and the preloader key their acquisition on this + * so they re-acquire a freshly-themed session instead of the stale warm one. + */ + var themeEpoch by mutableStateOf(0) + private set + /** Window-space bounds of the active tab's reserved content area. */ var contentBounds by mutableStateOf(Rect.Zero) private set @@ -158,4 +167,17 @@ object EmbeddedTabHost { warm.clear() copy.forEach { it.controller.teardown() } } + + /** + * The app theme changed: tear down every warm session (their WebViews are pinned to the old theme) + * and bump [themeEpoch] so the visible screen and the preloader re-acquire freshly-themed sessions. + * Unlike [evictAll] this keeps [activeId], so the visible tab re-activates the instant its screen + * re-acquires — the user just sees the current tab reload in the new theme, not a blanked-out surface. + */ + fun rebuildAllForTheme() { + val copy = warm.toList() + warm.clear() + copy.forEach { it.controller.teardown() } + themeEpoch += 1 + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabLayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabLayer.kt index 2b5e3e33de..98584c6460 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabLayer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabLayer.kt @@ -162,7 +162,9 @@ fun EmbeddedTabLayer(barFavoriteIds: List<String>) { }, ) { EmbeddedTabHost.sessions.forEach { session -> - key(session.id) { + // Key on the controller too: a theme rebuild replaces the controller under the same id, and + // the new one needs a fresh SandboxedSdkView (the factory below attaches the surface once). + key(session.id, session.controller) { val active = session.id == activeId LaunchedEffect(active) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabPreloader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabPreloader.kt index 312f76a25e..39ffea72e1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabPreloader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabPreloader.kt @@ -76,7 +76,9 @@ fun EmbeddedTabPreloader(accountViewModel: AccountViewModel) { } } - LaunchedEffect(favoriteIds, backgroundColor) { + // Re-warms after a theme flip: [rebuildAllForTheme] tears down the warm sessions and bumps the epoch, + // so this sweep re-acquires them in the new theme (keying on the epoch also orders it after the teardown). + LaunchedEffect(favoriteIds, backgroundColor, EmbeddedTabHost.themeEpoch) { if (favoriteIds.isEmpty()) return@LaunchedEffect // Hydrate the per-site Tor/open-web choices BEFORE the first preload: a cold start otherwise reads // the bare Tor default and would route a site the user pinned to the open web through Tor (or stall diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabThemeWatcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabThemeWatcher.kt new file mode 100644 index 0000000000..2a22372db8 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabThemeWatcher.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.ui.screen.loggedIn.embed + +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.compose.foundation.isSystemInDarkTheme +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.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.model.ThemeType + +/** + * Keeps the warm embedded tabs in sync with the app's DARK/LIGHT theme. An embed WebView resolves its + * theme from the context it's built with (`nightThemedContext`), once, at construction — a runtime config + * change does NOT re-flip the renderer — so the only way a live theme switch reaches an already-running + * surface is to rebuild it. This watches the resolved theme and, on an actual flip, asks + * [EmbeddedTabHost] to tear down + re-acquire every session in the new theme. + * + * Mount once next to [EmbeddedTabLayer]/[EmbeddedTabPreloader]. Draws nothing. + */ +@RequiresApi(Build.VERSION_CODES.R) +@Composable +fun EmbeddedTabThemeWatcher() { + val theme by Amethyst.instance.uiPrefs.value.theme + .collectAsStateWithLifecycle() + // SYSTEM resolves against the device night mode, so a scheduled/auto device flip also rebuilds. + val systemDark = isSystemInDarkTheme() + val resolvedDark = + when (theme) { + ThemeType.DARK -> true + ThemeType.LIGHT -> false + ThemeType.SYSTEM -> systemDark + } + + // Holds the theme the warm surfaces were last built in; a mismatch (only after a real flip — the + // first composition seeds it equal) triggers exactly one rebuild. + val applied = remember { mutableStateOf(resolvedDark) } + LaunchedEffect(resolvedDark) { + if (applied.value != resolvedDark) { + applied.value = resolvedDark + EmbeddedTabHost.rebuildAllForTheme() + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/NostrAppScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/NostrAppScreen.kt index bd33602d4f..9fe92ee7a9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/NostrAppScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/NostrAppScreen.kt @@ -112,8 +112,9 @@ private fun EmbeddedNostrAppTab( // Matches FavoriteApp.NostrApp.id, so warm-keep membership lines up with the bottom-bar favorites. val id = "nostr:$coordinate" - // Mint the verified launch params (a fresh token per resolve); null until the event loads. - val params = remember(coordinate) { FavoriteAppLauncher.embedParams(context, coordinate) } + // Mint the verified launch params (a fresh token per resolve); null until the event loads. Re-minted + // on a theme flip (the params carry the resolved theme into the sandbox host's WebView). + val params = remember(coordinate, EmbeddedTabHost.themeEpoch) { FavoriteAppLauncher.embedParams(context, coordinate) } if (params == null) { UnavailableTab(coordinate, accountViewModel, nav) return @@ -133,7 +134,7 @@ private fun EmbeddedNostrAppTab( val isFavorite = remember(apps, coordinate) { apps.any { it.id == "nostr:$coordinate" } } val controller = - remember(id) { + remember(id, EmbeddedTabHost.themeEpoch) { EmbeddedTabFactory.acquireNostrApp(context, coordinate, params, backgroundColor) } @@ -147,7 +148,7 @@ private fun EmbeddedNostrAppTab( // Stable per app (title/coordinate/isFavorite don't change often), so the tab layer isn't recomposed every frame. val chrome = - remember(title, coordinate, isFavorite) { + remember(title, coordinate, isFavorite, controller) { EmbeddedTabChrome( title = title.ifBlank { coordinate }, isSandbox = true,