From 8fa636bbe85c31f93a08913717c05c3ac3a8a76b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 02:12:53 +0000 Subject: [PATCH] refactor(model): lazy-pinned addressable notes on User via UserContext MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 --- .../amethyst/model/LocalCache.kt | 13 ++--- .../com/vitorpamplona/amethyst/model/User.kt | 2 + .../model/nip60Cashu/CashuWalletState.kt | 7 ++- .../amethyst/NewMessageTaggerKeyParseTest.kt | 9 +--- .../amethyst/commons/model/User.kt | 50 ++++++++++++++++++- .../commons/model/NoteOnchainZapTest.kt | 2 +- .../commons/search/SearchResultSorterTest.kt | 2 +- .../desktop/cache/DesktopLocalCache.kt | 16 +++--- 8 files changed, 75 insertions(+), 26 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index a5bb966492..7e09999a29 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/User.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/User.kt index cf154268eb..a5f93e790d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/User.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/User.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt index 0bbfacf00b..6a44ab4d12 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt @@ -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 diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/NewMessageTaggerKeyParseTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/NewMessageTaggerKeyParseTest.kt index 6a209241cb..3b17d86a14 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/NewMessageTaggerKeyParseTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/NewMessageTaggerKeyParseTest.kt @@ -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 diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/User.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/User.kt index 10f727abc4..74d6acf660 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/User.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/User.kt @@ -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 = 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() diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/NoteOnchainZapTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/NoteOnchainZapTest.kt index 5bd761e324..4c91551d71 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/NoteOnchainZapTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/NoteOnchainZapTest.kt @@ -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 } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResultSorterTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResultSorterTest.kt index bdf3b3cf6e..b3014c131b 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResultSorterTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResultSorterTest.kt @@ -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( diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt index 2a52e4b5d5..3804604b17 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt @@ -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) }