mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +00:00
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:
+165
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user