From 7aa5f10a7270a90c165fdef6bac7d179865dfc2c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Jun 2026 16:40:43 +0000 Subject: [PATCH] feat(napplets): richer browse cards + follow-list filter bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde --- .../amethyst/LocalPreferences.kt | 5 + .../vitorpamplona/amethyst/model/Account.kt | 3 + .../amethyst/model/AccountSettings.kt | 12 ++ .../topbars/UserDrawerSearchTopBar.kt | 2 +- .../loggedIn/napplets/NappletsScreen.kt | 110 ++++++++++++------ .../loggedIn/napplets/NappletsTopBar.kt | 105 +++++++++++++++++ 6 files changed, 203 insertions(+), 34 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletsTopBar.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 79ef5a3bd9..1800a9a4e0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -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), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 6ac097df8e..09b341afac 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -534,6 +534,9 @@ class Account( val livePicturesFollowLists: StateFlow = 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 = topNavFilterFlow(settings.defaultNappletsFollowList) + val liveWorkoutsFollowLists: StateFlow = topNavFilterFlow(settings.defaultWorkoutsFollowList) val liveWorkoutsFollowListsPerRelay = OutboxLoaderState(liveWorkoutsFollowLists, cache, scope).flow diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index df6f82ba37..b9653e89e8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -189,6 +189,7 @@ class AccountSettings( val defaultDiscoveryFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), val defaultPollsFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), val defaultPicturesFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), + val defaultNappletsFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), val defaultWorkoutsFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), val defaultCalendarsFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), val defaultProductsFollowList: MutableStateFlow = 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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/UserDrawerSearchTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/UserDrawerSearchTopBar.kt index 570a4d2737..efdcfff438 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/UserDrawerSearchTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/UserDrawerSearchTopBar.kt @@ -83,7 +83,7 @@ fun UserDrawerSearchTopBar( } @Composable -private fun LoggedInUserPictureDrawer( +internal fun LoggedInUserPictureDrawer( accountViewModel: AccountViewModel, onClick: () -> Unit, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletsScreen.kt index 6ccc5e4e29..2696f54a15 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletsScreen.kt @@ -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, + ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletsTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletsTopBar.kt new file mode 100644 index 0000000000..637c145655 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletsTopBar.kt @@ -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, + ) +}