mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
refactor(subscriptions): extract helpers from subscriptions (6/10)
File-size decomposition (no behavior change). Move the state-free TinyEventEmitter, the decrypt-retry helpers (isTransientDecryptError, computeRetryDelay + tables), the normalize helpers (normalizeHexPubkey, normalizeEncryptionToken), and the subscription-list codec (serializeSubscriptionTagMatrix, parseSubscriptionPlaintext, parseCachedSubscriptionSnapshot) into js/subscriptionsHelpers.js. subscriptions.js: 2584 -> 2361 lines (below its original 2426 baseline; re-recorded to 2361 to lock the reduction). Build + full unit suite green. Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
1 parent
8871c4e3a1
commit
87a29b8ff8
3 files changed
+253
-234
No files matched your search
+10
-233
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user