mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-11 00:37:41 +00:00
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:
@@ -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)
|
||||
|
||||
+155
@@ -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,
|
||||
|
||||
+5
-3
@@ -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)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user