Merge pull request #3313 from vitorpamplona/claude/recommended-apps-search-3628xk

Add search and follow-list filtering to app recommendations
This commit is contained in:
Vitor Pamplona
2026-06-21 10:30:39 -04:00
committed by GitHub
5 changed files with 114 additions and 22 deletions
@@ -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
@@ -407,6 +408,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()) {
@@ -730,6 +732,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;
@@ -819,6 +822,7 @@ object LocalPreferences {
val browseEmojiSets: TopFilter,
val communities: TopFilter,
val followPacks: TopFilter,
val appRecommendations: TopFilter,
)
/**
@@ -871,6 +875,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 <reified T : Any> parseOrNull(value: String?): T? {
@@ -586,6 +586,11 @@ class Account(
val liveFollowPacksFollowLists: StateFlow<IFeedTopNavFilter> = 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<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultAppRecommendationsFollowList)
override fun isWriteable(): Boolean = settings.isWriteable()
suspend fun updateWarnReports(warnReports: Boolean): Boolean {
@@ -207,6 +207,7 @@ class AccountSettings(
val defaultBrowseEmojiSetsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultCommunitiesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.AllFollows),
val defaultFollowPacksFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultAppRecommendationsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val nwcWallets: MutableStateFlow<List<NwcWalletEntryNorm>> = MutableStateFlow(emptyList()),
val clinkDebitWallets: MutableStateFlow<List<ClinkDebitWalletEntryNorm>> = MutableStateFlow(emptyList()),
// The unified default spend rail (an NWC wallet OR a CLINK debit). Persisted under a
@@ -826,6 +827,17 @@ class AccountSettings(
}
}
fun changeDefaultAppRecommendationsFollowList(name: FeedDefinition) {
changeDefaultAppRecommendationsFollowList(name.code)
}
fun changeDefaultAppRecommendationsFollowList(name: TopFilter) {
if (defaultAppRecommendationsFollowList.value != name) {
defaultAppRecommendationsFollowList.tryEmit(name)
saveAccountSettings()
}
}
// ---
// language services
// ---
@@ -31,6 +31,7 @@ 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.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -41,7 +42,6 @@ import androidx.compose.material3.Text
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
@@ -63,7 +63,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
@@ -73,31 +75,45 @@ 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
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 LocalCache emits a bundle with a new app definition, so
// the candidate snapshot below recomputes.
var appDefinitionsTick by remember { mutableIntStateOf(0) }
LaunchedEffect(myPubkey) {
launch(Dispatchers.IO) {
LocalCache.live.newEventBundles.collect { bundle ->
if (bundle.any { it.event is AppDefinitionEvent }) appDefinitionsTick++
}
// 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.
//
// 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 = cachedAppDefinitions)
val myRecommendationEvents by accountViewModel.account.appRecommendations.flow
.collectAsStateWithLifecycle()
@@ -130,10 +146,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<AddressableNote>()
.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 ||
@@ -162,10 +179,23 @@ fun ProfileAppRecommendationsScreen(
.map { LocalCache.getOrCreateAddressableNote(it) }
}
// 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.
// Applied to discovered apps only — apps I already recommend stay listed so I
// can always turn them off.
val navFilter by accountViewModel.account.liveAppRecommendationsFollowLists
.collectAsStateWithLifecycle()
val authorFilteredApps =
remember(apps, navFilter) {
apps.filter { navFilter.matchAuthor(it.address.pubKeyHex) }
}
// Full candidate list in display order; the search box filters this view.
val allApps =
remember(missingRecommended, apps) {
missingRecommended + apps
remember(missingRecommended, authorFilteredApps) {
missingRecommended + authorFilteredApps
}
var searchQuery by remember { mutableStateOf("") }
@@ -187,7 +217,7 @@ fun ProfileAppRecommendationsScreen(
Scaffold(
topBar = {
TopBarWithBackButton(stringRes(id = R.string.profile_app_recommendations_title), nav)
AppRecommendationsTopBar(accountViewModel, nav)
},
) { pad ->
Column(Modifier.padding(pad).fillMaxSize()) {
@@ -227,8 +257,16 @@ fun ProfileAppRecommendationsScreen(
HorizontalDivider()
if (allApps.isEmpty()) {
// Apps exist in cache but the author filter hid them all, vs.
// nothing discovered yet.
val emptyMessage =
if (apps.isNotEmpty()) {
stringRes(R.string.profile_app_recommendations_filter_empty)
} else {
stringRes(R.string.profile_app_recommendations_empty)
}
Text(
text = stringRes(R.string.profile_app_recommendations_empty),
text = emptyMessage,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(20.dp),
@@ -261,6 +299,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 AppRow(
appNote: AddressableNote,
+1
View File
@@ -606,6 +606,7 @@
<string name="profile_app_recommendations_empty">No apps found yet. Apps will appear here as they are discovered on your relays.</string>
<string name="profile_app_recommendations_search">Search apps by name</string>
<string name="profile_app_recommendations_search_empty">No apps match your search.</string>
<string name="profile_app_recommendations_filter_empty">No recommended apps match this filter.</string>
<string name="app_definition_untitled">Unnamed app</string>
<string name="app_definition_no_supported_kinds">Doesn\'t announce what content it handles</string>
<string name="app_definition_recommend">Recommend</string>