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:
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user