feat(napplets): richer browse cards + follow-list filter bar

The Napplets browse screen now matches the other feed screens:

- Top bar gains a follow-list FeedFilterSpinner (drawer/back · filter · search +
  manage-permissions), persisted in account settings as defaultNappletsFollowList
  and applied via a new Account.liveNappletsFollowLists author-matcher so you can
  scope the list to a people set (All/Follows/custom).
- Each row is a rich card: author avatar + name, title, description, the declared
  capability chips, and the standard reaction bar (reply/boost/like/zap) wired to
  the canonical cache Note — so napplets get the same social actions as any event.

Follow-list plumbing mirrors the existing categories (AccountSettings field +
change fns, LocalPreferences persist/load, FollowListPrefs). LoggedInUserPictureDrawer
is now internal so the new NappletsTopBar can reuse the drawer opener.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
This commit is contained in:
Claude
2026-06-22 16:42:16 +00:00
parent d60a618653
commit 7aa5f10a72
6 changed files with 203 additions and 34 deletions
@@ -105,6 +105,7 @@ private object PrefKeys {
const val DEFAULT_DISCOVERY_FOLLOW_LIST = "defaultDiscoveryFollowList"
const val DEFAULT_POLLS_FOLLOW_LIST = "defaultPollsFollowList"
const val DEFAULT_PICTURES_FOLLOW_LIST = "defaultPicturesFollowList"
const val DEFAULT_NAPPLETS_FOLLOW_LIST = "defaultNappletsFollowList"
const val DEFAULT_WORKOUTS_FOLLOW_LIST = "defaultWorkoutsFollowList"
const val DEFAULT_CALENDARS_FOLLOW_LIST = "defaultCalendarsFollowList"
const val DEFAULT_PRODUCTS_FOLLOW_LIST = "defaultProductsFollowList"
@@ -390,6 +391,7 @@ object LocalPreferences {
putString(PrefKeys.DEFAULT_POLLS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultPollsFollowList.value))
putString(PrefKeys.DEFAULT_PICTURES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultPicturesFollowList.value))
putString(PrefKeys.DEFAULT_NAPPLETS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultNappletsFollowList.value))
putString(PrefKeys.DEFAULT_WORKOUTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultWorkoutsFollowList.value))
putString(PrefKeys.DEFAULT_CALENDARS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultCalendarsFollowList.value))
putString(PrefKeys.DEFAULT_PRODUCTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultProductsFollowList.value))
@@ -714,6 +716,7 @@ object LocalPreferences {
defaultDiscoveryFollowList = MutableStateFlow(followListPrefs.discovery),
defaultPollsFollowList = MutableStateFlow(followListPrefs.polls),
defaultPicturesFollowList = MutableStateFlow(followListPrefs.pictures),
defaultNappletsFollowList = MutableStateFlow(followListPrefs.napplets),
defaultWorkoutsFollowList = MutableStateFlow(followListPrefs.workouts),
defaultCalendarsFollowList = MutableStateFlow(followListPrefs.calendars),
defaultProductsFollowList = MutableStateFlow(followListPrefs.products),
@@ -804,6 +807,7 @@ object LocalPreferences {
val discovery: TopFilter,
val polls: TopFilter,
val pictures: TopFilter,
val napplets: TopFilter,
val workouts: TopFilter,
val calendars: TopFilter,
val products: TopFilter,
@@ -857,6 +861,7 @@ object LocalPreferences {
discovery = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, null), TopFilter.Global),
polls = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_POLLS_FOLLOW_LIST, null), TopFilter.Global),
pictures = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_PICTURES_FOLLOW_LIST, null), TopFilter.Global),
napplets = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_NAPPLETS_FOLLOW_LIST, null), TopFilter.Global),
workouts = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_WORKOUTS_FOLLOW_LIST, null), TopFilter.Global),
calendars = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_CALENDARS_FOLLOW_LIST, null), TopFilter.Global),
products = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_PRODUCTS_FOLLOW_LIST, null), TopFilter.AroundMe),
@@ -534,6 +534,9 @@ class Account(
val livePicturesFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultPicturesFollowList)
val livePicturesFollowListsPerRelay = OutboxLoaderState(livePicturesFollowLists, cache, scope).flow
// Napplets read from the local cache (no outbox subscription yet), so only the author-matcher is needed.
val liveNappletsFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultNappletsFollowList)
val liveWorkoutsFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultWorkoutsFollowList)
val liveWorkoutsFollowListsPerRelay = OutboxLoaderState(liveWorkoutsFollowLists, cache, scope).flow
@@ -189,6 +189,7 @@ class AccountSettings(
val defaultDiscoveryFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultPollsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultPicturesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultNappletsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultWorkoutsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultCalendarsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultProductsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.AroundMe),
@@ -640,6 +641,17 @@ class AccountSettings(
}
}
fun changeDefaultNappletsFollowList(name: FeedDefinition) {
changeDefaultNappletsFollowList(name.code)
}
fun changeDefaultNappletsFollowList(name: TopFilter) {
if (defaultNappletsFollowList.value != name) {
defaultNappletsFollowList.tryEmit(name)
saveAccountSettings()
}
}
fun changeDefaultWorkoutsFollowList(name: FeedDefinition) {
changeDefaultWorkoutsFollowList(name.code)
}
@@ -83,7 +83,7 @@ fun UserDrawerSearchTopBar(
}
@Composable
private fun LoggedInUserPictureDrawer(
internal fun LoggedInUserPictureDrawer(
accountViewModel: AccountViewModel,
onClick: () -> Unit,
) {
@@ -24,15 +24,20 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SuggestionChip
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -46,14 +51,13 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.napplet.NappletLauncher
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.topbars.MyExtensibleTopAppBar
import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
import com.vitorpamplona.amethyst.ui.note.ReactionsRow
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser
import com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.datasource.NappletsFilterAssemblerSubscription
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
@@ -63,16 +67,15 @@ import com.vitorpamplona.quartz.nip5dNapplets.RootNappletEvent
/**
* Lists the napplet manifests currently in the local cache (NIP-5D kinds 15129/35129) and opens
* the selected one in the sandboxed [NappletLauncher] host. Reads the cache directly rather than
* standing up a full relay-backed feed — discovery/subscription is a later step.
* the selected one in the sandboxed [NappletLauncher] host. The top bar carries a follow-list filter
* (like the Pictures/Articles feeds) and each row is a rich card with the author, declared
* capabilities, and the usual reaction bar (reply/boost/like/zap).
*/
@Composable
fun NappletsScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
val context = LocalContext.current
// Pull napplet manifests from the user's relays into LocalCache while this screen is open.
NappletsFilterAssemblerSubscription(accountViewModel)
@@ -82,20 +85,18 @@ fun NappletsScreen(
)
}.collectAsStateWithLifecycle(emptyList())
val followFilter by accountViewModel.account.liveNappletsFollowLists
.collectAsStateWithLifecycle()
val visible =
remember(napplets, followFilter) {
napplets.filter { followFilter.matchAuthor(it.pubKey) }
}
Scaffold(
topBar = {
MyExtensibleTopAppBar(
title = { Text(stringResource(R.string.napplets)) },
navigationIcon = { IconButton(onClick = { nav.popBack() }) { ArrowBackIcon() } },
actions = {
IconButton(onClick = { nav.nav(Route.NappletPermissions) }) {
Icon(MaterialSymbols.Tune, contentDescription = stringResource(R.string.napplet_manage_permissions))
}
},
)
},
topBar = { NappletsTopBar(accountViewModel, nav) },
) { padding ->
if (napplets.isEmpty()) {
if (visible.isEmpty()) {
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
Text(
stringResource(R.string.napplet_none_found),
@@ -103,11 +104,15 @@ fun NappletsScreen(
)
}
} else {
val context = LocalContext.current
LazyColumn(Modifier.fillMaxSize().padding(padding)) {
items(napplets, key = { it.id }) { event ->
items(visible, key = { it.id }) { event ->
val manifest = event as? NappletManifest ?: return@items
NappletRow(
NappletCard(
event = event,
manifest = manifest,
accountViewModel = accountViewModel,
nav = nav,
onClick = {
NappletLauncher.launch(
context = context,
@@ -124,33 +129,72 @@ fun NappletsScreen(
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun NappletRow(
private fun NappletCard(
event: Event,
manifest: NappletManifest,
accountViewModel: AccountViewModel,
nav: INav,
onClick: () -> Unit,
) {
val note = remember(event.id) { Amethyst.instance.cache.getOrCreateNote(event) }
Column(
modifier =
Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
text = manifest.title()?.ifBlank { null } ?: stringResource(R.string.napplet_untitled),
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Row(verticalAlignment = Alignment.CenterVertically) {
UserPicture(userHex = event.pubKey, size = 48.dp, accountViewModel = accountViewModel, nav = nav)
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
text = manifest.title()?.ifBlank { null } ?: stringResource(R.string.napplet_untitled),
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
LoadUser(baseUserHex = event.pubKey, accountViewModel) { user ->
if (user != null) {
UsernameDisplay(user, accountViewModel = accountViewModel)
}
}
}
}
manifest.description()?.takeIf { it.isNotBlank() }?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
}
val requires = manifest.requires()
if (requires.isNotEmpty()) {
FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
requires.forEach { capability ->
SuggestionChip(
onClick = onClick,
label = { Text(capability.replaceFirstChar { it.uppercase() }) },
)
}
}
}
ReactionsRow(
baseNote = note,
showReactionDetail = true,
addPadding = false,
editState = null,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
@@ -0,0 +1,105 @@
/*
* 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.napplets
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.topbars.FeedFilterSpinner
import com.vitorpamplona.amethyst.ui.navigation.topbars.LoggedInUserPictureDrawer
import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar
import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
import com.vitorpamplona.amethyst.ui.note.SearchIcon
import com.vitorpamplona.amethyst.ui.screen.FeedDefinition
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size22Modifier
import com.vitorpamplona.amethyst.ui.theme.placeholderText
/**
* Top bar for the Napplets browse screen, matching the other feed screens (Pictures, Articles, …): a
* drawer/back navigation icon, a centered follow-list [FeedFilterSpinner] that filters which authors'
* napplets are shown, and Search + "manage permissions" actions.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NappletsTopBar(
accountViewModel: AccountViewModel,
nav: INav,
) {
ShorterTopAppBar(
title = {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
NappletsTopNavFilterBar(accountViewModel)
}
},
navigationIcon = {
if (nav.canPop()) {
IconButton(onClick = nav::popBack) { ArrowBackIcon() }
} else {
LoggedInUserPictureDrawer(accountViewModel, nav::openDrawer)
}
},
actions = {
IconButton(onClick = { nav.nav(Route.NappletPermissions) }) {
Icon(MaterialSymbols.Tune, contentDescription = stringResource(R.string.napplet_manage_permissions))
}
IconButton(onClick = { nav.nav(Route.Search) }) {
SearchIcon(modifier = Size22Modifier, MaterialTheme.colorScheme.placeholderText)
}
},
)
}
@Composable
private fun NappletsTopNavFilterBar(accountViewModel: AccountViewModel) {
val listName: TopFilter by accountViewModel.account.settings.defaultNappletsFollowList
.collectAsStateWithLifecycle()
val allLists by accountViewModel.feedStates.feedListOptions.kind3GlobalPeopleRoutes
.collectAsStateWithLifecycle()
FeedFilterSpinner(
placeholderCode = listName,
explainer = stringRes(R.string.select_list_to_filter),
options = allLists,
onSelect = { selected: FeedDefinition -> accountViewModel.account.settings.changeDefaultNappletsFollowList(selected) },
accountViewModel = accountViewModel,
)
}