diff --git a/js/services/hashtagPreferencesHelpers.js b/js/services/hashtagPreferencesHelpers.js new file mode 100644 index 00000000..78ea50b3 --- /dev/null +++ b/js/services/hashtagPreferencesHelpers.js @@ -0,0 +1,72 @@ +// js/services/hashtagPreferencesHelpers.js +// +// Pure helpers extracted from hashtagPreferencesService.js to keep that service +// under the file-size budget. No module state — safe to test in isolation. + +import { HEX64_REGEX } from "../utils/hex.js"; + +const MAX_DECRYPT_RETRY_DELAY_MS = 60000; + +// Transient decrypt failures that must RETRY in the background rather than give +// up: our own timeout, a severed nip-07 message port ("message channel closed"), +// and the circuit breaker's fast-fail. Without this a brief channel drop during +// the login burst abandons hashtag prefs until a refresh (KNOWN_BUGS #0). +const TRANSIENT_DECRYPT_ERROR_CODES = new Set([ + "hashtag-preferences-decrypt-timeout", + "nip07-channel-unresponsive", +]); +const TRANSIENT_DECRYPT_MESSAGE_PATTERNS = [ + "message channel closed", + "could not establish connection", + "receiving end does not exist", + "extension context invalidated", + "connection lost", + "channel is unresponsive", +]; + +export function isTransientDecryptError(error) { + if (!error) return false; + if (error.code && TRANSIENT_DECRYPT_ERROR_CODES.has(error.code)) return true; + const message = + typeof error.message === "string" ? error.message.toLowerCase() : ""; + if (TRANSIENT_DECRYPT_MESSAGE_PATTERNS.some((p) => message.includes(p))) { + return true; + } + // decryptEvent wraps the per-scheme Promise.any failures in a generic + // "decrypt-failed" error and nests the real causes under .errors/.cause — + // recurse so a channel-death sub-error still triggers a retry. + const nested = Array.isArray(error.errors) + ? error.errors + : Array.isArray(error.cause) + ? error.cause + : null; + if (nested) { + return nested.some((entry) => isTransientDecryptError(entry?.error || entry)); + } + return false; +} + +export function computeRetryDelay(baseDelayMs, attempt) { + const normalizedAttempt = + Number.isFinite(attempt) && attempt > 0 ? Math.floor(attempt) : 0; + const multiplier = 2 ** normalizedAttempt; + const nextDelay = Math.max(250, Math.floor(baseDelayMs * multiplier)); + return Math.min(MAX_DECRYPT_RETRY_DELAY_MS, nextDelay); +} + +export function normalizeHexPubkey(pubkey) { + if (typeof pubkey !== "string") { + return null; + } + + const trimmed = pubkey.trim(); + if (!trimmed) { + return null; + } + + if (HEX64_REGEX.test(trimmed)) { + return trimmed.toLowerCase(); + } + + return null; +} diff --git a/js/services/hashtagPreferencesService.js b/js/services/hashtagPreferencesService.js index 5ebbbba1..ef85233f 100644 --- a/js/services/hashtagPreferencesService.js +++ b/js/services/hashtagPreferencesService.js @@ -39,7 +39,11 @@ import { computeIncrementalSinceWithOverlap, selectNewestListEvent, } from "../nostr/listEventOrdering.js"; -import { HEX64_REGEX } from "../utils/hex.js"; +import { + isTransientDecryptError, + computeRetryDelay, + normalizeHexPubkey, +} from "./hashtagPreferencesHelpers.js"; import { SHORT_TIMEOUT_MS, NETWORK_RETRY_DELAY_MS } from "../constants.js"; const LOG_PREFIX = "[HashtagPreferences]"; @@ -57,54 +61,6 @@ const DECRYPT_TIMEOUT_MS = 30000; // PERF: Reduced from 3s to 1.5s — extensions that already granted permission // should recover near-instantly. Shorter delay speeds up the login path. const DECRYPT_RETRY_DELAY_MS = NETWORK_RETRY_DELAY_MS; -const MAX_DECRYPT_RETRY_DELAY_MS = 60000; - -// Transient decrypt failures that must RETRY in the background rather than give -// up: our own timeout, a severed nip-07 message port ("message channel closed"), -// and the circuit breaker's fast-fail. Without this a brief channel drop during -// the login burst abandons hashtag prefs until a refresh (KNOWN_BUGS #0). -const TRANSIENT_DECRYPT_ERROR_CODES = new Set([ - "hashtag-preferences-decrypt-timeout", - "nip07-channel-unresponsive", -]); -const TRANSIENT_DECRYPT_MESSAGE_PATTERNS = [ - "message channel closed", - "could not establish connection", - "receiving end does not exist", - "extension context invalidated", - "connection lost", - "channel is unresponsive", -]; -function isTransientDecryptError(error) { - if (!error) return false; - if (error.code && TRANSIENT_DECRYPT_ERROR_CODES.has(error.code)) return true; - const message = - typeof error.message === "string" ? error.message.toLowerCase() : ""; - if (TRANSIENT_DECRYPT_MESSAGE_PATTERNS.some((p) => message.includes(p))) { - return true; - } - // decryptEvent wraps the per-scheme Promise.any failures in a generic - // "decrypt-failed" error and nests the real causes under .errors/.cause — - // recurse so a channel-death sub-error still triggers a retry. - const nested = Array.isArray(error.errors) - ? error.errors - : Array.isArray(error.cause) - ? error.cause - : null; - if (nested) { - return nested.some((entry) => isTransientDecryptError(entry?.error || entry)); - } - return false; -} - -function computeRetryDelay(baseDelayMs, attempt) { - const normalizedAttempt = - Number.isFinite(attempt) && attempt > 0 ? Math.floor(attempt) : 0; - const multiplier = 2 ** normalizedAttempt; - const nextDelay = Math.max(250, Math.floor(baseDelayMs * multiplier)); - return Math.min(MAX_DECRYPT_RETRY_DELAY_MS, nextDelay); -} - class TinyEventEmitter { constructor() { this.listeners = new Map(); @@ -153,23 +109,6 @@ const EVENTS = Object.freeze({ CHANGE: "change", }); -function normalizeHexPubkey(pubkey) { - if (typeof pubkey !== "string") { - return null; - } - - const trimmed = pubkey.trim(); - if (!trimmed) { - return null; - } - - if (HEX64_REGEX.test(trimmed)) { - return trimmed.toLowerCase(); - } - - return null; -} - function normalizeEncryptionToken(value) { if (typeof value !== "string") { return "";