feat: discover followed nsites & napplets in the browser

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
This commit is contained in:
Claude
2026-06-27 19:10:57 +00:00
parent 64abf2d544
commit fb76705f71
2 changed files with 167 additions and 0 deletions
@@ -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,
+2
View File
@@ -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>