diff --git a/js/services/hashtagPreferencesService.js b/js/services/hashtagPreferencesService.js index 0b063ac3..7442f738 100644 --- a/js/services/hashtagPreferencesService.js +++ b/js/services/hashtagPreferencesService.js @@ -27,6 +27,9 @@ const HEX64_REGEX = /^[0-9a-f]{64}$/i; const DEFAULT_VERSION = 1; const DECRYPT_TIMEOUT_MS = 90000; const DECRYPT_RETRY_DELAY_MS = 10000; +const FAST_RELAY_LIMIT = 3; +const FAST_RELAY_TIMEOUT_MS = 4000; +const FULL_RELAY_TIMEOUT_MS = 12000; class TinyEventEmitter { constructor() { @@ -448,7 +451,8 @@ class HashtagPreferencesService { ); } - async load(pubkey) { + async load(pubkey, options = {}) { + const { skipFastRelays = false, isBackground = false, skipCache = false } = options; const normalized = normalizeHexPubkey(pubkey); let wasLoadedForUser = this.activePubkey && @@ -464,7 +468,7 @@ class HashtagPreferencesService { } // Try cache first - if (this.loadFromCache(normalized)) { + if (!skipCache && this.loadFromCache(normalized)) { wasLoadedForUser = true; } @@ -523,20 +527,43 @@ class HashtagPreferencesService { // If we have cached data, we ask for updates since that timestamp. // If we have no data, we force a full fetch (since=0). const since = wasLoadedForUser ? Number(this.eventCreatedAt) || 0 : 0; + const fastRelays = relays.slice(0, FAST_RELAY_LIMIT); + const shouldUseFastRelays = + !skipFastRelays && fastRelays.length > 0 && fastRelays.length < relays.length; + const initialRelays = shouldUseFastRelays ? fastRelays : relays; + const initialTimeoutMs = shouldUseFastRelays + ? FAST_RELAY_TIMEOUT_MS + : FULL_RELAY_TIMEOUT_MS; - const promises = kinds.map((kind) => - nostrClient.fetchListIncrementally({ - kind, - pubkey: normalized, - dTag: HASHTAG_IDENTIFIER, - relayUrls: relays, - since, - timeoutMs: 12000, - }), - ); + const fetchFromRelays = async (relayList, timeoutMs) => { + const promises = kinds.map((kind) => + nostrClient.fetchListIncrementally({ + kind, + pubkey: normalized, + dTag: HASHTAG_IDENTIFIER, + relayUrls: relayList, + since, + timeoutMs, + }), + ); - const results = await Promise.all(promises); - events = results.flat(); + const results = await Promise.all(promises); + return results.flat(); + }; + + events = await fetchFromRelays(initialRelays, initialTimeoutMs); + if (!events.length && shouldUseFastRelays && !isBackground) { + devLogger.log( + `${LOG_PREFIX} Fast relay fetch returned no events; continuing in background.`, + ); + this.load(normalized, { + skipFastRelays: true, + isBackground: true, + skipCache: true, + }).catch((error) => { + devLogger.warn(`${LOG_PREFIX} Background refresh failed`, error); + }); + } } catch (error) { fetchError = error; diff --git a/js/subscriptions.js b/js/subscriptions.js index 29f083d4..bdb5b6df 100644 --- a/js/subscriptions.js +++ b/js/subscriptions.js @@ -38,6 +38,9 @@ const SUBSCRIPTION_SET_KIND = getNostrEventSchema(NOTE_TYPES.SUBSCRIPTION_LIST)?.kind ?? 30000; const DECRYPT_TIMEOUT_MS = 90000; const DECRYPT_RETRY_DELAY_MS = 10000; +const FAST_RELAY_LIMIT = 3; +const FAST_RELAY_TIMEOUT_MS = 4000; +const FULL_RELAY_TIMEOUT_MS = 12000; function normalizeHexPubkey(value) { if (typeof value !== "string") { @@ -448,14 +451,16 @@ class SubscriptionsManager { ); } - async updateFromRelays(userPubkey) { + async updateFromRelays(userPubkey, options = {}) { if (!userPubkey) return; try { const normalizedUserPubkey = normalizeHexPubkey(userPubkey) || userPubkey; + const { relayUrlsOverride, skipFastRelays = false, isBackground = false } = options; const readRelays = relayManager.getReadRelayUrls(); - const relayUrls = readRelays.length > 0 ? readRelays : Array.from(DEFAULT_RELAY_URLS); + const fallbackRelayUrls = readRelays.length > 0 ? readRelays : Array.from(DEFAULT_RELAY_URLS); + const relayUrls = relayUrlsOverride?.length ? relayUrlsOverride : fallbackRelayUrls; if (!relayUrls.length) { devLogger.warn( "[SubscriptionsManager] No relay URLs available while loading subscriptions.", @@ -463,45 +468,75 @@ class SubscriptionsManager { return; } + const fastRelayUrls = relayUrls.slice(0, FAST_RELAY_LIMIT); + const shouldUseFastRelays = + !skipFastRelays && fastRelayUrls.length > 0 && fastRelayUrls.length < relayUrls.length; + const initialRelayUrls = shouldUseFastRelays ? fastRelayUrls : relayUrls; + const initialTimeoutMs = shouldUseFastRelays + ? FAST_RELAY_TIMEOUT_MS + : FULL_RELAY_TIMEOUT_MS; + // Use incremental fetch helper const cachedSnapshot = parseCachedSubscriptionSnapshot( profileCache.getProfileData(normalizedUserPubkey, "subscriptions"), ); const shouldForceFullFetch = !cachedSnapshot.hasSnapshot; - let events = await nostrClient.fetchListIncrementally({ - kind: SUBSCRIPTION_SET_KIND, - pubkey: normalizedUserPubkey, - dTag: SUBSCRIPTION_LIST_IDENTIFIER, - relayUrls, - since: shouldForceFullFetch ? 0 : undefined, - timeoutMs: 12000, - }); - const normalizedSessionActorPubkey = normalizeNostrPubkey( nostrClient?.sessionActor?.pubkey, ); const shouldFetchSessionActor = normalizedSessionActorPubkey && normalizedSessionActorPubkey !== normalizedUserPubkey; - if (shouldFetchSessionActor) { - const sessionCachedSnapshot = parseCachedSubscriptionSnapshot( - profileCache.getProfileData( - normalizedSessionActorPubkey, - "subscriptions", - ), - ); - const shouldForceSessionFetch = !sessionCachedSnapshot.hasSnapshot; - const sessionEvents = await nostrClient.fetchListIncrementally({ + + const fetchFromRelays = async (relayList, timeoutMs) => { + let events = await nostrClient.fetchListIncrementally({ kind: SUBSCRIPTION_SET_KIND, - pubkey: normalizedSessionActorPubkey, + pubkey: normalizedUserPubkey, dTag: SUBSCRIPTION_LIST_IDENTIFIER, - relayUrls, - since: shouldForceSessionFetch ? 0 : undefined, - timeoutMs: 12000, + relayUrls: relayList, + since: shouldForceFullFetch ? 0 : undefined, + timeoutMs, }); - if (sessionEvents.length) { - events = events.concat(sessionEvents); + + if (shouldFetchSessionActor) { + const sessionCachedSnapshot = parseCachedSubscriptionSnapshot( + profileCache.getProfileData( + normalizedSessionActorPubkey, + "subscriptions", + ), + ); + const shouldForceSessionFetch = !sessionCachedSnapshot.hasSnapshot; + const sessionEvents = await nostrClient.fetchListIncrementally({ + kind: SUBSCRIPTION_SET_KIND, + pubkey: normalizedSessionActorPubkey, + dTag: SUBSCRIPTION_LIST_IDENTIFIER, + relayUrls: relayList, + since: shouldForceSessionFetch ? 0 : undefined, + timeoutMs, + }); + if (sessionEvents.length) { + events = events.concat(sessionEvents); + } } + + return events; + }; + + let events = await fetchFromRelays(initialRelayUrls, initialTimeoutMs); + if (!events.length && shouldUseFastRelays && !isBackground) { + devLogger.log( + "[SubscriptionsManager] Fast relay fetch returned no events; continuing in background.", + ); + this.updateFromRelays(normalizedUserPubkey, { + relayUrlsOverride: relayUrls, + skipFastRelays: true, + isBackground: true, + }).catch((error) => { + devLogger.warn( + "[SubscriptionsManager] Background relay refresh failed:", + error, + ); + }); } const mergedEvents = []; diff --git a/js/userBlocks.js b/js/userBlocks.js index a59e995d..b6183836 100644 --- a/js/userBlocks.js +++ b/js/userBlocks.js @@ -73,7 +73,7 @@ export const USER_BLOCK_EVENTS = Object.freeze({ const FAST_BLOCKLIST_RELAY_LIMIT = 3; const FAST_BLOCKLIST_TIMEOUT_MS = 2500; -const BACKGROUND_BLOCKLIST_TIMEOUT_MS = 6000; +const FULL_BLOCKLIST_TIMEOUT_MS = 12000; const DECRYPT_TIMEOUT_MS = 90000; const DECRYPT_RETRY_DELAY_MS = 10000; @@ -751,28 +751,39 @@ class UserBlockListManager { return; } - try { - statusCallback?.(detail); - } catch (callbackError) { - userLogger.warn( - "[UserBlockList] statusCallback threw while emitting status", - callbackError, - ); - } + if (!options.isBackground) { + try { + statusCallback?.(detail); + } catch (callbackError) { + userLogger.warn( + "[UserBlockList] statusCallback threw while emitting status", + callbackError, + ); + } - try { - this.emitter.emit(USER_BLOCK_EVENTS.STATUS, detail); - } catch (emitterError) { - userLogger.warn( - "[UserBlockList] Failed to dispatch status event", - emitterError, - ); + try { + this.emitter.emit(USER_BLOCK_EVENTS.STATUS, detail); + } catch (emitterError) { + userLogger.warn( + "[UserBlockList] Failed to dispatch status event", + emitterError, + ); + } } }; const readRelays = relayManager.getReadRelayUrls(); const relays = readRelays.length > 0 ? readRelays : Array.from(DEFAULT_RELAY_URLS); - emitStatus({ status: "loading", relays }); + const fastRelays = relays.slice(0, FAST_BLOCKLIST_RELAY_LIMIT); + const shouldUseFastRelays = + !options.skipFastRelays && + fastRelays.length > 0 && + fastRelays.length < relays.length; + const initialRelays = shouldUseFastRelays ? fastRelays : relays; + const initialTimeoutMs = shouldUseFastRelays + ? FAST_BLOCKLIST_TIMEOUT_MS + : FULL_BLOCKLIST_TIMEOUT_MS; + emitStatus({ status: "loading", relays: initialRelays }); const localBlocks = this._loadLocal(normalized); const hasLocalData = !!localBlocks; @@ -1206,17 +1217,17 @@ class UserBlockListManager { nostrClient.fetchListIncrementally({ kind: 10000, pubkey: normalized, - relayUrls: relays, + relayUrls: initialRelays, since: fetchSince, - timeoutMs: 12000, + timeoutMs: initialTimeoutMs, }), nostrClient.fetchListIncrementally({ kind: 10000, pubkey: normalized, dTag: BLOCK_LIST_IDENTIFIER, - relayUrls: relays, + relayUrls: initialRelays, since: fetchSince, - timeoutMs: 12000, + timeoutMs: initialTimeoutMs, }), ]); @@ -1224,6 +1235,21 @@ class UserBlockListManager { // If combined is empty, check if we have loaded state if (!combined.length) { + if (shouldUseFastRelays && !options.isBackground) { + devLogger.log( + "[UserBlockList] Fast relay fetch returned no events; continuing in background.", + ); + this.loadBlocks(normalized, { + ...options, + skipFastRelays: true, + isBackground: true, + }).catch((error) => { + devLogger.warn( + "[UserBlockList] Background relay refresh failed:", + error, + ); + }); + } if (!this.loaded) { // Assuming empty if we have no prior state await applyEvents([], { source: "incremental" });