diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 449c7d1ef2..79ef5a3bd9 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 @@ -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 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 a20b82ea24..df6f82ba37 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 @@ -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 // --- 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 aab7d0a200..15403e6a07 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 @@ -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() + .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, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 551d7b9d84..e12ec10b28 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -606,6 +606,7 @@ No apps found yet. Apps will appear here as they are discovered on your relays. Search apps by name No apps match your search. + No recommended apps match this filter. Unnamed app Doesn\'t announce what content it handles Recommend