feat: show curated descriptions for discover web apps

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 <title>/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
This commit is contained in:
Claude
2026-06-27 18:34:53 +00:00
parent 5029acccee
commit 2d6221fd10
3 changed files with 158 additions and 121 deletions
@@ -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))
}
}
}
@@ -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()
},
)
}
}
}
@@ -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))
}
}