diff --git a/js/relayManager.js b/js/relayManager.js index 4c6fff37..d40549e2 100644 --- a/js/relayManager.js +++ b/js/relayManager.js @@ -8,6 +8,10 @@ import { const MODE_SEQUENCE = ["both", "read", "write"]; +const FAST_RELAY_FETCH_LIMIT = 3; +const FAST_RELAY_TIMEOUT_MS = 2500; +const BACKGROUND_RELAY_TIMEOUT_MS = 6000; + function normalizeHexPubkey(pubkey) { if (typeof pubkey !== "string") { return null; @@ -413,44 +417,180 @@ class RelayPreferencesManager { const filter = { kinds: [10002], authors: [normalizedPubkey], limit: 1 }; const targetRelays = this.getPublishTargets(); - const events = []; - - await Promise.all( - targetRelays.map(async (relayUrl) => { - try { - const result = await nostrClient.pool.list([relayUrl], [filter]); - if (Array.isArray(result) && result.length) { - events.push(...result); - } - } catch (error) { - if (isDevMode) { - console.warn(`[relayManager] Failed to fetch relay list from ${relayUrl}:`, error); - } + const applyEvents = (events, { skipIfEmpty = false } = {}) => { + if (!Array.isArray(events) || !events.length) { + if (skipIfEmpty) { + return null; } - }) - ); + this.lastEvent = null; + this.lastLoadSource = "default"; + this.setEntries(this.defaultEntries, { allowEmpty: false }); + return { ok: true, source: "default", events: [] }; + } + + const sorted = events + .filter((event) => event && event.pubkey === normalizedPubkey) + .sort((a, b) => (b?.created_at || 0) - (a?.created_at || 0)); + + if (!sorted.length) { + if (skipIfEmpty) { + return null; + } + this.lastEvent = null; + this.lastLoadSource = "default"; + this.setEntries(this.defaultEntries, { allowEmpty: false }); + return { ok: true, source: "default", events: [] }; + } + + const newest = sorted[0]; + this.lastEvent = newest; + + const parsed = parseRelayTags(newest?.tags); + if (parsed.length) { + this.setEntries(parsed, { allowEmpty: false }); + this.lastLoadSource = "event"; + return { ok: true, source: "event", event: newest }; + } - if (!events.length) { - this.lastEvent = null; this.lastLoadSource = "default"; this.setEntries(this.defaultEntries, { allowEmpty: false }); - return { ok: true, source: "default", events: [] }; + return { ok: true, source: "default", event: newest }; + }; + + const fetchFromRelay = (relayUrl, timeoutMs, requireEvent) => { + return new Promise((resolve, reject) => { + let settled = false; + const timer = setTimeout(() => { + if (settled) { + return; + } + settled = true; + const timeoutError = new Error( + `Timed out fetching relay list from ${relayUrl} after ${timeoutMs}ms` + ); + timeoutError.code = "timeout"; + timeoutError.relay = relayUrl; + timeoutError.timeoutMs = timeoutMs; + reject(timeoutError); + }, timeoutMs); + + Promise.resolve() + .then(() => nostrClient.pool.list([relayUrl], [filter])) + .then((result) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + const events = Array.isArray(result) + ? result.filter((event) => event && event.pubkey === normalizedPubkey) + : []; + if (requireEvent && !events.length) { + const emptyError = new Error( + `No relay list events returned from ${relayUrl}` + ); + emptyError.code = "empty"; + emptyError.relay = relayUrl; + reject(emptyError); + return; + } + resolve({ relayUrl, events }); + }) + .catch((error) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + const wrapped = + error instanceof Error ? error : new Error(String(error)); + wrapped.relay = relayUrl; + reject(wrapped); + }); + }); + }; + + const fastRelays = targetRelays.slice(0, FAST_RELAY_FETCH_LIMIT); + const backgroundRelays = targetRelays.slice(fastRelays.length); + + const fastPromises = fastRelays.map((relayUrl) => + fetchFromRelay(relayUrl, FAST_RELAY_TIMEOUT_MS, true) + ); + const backgroundPromises = backgroundRelays.map((relayUrl) => + fetchFromRelay(relayUrl, BACKGROUND_RELAY_TIMEOUT_MS, false) + ); + + const background = Promise.allSettled([ + ...fastPromises, + ...backgroundPromises, + ]) + .then((outcomes) => { + const aggregated = []; + outcomes.forEach((outcome) => { + if (outcome.status === "fulfilled") { + const events = Array.isArray(outcome.value?.events) + ? outcome.value.events + : []; + if (events.length) { + aggregated.push(...events); + } + } else { + const reason = outcome.reason; + if (reason?.code === "timeout") { + if (isDevMode) { + console.warn( + `[relayManager] Relay ${reason.relay} timed out while loading relay list (${reason.timeoutMs}ms)` + ); + } + } else if (isDevMode) { + console.warn( + `[relayManager] Relay ${reason?.relay || "unknown"} failed while loading relay list:`, + reason + ); + } + } + }); + + if (!aggregated.length) { + return; + } + + applyEvents(aggregated, { skipIfEmpty: true }); + }) + .catch((error) => { + if (isDevMode) { + console.warn("[relayManager] Background relay refresh failed", error); + } + }); + + let fastResult = null; + if (fastPromises.length) { + try { + fastResult = await Promise.any(fastPromises); + } catch (error) { + if (error instanceof AggregateError) { + error.errors?.forEach((err) => { + if (err?.code === "timeout" && isDevMode) { + console.warn( + `[relayManager] Relay ${err.relay} timed out while loading relay list (${err.timeoutMs}ms)` + ); + } + }); + } else if (isDevMode) { + console.warn("[relayManager] Fast relay fetch failed", error); + } + } } - events.sort((a, b) => (b?.created_at || 0) - (a?.created_at || 0)); - const newest = events[0]; - this.lastEvent = newest; - - const parsed = parseRelayTags(newest?.tags); - if (parsed.length) { - this.setEntries(parsed, { allowEmpty: false }); - this.lastLoadSource = "event"; - return { ok: true, source: "event", event: newest }; + if (fastResult?.events?.length) { + const result = applyEvents(fastResult.events); + background.catch(() => {}); + return result; } - this.lastLoadSource = "default"; - this.setEntries(this.defaultEntries, { allowEmpty: false }); - return { ok: true, source: "default", event: newest }; + const fallback = applyEvents([], { skipIfEmpty: false }); + background.catch(() => {}); + return fallback; } async publishRelayList(pubkey, options = {}) { diff --git a/js/services/authService.js b/js/services/authService.js index 4b95bc79..76b5a2e1 100644 --- a/js/services/authService.js +++ b/js/services/authService.js @@ -72,6 +72,10 @@ const FALLBACK_PROFILE = { lud06: "", }; +const FAST_PROFILE_RELAY_LIMIT = 3; +const FAST_PROFILE_TIMEOUT_MS = 2500; +const BACKGROUND_PROFILE_TIMEOUT_MS = 6000; + export default class AuthService { constructor({ nostrClient, userBlocks, relayManager, logger } = {}) { this.nostrClient = nostrClient || null; @@ -655,15 +659,37 @@ export default class AuthService { return FALLBACK_PROFILE; } - const events = await this.nostrClient.pool.list(this.nostrClient.relays, [ - { kinds: [0], authors: [normalized], limit: 1 }, - ]); + const relays = Array.isArray(this.nostrClient.relays) + ? this.nostrClient.relays.filter((url) => typeof url === "string" && url) + : []; - let profile = FALLBACK_PROFILE; - if (Array.isArray(events) && events.length && events[0]?.content) { + if (!relays.length) { + this.setProfileCacheEntry(normalized, FALLBACK_PROFILE, { + persist: true, + reason: "load-own-profile:fallback", + }); + return FALLBACK_PROFILE; + } + + const filter = [{ kinds: [0], authors: [normalized], limit: 1 }]; + + const selectNewestEvent = (eventList) => { + if (!Array.isArray(eventList) || !eventList.length) { + return null; + } + const sorted = eventList + .filter((event) => event && event.pubkey === normalized) + .sort((a, b) => (b?.created_at || 0) - (a?.created_at || 0)); + return sorted.length ? sorted[0] : null; + }; + + const buildProfileFromEvent = (event) => { + if (!event?.content) { + return FALLBACK_PROFILE; + } try { - const data = JSON.parse(events[0].content); - profile = { + const data = JSON.parse(event.content); + return { name: data.display_name || data.name || FALLBACK_PROFILE.name, picture: data.picture || FALLBACK_PROFILE.picture, about: typeof data.about === "string" ? data.about : FALLBACK_PROFILE.about, @@ -676,15 +702,157 @@ export default class AuthService { }; } catch (error) { this.log("[AuthService] Failed to parse profile metadata", error); + return FALLBACK_PROFILE; + } + }; + + const fetchFromRelay = (relayUrl, timeoutMs, requireEvent) => { + return new Promise((resolve, reject) => { + let settled = false; + const timer = setTimeout(() => { + if (settled) { + return; + } + settled = true; + const timeoutError = new Error( + `Timed out fetching profile from ${relayUrl} after ${timeoutMs}ms` + ); + timeoutError.code = "timeout"; + timeoutError.relay = relayUrl; + timeoutError.timeoutMs = timeoutMs; + reject(timeoutError); + }, timeoutMs); + + Promise.resolve() + .then(() => this.nostrClient.pool.list([relayUrl], filter)) + .then((result) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + const events = Array.isArray(result) + ? result.filter((event) => event && event.pubkey === normalized) + : []; + if (requireEvent && !events.length) { + const emptyError = new Error( + `No profile events returned from ${relayUrl}` + ); + emptyError.code = "empty"; + emptyError.relay = relayUrl; + reject(emptyError); + return; + } + resolve({ relayUrl, events }); + }) + .catch((error) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + const wrapped = + error instanceof Error ? error : new Error(String(error)); + wrapped.relay = relayUrl; + reject(wrapped); + }); + }); + }; + + const fastRelays = relays.slice(0, FAST_PROFILE_RELAY_LIMIT); + const backgroundRelays = relays.slice(fastRelays.length); + + const fastPromises = fastRelays.map((relayUrl) => + fetchFromRelay(relayUrl, FAST_PROFILE_TIMEOUT_MS, true) + ); + const backgroundPromises = backgroundRelays.map((relayUrl) => + fetchFromRelay(relayUrl, BACKGROUND_PROFILE_TIMEOUT_MS, false) + ); + + const background = Promise.allSettled([ + ...fastPromises, + ...backgroundPromises, + ]) + .then((outcomes) => { + const aggregated = []; + outcomes.forEach((outcome) => { + if (outcome.status === "fulfilled") { + const events = Array.isArray(outcome.value?.events) + ? outcome.value.events + : []; + if (events.length) { + aggregated.push(...events); + } + } else if (outcome.status === "rejected") { + const reason = outcome.reason; + if (reason?.code === "timeout") { + this.log( + `[AuthService] Relay ${reason.relay} timed out loading profile (background)` + ); + } else { + this.log( + `[AuthService] Relay ${reason?.relay || "unknown"} failed loading profile (background)`, + reason + ); + } + } + }); + + const newest = selectNewestEvent(aggregated); + if (!newest) { + return; + } + + const profile = buildProfileFromEvent(newest); + this.setProfileCacheEntry(normalized, profile, { + persist: true, + reason: "load-own-profile:background", + }); + }) + .catch((error) => { + this.log("[AuthService] Background profile refresh failed", error); + }); + + let fastResult = null; + if (fastPromises.length) { + try { + fastResult = await Promise.any(fastPromises); + } catch (error) { + if (error instanceof AggregateError) { + error.errors?.forEach((err) => { + if (err?.code === "timeout") { + this.log( + `[AuthService] Relay ${err.relay} timed out loading profile` + ); + } + }); + } else { + this.log("[AuthService] Failed to load profile from fast relays", error); + } } } - this.setProfileCacheEntry(normalized, profile, { + if (fastResult?.events?.length) { + const newest = selectNewestEvent(fastResult.events); + if (newest) { + const profile = buildProfileFromEvent(newest); + this.setProfileCacheEntry(normalized, profile, { + persist: true, + reason: "load-own-profile:fast", + }); + background.catch(() => {}); + return profile; + } + } + + this.setProfileCacheEntry(normalized, FALLBACK_PROFILE, { persist: true, - reason: "load-own-profile", + reason: "load-own-profile:fallback", }); - return profile; + background.catch(() => {}); + + return FALLBACK_PROFILE; } async fetchProfile(pubkey, { forceRefresh = false } = {}) { diff --git a/js/userBlocks.js b/js/userBlocks.js index 3cb4b115..2703efdb 100644 --- a/js/userBlocks.js +++ b/js/userBlocks.js @@ -6,6 +6,10 @@ import { assertAnyRelayAccepted, } from "./nostrPublish.js"; +const FAST_BLOCKLIST_RELAY_LIMIT = 3; +const FAST_BLOCKLIST_TIMEOUT_MS = 2500; +const BACKGROUND_BLOCKLIST_TIMEOUT_MS = 6000; + function normalizeHex(pubkey) { if (typeof pubkey !== "string") { return null; @@ -86,82 +90,214 @@ class UserBlockListManager { limit: 1, }; - const events = []; const relays = Array.isArray(nostrClient.relays) - ? nostrClient.relays + ? nostrClient.relays.filter((relay) => typeof relay === "string" && relay) : []; - const results = await Promise.allSettled( - relays.map((relay) => - Promise.resolve() - .then(() => nostrClient.pool.list([relay], [filter])) - .then((res) => ({ relay, res })) - .catch((error) => { - throw { relay, error }; - }) - ) - ); - - for (const outcome of results) { - if (outcome.status === "fulfilled") { - const { res } = outcome.value || {}; - if (Array.isArray(res) && res.length) { - events.push(...res); - } - } else { - const { relay, error } = outcome.reason || {}; - console.error( - `[UserBlockList] Relay error at ${relay}:`, - error ?? outcome.reason - ); - } - } - - if (!events.length) { + if (!relays.length) { this.blockedPubkeys.clear(); this.blockEventId = null; this.loaded = true; return; } - events.sort((a, b) => b.created_at - a.created_at); - const newest = events[0]; - this.blockEventId = newest.id; + const fastRelays = relays.slice(0, FAST_BLOCKLIST_RELAY_LIMIT); + const backgroundRelays = relays.slice(fastRelays.length); - let decrypted = ""; - try { - decrypted = await window.nostr.nip04.decrypt( - normalized, - newest.content - ); - } catch (err) { - console.error("[UserBlockList] Failed to decrypt block list:", err); - this.blockedPubkeys.clear(); - this.loaded = true; + const fetchFromRelay = (relayUrl, timeoutMs, requireEvent) => + new Promise((resolve, reject) => { + let settled = false; + const timer = setTimeout(() => { + if (settled) { + return; + } + settled = true; + const timeoutError = new Error( + `Timed out fetching block list from ${relayUrl} after ${timeoutMs}ms` + ); + timeoutError.code = "timeout"; + timeoutError.relay = relayUrl; + timeoutError.timeoutMs = timeoutMs; + reject(timeoutError); + }, timeoutMs); + + Promise.resolve() + .then(() => nostrClient.pool.list([relayUrl], [filter])) + .then((result) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + const events = Array.isArray(result) + ? result.filter((event) => event && event.pubkey === normalized) + : []; + if (requireEvent && !events.length) { + const emptyError = new Error( + `No block list events returned from ${relayUrl}` + ); + emptyError.code = "empty"; + emptyError.relay = relayUrl; + reject(emptyError); + return; + } + resolve({ relayUrl, events }); + }) + .catch((error) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + const wrapped = + error instanceof Error ? error : new Error(String(error)); + wrapped.relay = relayUrl; + reject(wrapped); + }); + }); + + const fastPromises = fastRelays.map((relayUrl) => + fetchFromRelay(relayUrl, FAST_BLOCKLIST_TIMEOUT_MS, true) + ); + const backgroundPromises = backgroundRelays.map((relayUrl) => + fetchFromRelay(relayUrl, BACKGROUND_BLOCKLIST_TIMEOUT_MS, false) + ); + + const applyEvents = async (events, { skipIfEmpty = false } = {}) => { + if (!Array.isArray(events) || !events.length) { + if (skipIfEmpty) { + return; + } + this.blockedPubkeys.clear(); + this.blockEventId = null; + return; + } + + const sorted = events + .filter((event) => event && event.pubkey === normalized) + .sort((a, b) => (b?.created_at || 0) - (a?.created_at || 0)); + + if (!sorted.length) { + if (skipIfEmpty) { + return; + } + this.blockedPubkeys.clear(); + this.blockEventId = null; + return; + } + + const newest = sorted[0]; + if (newest?.id && newest.id === this.blockEventId && this.blockedPubkeys.size) { + return; + } + + this.blockEventId = newest?.id || null; + + if (!newest?.content) { + this.blockedPubkeys.clear(); + return; + } + + let decrypted = ""; + try { + decrypted = await window.nostr.nip04.decrypt(normalized, newest.content); + } catch (err) { + console.error("[UserBlockList] Failed to decrypt block list:", err); + this.blockedPubkeys.clear(); + return; + } + + try { + const parsed = JSON.parse(decrypted); + const list = Array.isArray(parsed?.blockedPubkeys) + ? parsed.blockedPubkeys + : []; + const sanitized = list + .map((entry) => normalizeHex(entry)) + .filter((candidate) => { + if (!candidate) { + return false; + } + if (candidate === normalized) { + return false; + } + return true; + }); + this.blockedPubkeys = new Set(sanitized); + } catch (err) { + console.error("[UserBlockList] Failed to parse block list:", err); + this.blockedPubkeys.clear(); + } + }; + + const background = Promise.allSettled([ + ...fastPromises, + ...backgroundPromises, + ]) + .then(async (outcomes) => { + const aggregated = []; + for (const outcome of outcomes) { + if (outcome.status === "fulfilled") { + const events = Array.isArray(outcome.value?.events) + ? outcome.value.events + : []; + if (events.length) { + aggregated.push(...events); + } + } else { + const reason = outcome.reason; + if (reason?.code === "timeout") { + console.warn( + `[UserBlockList] Relay ${reason.relay} timed out while loading block list (${reason.timeoutMs}ms)` + ); + } else { + const relay = reason?.relay || reason?.relayUrl; + console.error( + `[UserBlockList] Relay error at ${relay}:`, + reason?.error ?? reason + ); + } + } + } + + if (!aggregated.length) { + return; + } + + await applyEvents(aggregated, { skipIfEmpty: true }); + }) + .catch((error) => { + console.error("[UserBlockList] background block list refresh failed:", error); + }); + + let fastResult = null; + if (fastPromises.length) { + try { + fastResult = await Promise.any(fastPromises); + } catch (error) { + if (error instanceof AggregateError) { + error.errors?.forEach((err) => { + if (err?.code === "timeout") { + console.warn( + `[UserBlockList] Relay ${err.relay} timed out while loading block list (${err.timeoutMs}ms)` + ); + } + }); + } else { + console.error("[UserBlockList] Fast block list fetch failed:", error); + } + } + } + + if (fastResult?.events?.length) { + await applyEvents(fastResult.events); + background.catch(() => {}); return; } - try { - const parsed = JSON.parse(decrypted); - const list = Array.isArray(parsed?.blockedPubkeys) - ? parsed.blockedPubkeys - : []; - const sanitized = list - .map((entry) => normalizeHex(entry)) - .filter((candidate) => { - if (!candidate) { - return false; - } - if (candidate === normalized) { - return false; - } - return true; - }); - this.blockedPubkeys = new Set(sanitized); - } catch (err) { - console.error("[UserBlockList] Failed to parse block list:", err); - this.blockedPubkeys.clear(); - } + this.blockedPubkeys.clear(); + this.blockEventId = null; + background.catch(() => {}); } catch (error) { console.error("[UserBlockList] loadBlocks failed:", error); this.blockedPubkeys.clear();