Merge pull request #493 from greenart7c3/claude/profile-user-relays-wnrwk3

Fetch user's NIP-65 relay list before fetching profile metadata
This commit is contained in:
greenart7c3
2026-07-06 07:28:29 -03:00
committed by GitHub
3 changed files with 122 additions and 38 deletions
+1 -1
View File
@@ -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<Boolean>` — 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
@@ -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<NormalizedRelayUrl> = 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<NormalizedRelayUrl>, 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) {
@@ -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<String, String>()
// hexKey -> subId of the kind-10002 relay list subscription that runs before the metadata one
private val relayListSubIds = mutableMapOf<String, String>()
private val relaysPerSubId = mutableMapOf<String, MutableSet<NormalizedRelayUrl>>()
private val timeoutJobs = mutableMapOf<String, Job>()
@@ -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<NormalizedRelayUrl, List<Filter>> {
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<NormalizedRelayUrl> {
val defaultRelays = LocalPreferences.loadSettingsFromEncryptedStorage().defaultProfileRelays
val userRelays = LocalPreferences.getUserRelays(appContext, account.npub)
return (defaultRelays + userRelays).toSet()
}
private fun createRelayListFilter(account: Account): Map<NormalizedRelayUrl, List<Filter>> = profileRelays(account).associateWith {
listOf(
Filter(
kinds = listOf(AdvertisedRelayListEvent.KIND),
authors = listOf(account.hexKey),
limit = 1,
),
)
}
private fun createProfileFilter(account: Account): Map<NormalizedRelayUrl, List<Filter>> = profileRelays(account).associateWith {
listOf(
Filter(
kinds = listOf(MetadataEvent.KIND),
authors = listOf(account.hexKey),
limit = 1,
),
)
}
}