// js/watchHistoryService.js import { nostrClient } from "./nostrClientFacade.js"; import { normalizePointerInput, pointerKey, normalizeActorKey, } from "./nostr/watchHistory.js"; import { WATCH_HISTORY_CACHE_TTL_MS, WATCH_HISTORY_MAX_ITEMS, } from "./config.js"; import { devLogger, userLogger } from "./utils/logger.js"; import { ONE_MINUTE_MS } from "./constants.js"; import { getLoggedInActorKey, getSessionActorKey, ensureWatchHistoryExtensionPermissions, } from "./watchHistoryActorHelpers.js"; const LOCAL_STORAGE_QUEUE_KEY = "bitvid:watch-history:queue:v1"; const SESSION_STORAGE_VERSION = 1; const POINTER_THROTTLE_MS = ONE_MINUTE_MS; // On a cold load (nothing cached to show) we decrypt only the newest chunks up // front so the UI renders quickly instead of freezing behind a deep history; // the rest are filled in the background. Monthly chunks, so ~3 covers recent // activity for the first paint. const WATCH_HISTORY_INITIAL_CHUNK_DECRYPT_LIMIT = 3; const state = { restored: false, queues: new Map(), inflightSnapshots: new Map(), fingerprintCache: new Map(), listeners: new Map(), // Actors with an in-flight background backfill of deferred (older) chunks, // so we never launch two at once for the same actor. chunkBackfills: new Set(), }; function resolveEffectiveActorKey(actorInput) { const supplied = typeof actorInput === "string" && actorInput.trim() ? normalizeActorKey(actorInput) : ""; if (supplied) { return supplied; } const logged = getLoggedInActorKey(); if (logged) { return logged; } const session = getSessionActorKey(); if (session) { return session; } return ""; } function isFeatureEnabled(actorInput) { const actorKey = resolveEffectiveActorKey(actorInput); const session = getSessionActorKey(); if (actorKey && session && actorKey === session) { return false; } return true; } function isLocalOnly(actorInput) { const actorKey = resolveEffectiveActorKey(actorInput); const session = getSessionActorKey(); if (!session) { return false; } if (!actorKey) { return true; } return actorKey === session; } function supportsLocalHistory(actorInput) { return isLocalOnly(actorInput); } function getQueueStorage() { try { if (typeof window !== "undefined" && window.localStorage) { return window.localStorage; } } catch (error) { devLogger.warn("[watchHistoryService] localStorage unavailable for queue:", error); } return null; } function getLocalStorage() { try { if (typeof window !== "undefined" && window.localStorage) { return window.localStorage; } } catch (error) { devLogger.warn("[watchHistoryService] localStorage unavailable:", error); } return null; } function emit(eventName, payload) { const listeners = state.listeners.get(eventName); if (listeners && listeners.size) { for (const callback of Array.from(listeners)) { try { callback(payload); } catch (error) { devLogger.warn( `[watchHistoryService] Listener for ${eventName} failed:`, error ); } } } const wildcard = state.listeners.get("*"); if (wildcard && wildcard.size) { for (const callback of Array.from(wildcard)) { try { callback({ event: eventName, payload }); } catch (error) { devLogger.warn( `[watchHistoryService] Wildcard listener error for ${eventName}:`, error ); } } } if (typeof window !== "undefined" && typeof window.dispatchEvent === "function") { try { const detail = { event: eventName, payload }; const evt = new CustomEvent("bitvid:watchHistory", { detail }); window.dispatchEvent(evt); } catch (error) { devLogger.warn( `[watchHistoryService] Failed to dispatch window event for ${eventName}:`, error ); } } } function subscribe(eventName, callback) { if (typeof callback !== "function") { return () => {}; } if (!state.listeners.has(eventName)) { state.listeners.set(eventName, new Set()); } const listeners = state.listeners.get(eventName); listeners.add(callback); return () => { listeners.delete(callback); if (!listeners.size) { state.listeners.delete(eventName); } }; } function getOrCreateQueue(actorKey) { let queue = state.queues.get(actorKey); if (!queue) { queue = { items: new Map(), throttle: new Map(), lastSnapshotReason: null, republishScheduled: false, }; state.queues.set(actorKey, queue); } return queue; } function restoreQueueState() { if (state.restored) { return; } state.restored = true; const storage = getQueueStorage(); if (!storage) { return; } let raw = null; try { raw = storage.getItem(LOCAL_STORAGE_QUEUE_KEY); } catch (error) { devLogger.warn("[watchHistoryService] Failed to read queue storage:", error); return; } if (!raw || typeof raw !== "string") { return; } let parsed = null; try { parsed = JSON.parse(raw); } catch (error) { devLogger.warn("[watchHistoryService] Failed to parse queue storage:", error); try { storage.removeItem(LOCAL_STORAGE_QUEUE_KEY); } catch (cleanupError) { devLogger.warn( "[watchHistoryService] Failed to clear corrupt queue storage:", cleanupError ); } return; } if (!parsed || parsed.version !== SESSION_STORAGE_VERSION) { try { storage.removeItem(LOCAL_STORAGE_QUEUE_KEY); } catch (cleanupError) { devLogger.warn( "[watchHistoryService] Failed to clear outdated queue storage:", cleanupError ); } return; } const actors = parsed.actors; if (!actors || typeof actors !== "object") { return; } const now = Date.now(); for (const [actor, entry] of Object.entries(actors)) { const actorKey = normalizeActorKey(actor); if (!actorKey) { continue; } const queue = getOrCreateQueue(actorKey); queue.items.clear(); const items = Array.isArray(entry?.items) ? entry.items : []; for (const candidate of items) { const pointer = normalizePointerInput(candidate); if (!pointer) { continue; } const key = pointerKey(pointer); if (!key) { continue; } queue.items.set(key, { pointer, addedAt: now, updatedAt: now, }); } if ( typeof entry?.lastSnapshotReason === "string" && entry.lastSnapshotReason ) { queue.lastSnapshotReason = entry.lastSnapshotReason; } else { queue.lastSnapshotReason = null; } // If there are items in the queue, schedule a sync if (queue.items.size > 0 && isFeatureEnabled(actorKey)) { scheduleRepublishForQueue(actorKey); } } } function ensureQueue(actorKey) { if (!actorKey) { return null; } if (!state.restored) { restoreQueueState(); } return getOrCreateQueue(actorKey); } function persistQueueState() { const storage = getQueueStorage(); if (!storage) { return; } const payload = { version: SESSION_STORAGE_VERSION, actors: {} }; let hasEntries = false; for (const [actorKey, queue] of state.queues.entries()) { const items = collectQueueItems(actorKey); if (!items.length) { continue; } payload.actors[actorKey] = { items, lastSnapshotReason: queue.lastSnapshotReason, }; hasEntries = true; } if (!hasEntries) { try { storage.removeItem(LOCAL_STORAGE_QUEUE_KEY); } catch (error) { devLogger.warn( "[watchHistoryService] Failed to clear empty queue storage:", error ); } return; } try { storage.setItem(LOCAL_STORAGE_QUEUE_KEY, JSON.stringify(payload)); } catch (error) { devLogger.warn("[watchHistoryService] Failed to persist queue storage:", error); } } function resolveActorKey(actorInput) { const supplied = typeof actorInput === "string" && actorInput.trim() ? normalizeActorKey(actorInput) : ""; if (supplied) { return supplied; } const logged = normalizeActorKey(nostrClient?.pubkey); if (logged) { return logged; } const session = normalizeActorKey(nostrClient?.sessionActor?.pubkey); if (session) { return session; } return ""; } function collectQueueItems(actorKey) { const queue = state.queues.get(actorKey); if (!queue) { return []; } const items = []; for (const { pointer } of queue.items.values()) { const normalized = normalizePointerInput(pointer); if (normalized) { items.push(normalized); } } items.sort((a, b) => { const watchedA = Number.isFinite(a?.watchedAt) ? a.watchedAt : 0; const watchedB = Number.isFinite(b?.watchedAt) ? b.watchedAt : 0; if (watchedA !== watchedB) { return watchedB - watchedA; } const keyA = pointerKey(a); const keyB = pointerKey(b); return keyA.localeCompare(keyB); }); return items.slice(0, WATCH_HISTORY_MAX_ITEMS); } function getCachedSnapshotItems(actorKey) { if (!actorKey) { return []; } const fingerprintEntry = state.fingerprintCache.get(actorKey); if (Array.isArray(fingerprintEntry?.items) && fingerprintEntry.items.length) { return Array.from(fingerprintEntry.items); } const clientCache = nostrClient && nostrClient.watchHistoryCache instanceof Map && nostrClient.watchHistoryCache.get(actorKey); if (Array.isArray(clientCache?.items) && clientCache.items.length) { return Array.from(clientCache.items); } if (typeof nostrClient?.getWatchHistoryStorage === "function") { try { const storage = nostrClient.getWatchHistoryStorage(); const storedItems = storage?.actors?.[actorKey]?.items; if (Array.isArray(storedItems) && storedItems.length) { return Array.from(storedItems); } } catch (error) { devLogger.warn( "[watchHistoryService] Failed to read cached watch history items:", error, ); } } return []; } function notifyQueueChange(actorKey) { emit("queue-changed", { actor: actorKey, items: collectQueueItems(actorKey), }); } function clearQueue(actorKey) { const queue = state.queues.get(actorKey); if (!queue) { return; } queue.items.clear(); queue.throttle.clear(); queue.republishScheduled = false; persistQueueState(); notifyQueueChange(actorKey); } // Replace the local watch-history queue with exactly `items` (used by a // local-only REPLACE snapshot, i.e. a logged-out removal/clear). Source of truth // for local history is loadLatest -> collectQueueItems(queue), so this is what // makes a local delete persist. function replaceLocalQueue(actorKey, items) { const queue = ensureQueue(actorKey); queue.items.clear(); queue.throttle.clear(); const now = Date.now(); for (const item of Array.isArray(items) ? items : []) { const pointer = normalizePointerInput(item); if (!pointer) { continue; } const key = pointerKey(pointer); if (!key) { continue; } queue.items.set(key, { pointer, addedAt: now, updatedAt: now }); } persistQueueState(); notifyQueueChange(actorKey); } function pruneQueueAfterSnapshot(actorKey, queue, keysToClear, snapshotStart) { if (!actorKey || !queue) { return; } const keySet = new Set(keysToClear || []); const normalizedStart = Number(snapshotStart); const cutoff = Number.isFinite(normalizedStart) ? normalizedStart : Date.now(); let mutated = false; if (keySet.size > 0) { for (const key of keySet) { const entry = queue.items.get(key); if (!entry) { continue; } const updatedAt = Number(entry?.updatedAt); const addedAt = Number(entry?.addedAt); const referenceTime = Number.isFinite(updatedAt) ? updatedAt : Number.isFinite(addedAt) ? addedAt : 0; if (referenceTime > cutoff) { continue; } queue.items.delete(key); queue.throttle.delete(key); mutated = true; } } if (queue.republishScheduled) { queue.republishScheduled = false; mutated = true; } if (mutated) { persistQueueState(); notifyQueueChange(actorKey); } } function resolveWatchedAt(...candidates) { for (const candidate of candidates) { if (!Number.isFinite(candidate)) { continue; } let value = Math.floor(Number(candidate)); if (value <= 0) { continue; } if (value > 10_000_000_000) { value = Math.floor(value / 1000); } if (value > 0) { return value; } } return Math.floor(Date.now() / 1000); } function scheduleRepublishForQueue(actorKey) { if (!isFeatureEnabled(actorKey)) { return; } const queue = state.queues.get(actorKey); if (!queue || !queue.items.size || queue.republishScheduled) { return; } const taskId = `watch-history-sync:${actorKey}`; queue.republishScheduled = true; nostrClient.scheduleWatchHistoryRepublish( taskId, async (attempt) => { const latestItems = collectQueueItems(actorKey); if (!latestItems.length) { queue.republishScheduled = false; persistQueueState(); return { ok: true, skipped: true, actor: actorKey, }; } const snapshotStartTime = Date.now(); const keysToClear = new Set( latestItems .map((pointer) => pointerKey(pointer)) .filter((keyValue) => typeof keyValue === "string" && keyValue) ); const publishResult = await nostrClient.updateWatchHistoryList( latestItems, { actorPubkey: actorKey, attempt, } ); if (publishResult?.ok) { pruneQueueAfterSnapshot( actorKey, queue, keysToClear, snapshotStartTime, ); await updateFingerprintCache( actorKey, publishResult.items || latestItems, ); if (queue.items.size > 0) { scheduleRepublishForQueue(actorKey); } } else if (!publishResult?.retryable) { // If not retryable, we clear the scheduled flag to allow new attempts later queue.republishScheduled = false; persistQueueState(); } return publishResult; }, { onSchedule: ({ delay }) => { emit("republish-scheduled", { actor: actorKey, delayMs: Number.isFinite(delay) ? delay : null, }); }, } ); } async function updateFingerprintCache(actorKey, items) { const ttl = Math.max(0, Number(WATCH_HISTORY_CACHE_TTL_MS) || 0); const fingerprint = await nostrClient.getWatchHistoryFingerprint( actorKey, items ); const entry = state.fingerprintCache.get(actorKey) || {}; const previousFingerprint = entry.fingerprint; const nextEntry = { ...entry, items: Array.isArray(items) ? items : [], fingerprint, expiresAt: Date.now() + ttl, }; delete nextEntry.promise; state.fingerprintCache.set(actorKey, nextEntry); // Only emit when the fingerprint actually CHANGED. Emitting unconditionally // created an infinite loop: the For You feed listens for "fingerprint" and // re-runs refreshFeed -> loadLatest -> updateFingerprintCache -> emit, which // pegged the CPU with a full feed rebuild every ~400ms. if (fingerprint !== previousFingerprint) { emit("fingerprint", { actor: actorKey, fingerprint, items: nextEntry.items, }); } return fingerprint; } async function publishView(pointerInput, createdAt) { const recordOptions = {}; if (Number.isFinite(createdAt)) { recordOptions.created_at = createdAt; } const viewResult = await nostrClient.recordVideoView(pointerInput, recordOptions); devLogger.info( "[watchHistoryService] Video view recorded. Preparing watch list update.", { pointer: pointerInput, createdAt, } ); const pointer = normalizePointerInput(pointerInput); if (!pointer) { userLogger.warn( "[watchHistoryService] Skipping watch list update because pointer normalization failed.", { pointerInput } ); return viewResult; } // Watch history belongs to the LOGGED-IN user. View events are signed by an // anonymous session actor (privacy for the public counter), so using the view // event's pubkey filed every logged-in watch under the session actor // (local-only) — it never synced. Prefer the logged-in pubkey. let actorCandidate = nostrClient?.pubkey || viewResult?.event?.pubkey || nostrClient?.sessionActor?.pubkey || ""; let actorKey = normalizeActorKey(actorCandidate); let usedSessionFallback = false; if (!actorKey && typeof nostrClient?.ensureSessionActor === "function") { await nostrClient.ensureSessionActor(); const sessionActorKey = normalizeActorKey(nostrClient?.sessionActor?.pubkey); if (sessionActorKey) { actorKey = sessionActorKey; actorCandidate = nostrClient?.sessionActor?.pubkey || ""; usedSessionFallback = true; } } if (!actorKey) { userLogger.warn( "[watchHistoryService] Unable to resolve actor for watch list update.", { actorCandidate } ); return viewResult; } if (usedSessionFallback) { devLogger.info( "[watchHistoryService] Using session actor fallback for watch history update.", { actor: actorKey } ); } const queue = ensureQueue(actorKey); if (!queue) { userLogger.warn( "[watchHistoryService] Failed to resolve queue for actor.", { actorKey } ); return viewResult; } const remoteEnabled = isFeatureEnabled(actorKey); const normalizedPointer = normalizePointerInput(pointer); if (!normalizedPointer) { userLogger.warn( "[watchHistoryService] Pointer normalization failed during queue preparation.", { pointerInput } ); return viewResult; } normalizedPointer.watchedAt = resolveWatchedAt( createdAt, viewResult?.event?.created_at ); delete normalizedPointer.video; delete normalizedPointer.profile; delete normalizedPointer.resumeAt; delete normalizedPointer.completed; const key = pointerKey(normalizedPointer); devLogger.info( "[watchHistoryService] Watch list process triggered by video view.", { actor: actorKey, pointerKey: key, watchedAt: normalizedPointer.watchedAt, sessionEvent: normalizedPointer.session === true, remoteSyncEnabled: remoteEnabled, } ); // A watch is a local-only "session" entry only when nobody is logged in. // (View events are always signed by the anonymous session actor, so comparing // against the view actor wrongly flagged every logged-in watch as session.) if (!normalizeActorKey(nostrClient?.pubkey)) { normalizedPointer.session = true; } if (!key) { userLogger.warn( "[watchHistoryService] Pointer key generation failed. Skipping queue update.", { pointer: normalizedPointer } ); return viewResult; } const now = Date.now(); const existing = queue.items.get(key); const lastSeen = queue.throttle.get(key) || 0; const throttled = !existing && now - lastSeen < POINTER_THROTTLE_MS; if (existing) { const currentWatched = Number.isFinite(existing.pointer?.watchedAt) ? existing.pointer.watchedAt : 0; if ( Number.isFinite(normalizedPointer.watchedAt) && normalizedPointer.watchedAt > currentWatched ) { existing.pointer.watchedAt = normalizedPointer.watchedAt; } if (normalizedPointer.session === true) { existing.pointer.session = true; } existing.updatedAt = now; queue.items.set(key, existing); queue.throttle.set(key, now); persistQueueState(); notifyQueueChange(actorKey); devLogger.debug( "[watchHistoryService] Updated existing watch list entry.", { actor: actorKey, pointerKey: key, watchedAt: existing.pointer.watchedAt, } ); return viewResult; } if (throttled) { queue.throttle.set(key, now); devLogger.debug( `[watchHistoryService] Throttled pointer ${key} for actor ${actorKey}.` ); return viewResult; } queue.items.set(key, { pointer: normalizedPointer, addedAt: now, updatedAt: now, }); queue.throttle.set(key, now); persistQueueState(); notifyQueueChange(actorKey); devLogger.info( "[watchHistoryService] Added pointer to pending watch list snapshot queue.", { actor: actorKey, pointerKey: key, queueSize: queue.items.size, } ); if (!remoteEnabled) { devLogger.debug( "[watchHistoryService] Watch history sync unavailable for this actor; retaining pointer in local session queue only.", { actor: actorKey, pointerKey: key, watchedAt: normalizedPointer.watchedAt, } ); } else { // Automatically schedule a republish if enabled scheduleRepublishForQueue(actorKey); } return viewResult; } async function snapshot(items, options = {}) { const reason = typeof options.reason === "string" ? options.reason : "manual"; // Removal/clear paths must REPLACE the published list — otherwise the items the // user just removed get merged back from the existing cache and the deletion // silently has no effect. Adds (the default) keep merge semantics. const replace = options.replace === true; const actorKey = resolveActorKey(options.actor); if (!actorKey) { return { ok: false, error: "missing-actor" }; } if (!isFeatureEnabled(actorKey)) { // Local-only actors (logged out / session) keep watch history in the local // queue, not on relays. A REPLACE snapshot (i.e. a removal/clear) must // rewrite that queue so the deletion actually sticks — otherwise the removed // item lingers locally and reappears on reload. Normal watch-tracking adds // still flow through the queue via recordVideoView. if (replace && Array.isArray(items)) { replaceLocalQueue(actorKey, items); return { ok: true, local: true, reason: "local-replaced", itemCount: items.length }; } devLogger.debug( "[watchHistoryService] Snapshot skipped because this actor is limited to local watch history.", { actor: actorKey, requestedItems: Array.isArray(items) ? items.length : 0, reason, } ); return { ok: true, skipped: true, reason: "local-only" }; } if (state.inflightSnapshots.has(actorKey)) { return state.inflightSnapshots.get(actorKey); } let payloadItems = []; if (Array.isArray(items) && items.length) { payloadItems = items .map((item) => normalizePointerInput(item)) .filter(Boolean); } else if (replace && Array.isArray(items)) { // Explicit replace with an empty list = clear the history. Must NOT fall // back to the pending queue (that would resurrect items just removed) and // must still publish so relays drop the old list. payloadItems = []; } else { payloadItems = collectQueueItems(actorKey); } if (!payloadItems.length && !replace) { const result = { ok: true, empty: true, actor: actorKey }; emit("snapshot-empty", result); return result; } const queue = ensureQueue(actorKey); const queuePayloadKeys = !items && queue && payloadItems.length ? new Set( payloadItems .map((pointer) => pointerKey(pointer)) .filter((keyValue) => typeof keyValue === "string" && keyValue) ) : new Set(); if (!items && !replace) { const cachedItems = getCachedSnapshotItems(actorKey); if (cachedItems.length) { payloadItems = [...payloadItems, ...cachedItems]; } } devLogger.info( "[watchHistoryService] Initiating watch list snapshot publish.", { actor: actorKey, reason, itemCount: payloadItems.length, queued: queue?.items?.size ?? 0, } ); const run = (async () => { const snapshotStartTime = Date.now(); emit("snapshot-start", { actor: actorKey, reason, items: payloadItems }); const publishResult = await nostrClient.updateWatchHistoryList( payloadItems, { actorPubkey: actorKey, replace, } ); devLogger.debug( "[watchHistoryService] Watch list snapshot publish completed.", { actor: actorKey, reason, success: !!publishResult?.ok, retryable: !!publishResult?.retryable, } ); if (!publishResult?.ok) { if (publishResult?.retryable) { if (queue) { queue.lastSnapshotReason = reason; queue.republishScheduled = false; persistQueueState(); scheduleRepublishForQueue(actorKey); } } const error = new Error("watch-history-snapshot-failed"); error.result = publishResult; emit("snapshot-error", { actor: actorKey, reason, error }); throw error; } if (publishResult?.ok) { if (!items) { pruneQueueAfterSnapshot(actorKey, queue, queuePayloadKeys, snapshotStartTime); } else if (queue) { queue.republishScheduled = false; persistQueueState(); } } await updateFingerprintCache(actorKey, publishResult.items || payloadItems); emit("snapshot-complete", { actor: actorKey, reason, result: publishResult }); devLogger.debug( "[watchHistoryService] Watch list snapshot processed and fingerprint cache updated.", { actor: actorKey, reason, fingerprintUpdated: true, } ); return publishResult; })() .catch((error) => { devLogger.warn("[watchHistoryService] Snapshot failed:", error); throw error; }) .finally(() => { state.inflightSnapshots.delete(actorKey); }); state.inflightSnapshots.set(actorKey, run); return run; } function maybeBackfillDeferredChunks(actorKey) { if (!actorKey || state.chunkBackfills.has(actorKey)) { return; } const deferred = typeof nostrClient.getWatchHistoryDeferredChunkCount === "function" ? Number(nostrClient.getWatchHistoryDeferredChunkCount(actorKey)) || 0 : 0; if (deferred <= 0) { return; } state.chunkBackfills.add(actorKey); devLogger.info("[watchHistoryService] Backfilling deferred watch history chunks.", { actor: actorKey, deferred, }); // Full decrypt pass (no chunk cap). Already-decrypted chunks are served from // cache, so only the deferred older chunks incur signer RPCs. The resulting // larger item set changes the fingerprint, which emits "fingerprint" and lets // listeners (e.g. the history view) re-render with the complete history. Promise.resolve() .then(() => nostrClient.resolveWatchHistory(actorKey, { forceRefresh: true })) .then((fullItems) => updateFingerprintCache(actorKey, fullItems)) .catch((error) => { devLogger.warn( "[watchHistoryService] Deferred chunk backfill failed:", error, ); }) .finally(() => { state.chunkBackfills.delete(actorKey); }); } function scheduleWatchHistoryRefresh(actorKey, cacheEntry = {}, options = {}) { if (!actorKey) { return Promise.resolve([]); } if (cacheEntry && typeof cacheEntry === "object" && cacheEntry.promise) { return cacheEntry.promise; } // Cold load = nothing cached to show yet, which is exactly when a deep // history would freeze the first render. Bound the first decrypt pass to the // newest chunks and backfill the rest in the background. On a warm refresh we // already have items on screen, so resolve the full history directly to avoid // a transient "history shrank then grew" flicker. const hasCachedItems = Array.isArray(cacheEntry?.items) && cacheEntry.items.length > 0; const useBoundedFirstPass = !hasCachedItems; const promise = (async () => { const permissionResult = await ensureWatchHistoryExtensionPermissions( actorKey, options ); if (!permissionResult.ok) { throw permissionResult.error; } return nostrClient.resolveWatchHistory(actorKey, { forceRefresh: true, ...(useBoundedFirstPass ? { chunkDecryptLimit: WATCH_HISTORY_INITIAL_CHUNK_DECRYPT_LIMIT } : {}), }); })() .then((resolvedItems) => updateFingerprintCache(actorKey, resolvedItems)) .then(() => { if (useBoundedFirstPass) { // Older chunks were skipped to render fast; fill them in the background // (paced by the serial NIP-46 queue) and emit when done. maybeBackfillDeferredChunks(actorKey); } const latest = state.fingerprintCache.get(actorKey); return latest?.items || []; }) .catch((error) => { devLogger.warn("[watchHistoryService] Failed to load watch history:", error); throw error; }) .finally(() => { const entry = state.fingerprintCache.get(actorKey); if (entry) { delete entry.promise; } }); devLogger.info("[watchHistoryService] Triggered nostr watch list refresh.", { actor: actorKey, }); const baseEntry = cacheEntry && typeof cacheEntry === "object" ? cacheEntry : {}; state.fingerprintCache.set(actorKey, { ...baseEntry, promise, }); return promise; } function resolveEntryPointer(entry) { if (!entry) { return null; } const candidate = entry.pointer || entry; return normalizePointerInput(candidate); } function resolveEntryWatchedAt(entry, pointer) { const watchedAtRaw = Number.isFinite(entry?.watchedAt) ? entry.watchedAt : Number.isFinite(entry?.pointer?.watchedAt) ? entry.pointer.watchedAt : Number.isFinite(entry?.timestamp) ? entry.timestamp : Number.isFinite(pointer?.watchedAt) ? pointer.watchedAt : 0; return Math.max(0, Math.floor(Number(watchedAtRaw) || 0)); } function getLatestWatchedAt(items) { if (!Array.isArray(items) || !items.length) { return 0; } let latest = 0; for (const entry of items) { const pointer = resolveEntryPointer(entry); if (!pointer) { continue; } const watchedAt = resolveEntryWatchedAt(entry, pointer); if (watchedAt > latest) { latest = watchedAt; } } return latest; } function buildMergedEntry(entry, pointer, watchedAt) { const normalizedPointer = pointer ? { ...pointer } : null; if (normalizedPointer) { normalizedPointer.watchedAt = watchedAt; if (pointer?.session === true) { normalizedPointer.session = true; } } if (entry && typeof entry === "object" && !Array.isArray(entry)) { const next = { ...entry }; if (entry.pointer && typeof entry.pointer === "object") { next.pointer = { ...(entry.pointer || {}), ...(normalizedPointer || {}), watchedAt, }; } else if (normalizedPointer) { Object.assign(next, normalizedPointer); next.watchedAt = watchedAt; } else { next.watchedAt = watchedAt; } if (normalizedPointer?.session === true) { if (next.pointer) { next.pointer.session = true; } else { next.session = true; } } return next; } if (normalizedPointer) { return normalizedPointer; } return entry; } function mergeWatchHistoryItems(baseItems, queueItems) { const merged = new Map(); const ingest = (entry, pointer, watchedAt) => { const key = pointerKey(pointer); if (!key) { return; } merged.set(key, { entry, watchedAt, key, }); }; for (const entry of Array.isArray(baseItems) ? baseItems : []) { const pointer = resolveEntryPointer(entry); if (!pointer) { continue; } const watchedAt = resolveEntryWatchedAt(entry, pointer); ingest(entry, pointer, watchedAt); } for (const entry of Array.isArray(queueItems) ? queueItems : []) { const pointer = resolveEntryPointer(entry); if (!pointer) { continue; } const watchedAt = resolveEntryWatchedAt(entry, pointer); const key = pointerKey(pointer); if (!key) { continue; } const existing = merged.get(key); if (!existing || watchedAt > existing.watchedAt) { const mergedEntry = buildMergedEntry(existing?.entry || entry, pointer, watchedAt); merged.set(key, { entry: mergedEntry, watchedAt, key, }); } } const ordered = Array.from(merged.values()); ordered.sort((a, b) => { if (a.watchedAt !== b.watchedAt) { return b.watchedAt - a.watchedAt; } return a.key.localeCompare(b.key); }); return ordered.map((entry) => entry.entry); } function mergeQueuedItemsIfNeeded(actorKey, items) { const queueItems = collectQueueItems(actorKey); if (!queueItems.length) { return items; } const snapshotInflight = state.inflightSnapshots.has(actorKey); if (!snapshotInflight) { const latestQueue = getLatestWatchedAt(queueItems); const latestBase = getLatestWatchedAt(items); if (latestQueue <= latestBase) { return items; } } return mergeWatchHistoryItems(items, queueItems); } async function loadLatest(actorInput, options = {}) { const actorKey = resolveActorKey(actorInput); if (!actorKey) { return []; } if (!state.restored) { restoreQueueState(); } const normalizedOptions = options && typeof options === "object" ? options : {}; // Only callers that can react to a later fingerprint update should opt into // stale cache reads; everyone else waits for the fresh list so they do not // miss entries. const allowStale = normalizedOptions.allowStale === true; const forceRefresh = normalizedOptions.forceRefresh === true; if (!isFeatureEnabled(actorKey)) { const items = collectQueueItems(actorKey); devLogger.debug( "[watchHistoryService] loadLatest returning local-only watch history queue.", { actor: actorKey, itemCount: items.length, } ); return items; } const now = Date.now(); const cacheEntry = state.fingerprintCache.get(actorKey); devLogger.info( "[watchHistoryService] loadLatest invoked.", { actor: actorKey, cacheHasItems: Array.isArray(cacheEntry?.items), cacheExpiresAt: cacheEntry?.expiresAt || null, forceRefresh, } ); const hasCachedItems = Array.isArray(cacheEntry?.items); const cacheIsFresh = hasCachedItems && cacheEntry?.expiresAt && cacheEntry.expiresAt > now; if (cacheIsFresh && !forceRefresh) { devLogger.debug( "[watchHistoryService] Returning cached watch list items.", { actor: actorKey, itemCount: cacheEntry.items.length, } ); return mergeQueuedItemsIfNeeded(actorKey, cacheEntry.items); } if (cacheEntry?.promise) { devLogger.debug( "[watchHistoryService] Awaiting in-flight watch list refresh.", { actor: actorKey, } ); if (allowStale && hasCachedItems) { devLogger.debug( "[watchHistoryService] Returning stale watch list items while refresh completes.", { actor: actorKey, itemCount: cacheEntry.items.length, } ); return mergeQueuedItemsIfNeeded(actorKey, cacheEntry.items); } const resolved = await cacheEntry.promise; return mergeQueuedItemsIfNeeded(actorKey, resolved); } if (!allowStale) { const resolved = await scheduleWatchHistoryRefresh( actorKey, cacheEntry, normalizedOptions ); return mergeQueuedItemsIfNeeded(actorKey, resolved); } // allowStale callers (e.g. the For You feed) must NEVER block on the full // fetch — even on a cold load with no cache. Previously this path awaited the // refresh whenever there were no cached items, so the feed froze behind the // entire ~1500-item watch-history resolution. Instead, kick the refresh off in // the background and return immediately with whatever is already available // (cached items, else the local queue). These callers re-run when the later // "fingerprint" event fires with the resolved history. const refreshPromise = scheduleWatchHistoryRefresh( actorKey, cacheEntry, normalizedOptions ); refreshPromise.catch(() => {}); // Return the best available items immediately — in-memory cache, else the // persisted localStorage snapshot. The in-memory caches are empty after a page // reload, so without the persisted fallback the For You feed would suppress // nothing until the (slow) background network refresh completes. Persisted // items were previously valid; the background refresh corrects them. const immediateItems = getCachedSnapshotItems(actorKey); devLogger.info( "[watchHistoryService] Returning immediately; watch history refreshing in background.", { actor: actorKey, itemCount: immediateItems.length, } ); return mergeQueuedItemsIfNeeded(actorKey, immediateItems); } async function getFingerprint(actorInput) { const actorKey = resolveActorKey(actorInput); if (!actorKey) { return ""; } if (!isFeatureEnabled(actorKey)) { return ""; } const now = Date.now(); const cacheEntry = state.fingerprintCache.get(actorKey); if ( cacheEntry?.fingerprint && cacheEntry.expiresAt && cacheEntry.expiresAt > now ) { return cacheEntry.fingerprint; } const fingerprint = await nostrClient.getWatchHistoryFingerprint(actorKey); const ttl = Math.max(0, Number(WATCH_HISTORY_CACHE_TTL_MS) || 0); const previousFingerprint = cacheEntry?.fingerprint; const nextEntry = { ...(cacheEntry || {}), fingerprint, expiresAt: now + ttl, }; state.fingerprintCache.set(actorKey, nextEntry); if (fingerprint !== previousFingerprint) { emit("fingerprint", { actor: actorKey, fingerprint }); } return fingerprint; } function resetProgress(actorInput) { const actorKey = normalizeActorKey(actorInput); if (actorKey) { state.queues.delete(actorKey); state.fingerprintCache.delete(actorKey); state.inflightSnapshots.delete(actorKey); state.chunkBackfills.delete(actorKey); persistQueueState(); notifyQueueChange(actorKey); return; } state.queues.clear(); state.fingerprintCache.clear(); state.inflightSnapshots.clear(); state.chunkBackfills.clear(); persistQueueState(); emit("queue-changed", { actor: null, items: [] }); } function getQueuedPointers(actorInput) { const actorKey = normalizeActorKey(actorInput); if (!actorKey) { return []; } if (!state.restored) { restoreQueueState(); } return collectQueueItems(actorKey); } function getAllQueues() { if (!state.restored) { restoreQueueState(); } const summary = {}; for (const actorKey of state.queues.keys()) { const items = collectQueueItems(actorKey); if (items.length) { summary[actorKey] = items; } } return summary; } const watchHistoryService = { isEnabled: isFeatureEnabled, supportsLocalHistory, isLocalOnly, publishView, snapshot, loadLatest, getFingerprint, resetProgress, getQueuedPointers, getAllQueues, subscribe, }; if (typeof window !== "undefined") { window.bitvid = window.bitvid || {}; window.bitvid.watchHistory = watchHistoryService; } export { watchHistoryService }; export default watchHistoryService;