mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +00:00
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:
@@ -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
|
||||
// ---
|
||||
|
||||
+91
-22
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user