From fb76705f71bb2c74e4b80b2172d7ce88f570be28 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 19:10:57 +0000 Subject: [PATCH] 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 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().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, favoriteUrls: Set, suggested: List, + nsites: List, + napplets: List, 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.toDiscoverApps( + mine: Boolean, + myPubkey: String, + matchAuthor: (String) -> Boolean, + excludeCoordinates: Set, +): List = + 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 @@ Clear Favorites Discover web apps + Sites from people you follow + Apps from people you follow Options Remove from history Web apps