From 2a986d582b99aeb98eef901eb0afb1af5dbd6313 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Jul 2026 16:09:34 +0000 Subject: [PATCH] Fetch user's NIP-65 relay list before fetching profile metadata When fetching profiles, first fetch the user's relay list (kind 10002) from the profile relays, save the write relays locally per account, and then fetch the profile metadata (kind 0) from the default profile relays plus the saved user relays. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01V9rbXR3WuwKEbzBdypYFUV --- CLAUDE.md | 2 +- .../nostrsigner/LocalPreferences.kt | 18 +++ .../service/ProfileSubscription.kt | 140 +++++++++++++----- 3 files changed, 122 insertions(+), 38 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index da2674e5..810470f4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,7 +41,7 @@ All three paths converge on `Account.sign()` / encrypt/decrypt methods backed by - `applicationIOScope` — `CoroutineScope(Dispatchers.IO + SupervisorJob() + exceptionHandler)`, used for all background work - `client: NostrClient` — the Quartz Nostr relay client - `notificationSubscription` — keeps the NIP-46 filter alive in the background -- `profileSubscription` — per-account, throttled one-shot metadata (kind 0) fetch; started/stopped by the composables that display each account via `ProfileSubscriptionEffect` (not app-wide) +- `profileSubscription` — per-account, throttled one-shot metadata fetch; first fetches the user's NIP-65 relay list (kind 10002) and saves it locally, then fetches the metadata (kind 0) from the default profile relays plus the saved user relays; started/stopped by the composables that display each account via `ProfileSubscriptionEffect` (not app-wide) - `isStartingAppState: MutableStateFlow` — set to `true` during `runMigrations()`; code that must wait for startup calls `isStartingAppState.first { !it }` - `settings.killSwitch` — when true, all relays are disconnected; checked before every relay operation diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/LocalPreferences.kt b/app/src/main/java/com/greenart7c3/nostrsigner/LocalPreferences.kt index 5d9158de..533dbe4b 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/LocalPreferences.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/LocalPreferences.kt @@ -20,6 +20,7 @@ import com.greenart7c3.nostrsigner.service.TorManager import com.greenart7c3.nostrsigner.ui.parseBiometricsTimeType import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip19Bech32.toNpub @@ -37,6 +38,8 @@ private enum class PrefKeys(val key: String) { PROFILE_URL("profile_url"), LAST_METADATA_UPDATE("last_metadata_update"), LAST_CHECK("last_check"), + USER_RELAYS("user_relays"), + USER_RELAYS_CREATED_AT("user_relays_created_at"), DID_BACKUP("did_backup"), BACKUP_APPLICATIONS("backup_applications"), } @@ -187,6 +190,21 @@ object LocalPreferences { } } + fun getUserRelays(context: Context, npub: String): List = sharedPrefs(context, npub).getStringSet(PrefKeys.USER_RELAYS.key, null)?.mapNotNull { + RelayUrlNormalizer.normalizeOrNull(it) + } ?: emptyList() + + fun getUserRelaysCreatedAt(context: Context, npub: String): Long = sharedPrefs(context, npub).getLong(PrefKeys.USER_RELAYS_CREATED_AT.key, 0) + + fun setUserRelays(context: Context, npub: String, relays: List, createdAt: Long) { + sharedPrefs(context, npub).edit { + apply { + putStringSet(PrefKeys.USER_RELAYS.key, relays.map { it.url }.toSet()) + putLong(PrefKeys.USER_RELAYS_CREATED_AT.key, createdAt) + } + } + } + fun getLastMetadataUpdate(context: Context, npub: String): Long = sharedPrefs(context, npub).getLong(PrefKeys.LAST_METADATA_UPDATE.key, 0) fun setLastMetadataUpdate(context: Context, npub: String, time: Long) { diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/service/ProfileSubscription.kt b/app/src/main/java/com/greenart7c3/nostrsigner/service/ProfileSubscription.kt index 247c60f5..d7cd6b7a 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/service/ProfileSubscription.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/ProfileSubscription.kt @@ -36,6 +36,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.utils.TimeUtils import java.util.UUID import kotlinx.coroutines.CoroutineScope @@ -51,7 +52,11 @@ class ProfileSubscription( val appContext: Context, val scope: CoroutineScope, ) : RelayConnectionListener { + // hexKey -> subId of the kind-0 metadata subscription private val subIds = mutableMapOf() + + // hexKey -> subId of the kind-10002 relay list subscription that runs before the metadata one + private val relayListSubIds = mutableMapOf() private val relaysPerSubId = mutableMapOf>() private val timeoutJobs = mutableMapOf() @@ -71,10 +76,8 @@ class ProfileSubscription( if (relays != null) { relays.remove(relay.url) if (relays.isEmpty()) { - timeoutJobs.remove(subId)?.cancel() - Amber.instance.intentionalDisconnectTime = System.currentTimeMillis() - client.unsubscribe(subId) - relaysPerSubId.remove(subId) + unsubscribe(subId) + onRelayListSubFinished(subId) } } @@ -87,6 +90,11 @@ class ProfileSubscription( } } if (msg is EventMessage) { + if (this.relayListSubIds.containsValue(msg.subId)) { + if (msg.event.kind == AdvertisedRelayListEvent.KIND && msg.event.verify()) { + (msg.event as? AdvertisedRelayListEvent)?.let { saveUserRelays(it) } + } + } if (this.subIds.containsValue(msg.subId)) { if (msg.event.kind == MetadataEvent.KIND && msg.event.verify()) { val account = accounts[msg.event.pubKey] ?: return @@ -123,6 +131,8 @@ class ProfileSubscription( /** * Starts (or refreshes) the throttled, one-shot metadata fetch for [account]. + * First fetches the user's NIP-65 relay list (kind 10002), saves it locally, then + * fetches the metadata from the default profile relays plus the user's own relays. * Tracks the account so incoming events update its StateFlows; safe to call from * any composable displaying the account. */ @@ -134,9 +144,6 @@ class ProfileSubscription( accounts[account.hexKey] = account - if (!subIds.containsKey(account.hexKey)) { - subIds[account.hexKey] = UUID.randomUUID().toString() - } val shouldFetch = if (interval == ProfileFetchInterval.ALWAYS) { true } else { @@ -147,22 +154,70 @@ class ProfileSubscription( (lastMetaData == 0L || oneDayAgo > lastMetaData) && (lastCheck == 0L || fetchIntervalAgo > lastCheck) } if (shouldFetch) { - val subId = subIds[account.hexKey]!! - val profileFilter = createProfileFilter(account) - relaysPerSubId[subId] = profileFilter.keys.toMutableSet() - client.subscribe(subId, profileFilter) - timeoutJobs[subId] = scope.launch { - delay(EOSE_TIMEOUT_MS) - if (relaysPerSubId.containsKey(subId)) { - Amber.instance.intentionalDisconnectTime = System.currentTimeMillis() - client.unsubscribe(subId) - relaysPerSubId.remove(subId) - timeoutJobs.remove(subId) - } + subscribeToUserRelayList(account) + } + } + + private fun subscribeToUserRelayList(account: Account) { + val subId = relayListSubIds.getOrPut(account.hexKey) { UUID.randomUUID().toString() } + val relayListFilter = createRelayListFilter(account) + timeoutJobs.remove(subId)?.cancel() + relaysPerSubId[subId] = relayListFilter.keys.toMutableSet() + client.subscribe(subId, relayListFilter) + timeoutJobs[subId] = scope.launch { + delay(EOSE_TIMEOUT_MS) + if (relaysPerSubId.containsKey(subId)) { + unsubscribe(subId) + // still fetch the profile with whatever relay list we have saved + onRelayListSubFinished(subId) } } } + private fun subscribeToProfile(account: Account) { + val subId = subIds.getOrPut(account.hexKey) { UUID.randomUUID().toString() } + val profileFilter = createProfileFilter(account) + timeoutJobs.remove(subId)?.cancel() + relaysPerSubId[subId] = profileFilter.keys.toMutableSet() + client.subscribe(subId, profileFilter) + timeoutJobs[subId] = scope.launch { + delay(EOSE_TIMEOUT_MS) + if (relaysPerSubId.containsKey(subId)) { + unsubscribe(subId) + } + } + } + + /** + * Called when a relay list subscription completes (all relays sent EOSE or the timeout + * fired). Starts the metadata fetch for the account using the just-saved relay list. + * No-op for metadata subscription ids. + */ + private fun onRelayListSubFinished(subId: String) { + val hexKey = relayListSubIds.entries.firstOrNull { it.value == subId }?.key ?: return + relayListSubIds.remove(hexKey) + accounts[hexKey]?.let { subscribeToProfile(it) } + } + + /** + * Saves the newest kind-10002 write relay list locally so profile fetches can also + * query the user's own relays. + */ + private fun saveUserRelays(event: AdvertisedRelayListEvent) { + val account = accounts[event.pubKey] ?: return + val relays = event.writeRelaysNorm() ?: event.relaysNorm() + if (relays.isEmpty()) return + if (event.createdAt <= LocalPreferences.getUserRelaysCreatedAt(appContext, account.npub)) return + LocalPreferences.setUserRelays(appContext, account.npub, relays, event.createdAt) + } + + private fun unsubscribe(subId: String) { + timeoutJobs.remove(subId)?.cancel() + Amber.instance.intentionalDisconnectTime = System.currentTimeMillis() + client.unsubscribe(subId) + relaysPerSubId.remove(subId) + } + /** * Re-runs the fetch for every currently tracked account. Call when the relay list changes. */ @@ -175,11 +230,8 @@ class ProfileSubscription( */ fun closeSub(account: Account) { accounts.remove(account.hexKey) - val subId = subIds.remove(account.hexKey) ?: return - timeoutJobs.remove(subId)?.cancel() - Amber.instance.intentionalDisconnectTime = System.currentTimeMillis() - client.unsubscribe(subId) - relaysPerSubId.remove(subId) + relayListSubIds.remove(account.hexKey)?.let { unsubscribe(it) } + subIds.remove(account.hexKey)?.let { unsubscribe(it) } } /** @@ -187,26 +239,40 @@ class ProfileSubscription( */ fun closeSub() { Amber.instance.intentionalDisconnectTime = System.currentTimeMillis() - subIds.values.forEach { + (subIds.values + relayListSubIds.values).forEach { timeoutJobs.remove(it)?.cancel() client.unsubscribe(it) } relaysPerSubId.clear() subIds.clear() + relayListSubIds.clear() accounts.clear() } - private fun createProfileFilter(account: Account): Map> { - val relays = LocalPreferences.loadSettingsFromEncryptedStorage().defaultProfileRelays - val accounts = listOf(account.hexKey) - return relays.associateWith { - listOf( - Filter( - kinds = listOf(MetadataEvent.KIND), - authors = accounts, - limit = accounts.size, - ), - ) - } + /** Default profile relays from the settings plus the user's own saved relay list. */ + private fun profileRelays(account: Account): Set { + val defaultRelays = LocalPreferences.loadSettingsFromEncryptedStorage().defaultProfileRelays + val userRelays = LocalPreferences.getUserRelays(appContext, account.npub) + return (defaultRelays + userRelays).toSet() + } + + private fun createRelayListFilter(account: Account): Map> = profileRelays(account).associateWith { + listOf( + Filter( + kinds = listOf(AdvertisedRelayListEvent.KIND), + authors = listOf(account.hexKey), + limit = 1, + ), + ) + } + + private fun createProfileFilter(account: Account): Map> = profileRelays(account).associateWith { + listOf( + Filter( + kinds = listOf(MetadataEvent.KIND), + authors = listOf(account.hexKey), + limit = 1, + ), + ) } }