From 3cfc6f10e1e1c3d929b4de4985e257b6eeec61e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 16:23:50 +0000 Subject: [PATCH 1/5] feat: add local search to recommended apps screen Adds an in-screen text filter at the top of the Recommended apps (NIP-89 kind 31990) screen that filters the loaded app list by name and description as the user types, with a clear button and an empty-results message. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01J8oLe5cMzSY8AzTiULJZiW --- .../ProfileAppRecommendationsScreen.kt | 85 ++++++++++++++++++- amethyst/src/main/res/values/strings.xml | 2 + 2 files changed, 84 insertions(+), 3 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/ProfileAppRecommendationsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/ProfileAppRecommendationsScreen.kt index c7d403f383..56bef3d691 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/ProfileAppRecommendationsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/ProfileAppRecommendationsScreen.kt @@ -24,6 +24,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -31,11 +32,15 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Switch import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -46,6 +51,7 @@ 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.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -62,11 +68,15 @@ import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.note.ClearTextIcon +import com.vitorpamplona.amethyst.ui.note.SearchIcon import com.vitorpamplona.amethyst.ui.note.types.ByAuthorChip import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.apps.recommendations.datasource.ProfileAppRecommendationsFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.kindDisplayName import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size20Modifier +import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -156,6 +166,25 @@ fun ProfileAppRecommendationsScreen( .map { LocalCache.getOrCreateAddressableNote(it) } } + // Local, in-memory text filter over the apps already loaded into the list. + // Matches the app name and description; the kind-31990-less missing rows + // have nothing searchable, so they only show when the query is blank. + var searchQuery by remember { mutableStateOf("") } + val filteredApps = + remember(apps, searchQuery) { + val query = searchQuery.trim() + if (query.isEmpty()) { + apps + } else { + apps.filter { note -> + val metadata = (note.event as? AppDefinitionEvent)?.appMetaData() + metadata?.anyName()?.contains(query, ignoreCase = true) == true || + metadata?.about?.contains(query, ignoreCase = true) == true + } + } + } + val visibleMissing = if (searchQuery.isBlank()) missingRecommended else emptyList() + Scaffold( topBar = { TopBarWithBackButton(stringRes(id = R.string.profile_app_recommendations_title), nav) @@ -168,11 +197,24 @@ fun ProfileAppRecommendationsScreen( color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp), ) + + if (apps.isNotEmpty() || searchQuery.isNotBlank()) { + AppSearchField( + query = searchQuery, + onQueryChange = { searchQuery = it }, + ) + } + HorizontalDivider() - if (apps.isEmpty() && missingRecommended.isEmpty()) { + if (visibleMissing.isEmpty() && filteredApps.isEmpty()) { Text( - text = stringRes(R.string.profile_app_recommendations_empty), + text = + if (searchQuery.isBlank()) { + stringRes(R.string.profile_app_recommendations_empty) + } else { + stringRes(R.string.profile_app_recommendations_search_empty, searchQuery) + }, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(20.dp), @@ -180,7 +222,7 @@ fun ProfileAppRecommendationsScreen( } else { LazyColumn(modifier = Modifier.fillMaxSize()) { items( - items = missingRecommended + apps, + items = visibleMissing + filteredApps, key = { it.idHex }, ) { appNote -> AppRow( @@ -198,6 +240,43 @@ fun ProfileAppRecommendationsScreen( } } +@Composable +private fun AppSearchField( + query: String, + onQueryChange: (String) -> Unit, +) { + TextField( + value = query, + onValueChange = onQueryChange, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp) + .defaultMinSize(minHeight = 20.dp), + shape = RoundedCornerShape(25.dp), + leadingIcon = { SearchIcon(modifier = Size20Modifier, MaterialTheme.colorScheme.placeholderText) }, + placeholder = { + Text( + text = stringRes(R.string.profile_app_recommendations_search_hint), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + trailingIcon = { + if (query.isNotEmpty()) { + IconButton(onClick = { onQueryChange("") }) { + ClearTextIcon() + } + } + }, + singleLine = true, + colors = + TextFieldDefaults.colors( + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + ), + ) +} + @Composable private fun AppRow( appNote: AddressableNote, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 453bcf2bae..9049cb8234 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -678,6 +678,8 @@ Recommended apps Choose which Nostr apps you publicly recommend. Recommendations appear on your profile and help others discover apps for content this client can\'t open. No apps found yet. Apps will appear here as they are discovered on your relays. + Search apps + No apps match \"%1$s\". Unnamed app Doesn\'t announce what content it handles Recommend From 9a43e037488533110a9edfc864fac02d17dd128e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 16:47:23 +0000 Subject: [PATCH 2/5] feat: add top-nav feed filter to recommended apps screen Wires the shared FeedFilterSpinner into the Recommended apps (NIP-89) screen so it matches the other feed screens. The selection persists per account via a new defaultAppRecommendationsFollowList setting and resolves through the existing topNavFilterFlow machinery. App definitions only carry an author dimension, so the Follows-style filters narrow the list to apps made by those authors (matchAuthor); hashtag/relay/community variants are no-ops on apps, as expected. Combines with the local text search and adds a filter-empty message. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01J8oLe5cMzSY8AzTiULJZiW --- .../amethyst/LocalPreferences.kt | 5 ++ .../vitorpamplona/amethyst/model/Account.kt | 5 ++ .../amethyst/model/AccountSettings.kt | 12 +++ .../ProfileAppRecommendationsScreen.kt | 81 ++++++++++++++----- amethyst/src/main/res/values/strings.xml | 1 + 5 files changed, 86 insertions(+), 18 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 4f5854a76c..e27cbf1a51 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -123,6 +123,7 @@ private object PrefKeys { const val DEFAULT_BROWSE_EMOJI_SETS_FOLLOW_LIST = "defaultBrowseEmojiSetsFollowList" const val DEFAULT_COMMUNITIES_FOLLOW_LIST = "defaultCommunitiesFollowList" const val DEFAULT_FOLLOW_PACKS_FOLLOW_LIST = "defaultFollowPacksFollowList" + const val DEFAULT_APP_RECOMMENDATIONS_FOLLOW_LIST = "defaultAppRecommendationsFollowList" const val ZAP_PAYMENT_REQUEST_SERVER = "zapPaymentServer" // legacy, kept for migration const val NWC_WALLETS = "nwcWallets" const val DEFAULT_NWC_WALLET_ID = "defaultNwcWalletId" // legacy, migrated into DEFAULT_PAYMENT_SOURCE_ID @@ -406,6 +407,7 @@ object LocalPreferences { putString(PrefKeys.DEFAULT_BROWSE_EMOJI_SETS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultBrowseEmojiSetsFollowList.value)) putString(PrefKeys.DEFAULT_COMMUNITIES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultCommunitiesFollowList.value)) putString(PrefKeys.DEFAULT_FOLLOW_PACKS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultFollowPacksFollowList.value)) + putString(PrefKeys.DEFAULT_APP_RECOMMENDATIONS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultAppRecommendationsFollowList.value)) val walletEntries = settings.nwcWallets.value.mapNotNull { it.denormalize() } if (walletEntries.isNotEmpty()) { @@ -727,6 +729,7 @@ object LocalPreferences { defaultBrowseEmojiSetsFollowList = MutableStateFlow(followListPrefs.browseEmojiSets), defaultCommunitiesFollowList = MutableStateFlow(followListPrefs.communities), defaultFollowPacksFollowList = MutableStateFlow(followListPrefs.followPacks), + defaultAppRecommendationsFollowList = MutableStateFlow(followListPrefs.appRecommendations), nwcWallets = MutableStateFlow(nwcWalletsLoaded.await().first), clinkDebitWallets = MutableStateFlow(clinkDebitsLoaded.await()), // Prefer the new unified default; migrate from the legacy NWC default; @@ -815,6 +818,7 @@ object LocalPreferences { val browseEmojiSets: TopFilter, val communities: TopFilter, val followPacks: TopFilter, + val appRecommendations: TopFilter, ) /** @@ -867,6 +871,7 @@ object LocalPreferences { browseEmojiSets = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_BROWSE_EMOJI_SETS_FOLLOW_LIST, null), TopFilter.Global), communities = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_COMMUNITIES_FOLLOW_LIST, null), TopFilter.AllFollows), followPacks = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_FOLLOW_PACKS_FOLLOW_LIST, null), TopFilter.Global), + appRecommendations = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_APP_RECOMMENDATIONS_FOLLOW_LIST, null), TopFilter.Global), ) private inline fun parseOrNull(value: String?): T? { 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 b2bcd2daf8..5952610a09 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -586,6 +586,11 @@ class Account( val liveFollowPacksFollowLists: StateFlow = topNavFilterFlow(settings.defaultFollowPacksFollowList) val liveFollowPacksFollowListsPerRelay = OutboxLoaderState(liveFollowPacksFollowLists, cache, scope).flow + // App recommendations are read straight from LocalCache (no relay feed of its + // own), so only the in-memory author/tag matcher is needed here, not a + // per-relay outbox loader. + val liveAppRecommendationsFollowLists: StateFlow = topNavFilterFlow(settings.defaultAppRecommendationsFollowList) + override fun isWriteable(): Boolean = settings.isWriteable() suspend fun updateWarnReports(warnReports: Boolean): Boolean { 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 93e89ca49e..1d9b0d5000 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -207,6 +207,7 @@ class AccountSettings( val defaultBrowseEmojiSetsFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), val defaultCommunitiesFollowList: MutableStateFlow = MutableStateFlow(TopFilter.AllFollows), val defaultFollowPacksFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), + val defaultAppRecommendationsFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), val nwcWallets: MutableStateFlow> = MutableStateFlow(emptyList()), val clinkDebitWallets: MutableStateFlow> = MutableStateFlow(emptyList()), // The unified default spend rail (an NWC wallet OR a CLINK debit). Persisted under a @@ -818,6 +819,17 @@ class AccountSettings( } } + fun changeDefaultAppRecommendationsFollowList(name: FeedDefinition) { + changeDefaultAppRecommendationsFollowList(name.code) + } + + fun changeDefaultAppRecommendationsFollowList(name: TopFilter) { + if (defaultAppRecommendationsFollowList.value != name) { + defaultAppRecommendationsFollowList.tryEmit(name) + saveAccountSettings() + } + } + // --- // language services // --- diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/ProfileAppRecommendationsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/ProfileAppRecommendationsScreen.kt index 56bef3d691..b858150604 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/ProfileAppRecommendationsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/ProfileAppRecommendationsScreen.kt @@ -33,6 +33,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -67,7 +68,9 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNo import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor -import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.navigation.topbars.FeedFilterSpinner +import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar +import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon import com.vitorpamplona.amethyst.ui.note.ClearTextIcon import com.vitorpamplona.amethyst.ui.note.SearchIcon import com.vitorpamplona.amethyst.ui.note.types.ByAuthorChip @@ -166,28 +169,36 @@ fun ProfileAppRecommendationsScreen( .map { LocalCache.getOrCreateAddressableNote(it) } } - // Local, in-memory text filter over the apps already loaded into the list. - // Matches the app name and description; the kind-31990-less missing rows - // have nothing searchable, so they only show when the query is blank. + // The shared top-nav feed filter, resolved to an author/tag matcher. Only the + // author dimension is meaningful for app definitions, so the matchAuthor side + // does the work (Follows lists narrow to apps by those authors); the + // hashtag/relay/community variants leave matchAuthor == true and act as no-ops. + val navFilter by accountViewModel.account.liveAppRecommendationsFollowLists + .collectAsStateWithLifecycle() + + // Local, in-memory text filter over the apps already loaded into the list, + // applied on top of the author filter. Matches the app name and description; + // the kind-31990-less missing rows have nothing searchable, so they only show + // when the query is blank. var searchQuery by remember { mutableStateOf("") } val filteredApps = - remember(apps, searchQuery) { + remember(apps, searchQuery, navFilter) { val query = searchQuery.trim() - if (query.isEmpty()) { - apps - } else { - apps.filter { note -> - val metadata = (note.event as? AppDefinitionEvent)?.appMetaData() - metadata?.anyName()?.contains(query, ignoreCase = true) == true || - metadata?.about?.contains(query, ignoreCase = true) == true - } + apps.filter { note -> + if (!navFilter.matchAuthor(note.address.pubKeyHex)) return@filter false + if (query.isEmpty()) return@filter true + val metadata = (note.event as? AppDefinitionEvent)?.appMetaData() + metadata?.anyName()?.contains(query, ignoreCase = true) == true || + metadata?.about?.contains(query, ignoreCase = true) == true } } + // Apps I recommend stay visible regardless of the author filter so they can + // always be turned off; they only hide while a text search is active. val visibleMissing = if (searchQuery.isBlank()) missingRecommended else emptyList() Scaffold( topBar = { - TopBarWithBackButton(stringRes(id = R.string.profile_app_recommendations_title), nav) + AppRecommendationsTopBar(accountViewModel, nav) }, ) { pad -> Column(Modifier.padding(pad).fillMaxSize()) { @@ -210,10 +221,13 @@ fun ProfileAppRecommendationsScreen( if (visibleMissing.isEmpty() && filteredApps.isEmpty()) { Text( text = - if (searchQuery.isBlank()) { - stringRes(R.string.profile_app_recommendations_empty) - } else { - stringRes(R.string.profile_app_recommendations_search_empty, searchQuery) + when { + searchQuery.isNotBlank() -> + stringRes(R.string.profile_app_recommendations_search_empty, searchQuery) + // There are apps in cache, but the author filter hid them all. + apps.isNotEmpty() -> + stringRes(R.string.profile_app_recommendations_filter_empty) + else -> stringRes(R.string.profile_app_recommendations_empty) }, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, @@ -240,6 +254,37 @@ fun ProfileAppRecommendationsScreen( } } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun AppRecommendationsTopBar( + accountViewModel: AccountViewModel, + nav: INav, +) { + ShorterTopAppBar( + navigationIcon = { + if (nav.canPop()) { + IconButton(nav::popBack) { + ArrowBackIcon() + } + } + }, + title = { + val listName by accountViewModel.account.settings.defaultAppRecommendationsFollowList + .collectAsStateWithLifecycle() + val options by accountViewModel.feedStates.feedListOptions.kind3GlobalPeople + .collectAsStateWithLifecycle() + + FeedFilterSpinner( + placeholderCode = listName, + explainer = stringRes(R.string.select_list_to_filter), + options = options, + onSelect = accountViewModel.account.settings::changeDefaultAppRecommendationsFollowList, + accountViewModel = accountViewModel, + ) + }, + ) +} + @Composable private fun AppSearchField( query: String, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 9049cb8234..bbd24dda45 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -680,6 +680,7 @@ No apps found yet. Apps will appear here as they are discovered on your relays. Search apps No apps match \"%1$s\". + No recommended apps match this filter. Unnamed app Doesn\'t announce what content it handles Recommend From 21805f68570dd8eeb0e5027953f906b77ef3ee5f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 19:13:30 +0000 Subject: [PATCH 3/5] refactor: use indexed observeNewEvents for app definition ticks Replaces the global newEventBundles firehose (woke on every new event of every kind, then filtered for AppDefinitionEvent client-side) with the indexed LocalCache.observeNewEvents for kind 31990, so the cache delivers only app-definition insertions to the recompute tick. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01J8oLe5cMzSY8AzTiULJZiW --- .../ProfileAppRecommendationsScreen.kt | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/ProfileAppRecommendationsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/ProfileAppRecommendationsScreen.kt index b858150604..5101c013c6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/ProfileAppRecommendationsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/ProfileAppRecommendationsScreen.kt @@ -80,6 +80,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.kindDisplayName import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -95,14 +96,16 @@ fun ProfileAppRecommendationsScreen( // relays so the list below has candidates while this screen is open. ProfileAppRecommendationsFilterAssemblerSubscription(accountViewModel) - // Ticks whenever LocalCache emits a bundle with a new app definition, so - // the candidate snapshot below recomputes. + // Ticks whenever a new app definition (kind 31990) is inserted into the + // cache, so the candidate snapshot below recomputes. observeNewEvents lets + // the cache index do the kind narrowing instead of scanning the global + // firehose of every new event. var appDefinitionsTick by remember { mutableIntStateOf(0) } LaunchedEffect(myPubkey) { launch(Dispatchers.IO) { - LocalCache.live.newEventBundles.collect { bundle -> - if (bundle.any { it.event is AppDefinitionEvent }) appDefinitionsTick++ - } + LocalCache + .observeNewEvents(Filter(kinds = listOf(AppDefinitionEvent.KIND))) + .collect { appDefinitionsTick++ } } } From a8feb88c2e71a272efced50b42f1f35633b61bac Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 19:30:33 +0000 Subject: [PATCH 4/5] refactor: derive app list from observeNotes, drop tick + rescan The screen previously used observeNewEvents purely as an invalidation tick to re-scan LocalCache.addressables on every app-definition insertion. Since observeNotes already seeds with the cached kind-31990 notes and re-emits as new ones arrive, the candidate list now derives straight from those notes: the appDefinitionsTick counter and the repeated full-cache rescan are gone. Initial value is seeded from the current cache snapshot to preserve the first-frame content. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01J8oLe5cMzSY8AzTiULJZiW --- .../ProfileAppRecommendationsScreen.kt | 45 ++++++++++--------- 1 file changed, 25 insertions(+), 20 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/ProfileAppRecommendationsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/ProfileAppRecommendationsScreen.kt index 5101c013c6..1d675f2502 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/ProfileAppRecommendationsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/ProfileAppRecommendationsScreen.kt @@ -45,7 +45,6 @@ import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -83,31 +82,36 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.flowOn @Composable fun ProfileAppRecommendationsScreen( accountViewModel: AccountViewModel, nav: INav, ) { - val myPubkey = accountViewModel.userProfile().pubkeyHex - // Pull my kind 31989 events plus recent kind 31990 app definitions from // relays so the list below has candidates while this screen is open. ProfileAppRecommendationsFilterAssemblerSubscription(accountViewModel) - // Ticks whenever a new app definition (kind 31990) is inserted into the - // cache, so the candidate snapshot below recomputes. observeNewEvents lets - // the cache index do the kind narrowing instead of scanning the global - // firehose of every new event. - var appDefinitionsTick by remember { mutableIntStateOf(0) } - LaunchedEffect(myPubkey) { - launch(Dispatchers.IO) { - LocalCache - .observeNewEvents(Filter(kinds = listOf(AppDefinitionEvent.KIND))) - .collect { appDefinitionsTick++ } - } - } + // Kind 31990 app definitions, kept live. observeNotes seeds with what is + // already cached and re-emits as new definitions are inserted, so the + // candidate list below derives straight from these notes — no manual tick or + // full-cache rescan. (Per-row metadata changes are watched by AppRow itself, + // which is why an in-place addressable replacement not re-emitting here is + // fine.) The initial value is the current cache snapshot so the first frame + // matches the seeded emission instead of flashing empty. + val appDefinitionNotes by remember { + LocalCache + .observeNotes(Filter(kinds = listOf(AppDefinitionEvent.KIND))) + .flowOn(Dispatchers.IO) + }.collectAsStateWithLifecycle( + initialValue = + remember { + LocalCache.addressables + .filterIntoSet(AppDefinitionEvent.KIND) { _, _ -> true } + .toList() + }, + ) val myRecommendationEvents by accountViewModel.account.appRecommendations.flow .collectAsStateWithLifecycle() @@ -140,10 +144,11 @@ fun ProfileAppRecommendationsScreen( } val apps = - remember(appDefinitionsTick, pinnedRecommended, pinnedFollows) { - LocalCache.addressables - .filterIntoSet(AppDefinitionEvent.KIND) { _, note -> - val event = note.event as? AppDefinitionEvent ?: return@filterIntoSet false + remember(appDefinitionNotes, pinnedRecommended, pinnedFollows) { + appDefinitionNotes + .filterIsInstance() + .filter { note -> + val event = note.event as? AppDefinitionEvent ?: return@filter false // Unnamed apps are poor recommendation candidates; keep them // only when already recommended, so they can be turned off. note.address in pinnedRecommended || From 45656e5476ed8b6786207b367c85de38b9c4a18a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 20:00:33 +0000 Subject: [PATCH 5/5] docs: clarify observeNotes replacement caveat; hoist initial-value scan Audit follow-up. The previous comment claimed an in-place addressable replacement "not re-emitting here is fine" because AppRow watches each note; that conflated per-row content (which does stay live) with list membership and sort order (which do not re-evaluate on a kind-31990 replacement). Documents the actual behavior and why it is acceptable. Also hoists the nested remember{} initial-value cache scan out of the collectAsStateWithLifecycle argument for readability; no behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01J8oLe5cMzSY8AzTiULJZiW --- .../ProfileAppRecommendationsScreen.kt | 32 +++++++++++-------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/ProfileAppRecommendationsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/ProfileAppRecommendationsScreen.kt index 1d675f2502..5f51aae502 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/ProfileAppRecommendationsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/ProfileAppRecommendationsScreen.kt @@ -93,25 +93,31 @@ fun ProfileAppRecommendationsScreen( // relays so the list below has candidates while this screen is open. ProfileAppRecommendationsFilterAssemblerSubscription(accountViewModel) - // Kind 31990 app definitions, kept live. observeNotes seeds with what is + // Kind 31990 app definitions, kept live. observeNotes seeds with the notes // already cached and re-emits as new definitions are inserted, so the // candidate list below derives straight from these notes — no manual tick or - // full-cache rescan. (Per-row metadata changes are watched by AppRow itself, - // which is why an in-place addressable replacement not re-emitting here is - // fine.) The initial value is the current cache snapshot so the first frame - // matches the seeded emission instead of flashing empty. + // full-cache rescan. + // + // Caveat: observeNotes does NOT re-emit when an already-listed addressable is + // replaced in place, so the list isn't re-filtered/re-sorted on a kind-31990 + // update (a blank app that later gains a name, or a newer createdAt changing + // the tier order). That's acceptable here: membership rarely flips on an + // update, each row's own content stays live via AppRow's observeNoteEvent, and + // the list recomputes anyway as the recommendation/follow lists stream in. + // + // The initial value is the current cache snapshot so the first frame matches + // the seeded emission instead of flashing empty. + val cachedAppDefinitions = + remember { + LocalCache.addressables + .filterIntoSet(AppDefinitionEvent.KIND) { _, _ -> true } + .toList() + } val appDefinitionNotes by remember { LocalCache .observeNotes(Filter(kinds = listOf(AppDefinitionEvent.KIND))) .flowOn(Dispatchers.IO) - }.collectAsStateWithLifecycle( - initialValue = - remember { - LocalCache.addressables - .filterIntoSet(AppDefinitionEvent.KIND) { _, _ -> true } - .toList() - }, - ) + }.collectAsStateWithLifecycle(initialValue = cachedAppDefinitions) val myRecommendationEvents by accountViewModel.account.appRecommendations.flow .collectAsStateWithLifecycle()