From 7aa5f10a7270a90c165fdef6bac7d179865dfc2c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Jun 2026 16:40:43 +0000 Subject: [PATCH 1/4] 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, + ) +} From 4cd71b140ce49a1ddd5a963012da4136eaaf4876 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Jun 2026 16:47:05 +0000 Subject: [PATCH 2/4] refactor(napplets): render browse rows via shared NoteCompose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the bespoke NappletCard with NoteCompose — the same path the main feed uses for kind 15129/35129 events. This reuses the author header, the shared StaticWebsiteCard (title/description/source/servers/capability list + Open button wired to NappletLauncher), and the standard reaction bar (reply/boost/like/zap), instead of a second hand-rolled card that could drift from the feed and was missing NoteCompose's timestamp, hidden-user handling, zap-amount menus, etc. The new top bar + follow-list filter (defaultNappletsFollowList / matchAuthor) are genuinely new and kept as-is. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde --- .../loggedIn/napplets/NappletsScreen.kt | 116 ++---------------- 1 file changed, 12 insertions(+), 104 deletions(-) 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 2696f54a15..f13bfb4fd8 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 @@ -20,56 +20,42 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets -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.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 import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextOverflow -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.napplet.NappletLauncher import com.vitorpamplona.amethyst.ui.navigation.navs.INav -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.note.NoteCompose 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 import com.vitorpamplona.quartz.nip5dNapplets.NamedNappletEvent -import com.vitorpamplona.quartz.nip5dNapplets.NappletManifest 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. 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). + * Lists the napplet manifests currently in the local cache (NIP-5D kinds 15129/35129). The top bar + * carries a follow-list filter (like the Pictures/Articles feeds) and each row is rendered through the + * shared [NoteCompose] — the same path the main feed uses for these events — so it gets the author + * header, the [com.vitorpamplona.amethyst.commons.ui.note.StaticWebsiteCard] (title, description, + * capability chips, and an Open button wired to the sandboxed [NappletLauncher]), and the standard + * reaction bar (reply/boost/like/zap) for free, without a second card implementation that could drift. */ @Composable fun NappletsScreen( @@ -104,23 +90,15 @@ fun NappletsScreen( ) } } else { - val context = LocalContext.current LazyColumn(Modifier.fillMaxSize().padding(padding)) { items(visible, key = { it.id }) { event -> - val manifest = event as? NappletManifest ?: return@items - NappletCard( - event = event, - manifest = manifest, + val note = remember(event.id) { Amethyst.instance.cache.getOrCreateNote(event) } + NoteCompose( + baseNote = note, + modifier = Modifier.fillMaxWidth(), + quotesLeft = 3, accountViewModel = accountViewModel, nav = nav, - onClick = { - NappletLauncher.launch( - context = context, - manifest = manifest, - authorPubKey = event.pubKey, - identifier = (event as? NamedNappletEvent)?.identifier() ?: "", - ) - }, ) HorizontalDivider() } @@ -128,73 +106,3 @@ fun NappletsScreen( } } } - -@OptIn(ExperimentalLayoutApi::class) -@Composable -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(6.dp), - ) { - 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 = 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, - ) - } -} From a2eae42c078ef591c4df150e1d32bf2085f34c78 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Jun 2026 17:13:41 +0000 Subject: [PATCH 3/4] feat(napplets): app-store-style card + icon manifest tag; demote capabilities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Redesign the shared StaticWebsiteCard (used by the feed AND the napplets browse screen) to look like an app entry instead of a manifest dump: square app icon (with a colored monogram fallback), name, a NAPPLET/WEBSITE type label, a short description, and an Open button. The technical details users don't care about — declared capabilities, Blossom servers, source URL — move behind a tap-to-expand "What it can access" disclosure; capabilities are still re-confirmed at the consent prompt when actually used and remain fully manageable in the permissions screen. Add an `icon` tag (NIP-5A/5D) end-to-end: - quartz: IconTag + siteIcon() accessor/builder, NappletManifest.icon(), and an icon param on all four site/napplet build() factories (+ round-trip test). - amy: `--icon URL` on `nsite/napplet publish`, surfaced in the publish output. - card: renders the icon via Coil, monogram fallback when absent. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde --- .../amethyst/ui/note/types/StaticWebsite.kt | 4 + .../amethyst/cli/commands/NappletCommands.kt | 6 +- .../amethyst/cli/commands/NsiteCommands.kt | 6 +- .../cli/commands/StaticSitePublish.kt | 5 +- .../composeResources/values/strings.xml | 4 +- .../commons/ui/note/StaticWebsiteCard.kt | 269 +++++++++++++----- .../nip5aStaticWebsites/NamedSiteEvent.kt | 4 + .../nip5aStaticWebsites/RootSiteEvent.kt | 4 + .../nip5aStaticWebsites/TagArrayBuilderExt.kt | 3 + .../quartz/nip5aStaticWebsites/TagArrayExt.kt | 3 + .../nip5aStaticWebsites/tags/IconTag.kt | 40 +++ .../quartz/nip5dNapplets/NamedNappletEvent.kt | 3 + .../quartz/nip5dNapplets/NappletManifest.kt | 4 + .../quartz/nip5dNapplets/RootNappletEvent.kt | 3 + .../quartz/nip5dNapplets/NappletEventTest.kt | 2 + tools/napplet-test/README.md | 2 + 16 files changed, 278 insertions(+), 84 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/tags/IconTag.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/StaticWebsite.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/StaticWebsite.kt index 1bb7fae45a..79118e9c6b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/StaticWebsite.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/StaticWebsite.kt @@ -49,6 +49,7 @@ fun RenderRootNappletEvent( identifier = null, isNapplet = true, requires = event.requires(), + icon = event.icon(), // Tapping Open hands off to the sandboxed :napplet process; the card itself never executes code. onOpen = if (event.paths().isNotEmpty()) { @@ -76,6 +77,7 @@ fun RenderNamedNappletEvent( identifier = event.identifier(), isNapplet = true, requires = event.requires(), + icon = event.icon(), onOpen = if (event.paths().isNotEmpty()) { { NappletLauncher.launch(context = context, manifest = event, authorPubKey = event.pubKey, identifier = event.identifier()) } @@ -101,6 +103,7 @@ fun RenderRootSiteEvent( servers = event.servers(), identifier = null, isNapplet = false, + icon = event.icon(), onOpen = if (event.paths().isNotEmpty()) { { @@ -137,6 +140,7 @@ fun RenderNamedSiteEvent( servers = event.servers(), identifier = event.identifier(), isNapplet = false, + icon = event.icon(), onOpen = if (event.paths().isNotEmpty()) { { diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NappletCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NappletCommands.kt index 45f12f106e..457d1915c8 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NappletCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NappletCommands.kt @@ -166,12 +166,12 @@ object NappletCommands { StaticSitePublish.run( dataDir, rest, - "napplet publish --server [--requires identity,relay,…] [--d ID] [--relay R] [--title T]", + "napplet publish --server [--requires identity,relay,…] [--d ID] [--relay R] [--title T] [--icon URL]", ) { m -> if (m.identifier != null) { - NamedNappletEvent.build(m.identifier, m.paths, m.servers, m.requires, m.title, m.description, m.source) + NamedNappletEvent.build(m.identifier, m.paths, m.servers, m.requires, m.title, m.description, m.source, m.icon) } else { - RootNappletEvent.build(m.paths, m.servers, m.requires, m.title, m.description, m.source) + RootNappletEvent.build(m.paths, m.servers, m.requires, m.title, m.description, m.source, m.icon) } } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NsiteCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NsiteCommands.kt index 2a3c2b35f8..e00b767220 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NsiteCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NsiteCommands.kt @@ -176,14 +176,14 @@ object NsiteCommands { StaticSitePublish.run( dataDir, rest, - "nsite publish --server [--d ID] [--relay R] [--title T] [--description D] [--source URL]", + "nsite publish --server [--d ID] [--relay R] [--title T] [--description D] [--source URL] [--icon URL]", ) { m -> if (m.identifier != null) { - NamedSiteEvent.build(m.identifier, m.paths, m.servers, m.title, m.description, m.source) { + NamedSiteEvent.build(m.identifier, m.paths, m.servers, m.title, m.description, m.source, m.icon) { siteAggregateHash(m.paths) } } else { - RootSiteEvent.build(m.paths, m.servers, m.title, m.description, m.source) { + RootSiteEvent.build(m.paths, m.servers, m.title, m.description, m.source, m.icon) { siteAggregateHash(m.paths) } } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StaticSitePublish.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StaticSitePublish.kt index 2b1b18f0ef..fd702ae3db 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StaticSitePublish.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StaticSitePublish.kt @@ -49,6 +49,7 @@ object StaticSitePublish { val title: String?, val description: String?, val source: String?, + val icon: String?, ) suspend fun run( @@ -70,6 +71,7 @@ object StaticSitePublish { val title = args.flag("title") val description = args.flag("description") val sourceUrl = args.flag("source") + val icon = args.flag("icon") val extraRelays = StaticSiteFetch.commaList(args.flag("relay")) Context.open(dataDir).use { ctx -> @@ -82,7 +84,7 @@ object StaticSitePublish { return Output.error("upload_failed", e.message ?: "upload failed") } - val manifest = Manifest(identifier, result.pathTags, servers, requires, title, description, sourceUrl) + val manifest = Manifest(identifier, result.pathTags, servers, requires, title, description, sourceUrl, icon) val signed = ctx.signer.sign(buildEvent(manifest)) val relays = @@ -100,6 +102,7 @@ object StaticSitePublish { "kind" to signed.kind, "d" to identifier, "title" to title, + "icon" to icon, "servers" to servers, "requires" to requires, "aggregate_sha256" to SiteAggregateHash.compute(result.pathTags), diff --git a/commons/src/commonMain/composeResources/values/strings.xml b/commons/src/commonMain/composeResources/values/strings.xml index 56098b0851..0e3d8e9bde 100644 --- a/commons/src/commonMain/composeResources/values/strings.xml +++ b/commons/src/commonMain/composeResources/values/strings.xml @@ -83,7 +83,9 @@ Static Website: %1$s Napplet: %1$s - Permissions: + Napplet + Website + What it can access Root Site Source: Servers: diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/StaticWebsiteCard.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/StaticWebsiteCard.kt index 73e4165be3..0680732b87 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/StaticWebsiteCard.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/StaticWebsiteCard.kt @@ -20,47 +20,63 @@ */ package com.vitorpamplona.amethyst.commons.ui.note +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background import androidx.compose.foundation.border 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.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Button -import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.napplet_card_kind import com.vitorpamplona.amethyst.commons.resources.napplet_card_permissions -import com.vitorpamplona.amethyst.commons.resources.napplet_card_title import com.vitorpamplona.amethyst.commons.resources.nsite_open import com.vitorpamplona.amethyst.commons.resources.nsite_root_site import com.vitorpamplona.amethyst.commons.resources.nsite_servers import com.vitorpamplona.amethyst.commons.resources.nsite_source -import com.vitorpamplona.amethyst.commons.resources.nsite_title +import com.vitorpamplona.amethyst.commons.resources.nsite_website_kind import org.jetbrains.compose.resources.stringResource -// Card chrome inlined so the shared component carries no app-theme dependency (matches Amethyst's -// QuoteBorder / subtleBorder / spacing tokens). -private val CardShape = RoundedCornerShape(15.dp) -private val CardPadding = 10.dp -private val RowSpacing = 5.dp -private val DividerThickness = 0.25.dp +private val CardShape = RoundedCornerShape(16.dp) +private val IconShape = RoundedCornerShape(14.dp) +private val CardPadding = 12.dp +private val IconSize = 56.dp /** - * The inert, host-agnostic preview card for a NIP-5A static site or NIP-5D napplet event. It only - * displays manifest metadata (title, description, source, Blossom servers, declared permissions) and - * an **Open** button — it never executes applet code. Launching runs in the platform host's sandbox, - * supplied by the caller via [onOpen]; a null [onOpen] (no paths) hides the button. + * The inert, host-agnostic preview card for a NIP-5A static site or NIP-5D napplet event, styled like + * an app-store entry: square [icon] (with a colored monogram fallback), name, a type label, a short + * description, and an **Open** button. The technical bits a typical user doesn't care about — the + * declared capabilities, Blossom servers, and source URL — are tucked behind a "What it can access" + * disclosure; capabilities are also re-confirmed at the consent prompt when the napplet actually uses + * one. It never executes applet code; launching runs in the platform host's sandbox via [onOpen] + * (a null [onOpen] — no paths — hides the button). * * Both the Android feed and a future desktop feed render this identical card so the two can't drift. */ @@ -73,95 +89,195 @@ fun StaticWebsiteCard( identifier: String?, isNapplet: Boolean, requires: List = emptyList(), + icon: String? = null, onOpen: (() -> Unit)? = null, ) { - Row( + val displayTitle = title?.ifBlank { null } ?: identifier?.ifBlank { null } ?: stringResource(Res.string.nsite_root_site) + val kindLabel = stringResource(if (isNapplet) Res.string.napplet_card_kind else Res.string.nsite_website_kind) + + Column( modifier = Modifier - .clip(shape = CardShape) - .border( - 1.dp, - MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), - CardShape, - ).padding(CardPadding), + .fillMaxWidth() + .clip(CardShape) + .border(1.dp, MaterialTheme.colorScheme.outlineVariant, CardShape) + .padding(CardPadding), + verticalArrangement = Arrangement.spacedBy(10.dp), ) { - Column { - val displayTitle = title ?: identifier ?: stringResource(Res.string.nsite_root_site) - val header = if (isNapplet) Res.string.napplet_card_title else Res.string.nsite_title + Row(verticalAlignment = Alignment.CenterVertically) { + AppIcon(icon, displayTitle) - Text( - text = stringResource(header, displayTitle), - style = MaterialTheme.typography.titleMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth(), - ) + Spacer(Modifier.width(12.dp)) - description?.let { + Column(Modifier.weight(1f)) { Text( - text = it, - modifier = Modifier.fillMaxWidth().padding(vertical = RowSpacing), - maxLines = 3, + text = kindLabel.uppercase(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + ) + Text( + text = displayTitle, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, overflow = TextOverflow.Ellipsis, ) } - HorizontalDivider(thickness = DividerThickness) + onOpen?.let { + Spacer(Modifier.width(8.dp)) + FilledTonalButton(onClick = it) { Text(stringResource(Res.string.nsite_open)) } + } + } - source?.let { - Row(Modifier.fillMaxWidth().padding(top = RowSpacing)) { - Text( - text = stringResource(Res.string.nsite_source), - maxLines = 1, - overflow = TextOverflow.Ellipsis, + description?.takeIf { it.isNotBlank() }?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + + if (requires.isNotEmpty() || !source.isNullOrBlank() || servers.isNotEmpty()) { + DetailsDisclosure(requires = requires, source = source, servers = servers) + } + } +} + +/** Square app icon: the manifest [iconUrl] when present, otherwise a colored monogram from [title]. */ +@Composable +private fun AppIcon( + iconUrl: String?, + title: String, +) { + if (!iconUrl.isNullOrBlank()) { + AsyncImage( + model = iconUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.size(IconSize).clip(IconShape), + ) + } else { + Box( + modifier = + Modifier + .size(IconSize) + .clip(IconShape) + .background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center, + ) { + Text( + text = title.trim().take(1).uppercase(), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onPrimaryContainer, + fontWeight = FontWeight.Bold, + ) + } + } +} + +/** Tap-to-expand "What it can access": capability rows (icon + name) plus source/servers details. */ +@Composable +private fun DetailsDisclosure( + requires: List, + source: String?, + servers: List, +) { + var expanded by remember { mutableStateOf(false) } + + Row( + modifier = Modifier.fillMaxWidth().clickable { expanded = !expanded }, + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + MaterialSymbols.Shield, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(8.dp)) + Text( + text = stringResource(Res.string.napplet_card_permissions), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + Icon( + if (expanded) MaterialSymbols.ExpandLess else MaterialSymbols.ExpandMore, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + } + + AnimatedVisibility(visible = expanded) { + Column( + modifier = Modifier.fillMaxWidth().padding(top = 6.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + requires.forEach { capability -> + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + capabilitySymbol(capability), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + Spacer(Modifier.width(10.dp)) + Text( + text = capability.replaceFirstChar { it.uppercase() }, + style = MaterialTheme.typography.bodyMedium, ) - Spacer(modifier = Modifier.width(RowSpacing)) - ClickableUrl(it) } } + source?.takeIf { it.isNotBlank() }?.let { + LabeledUrl(stringResource(Res.string.nsite_source), it) + } + if (servers.isNotEmpty()) { - Row(Modifier.fillMaxWidth().padding(top = RowSpacing)) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { Text( text = stringResource(Res.string.nsite_servers), - maxLines = 1, - overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) - Spacer(modifier = Modifier.width(RowSpacing)) - Column { - servers.forEach { server -> ClickableUrl(server) } - } - } - } - - if (requires.isNotEmpty()) { - Row(Modifier.fillMaxWidth().padding(top = RowSpacing)) { - Text( - text = stringResource(Res.string.napplet_card_permissions), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Spacer(modifier = Modifier.width(RowSpacing)) - Text( - text = requires.joinToString(", "), - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } - } - - onOpen?.let { - Button( - onClick = it, - modifier = Modifier.fillMaxWidth().padding(top = RowSpacing), - ) { - Text(stringResource(Res.string.nsite_open)) + servers.forEach { ClickableUrl(it) } } } } } } +@Composable +private fun LabeledUrl( + label: String, + url: String, +) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(Modifier.width(6.dp)) + ClickableUrl(url) + } +} + +/** Best-effort capability → icon map for the disclosure (host-agnostic; unknown domains fall back). */ +private fun capabilitySymbol(domain: String): MaterialSymbol = + when (domain.lowercase()) { + "identity" -> MaterialSymbols.AccountCircle + "keys" -> MaterialSymbols.Key + "relay" -> MaterialSymbols.Public + "storage" -> MaterialSymbols.Storage + "value" -> MaterialSymbols.Bolt + "resource" -> MaterialSymbols.Language + "upload" -> MaterialSymbols.Upload + "shell" -> MaterialSymbols.Tune + else -> MaterialSymbols.Lock + } + /** A primary-colored, single-line clickable URL that opens in the platform's default handler. */ @Composable private fun ClickableUrl(url: String) { @@ -169,6 +285,7 @@ private fun ClickableUrl(url: String) { Text( text = url.removePrefix("https://").removePrefix("http://"), color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/NamedSiteEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/NamedSiteEvent.kt index fd235c3c43..a61407388f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/NamedSiteEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/NamedSiteEvent.kt @@ -52,6 +52,8 @@ class NamedSiteEvent( fun source() = tags.siteSource() + fun icon() = tags.siteIcon() + fun identifier() = dTag() companion object { @@ -64,6 +66,7 @@ class NamedSiteEvent( title: String? = null, description: String? = null, source: String? = null, + icon: String? = null, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, "", createdAt) { @@ -73,6 +76,7 @@ class NamedSiteEvent( title?.let { siteTitle(it) } description?.let { siteDescription(it) } source?.let { siteSource(it) } + icon?.let { siteIcon(it) } initializer() } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/RootSiteEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/RootSiteEvent.kt index 52b45cfdc5..647a4ce17a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/RootSiteEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/RootSiteEvent.kt @@ -51,6 +51,8 @@ class RootSiteEvent( fun source() = tags.siteSource() + fun icon() = tags.siteIcon() + companion object { const val KIND = 15128 @@ -60,6 +62,7 @@ class RootSiteEvent( title: String? = null, description: String? = null, source: String? = null, + icon: String? = null, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, "", createdAt) { @@ -68,6 +71,7 @@ class RootSiteEvent( title?.let { siteTitle(it) } description?.let { siteDescription(it) } source?.let { siteSource(it) } + icon?.let { siteIcon(it) } initializer() } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/TagArrayBuilderExt.kt index 6668880538..6937d7866f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/TagArrayBuilderExt.kt @@ -24,6 +24,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.DescriptionTag +import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.IconTag import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.ServerTag import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.SourceTag @@ -40,6 +41,8 @@ fun TagArrayBuilder.siteDescription(description: String) = addUni fun TagArrayBuilder.siteSource(url: String) = addUnique(SourceTag.assemble(url)) +fun TagArrayBuilder.siteIcon(url: String) = addUnique(IconTag.assemble(url)) + fun TagArrayBuilder.siteAggregateHash(aggregateHash: HexKey) = addUnique(XTag.assemble(aggregateHash)) /** Computes the NIP-5A aggregate hash from [paths] and adds it as the `x` tag. */ diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/TagArrayExt.kt index c8c2913f8a..75afa97a2d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/TagArrayExt.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.nip5aStaticWebsites import com.vitorpamplona.quartz.nip01Core.core.TagArray import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.DescriptionTag +import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.IconTag import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.ServerTag import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.SourceTag @@ -38,4 +39,6 @@ fun TagArray.siteDescription() = firstNotNullOfOrNull(DescriptionTag::parse) fun TagArray.siteSource() = firstNotNullOfOrNull(SourceTag::parse) +fun TagArray.siteIcon() = firstNotNullOfOrNull(IconTag::parse) + fun TagArray.siteAggregateHash() = firstNotNullOfOrNull(XTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/tags/IconTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/tags/IconTag.kt new file mode 100644 index 0000000000..dc19d5fdbe --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/tags/IconTag.kt @@ -0,0 +1,40 @@ +/* + * 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.quartz.nip5aStaticWebsites.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** The `icon` tag: a URL to the site's / napplet's square app icon, for richer launcher cards. */ +class IconTag { + companion object { + const val TAG_NAME = "icon" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(url: String) = arrayOf(TAG_NAME, url) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5dNapplets/NamedNappletEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5dNapplets/NamedNappletEvent.kt index 40faba2996..df7bd1c746 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5dNapplets/NamedNappletEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5dNapplets/NamedNappletEvent.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.nip5aStaticWebsites.siteAggregateHash import com.vitorpamplona.quartz.nip5aStaticWebsites.siteDescription +import com.vitorpamplona.quartz.nip5aStaticWebsites.siteIcon import com.vitorpamplona.quartz.nip5aStaticWebsites.sitePaths import com.vitorpamplona.quartz.nip5aStaticWebsites.siteServers import com.vitorpamplona.quartz.nip5aStaticWebsites.siteSource @@ -67,6 +68,7 @@ class NamedNappletEvent( title: String? = null, description: String? = null, source: String? = null, + icon: String? = null, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, "", createdAt) { @@ -78,6 +80,7 @@ class NamedNappletEvent( title?.let { siteTitle(it) } description?.let { siteDescription(it) } source?.let { siteSource(it) } + icon?.let { siteIcon(it) } initializer() } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5dNapplets/NappletManifest.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5dNapplets/NappletManifest.kt index ce0e9bbc77..728e9f1323 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5dNapplets/NappletManifest.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5dNapplets/NappletManifest.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.nip01Core.core.TagArray import com.vitorpamplona.quartz.nip5aStaticWebsites.SiteAggregateHash import com.vitorpamplona.quartz.nip5aStaticWebsites.siteAggregateHash import com.vitorpamplona.quartz.nip5aStaticWebsites.siteDescription +import com.vitorpamplona.quartz.nip5aStaticWebsites.siteIcon import com.vitorpamplona.quartz.nip5aStaticWebsites.sitePaths import com.vitorpamplona.quartz.nip5aStaticWebsites.siteServers import com.vitorpamplona.quartz.nip5aStaticWebsites.siteSource @@ -59,6 +60,9 @@ interface NappletManifest { fun source(): String? = tags.siteSource() + /** `icon` tag: URL to the napplet's square app icon, when the publisher supplied one. */ + fun icon(): String? = tags.siteIcon() + /** The NIP-5A aggregate hash recomputed from this manifest's [paths]. */ fun computeAggregateHash(): HexKey = SiteAggregateHash.compute(paths()) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5dNapplets/RootNappletEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5dNapplets/RootNappletEvent.kt index 9f029a4eb9..d7d4b35182 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5dNapplets/RootNappletEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5dNapplets/RootNappletEvent.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.nip5aStaticWebsites.siteAggregateHash import com.vitorpamplona.quartz.nip5aStaticWebsites.siteDescription +import com.vitorpamplona.quartz.nip5aStaticWebsites.siteIcon import com.vitorpamplona.quartz.nip5aStaticWebsites.sitePaths import com.vitorpamplona.quartz.nip5aStaticWebsites.siteServers import com.vitorpamplona.quartz.nip5aStaticWebsites.siteSource @@ -63,6 +64,7 @@ class RootNappletEvent( title: String? = null, description: String? = null, source: String? = null, + icon: String? = null, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, "", createdAt) { @@ -73,6 +75,7 @@ class RootNappletEvent( title?.let { siteTitle(it) } description?.let { siteDescription(it) } source?.let { siteSource(it) } + icon?.let { siteIcon(it) } initializer() } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip5dNapplets/NappletEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip5dNapplets/NappletEventTest.kt index 26c7b894eb..77a417dea4 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip5dNapplets/NappletEventTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip5dNapplets/NappletEventTest.kt @@ -57,6 +57,7 @@ class NappletEventTest { title = "Calc", description = "a calculator", source = "https://github.com/x/calc", + icon = "https://example.com/calc.png", ), ::NamedNappletEvent, ) @@ -70,6 +71,7 @@ class NappletEventTest { assertEquals("Calc", event.title()) assertEquals("a calculator", event.description()) assertEquals("https://github.com/x/calc", event.source()) + assertEquals("https://example.com/calc.png", event.icon()) // build() stamps the x aggregate, and it verifies against the path tags. assertNotNull(event.declaredAggregateHash()) diff --git a/tools/napplet-test/README.md b/tools/napplet-test/README.md index 380bc4bcd5..863f3017ca 100644 --- a/tools/napplet-test/README.md +++ b/tools/napplet-test/README.md @@ -23,6 +23,8 @@ amy napplet publish tools/napplet-test \ ``` - `--d` makes it an addressable kind-35129 napplet; omit it for a root kind-15129. +- `--icon https://…/icon.png` sets the square app icon shown on the launcher card (optional; the card + falls back to a colored monogram from the title when absent). - For a plain static site, use `amy nsite publish --server …` (kind 15128/35128). - Use the **same key you're logged in as in Amethyst**, so the napplet appears under your account and the identity reads (`getProfile`, `getFollows`, …) have data. From c2c00e7867eb74a144fe4d4578a6bd95d69a38a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Jun 2026 18:27:04 +0000 Subject: [PATCH 4/4] fix(napplets): respect system bar / cutout insets in the host WebView NappletHostActivity is edge-to-edge by default on recent Android, so the sandboxed applet/nsite content drew under the status and navigation bars. Pad the WebView by the system-bar + display-cutout insets so the content sits in the safe area. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde --- .../amethyst/napplet/NappletHostActivity.kt | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletHostActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletHostActivity.kt index 118a8ede24..8e2619912f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletHostActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletHostActivity.kt @@ -39,6 +39,8 @@ import android.webkit.WebView import android.webkit.WebViewClient import android.widget.Toast import androidx.activity.ComponentActivity +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat import androidx.webkit.JavaScriptReplyProxy import androidx.webkit.WebMessageCompat import androidx.webkit.WebViewCompat @@ -135,6 +137,13 @@ class NappletHostActivity : ComponentActivity() { webView = WebView(this) setContentView(webView) + // Activities are edge-to-edge by default on recent Android; pad the WebView by the system bar + // and display-cutout insets so the applet's own content isn't drawn under the status/nav bars. + ViewCompat.setOnApplyWindowInsetsListener(webView) { view, insets -> + val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout()) + view.setPadding(bars.left, bars.top, bars.right, bars.bottom) + insets + } hardenWebView(webView) // Origin-restricted bridge: only the trusted shell page (main frame) can reach native.