fix: preload favorited nsite/napplet manifests in the browser

Favorites store only the addressable coordinate (kind:pubkey:dtag); the
launch path re-resolves the live event from LocalCache at tap time. When
that event hadn't streamed in yet, tapping a favorited nsite/napplet showed
"isn't loaded yet" and never recovered on its own — the only thing that
pulled the event into the cache was visiting the nsite/napplet feed (it
subscribes by author), which is why opening that feed and coming back made
the favorite suddenly launchable.

Add PreloadFavoriteNostrApps, which subscribes each favorited coordinate to
the shared EventFinder (the same lifecycle-aware loader observeNote uses) so
the manifests fetch via the author's outbox relays as soon as the launcher
opens. Wire it into the Browser tab and the Favorite Apps tab. The loader
drops each coordinate once its event arrives, so this is a one-shot fetch,
not a standing feed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XH7D6xUhHDKUbrYHoBnZyL
This commit is contained in:
Claude
2026-06-26 20:53:46 +00:00
parent 52a467db5f
commit e70688841c
3 changed files with 77 additions and 0 deletions
@@ -0,0 +1,67 @@
/*
* 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.favorites
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
/**
* Pre-fetches the addressable events behind the user's favorited [FavoriteApp.NostrApp]s (nSites /
* nApplets) while a screen that can launch them is on screen.
*
* A favorite stores only the addressable coordinate `kind:pubkey:dtag`, never the event. The launch
* path ([FavoriteAppLauncher.launchNostrApp] / [FavoriteAppLauncher.embedParams]) re-resolves the live
* event from [LocalCache] at tap time, so a favorite whose event hasn't streamed in yet can't launch —
* the user gets the "isn't loaded yet" toast / unavailable tab. Before this preloader, the only thing
* that pulled those events into the cache was visiting the nsite/napplet feed (it subscribes by author),
* which is why opening that feed and coming back made a favorite suddenly launchable.
*
* This subscribes each favorited coordinate to the shared
* [EventFinder][com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssembler]
* — the same lifecycle-aware loader [observeNote][com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote]
* uses — so the manifests fetch (via the author's outbox relays) as soon as the launcher opens and are
* already in [LocalCache] by the time the user taps. The loader drops each coordinate from its filter
* once the event arrives, so this is a one-shot fetch, not a standing feed.
*/
@Composable
fun PreloadFavoriteNostrApps(
apps: List<FavoriteApp>,
accountViewModel: AccountViewModel,
) {
val account = accountViewModel.account
// Reuse the same query-state instances across recompositions (the manager ref-counts by identity),
// recomputing only when the favorite list or account changes. Events that already loaded are still
// included; the loader simply skips them because their note already has an event.
val states =
remember(apps, account) {
apps
.filterIsInstance<FavoriteApp.NostrApp>()
.mapNotNull { LocalCache.checkGetOrCreateAddressableNote(it.coordinate) }
.map { EventFinderQueryState(it, account) }
}
LifecycleAwareKeyDataSourceSubscription(states, accountViewModel.dataSources().eventFinder)
}
@@ -82,6 +82,7 @@ import com.vitorpamplona.amethyst.favorites.BrowserHistoryRegistry
import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry
import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher
import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
import com.vitorpamplona.amethyst.favorites.PreloadFavoriteNostrApps
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
@@ -125,6 +126,10 @@ private fun BrowserLauncher(
val history by BrowserHistoryRegistry.history.collectAsStateWithLifecycle()
val iconKeys by BrowserIconRegistry.keys.collectAsStateWithLifecycle()
// Fetch favorited nsite/napplet manifests up front so tapping one launches immediately instead of
// showing "isn't loaded yet" until the user happens to visit the nsite/napplet feed.
PreloadFavoriteNostrApps(apps, accountViewModel)
var field by remember { mutableStateOf(TextFieldValue("")) }
// Favorites + visit history flattened into the neutral candidate shape the ranker consumes.
@@ -69,6 +69,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry
import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher
import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
import com.vitorpamplona.amethyst.favorites.PreloadFavoriteNostrApps
import com.vitorpamplona.amethyst.favorites.rememberNappletIconModel
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
@@ -90,6 +91,10 @@ fun FavoriteAppsScreen(
val context = LocalContext.current
val apps by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle()
// Fetch favorited nsite/napplet manifests up front so a tap launches immediately instead of showing
// "isn't loaded yet" until the user happens to visit the nsite/napplet feed.
PreloadFavoriteNostrApps(apps, accountViewModel)
Scaffold(
topBar = {
TopAppBar(title = { Text(stringResource(R.string.favorite_apps)) })