feat(napplet): relay subscription to discover napplet manifests

NappletsScreen previously showed only what was already cached. Adds a lightweight
discovery subscription so the list actually populates:

- NappletsFilterAssembler / NappletsFilterSubAssembler (SingleSubEoseManager):
  one REQ per read relay for kinds 15129/35129, deduped to a single subscription
  per account. No follow-list/feed-state machinery — the screen reads LocalCache
  directly, so the query state carries only the account + scope.
- Registered as an app-lifetime singleton in RelaySubscriptionsCoordinator (the
  EOSE manager opens its relay sub at construction, so it can't be per-screen).
- NappletsScreen invokes NappletsFilterAssemblerSubscription, which subscribes
  on STARTED and tears down after the lifecycle grace window.

:amethyst:compileFdroidDebugKotlin passes; spotless clean.

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-20 03:26:15 +00:00
parent d4899c2afc
commit 2bccab797c
6 changed files with 172 additions and 4 deletions
@@ -225,9 +225,9 @@ Deferred to v2; v1 nails the single-applet boundary first.
- ✅ **Privacy:** the host routes blob fetches through the user's Tor SOCKS proxy
when active; the port is passed in by the launcher (main process) so the sandbox
process never touches the account-bound HTTP stack.
- **Relay discovery:** `NappletsScreen` reads only what's already in `LocalCache` —
no dedicated subscription fetches napplet manifests yet, so the list is empty
until one arrives via another feed. A `NappletsFilterAssemblerSubscription` is the
next step.
- **Relay discovery:** `NappletsFilterAssembler` (registered in
`RelaySubscriptionsCoordinator`, invoked by `NappletsScreen`) REQs kinds
15129/35129 from the user's read relays while the screen is open, so manifests
flow into `LocalCache` for the list to render.
- **Consent UX:** reuse `commons/.../ui/signing` styling; show the manifest title
and a per-capability rationale; batch-grant on first run.
@@ -53,6 +53,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.livestreams.datasource.Live
import com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.datasource.LongsFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource.MusicPlaylistsFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource.MusicTracksFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.datasource.NappletsFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.NestRoomFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.NestRoomLivenessAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.NestsFilterAssembler
@@ -139,6 +140,7 @@ class RelaySubscriptionsCoordinator(
val podcasts = PodcastsFilterAssembler(client)
val onePodcast = OnePodcastFilterAssembler(client)
val softwareApps = SoftwareAppsFilterAssembler(client)
val napplets = NappletsFilterAssembler(client)
val badges = BadgesFilterAssembler(client)
val profileBadges = ProfileBadgesFilterAssembler(client)
val profileAppRecommendations = ProfileAppRecommendationsFilterAssembler(client)
@@ -49,6 +49,7 @@ import com.vitorpamplona.amethyst.napplet.NappletLauncher
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
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
@@ -66,6 +67,10 @@ fun NappletsScreen(
nav: INav,
) {
val context = LocalContext.current
// Pull napplet manifests from the user's relays into LocalCache while this screen is open.
NappletsFilterAssemblerSubscription(accountViewModel)
val napplets by remember {
Amethyst.instance.cache.observeEvents<Event>(
Filter(kinds = listOf(RootNappletEvent.KIND, NamedNappletEvent.KIND)),
@@ -0,0 +1,60 @@
/*
* 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.datasource
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import kotlinx.coroutines.CoroutineScope
/**
* Keyspace for the napplet-discovery subscription. Unlike a feed datasource it carries no
* `FeedContentState` [com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.NappletsScreen]
* reads the manifests straight out of `LocalCache`, so this only needs the account (for relay
* selection) and a scope.
*/
class NappletsQueryState(
val account: Account,
val scope: CoroutineScope,
)
/**
* Subscribes to NIP-5D napplet manifests (kinds 15129/35129) on the user's read relays while a
* napplet screen is open, dumping them into `LocalCache` for the screen to observe. Registered as
* an app-lifetime singleton in `RelaySubscriptionsCoordinator` (the underlying EOSE manager opens
* its relay subscription at construction, so it must not be created per screen).
*/
@Stable
class NappletsFilterAssembler(
client: INostrClient,
) : ComposeSubscriptionManager<NappletsQueryState>() {
val group =
listOf(
NappletsFilterSubAssembler(client, ::allKeys),
)
override fun invalidateKeys() = invalidateFilters()
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
override fun destroy() = group.forEach { it.destroy() }
}
@@ -0,0 +1,37 @@
/*
* 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.datasource
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun NappletsFilterAssemblerSubscription(accountViewModel: AccountViewModel) {
val state =
remember(accountViewModel.account) {
NappletsQueryState(accountViewModel.account, accountViewModel.viewModelScope)
}
LifecycleAwareKeyDataSourceSubscription(state, accountViewModel.dataSources().napplets)
}
@@ -0,0 +1,64 @@
/*
* 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.datasource
import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip5dNapplets.NamedNappletEvent
import com.vitorpamplona.quartz.nip5dNapplets.RootNappletEvent
private const val NAPPLET_PAGE_LIMIT = 200
/**
* Emits one REQ per read relay for both napplet manifest kinds. A single subscription per account
* (deduped by [distinct]) is enough the screen wants "what napplets exist", not a per-author
* feed so this stays far lighter than the follow-list feed assemblers.
*/
class NappletsFilterSubAssembler(
client: INostrClient,
allKeys: () -> Set<NappletsQueryState>,
) : SingleSubEoseManager<NappletsQueryState>(client, allKeys) {
override fun updateFilter(
keys: List<NappletsQueryState>,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
if (keys.isEmpty()) return emptyList()
return keys.flatMap { key ->
key.account.homeRelays.flow.value.map { relay ->
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = listOf(RootNappletEvent.KIND, NamedNappletEvent.KIND),
limit = NAPPLET_PAGE_LIMIT,
since = since?.get(relay)?.time,
),
)
}
}
}
override fun distinct(key: NappletsQueryState) = key.account.signer.pubKey
}