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
This commit is contained in:
Claude
2026-06-12 15:40:15 +00:00
parent cbe4b030e1
commit ac7f55eb3e
4 changed files with 168 additions and 120 deletions
@@ -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<AppRecommendationEvent> =
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<List<AppRecommendationEvent>> =
cache
.observeEvents<AppRecommendationEvent>(
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<RecommendationTag> {
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)
@@ -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<AppRecommendationEvent> =
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<List<AppRecommendationEvent>> =
cache
.observeEvents<AppRecommendationEvent>(
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<RecommendationTag> {
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))
}
}
}
}
@@ -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,
@@ -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)
}
}
},