mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 09:13:23 +00:00
refactor(model): lazy-pinned addressable notes on User via UserContext
Pins each per-user replaceable note to the User's lifetime so weak-ref
eviction from LocalCache.addressables can't lose them — same fix the
NIP-65 / DM relay list notes already had, generalised so adding new
pinned kinds is a one-liner.
Background: LocalCache.addressables is a LargeSoftCache<Address,
AddressableNote> backed by WeakReference. Without a strong reference
somewhere, an addressable note shell (and any event loaded into it) can
be cleared on any GC cycle even though it was successfully delivered.
The User constructor already held nip65RelayListNote / dmRelayListNote
fields exactly to defeat this for kinds 10002 and 10050. kind:10019
(NutzapInfoEvent) had no such pin, so the zap picker's "does this user
accept nutzaps?" check would silently return null for an evicted note —
the chip never showed even when the recipient had actually published.
This refactor:
1. Adds `UserContext` — a one-method `fun interface` exposing
`addressableNote(addr): Note`. User holds it for life; LocalCache
implements it via a single instance bound to ::getOrCreateAddressableNoteInternal.
2. Converts the three per-user pinned notes (nip65 / dm / nutzapInfo)
to `by lazy` fields backed by the context. Each is resolved the
first time it's read and then held by the User's strong reference
until the User itself is collected. `by lazy`'s default SYNCHRONIZED
mode handles concurrent reads from the zap picker + wallet state.
3. Adds typed accessors on User: nutzapInfo(), acceptsNutzaps(),
nutzapMints(), nutzapP2pkPubkey() — mirrors the existing
authorRelayList() / dmInboxRelayList() shape.
4. CashuWalletState.peekNutzapTarget now reads via
`cache.getOrCreateUser(recipientPubKey).nutzapInfo()` instead of
touching the cache's addressable map directly.
Tradeoffs vs the eager-constructor approach:
- No upfront allocation for kinds the screen never reads.
- Adding a new pinned kind (mute list, blocked relays, bookmark list)
is one `by lazy { context.addressableNote(...) }` line in User —
no constructor-signature churn across call sites.
- User now depends on a narrow `UserContext` interface; test fakes are
a one-liner: `User(hex) { addr -> Note(addr.toValue()) }`.
Migration:
- Single User constructor call site (LocalCache.getOrCreateUser) updated.
- Two existing test fakes (NoteOnchainZapTest, SearchResultSorterTest)
switched to the SAM-lambda form.
- No external behaviour change — the public `nip65RelayListNote` /
`dmRelayListNote` fields keep the same names and types, so the few
consumers (RelayFeedViewModel, ChatNewMessageViewModel) need no edits.
https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
This commit is contained in:
@@ -536,14 +536,15 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
|
||||
override fun getOrCreateUser(pubkey: HexKey): User {
|
||||
require(isValidHex(key = pubkey)) { "$pubkey is not a valid hex" }
|
||||
|
||||
return users.getOrCreate(pubkey) {
|
||||
val nip65RelayListNote = getOrCreateAddressableNoteInternal(AdvertisedRelayListEvent.createAddress(pubkey))
|
||||
val dmRelayListNote = getOrCreateAddressableNoteInternal(ChatMessageRelayListEvent.createAddress(pubkey))
|
||||
User(it, nip65RelayListNote, dmRelayListNote)
|
||||
}
|
||||
// Pass `this` as the UserContext — User now resolves each pinned
|
||||
// addressable note (kind:10002 / 10050 / 10019) lazily on first
|
||||
// read, instead of all-or-nothing at construction time.
|
||||
return users.getOrCreate(pubkey) { User(it, userContext) }
|
||||
}
|
||||
|
||||
/** [UserContext] bridge to this cache's addressable lookup. */
|
||||
private val userContext = UserContext(::getOrCreateAddressableNoteInternal)
|
||||
|
||||
override fun getUserIfExists(pubkey: String): User? {
|
||||
if (pubkey.isEmpty()) return null
|
||||
return users.get(pubkey)
|
||||
|
||||
@@ -22,3 +22,5 @@ package com.vitorpamplona.amethyst.model
|
||||
|
||||
// Re-export from commons for backwards compatibility
|
||||
typealias User = com.vitorpamplona.amethyst.commons.model.User
|
||||
|
||||
typealias UserContext = com.vitorpamplona.amethyst.commons.model.UserContext
|
||||
|
||||
+5
-2
@@ -628,8 +628,11 @@ class CashuWalletState(
|
||||
val ourMints = _mints.value.toSet()
|
||||
if (ourMints.isEmpty()) return null
|
||||
|
||||
val infoNote = cache.getOrCreateAddressableNote(NutzapInfoEvent.createAddress(recipientPubKey))
|
||||
val info = infoNote.event as? NutzapInfoEvent ?: return null
|
||||
// Read the recipient's kind:10019 via their User — User pins the
|
||||
// addressable note for its own lifetime, so the previous race
|
||||
// (notes.LargeSoftCache evicts the WeakReference even though the
|
||||
// event was delivered) no longer drops the chip.
|
||||
val info = cache.getOrCreateUser(recipientPubKey).nutzapInfo() ?: return null
|
||||
|
||||
val recipientPubkeyHex = info.p2pkPubkey() ?: return null
|
||||
val shared = info.mints().firstOrNull { it.mintUrl in ourMints } ?: return null
|
||||
|
||||
@@ -25,10 +25,8 @@ import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.ui.actions.Dao
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
|
||||
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
@@ -41,12 +39,7 @@ import org.junit.Test
|
||||
class NewMessageTaggerKeyParseTest {
|
||||
val dao: Dao =
|
||||
object : Dao {
|
||||
override suspend fun getOrCreateUser(hex: String): User =
|
||||
User(
|
||||
hex,
|
||||
getOrCreateAddressableNoteInternal(AdvertisedRelayListEvent.createAddress(hex)),
|
||||
getOrCreateAddressableNoteInternal(ChatMessageRelayListEvent.createAddress(hex)),
|
||||
)
|
||||
override suspend fun getOrCreateUser(hex: String): User = User(hex) { addr -> getOrCreateAddressableNoteInternal(addr) }
|
||||
|
||||
override suspend fun getOrCreateNote(hex: String) =
|
||||
com.vitorpamplona.amethyst.model
|
||||
|
||||
@@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.UserStatusCach
|
||||
import com.vitorpamplona.amethyst.commons.model.nip56Reports.UserReportCache
|
||||
import com.vitorpamplona.amethyst.commons.model.trustedAssertions.UserCardsCache
|
||||
import com.vitorpamplona.amethyst.commons.util.toShortDisplay
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
@@ -35,17 +36,51 @@ import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
||||
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
|
||||
import com.vitorpamplona.quartz.nip19Bech32.toNpub
|
||||
import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent
|
||||
import com.vitorpamplona.quartz.nip61Nutzaps.info.tags.NutzapMintTag
|
||||
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
|
||||
import com.vitorpamplona.quartz.utils.Hex
|
||||
|
||||
interface UserDependencies
|
||||
|
||||
/**
|
||||
* Lookup capability the [User] needs from the surrounding cache. Kept
|
||||
* narrow on purpose — only what the lazy pinned-note accessors require,
|
||||
* so test fakes are a one-liner and User stays decoupled from the full
|
||||
* `LocalCache` surface.
|
||||
*/
|
||||
fun interface UserContext {
|
||||
fun addressableNote(addr: Address): Note
|
||||
}
|
||||
|
||||
@Stable
|
||||
class User(
|
||||
val pubkeyHex: String,
|
||||
val nip65RelayListNote: Note,
|
||||
val dmRelayListNote: Note,
|
||||
private val context: UserContext,
|
||||
) {
|
||||
// ============================================================
|
||||
// Per-user pinned replaceable notes (kind:10002 / 10050 / 10019)
|
||||
// ============================================================
|
||||
// Each is resolved lazily on first read and then held by this
|
||||
// strong-reference field until the User itself is collected. The
|
||||
// underlying `LocalCache.addressables` map is WeakReference-backed,
|
||||
// so without these strong refs the note shells (and any event
|
||||
// loaded into them) could vanish on the next GC even though the
|
||||
// event was successfully delivered to the cache. Adding a new
|
||||
// pinned kind is a one-liner here.
|
||||
|
||||
val nip65RelayListNote: Note by lazy {
|
||||
context.addressableNote(AdvertisedRelayListEvent.createAddress(pubkeyHex))
|
||||
}
|
||||
|
||||
val dmRelayListNote: Note by lazy {
|
||||
context.addressableNote(ChatMessageRelayListEvent.createAddress(pubkeyHex))
|
||||
}
|
||||
|
||||
val nutzapInfoNote: Note by lazy {
|
||||
context.addressableNote(NutzapInfoEvent.createAddress(pubkeyHex))
|
||||
}
|
||||
|
||||
// These objects are designed to keep the cache
|
||||
// while this user obj is being used anywhere.
|
||||
private var metadata: UserMetadataCache? = null
|
||||
@@ -65,6 +100,17 @@ class User(
|
||||
|
||||
fun authorRelayList() = nip65RelayListNote.event as? AdvertisedRelayListEvent
|
||||
|
||||
fun nutzapInfo() = nutzapInfoNote.event as? NutzapInfoEvent
|
||||
|
||||
/** True when this user has published a kind:10019 with a P2PK pubkey. */
|
||||
fun acceptsNutzaps(): Boolean = nutzapInfo()?.p2pkPubkey() != null
|
||||
|
||||
/** Mints the user has declared accept nutzaps. Empty when no kind:10019. */
|
||||
fun nutzapMints(): List<NutzapMintTag> = nutzapInfo()?.mints().orEmpty()
|
||||
|
||||
/** The recipient P2PK pubkey nutzaps to this user must lock to. */
|
||||
fun nutzapP2pkPubkey(): String? = nutzapInfo()?.p2pkPubkey()
|
||||
|
||||
fun toNProfile() = NProfile.create(pubkeyHex, relayHints())
|
||||
|
||||
fun outboxRelays() = authorRelayList()?.writeRelaysNorm()
|
||||
|
||||
+1
-1
@@ -41,7 +41,7 @@ class NoteOnchainZapTest {
|
||||
// must be wired even in unit tests.
|
||||
private fun sourceNote(pubKey: HexKey): Note {
|
||||
val src = Note(pubKey)
|
||||
src.author = User(pubKey, Note(pubKey + "n65"), Note(pubKey + "dm"))
|
||||
src.author = User(pubKey) { addr -> Note(addr.toValue()) }
|
||||
return src
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -72,7 +72,7 @@ class SearchResultSorterTest {
|
||||
hex: String,
|
||||
displayName: String,
|
||||
): User {
|
||||
val u = User(hex, Note("r1-$hex"), Note("r2-$hex"))
|
||||
val u = User(hex) { addr -> Note(addr.toValue()) }
|
||||
val meta = UserMetadata().apply { this.displayName = displayName }
|
||||
val metaEvent =
|
||||
MetadataEvent(
|
||||
|
||||
Vendored
+10
-6
@@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.commons.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.commons.model.Channel
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.model.User
|
||||
import com.vitorpamplona.amethyst.commons.model.UserContext
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.ICacheEventStream
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.LargeSoftCache
|
||||
@@ -109,12 +110,15 @@ class DesktopLocalCache : ICacheProvider {
|
||||
|
||||
override fun getUserIfExists(pubkey: HexKey): User? = users.get(pubkey)
|
||||
|
||||
override fun getOrCreateUser(pubkey: HexKey): User =
|
||||
users.getOrCreate(pubkey) {
|
||||
val nip65Note = getOrCreateNote("nip65:$pubkey")
|
||||
val dmNote = getOrCreateNote("dm:$pubkey")
|
||||
User(pubkey, nip65Note, dmNote)
|
||||
}
|
||||
override fun getOrCreateUser(pubkey: HexKey): User = users.getOrCreate(pubkey) { User(pubkey, userContext) }
|
||||
|
||||
/**
|
||||
* [UserContext] bridge — desktop's note store is keyed on string ids
|
||||
* rather than full addressable maps, so we synthesise a stable id
|
||||
* from the Address. User's lazy fields hold the resulting Note for
|
||||
* its lifetime, same pinning guarantee as on Android.
|
||||
*/
|
||||
private val userContext = UserContext { addr -> getOrCreateNote(addr.toValue()) }
|
||||
|
||||
override fun countUsers(predicate: (String, User) -> Boolean): Int = users.count { key, user -> predicate(key, user) }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user