Files
bitvid/js/watchHistoryService.js
thePR0M3TH3ANandClaude Opus 4.8 48b6f10e0d fix(watch-history): record new watches under the logged-in identity, not the anonymous session actor
View events are always signed by the anonymous session actor (privacy for the
public view counter). publishView read the watched-actor from that view event's
pubkey, so a logged-in user's new watches were enqueued — and republished via
updateWatchHistoryList — under the SESSION actor. The read path queries
authors:[loggedIn] only, so those session-authored months were never
discovered: newly watched videos never appeared in the synced watch history
(e.g. a 2026-06 month that never showed up despite watching June videos).

Fix:
- publishView now resolves the watch-history actor to the logged-in pubkey
  first, falling back to the view event / session actor only when logged out.
- A watch is flagged session/local-only only when nobody is logged in
  (previously it was flagged whenever the view actor differed from the
  logged-in user, which was always true given anonymized views).

Adds tests/watch-history-records-under-login.test.mjs (mutation-verified: both
the actor-resolution and the session-flag fixes are independently load-bearing).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-21 10:55:27 -04:00

1434 lines
39 KiB
JavaScript

// 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;