From ac7f55eb3e84e9e27410e5b6b4d82035942cbee2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 15:40:15 +0000 Subject: [PATCH] refactor: extract NIP-89 recommendation logic into AppRecommendationsState Follows the Account state-class convention (InterestSetsState et al.): the observed flow, the publish mutex, and recommendApp/unrecommendApp now live in model/nip89AppHandlers/AppRecommendationsState, publishing through account.sendMyPublicAndPrivateOutbox. Account just links it as val appRecommendations = AppRecommendationsState(signer, cache, scope). https://claude.ai/code/session_015dX5vWqvXUYD8rzPYX8vTB --- .../vitorpamplona/amethyst/model/Account.kt | 116 +------------ .../AppRecommendationsState.kt | 155 ++++++++++++++++++ .../amethyst/ui/note/types/AppDefinition.kt | 9 +- .../ProfileAppRecommendationsScreen.kt | 8 +- 4 files changed, 168 insertions(+), 120 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip89AppHandlers/AppRecommendationsState.kt 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 fd3b47abf1..b3a6099d8e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -102,6 +102,7 @@ import com.vitorpamplona.amethyst.model.nip62Vanish.VanishRequestsState import com.vitorpamplona.amethyst.model.nip65RelayList.Nip65RelayListState import com.vitorpamplona.amethyst.model.nip72Communities.CommunityListState import com.vitorpamplona.amethyst.model.nip78AppSpecific.AppSpecificState +import com.vitorpamplona.amethyst.model.nip89AppHandlers.AppRecommendationsState import com.vitorpamplona.amethyst.model.nipA3PaymentTargets.NipA3PaymentTargetsState import com.vitorpamplona.amethyst.model.nipB7Blossom.BlossomServerListState import com.vitorpamplona.amethyst.model.serverList.MergedFollowListsState @@ -247,10 +248,6 @@ import com.vitorpamplona.quartz.nip72ModCommunities.rules.tags.WotTag import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent -import com.vitorpamplona.quartz.nip89AppHandlers.PlatformType -import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent -import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent -import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.tags.RecommendationTag import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.NIP90ContentDiscoveryRequestEvent import com.vitorpamplona.quartz.nip92IMeta.IMetaTag import com.vitorpamplona.quartz.nip92IMeta.imetas @@ -393,6 +390,7 @@ class Account( val labeledBookmarkLists = LabeledBookmarkListsState(signer, cache, scope) val interestSets = InterestSetsState(signer, cache, scope) + val appRecommendations = AppRecommendationsState(signer, cache, scope) val oldBookmarkState = OldBookmarkListState(signer, cache, scope) val bookmarkState = BookmarkListState(signer, cache, scope) val pinState = PinListState(signer, cache, scope) @@ -1613,116 +1611,6 @@ class Account( client.publish(signedEvent, outboxRelays.flow.value) } - /** - * Serializes read-modify-write of the per-kind app recommendation events - * (kind 31989, one addressable event per supported kind) so two rapid - * toggles can't race each other into losing updates. - */ - private val appRecommendationsMutex = Mutex() - - /** - * Synchronous cache scan, used to seed [myAppRecommendations] and by the - * read-modify-write publishers below, which must read current truth from - * the cache while holding [appRecommendationsMutex]. - */ - fun myAppRecommendationEvents(): List = - cache.addressables - .filterIntoSet(AppRecommendationEvent.KIND, signer.pubKey) - .mapNotNull { it.event as? AppRecommendationEvent } - - /** - * My kind 31989 recommendation events (one per handled kind), kept in - * sync as the cache consumes new versions. UI should collect this - * instead of rescanning the cache on every event bundle. - */ - val myAppRecommendations: StateFlow> = - cache - .observeEvents( - Filter(kinds = listOf(AppRecommendationEvent.KIND), authors = listOf(signer.pubKey)), - ).flowOn(Dispatchers.IO) - .stateIn(scope, SharingStarted.WhileSubscribed(30000), myAppRecommendationEvents()) - - /** - * Returns a createdAt strictly greater than whatever AppRecommendationEvent - * currently sits in cache for this d-tag. Needed because - * LocalCache.consumeBaseReplaceable drops updates whose createdAt isn't - * strictly greater, and TimeUtils.now() has only second resolution. - */ - private fun nextAppRecommendationCreatedAt(supportedKind: String): Long { - val address = Address(AppRecommendationEvent.KIND, signer.pubKey, supportedKind) - val latest = cache.getAddressableNoteIfExists(address)?.event?.createdAt ?: 0L - return maxOf(TimeUtils.now(), latest + 1) - } - - private fun currentAppRecommendations(supportedKind: String): List { - val address = Address(AppRecommendationEvent.KIND, signer.pubKey, supportedKind) - val event = cache.getAddressableNoteIfExists(address)?.event as? AppRecommendationEvent - return event?.recommendations() ?: emptyList() - } - - /** - * Adds [app] to this user's public NIP-89 recommendations, one kind 31989 - * event per event kind the app declares to handle via `k` tags. - */ - suspend fun recommendApp( - app: AppDefinitionEvent, - relayHint: NormalizedRelayUrl?, - ) { - if (!isWriteable()) return - - val kinds = app.supportedKinds() - if (kinds.isEmpty()) return - - val newTag = RecommendationTag(app.address(), relayHint, PlatformType.ANDROID.code) - - val signedEvents = - appRecommendationsMutex.withLock { - kinds.mapNotNull { kind -> - val supportedKind = kind.toString() - val current = currentAppRecommendations(supportedKind) - if (current.any { it.address == app.address() }) return@mapNotNull null - - val template = - AppRecommendationEvent.buildFromTags( - supportedKind = supportedKind, - recommendations = current + newTag, - createdAt = nextAppRecommendationCreatedAt(supportedKind), - ) - val signed = signer.sign(template) - cache.justConsumeMyOwnEvent(signed) - signed - } - } - - signedEvents.forEach { client.publish(it, outboxRelays.flow.value) } - } - - /** Removes the app at [address] from every kind 31989 recommendation event of this user. */ - suspend fun unrecommendApp(address: Address) { - if (!isWriteable()) return - - val signedEvents = - appRecommendationsMutex.withLock { - myAppRecommendationEvents().mapNotNull { event -> - val current = event.recommendations() - val updated = current.filterNot { it.address == address } - if (updated.size == current.size) return@mapNotNull null - - val template = - AppRecommendationEvent.buildFromTags( - supportedKind = event.dTag(), - recommendations = updated, - createdAt = nextAppRecommendationCreatedAt(event.dTag()), - ) - val signed = signer.sign(template) - cache.justConsumeMyOwnEvent(signed) - signed - } - } - - signedEvents.forEach { client.publish(it, outboxRelays.flow.value) } - } - fun sendMyPublicAndPrivateOutbox(event: Event?) { if (event == null) return cache.justConsumeMyOwnEvent(event) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip89AppHandlers/AppRecommendationsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip89AppHandlers/AppRecommendationsState.kt new file mode 100644 index 0000000000..d2e5879e5f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip89AppHandlers/AppRecommendationsState.kt @@ -0,0 +1,155 @@ +/* + * 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.model.nip89AppHandlers + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.filterIntoSet +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip89AppHandlers.PlatformType +import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent +import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent +import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.tags.RecommendationTag +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * This user's public NIP-89 app recommendations: one kind 31989 addressable + * event per handled kind, each listing the recommended apps for that kind. + */ +class AppRecommendationsState( + val signer: NostrSigner, + val cache: LocalCache, + val scope: CoroutineScope, +) { + /** + * Synchronous cache scan. Seeds [flow] and feeds the read-modify-write + * publishers below, which must read current truth from the cache while + * holding [publishMutex]. + */ + fun existingRecommendationEvents(): List = + cache.addressables + .filterIntoSet(AppRecommendationEvent.KIND, signer.pubKey) + .mapNotNull { it.event as? AppRecommendationEvent } + + /** + * My kind 31989 recommendation events (one per handled kind), kept in + * sync as the cache consumes new versions. UI should collect this + * instead of rescanning the cache on every event bundle. + */ + val flow: StateFlow> = + cache + .observeEvents( + Filter(kinds = listOf(AppRecommendationEvent.KIND), authors = listOf(signer.pubKey)), + ).flowOn(Dispatchers.IO) + .stateIn(scope, SharingStarted.WhileSubscribed(30000), existingRecommendationEvents()) + + /** + * Serializes read-modify-write of the per-kind recommendation events so + * two rapid toggles can't race each other into losing updates. + */ + private val publishMutex = Mutex() + + /** + * Returns a createdAt strictly greater than whatever AppRecommendationEvent + * currently sits in cache for this d-tag. Needed because + * LocalCache.consumeBaseReplaceable drops updates whose createdAt isn't + * strictly greater, and TimeUtils.now() has only second resolution. + */ + private fun nextCreatedAt(supportedKind: String): Long { + val address = Address(AppRecommendationEvent.KIND, signer.pubKey, supportedKind) + val latest = cache.getAddressableNoteIfExists(address)?.event?.createdAt ?: 0L + return maxOf(TimeUtils.now(), latest + 1) + } + + private fun currentRecommendations(supportedKind: String): List { + val address = Address(AppRecommendationEvent.KIND, signer.pubKey, supportedKind) + val event = cache.getAddressableNoteIfExists(address)?.event as? AppRecommendationEvent + return event?.recommendations() ?: emptyList() + } + + /** + * Adds [app] to this user's public NIP-89 recommendations, one kind 31989 + * event per event kind the app declares to handle via `k` tags. + */ + suspend fun recommendApp( + app: AppDefinitionEvent, + relayHint: NormalizedRelayUrl?, + account: Account, + ) { + if (!account.isWriteable()) return + + val kinds = app.supportedKinds() + if (kinds.isEmpty()) return + + val newTag = RecommendationTag(app.address(), relayHint, PlatformType.ANDROID.code) + + publishMutex.withLock { + kinds.forEach { kind -> + val supportedKind = kind.toString() + val current = currentRecommendations(supportedKind) + if (current.any { it.address == app.address() }) return@forEach + + val template = + AppRecommendationEvent.buildFromTags( + supportedKind = supportedKind, + recommendations = current + newTag, + createdAt = nextCreatedAt(supportedKind), + ) + account.sendMyPublicAndPrivateOutbox(account.signer.sign(template)) + } + } + } + + /** Removes the app at [address] from every kind 31989 recommendation event of this user. */ + suspend fun unrecommendApp( + address: Address, + account: Account, + ) { + if (!account.isWriteable()) return + + publishMutex.withLock { + existingRecommendationEvents().forEach { event -> + val current = event.recommendations() + val updated = current.filterNot { it.address == address } + if (updated.size == current.size) return@forEach + + val template = + AppRecommendationEvent.buildFromTags( + supportedKind = event.dTag(), + recommendations = updated, + createdAt = nextCreatedAt(event.dTag()), + ) + account.sendMyPublicAndPrivateOutbox(account.signer.sign(template)) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AppDefinition.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AppDefinition.kt index f44b20c33b..06810eee1a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AppDefinition.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AppDefinition.kt @@ -429,7 +429,8 @@ private fun RecommendAppButton( note: Note, accountViewModel: AccountViewModel, ) { - val myRecommendations by accountViewModel.account.myAppRecommendations.collectAsStateWithLifecycle() + val myRecommendations by accountViewModel.account.appRecommendations.flow + .collectAsStateWithLifecycle() val isRecommended = remember(myRecommendations, noteEvent) { @@ -446,7 +447,8 @@ private fun RecommendAppButton( OutlinedButton( onClick = { accountViewModel.launchSigner { - accountViewModel.account.unrecommendApp(noteEvent.address()) + val account = accountViewModel.account + account.appRecommendations.unrecommendApp(noteEvent.address(), account) } }, modifier = compactHeight, @@ -459,7 +461,8 @@ private fun RecommendAppButton( enabled = noteEvent.supportedKinds().isNotEmpty(), onClick = { accountViewModel.launchSigner { - accountViewModel.account.recommendApp(noteEvent, note.relayHintUrl()) + val account = accountViewModel.account + account.appRecommendations.recommendApp(noteEvent, note.relayHintUrl(), account) } }, modifier = compactHeight, 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 5bd22acc76..c7d403f383 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,7 +93,8 @@ fun ProfileAppRecommendationsScreen( } } - val myRecommendationEvents by accountViewModel.account.myAppRecommendations.collectAsStateWithLifecycle() + val myRecommendationEvents by accountViewModel.account.appRecommendations.flow + .collectAsStateWithLifecycle() val recommendedAddresses = remember(myRecommendationEvents) { @@ -274,11 +275,12 @@ private fun AppRow( onCheckedChange = { checked -> onUserEdited() accountViewModel.launchSigner { + val account = accountViewModel.account if (checked) { val event = definition ?: return@launchSigner - accountViewModel.account.recommendApp(event, appNote.relayHintUrl()) + account.appRecommendations.recommendApp(event, appNote.relayHintUrl(), account) } else { - accountViewModel.account.unrecommendApp(appNote.address) + account.appRecommendations.unrecommendApp(appNote.address, account) } } },