diff --git a/js/subscriptions.js b/js/subscriptions.js index 5007883d..15081616 100644 --- a/js/subscriptions.js +++ b/js/subscriptions.js @@ -27,6 +27,16 @@ import { getApplication } from "./applicationContext.js"; import { VideoListView } from "./ui/views/VideoListView.js"; import { ALLOW_NSFW_CONTENT } from "./config.js"; import { devLogger, userLogger } from "./utils/logger.js"; +import { + TinyEventEmitter, + isTransientDecryptError, + computeRetryDelay, + normalizeHexPubkey, + normalizeEncryptionToken, + serializeSubscriptionTagMatrix, + parseSubscriptionPlaintext, + parseCachedSubscriptionSnapshot, +} from "./subscriptionsHelpers.js"; import { noteSignerDecryptTimeout, noteSignerDecryptSuccess, @@ -65,68 +75,6 @@ const DECRYPT_TIMEOUT_MS = 30000; // should recover near-instantly. The shorter delay prevents unnecessary wait // time during the critical login path when the first decrypt attempt fails. const DECRYPT_RETRY_DELAY_MS = 1500; -const MAX_DECRYPT_RETRY_DELAY_MS = 60000; - -// Transient decrypt failures that must RETRY 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 the subscription list until a refresh (KNOWN_BUGS #0). -const TRANSIENT_DECRYPT_ERROR_CODES = new Set([ - "subscriptions-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; - } - 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); -} - -function normalizeHexPubkey(value) { - if (typeof value !== "string") { - return ""; - } - const trimmed = value.trim(); - return trimmed ? trimmed.toLowerCase() : ""; -} - -function normalizeEncryptionToken(value) { - if (typeof value !== "string") { - return ""; - } - return value - .trim() - .toLowerCase() - .replace(/[^a-z0-9]/g, ""); -} function extractEncryptionHints(event) { if (!event || typeof event !== "object") { @@ -212,133 +160,6 @@ function determineDecryptionOrder(event, availableSchemes, cachedScheme = null) return prioritized.length ? prioritized : available; } -function serializeSubscriptionTagMatrix(values) { - const tags = []; - const seen = new Set(); - if (!values) { - return JSON.stringify(tags); - } - const iterable = Array.isArray(values) - ? values - : values instanceof Set - ? Array.from(values) - : []; - for (const candidate of iterable) { - const normalized = normalizeHexPubkey(candidate); - if (!normalized || seen.has(normalized)) { - continue; - } - seen.add(normalized); - tags.push(["p", normalized]); - } - return JSON.stringify(tags); -} - -function parseSubscriptionPlaintext(plaintext) { - if (typeof plaintext !== "string" || !plaintext) { - return []; - } - - let parsed; - try { - parsed = JSON.parse(plaintext); - } catch (error) { - devLogger.warn( - "[SubscriptionsManager] Failed to parse subscription ciphertext as JSON; treating as empty.", - error, - ); - return []; - } - - if (Array.isArray(parsed)) { - const collected = []; - const seen = new Set(); - for (const entry of parsed) { - if (!Array.isArray(entry) || entry.length < 2) { - continue; - } - const marker = typeof entry[0] === "string" ? entry[0].trim().toLowerCase() : ""; - if (marker !== "p") { - continue; - } - const normalized = normalizeHexPubkey(entry[1]); - if (!normalized || seen.has(normalized)) { - continue; - } - seen.add(normalized); - collected.push(normalized); - } - return collected; - } - - if (parsed && typeof parsed === "object") { - const tagArray = Array.isArray(parsed.tags) ? parsed.tags : []; - if (tagArray.length) { - try { - return parseSubscriptionPlaintext(JSON.stringify(tagArray)); - } catch { - // fall through to legacy handling - } - } - - const legacy = Array.isArray(parsed.subPubkeys) ? parsed.subPubkeys : []; - return legacy.map((value) => normalizeHexPubkey(value)).filter(Boolean); - } - - return []; -} - -function parseCachedSubscriptionSnapshot(cached) { - if (Array.isArray(cached)) { - const normalized = cached.map((value) => normalizeHexPubkey(value)).filter(Boolean); - return { - subscribedPubkeys: normalized, - eventId: null, - createdAt: null, - hasSnapshot: true, - }; - } - - if (cached && typeof cached === "object") { - const listCandidate = Array.isArray(cached.subscribedPubkeys) - ? cached.subscribedPubkeys - : Array.isArray(cached.subPubkeys) - ? cached.subPubkeys - : []; - const normalized = listCandidate - .map((value) => normalizeHexPubkey(value)) - .filter(Boolean); - const eventId = typeof cached.eventId === "string" ? cached.eventId : null; - const createdAtRaw = cached.createdAt; - const createdAtCandidate = - typeof createdAtRaw === "number" - ? createdAtRaw - : typeof createdAtRaw === "string" && createdAtRaw.trim() - ? Number(createdAtRaw) - : Number.NaN; - const createdAt = Number.isFinite(createdAtCandidate) - ? Math.floor(createdAtCandidate) - : null; - const hasSnapshot = - Array.isArray(cached.subscribedPubkeys) || - Array.isArray(cached.subPubkeys) || - Boolean(eventId) || - Number.isFinite(createdAtCandidate); - return { - subscribedPubkeys: normalized, - eventId, - createdAt, - hasSnapshot, - }; - } - - return { - subscribedPubkeys: [], - eventId: null, - createdAt: null, - hasSnapshot: false, - }; -} const getApp = () => getApplication(); @@ -347,50 +168,6 @@ const listVideoViewEventsApi = (pointer, options) => const subscribeVideoViewEventsApi = (pointer, options) => subscribeVideoViewEvents(nostrClient, pointer, options); -class TinyEventEmitter { - constructor() { - this.listeners = new Map(); - } - - on(eventName, handler) { - if (typeof eventName !== "string" || typeof handler !== "function") { - return () => {}; - } - - if (!this.listeners.has(eventName)) { - this.listeners.set(eventName, new Set()); - } - - const handlers = this.listeners.get(eventName); - handlers.add(handler); - - return () => { - handlers.delete(handler); - if (!handlers.size) { - this.listeners.delete(eventName); - } - }; - } - - emit(eventName, detail) { - const handlers = this.listeners.get(eventName); - if (!handlers || !handlers.size) { - return; - } - - for (const handler of Array.from(handlers)) { - try { - handler(detail); - } catch (error) { - userLogger.warn( - `[SubscriptionsManager] listener for "${eventName}" threw an error`, - error, - ); - } - } - } -} - /** * Manages the user's subscription list (kind=30000 follow set) *privately*, * using encrypted NIP-51 tag arrays (prefer NIP-44 v2) for the content field. diff --git a/js/subscriptionsHelpers.js b/js/subscriptionsHelpers.js new file mode 100644 index 00000000..8ae47e84 --- /dev/null +++ b/js/subscriptionsHelpers.js @@ -0,0 +1,242 @@ +// js/subscriptionsHelpers.js +// +// State-free helpers extracted from subscriptions.js to keep that module under +// the file-size budget. No behavior change. + +import { devLogger, userLogger } from "./utils/logger.js"; + +export class TinyEventEmitter { + constructor() { + this.listeners = new Map(); + } + + on(eventName, handler) { + if (typeof eventName !== "string" || typeof handler !== "function") { + return () => {}; + } + + if (!this.listeners.has(eventName)) { + this.listeners.set(eventName, new Set()); + } + + const handlers = this.listeners.get(eventName); + handlers.add(handler); + + return () => { + handlers.delete(handler); + if (!handlers.size) { + this.listeners.delete(eventName); + } + }; + } + + emit(eventName, detail) { + const handlers = this.listeners.get(eventName); + if (!handlers || !handlers.size) { + return; + } + + for (const handler of Array.from(handlers)) { + try { + handler(detail); + } catch (error) { + userLogger.warn( + `[SubscriptionsManager] listener for "${eventName}" threw an error`, + error, + ); + } + } + } +} + +const MAX_DECRYPT_RETRY_DELAY_MS = 60000; + +// Transient decrypt failures that must RETRY 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 the subscription list until a refresh (KNOWN_BUGS #0). +const TRANSIENT_DECRYPT_ERROR_CODES = new Set([ + "subscriptions-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; + } + 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(value) { + if (typeof value !== "string") { + return ""; + } + const trimmed = value.trim(); + return trimmed ? trimmed.toLowerCase() : ""; +} + +export function normalizeEncryptionToken(value) { + if (typeof value !== "string") { + return ""; + } + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]/g, ""); +} + +export function serializeSubscriptionTagMatrix(values) { + const tags = []; + const seen = new Set(); + if (!values) { + return JSON.stringify(tags); + } + const iterable = Array.isArray(values) + ? values + : values instanceof Set + ? Array.from(values) + : []; + for (const candidate of iterable) { + const normalized = normalizeHexPubkey(candidate); + if (!normalized || seen.has(normalized)) { + continue; + } + seen.add(normalized); + tags.push(["p", normalized]); + } + return JSON.stringify(tags); +} + +export function parseSubscriptionPlaintext(plaintext) { + if (typeof plaintext !== "string" || !plaintext) { + return []; + } + + let parsed; + try { + parsed = JSON.parse(plaintext); + } catch (error) { + devLogger.warn( + "[SubscriptionsManager] Failed to parse subscription ciphertext as JSON; treating as empty.", + error, + ); + return []; + } + + if (Array.isArray(parsed)) { + const collected = []; + const seen = new Set(); + for (const entry of parsed) { + if (!Array.isArray(entry) || entry.length < 2) { + continue; + } + const marker = typeof entry[0] === "string" ? entry[0].trim().toLowerCase() : ""; + if (marker !== "p") { + continue; + } + const normalized = normalizeHexPubkey(entry[1]); + if (!normalized || seen.has(normalized)) { + continue; + } + seen.add(normalized); + collected.push(normalized); + } + return collected; + } + + if (parsed && typeof parsed === "object") { + const tagArray = Array.isArray(parsed.tags) ? parsed.tags : []; + if (tagArray.length) { + try { + return parseSubscriptionPlaintext(JSON.stringify(tagArray)); + } catch { + // fall through to legacy handling + } + } + + const legacy = Array.isArray(parsed.subPubkeys) ? parsed.subPubkeys : []; + return legacy.map((value) => normalizeHexPubkey(value)).filter(Boolean); + } + + return []; +} + +export function parseCachedSubscriptionSnapshot(cached) { + if (Array.isArray(cached)) { + const normalized = cached.map((value) => normalizeHexPubkey(value)).filter(Boolean); + return { + subscribedPubkeys: normalized, + eventId: null, + createdAt: null, + hasSnapshot: true, + }; + } + + if (cached && typeof cached === "object") { + const listCandidate = Array.isArray(cached.subscribedPubkeys) + ? cached.subscribedPubkeys + : Array.isArray(cached.subPubkeys) + ? cached.subPubkeys + : []; + const normalized = listCandidate + .map((value) => normalizeHexPubkey(value)) + .filter(Boolean); + const eventId = typeof cached.eventId === "string" ? cached.eventId : null; + const createdAtRaw = cached.createdAt; + const createdAtCandidate = + typeof createdAtRaw === "number" + ? createdAtRaw + : typeof createdAtRaw === "string" && createdAtRaw.trim() + ? Number(createdAtRaw) + : Number.NaN; + const createdAt = Number.isFinite(createdAtCandidate) + ? Math.floor(createdAtCandidate) + : null; + const hasSnapshot = + Array.isArray(cached.subscribedPubkeys) || + Array.isArray(cached.subPubkeys) || + Boolean(eventId) || + Number.isFinite(createdAtCandidate); + return { + subscribedPubkeys: normalized, + eventId, + createdAt, + hasSnapshot, + }; + } + + return { + subscribedPubkeys: [], + eventId: null, + createdAt: null, + hasSnapshot: false, + }; +} diff --git a/scripts/check-file-size.mjs b/scripts/check-file-size.mjs index 6afb975b..994b5440 100644 --- a/scripts/check-file-size.mjs +++ b/scripts/check-file-size.mjs @@ -36,7 +36,7 @@ const GRANDFATHERED = { "js/ui/loginModalController.js": 2552, "js/historyView.js": 2613, "js/services/moderationService.js": 2512, - "js/subscriptions.js": 2426, + "js/subscriptions.js": 2361, "js/userBlocks.js": 2428, "js/services/nostrService.js": 2227, "js/nostr/watchHistory.js": 2388,