diff --git a/js/services/hashtagPreferencesService.js b/js/services/hashtagPreferencesService.js index bcc369d9..784989f4 100644 --- a/js/services/hashtagPreferencesService.js +++ b/js/services/hashtagPreferencesService.js @@ -23,6 +23,8 @@ const LOG_PREFIX = "[HashtagPreferences]"; const HASHTAG_IDENTIFIER = "bitvid:tag-preferences"; const HEX64_REGEX = /^[0-9a-f]{64}$/i; const DEFAULT_VERSION = 1; +const DECRYPT_TIMEOUT_MS = 90000; +const DECRYPT_RETRY_DELAY_MS = 10000; class TinyEventEmitter { constructor() { @@ -235,6 +237,7 @@ class HashtagPreferencesService { this.eventCreatedAt = null; this.loaded = false; this.preferencesVersion = DEFAULT_VERSION; + this.decryptRetryTimeoutId = null; profileCache.subscribe((event, detail) => { if (event === "profileChanged") { @@ -262,6 +265,10 @@ class HashtagPreferencesService { this.eventCreatedAt = null; this.loaded = false; this.preferencesVersion = DEFAULT_VERSION; + if (this.decryptRetryTimeoutId) { + clearTimeout(this.decryptRetryTimeoutId); + this.decryptRetryTimeoutId = null; + } this.emitChange("reset"); } @@ -416,6 +423,29 @@ class HashtagPreferencesService { } } + scheduleDecryptRetry(pubkey, error) { + const normalized = normalizeHexPubkey(pubkey); + if (!normalized) { + return; + } + if (this.decryptRetryTimeoutId) { + clearTimeout(this.decryptRetryTimeoutId); + } + this.decryptRetryTimeoutId = setTimeout(() => { + this.decryptRetryTimeoutId = null; + if (this.activePubkey && this.activePubkey !== normalized) { + return; + } + this.load(normalized).catch((retryError) => { + userLogger.warn(`${LOG_PREFIX} Decryption retry failed`, retryError); + }); + }, DECRYPT_RETRY_DELAY_MS); + devLogger.log( + `${LOG_PREFIX} Decryption timed out; scheduling retry in ${DECRYPT_RETRY_DELAY_MS / 1000}s.`, + error, + ); + } + async load(pubkey) { const normalized = normalizeHexPubkey(pubkey); let wasLoadedForUser = @@ -610,8 +640,14 @@ class HashtagPreferencesService { const decryptPromise = this.decryptEvent(latest, normalized); const timeoutPromise = new Promise((_, reject) => setTimeout( - () => reject(new Error("Decryption timed out after 15s")), - 15000, + () => { + const timeoutError = new Error( + `Decryption timed out after ${DECRYPT_TIMEOUT_MS / 1000}s`, + ); + timeoutError.code = "hashtag-preferences-decrypt-timeout"; + reject(timeoutError); + }, + DECRYPT_TIMEOUT_MS, ), ); decryptResult = await Promise.race([decryptPromise, timeoutPromise]); @@ -625,6 +661,14 @@ class HashtagPreferencesService { decryptResult.error, ); + if (decryptResult.error?.code === "hashtag-preferences-decrypt-timeout") { + if (!wasLoadedForUser && !this.loaded) { + this.loaded = true; + } + this.scheduleDecryptRetry(normalized, decryptResult.error); + return; + } + // If we already have loaded preferences (e.g. from cache), preserve them // rather than wiping everything just because the remote update couldn't be decrypted. if (wasLoadedForUser) { diff --git a/js/subscriptions.js b/js/subscriptions.js index a4f13085..f01e9f43 100644 --- a/js/subscriptions.js +++ b/js/subscriptions.js @@ -33,6 +33,8 @@ import { profileCache } from "./state/profileCache.js"; const SUBSCRIPTION_SET_KIND = getNostrEventSchema(NOTE_TYPES.SUBSCRIPTION_LIST)?.kind ?? 30000; +const DECRYPT_TIMEOUT_MS = 90000; +const DECRYPT_RETRY_DELAY_MS = 10000; function normalizeHexPubkey(value) { if (typeof value !== "string") { @@ -335,6 +337,7 @@ class SubscriptionsManager { this.isRunningFeed = false; this.hasRenderedOnce = false; this.emitter = new TinyEventEmitter(); + this.decryptRetryTimeoutId = null; this.ensureNostrServiceListener(); profileCache.subscribe((event, detail) => { @@ -397,6 +400,29 @@ class SubscriptionsManager { return this.emitter.on(eventName, handler); } + scheduleDecryptRetry(userPubkey, error) { + const normalized = normalizeHexPubkey(userPubkey) || userPubkey; + if (!normalized) { + return; + } + if (this.decryptRetryTimeoutId) { + clearTimeout(this.decryptRetryTimeoutId); + } + this.decryptRetryTimeoutId = setTimeout(() => { + this.decryptRetryTimeoutId = null; + if (this.currentUserPubkey && this.currentUserPubkey !== normalized) { + return; + } + this.updateFromRelays(normalized).catch((retryError) => { + userLogger.warn("[SubscriptionsManager] Decryption retry failed:", retryError); + }); + }, DECRYPT_RETRY_DELAY_MS); + devLogger.log( + `[SubscriptionsManager] Decryption timed out; scheduling retry in ${DECRYPT_RETRY_DELAY_MS / 1000}s.`, + error, + ); + } + async updateFromRelays(userPubkey) { if (!userPubkey) return; @@ -538,8 +564,14 @@ class SubscriptionsManager { const decryptPromise = this.decryptSubscriptionEvent(newest, userPubkey); const timeoutPromise = new Promise((_, reject) => setTimeout( - () => reject(new Error("Decryption timed out after 15s")), - 15000, + () => { + const timeoutError = new Error( + `Decryption timed out after ${DECRYPT_TIMEOUT_MS / 1000}s`, + ); + timeoutError.code = "subscriptions-decrypt-timeout"; + reject(timeoutError); + }, + DECRYPT_TIMEOUT_MS, ), ); decryptResult = await Promise.race([decryptPromise, timeoutPromise]); @@ -548,6 +580,13 @@ class SubscriptionsManager { } if (!decryptResult.ok) { + if (decryptResult.error?.code === "subscriptions-decrypt-timeout") { + if (!this.loaded && !cachedSnapshot.hasSnapshot) { + this.loaded = true; + } + this.scheduleDecryptRetry(normalizedUserPubkey, decryptResult.error); + return; + } userLogger.error( "[SubscriptionsManager] Failed to decrypt subscription list:", decryptResult.error, @@ -614,6 +653,10 @@ class SubscriptionsManager { this.lastRunOptions = null; this.lastResult = null; this.hasRenderedOnce = false; + if (this.decryptRetryTimeoutId) { + clearTimeout(this.decryptRetryTimeoutId); + this.decryptRetryTimeoutId = null; + } this.emitter.emit("change", { action: "reset", subscribedPubkeys: [] }); } diff --git a/js/userBlocks.js b/js/userBlocks.js index 7f03be96..c3abc42c 100644 --- a/js/userBlocks.js +++ b/js/userBlocks.js @@ -72,6 +72,8 @@ 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 DECRYPT_TIMEOUT_MS = 90000; +const DECRYPT_RETRY_DELAY_MS = 10000; function sanitizeRelayList(candidate) { return Array.isArray(candidate) @@ -643,6 +645,7 @@ class UserBlockListManager { this.loaded = false; this.emitter = new TinyEventEmitter(); this.seedStateCache = new Map(); + this.decryptRetryTimeoutId = null; profileCache.subscribe((event, detail) => { if (event === "profileChanged") { @@ -664,6 +667,10 @@ class UserBlockListManager { this.muteEventId = null; this.muteEventCreatedAt = null; this.loaded = false; + if (this.decryptRetryTimeoutId) { + clearTimeout(this.decryptRetryTimeoutId); + this.decryptRetryTimeoutId = null; + } } on(eventName, handler) { @@ -674,6 +681,32 @@ class UserBlockListManager { return Array.from(this.blockedPubkeys); } + scheduleDecryptRetry(userPubkey, error) { + if (!userPubkey) { + return; + } + if (this.decryptRetryTimeoutId) { + clearTimeout(this.decryptRetryTimeoutId); + } + this.decryptRetryTimeoutId = setTimeout(() => { + this.decryptRetryTimeoutId = null; + if (this.activePubkey !== userPubkey) { + return; + } + this.loadBlocks(userPubkey, { + since: Number.isFinite(this.blockEventCreatedAt) + ? this.blockEventCreatedAt + : undefined, + }).catch((retryError) => { + userLogger.warn("[UserBlockList] Decryption retry failed:", retryError); + }); + }, DECRYPT_RETRY_DELAY_MS); + devLogger.log( + `[UserBlockList] Decryption timed out; scheduling retry in ${DECRYPT_RETRY_DELAY_MS / 1000}s.`, + error, + ); + } + isBlocked(pubkey) { const normalized = normalizeHex(pubkey); if (!normalized) { @@ -1064,8 +1097,14 @@ class UserBlockListManager { ]); const timeoutPromise = new Promise((_, reject) => setTimeout( - () => reject(new Error("Decryption timed out after 15s")), - 15000, + () => { + const timeoutError = new Error( + `Decryption timed out after ${DECRYPT_TIMEOUT_MS / 1000}s`, + ); + timeoutError.code = "user-blocklist-decrypt-timeout"; + reject(timeoutError); + }, + DECRYPT_TIMEOUT_MS, ), ); @@ -1082,6 +1121,15 @@ class UserBlockListManager { } if (decryptionError) { + if (decryptionError.code === "user-blocklist-decrypt-timeout") { + this.blockedPubkeys = previousState.blockedPubkeys; + this.blockEventId = previousState.blockEventId; + this.blockEventCreatedAt = previousState.blockEventCreatedAt; + this.loaded = true; + emitStatus({ status: "settled" }); + this.scheduleDecryptRetry(normalized, decryptionError); + return; + } userLogger.warn( "[UserBlockList] Decryption failed during merge.", decryptionError,