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) }