diff --git a/js/app.js b/js/app.js index 54162d5b..f33c9653 100644 --- a/js/app.js +++ b/js/app.js @@ -6749,10 +6749,19 @@ class bitvidApp { this.resetViewLoggingState(); } + const trimmedPubkey = + typeof pubkey === "string" && pubkey.trim() ? pubkey.trim() : ""; + if (normalizedPubkey) { this.pubkey = normalizedPubkey; + if (nostrClient && typeof nostrClient === "object") { + nostrClient.pubkey = normalizedPubkey; + } } else { this.pubkey = pubkey; + if (nostrClient && typeof nostrClient === "object") { + nostrClient.pubkey = trimmedPubkey ? trimmedPubkey.toLowerCase() : ""; + } } this.currentUserNpub = this.safeEncodeNpub(this.pubkey); @@ -7585,11 +7594,54 @@ class bitvidApp { (async () => { let viewResult; try { - const watchHistoryEnabled = - watchHistoryService?.isEnabled?.() === true && - typeof watchHistoryService.publishView === "function"; - if (watchHistoryEnabled) { - viewResult = await watchHistoryService.publishView(thresholdPointer); + const canUseWatchHistoryService = + typeof watchHistoryService?.publishView === "function"; + const resolveWatchActor = () => { + const normalizedUser = this.normalizeHexPubkey(this.pubkey); + if (normalizedUser) { + return normalizedUser; + } + + const normalizedClient = this.normalizeHexPubkey( + nostrClient?.pubkey + ); + if (normalizedClient) { + return normalizedClient; + } + + const normalizedSession = this.normalizeHexPubkey( + nostrClient?.sessionActor?.pubkey + ); + if (normalizedSession) { + return normalizedSession; + } + + if ( + typeof nostrClient?.pubkey === "string" && + nostrClient.pubkey.trim() + ) { + return nostrClient.pubkey.trim().toLowerCase(); + } + + if ( + typeof nostrClient?.sessionActor?.pubkey === "string" && + nostrClient.sessionActor.pubkey.trim() + ) { + return nostrClient.sessionActor.pubkey.trim().toLowerCase(); + } + + return ""; + }; + + const activeWatchActor = resolveWatchActor(); + const watchMetadata = activeWatchActor ? { actor: activeWatchActor } : undefined; + + if (canUseWatchHistoryService) { + viewResult = await watchHistoryService.publishView( + thresholdPointer, + undefined, + watchMetadata + ); } else if (typeof nostrClient?.recordVideoView === "function") { viewResult = await nostrClient.recordVideoView(thresholdPointer); } else { @@ -11641,5 +11693,11 @@ function dedupeToNewestByRoot(videos) { export const app = new bitvidApp(); export const appReady = app.init(); -window.app = app; -window.appReady = appReady; + +if (typeof window !== "undefined") { + window.app = app; + window.appReady = appReady; + window.bitvid = window.bitvid || {}; + window.bitvid.app = app; + window.bitvid.appReady = appReady; +} diff --git a/js/historyView.js b/js/historyView.js index 0f9b12bc..ffd321a2 100644 --- a/js/historyView.js +++ b/js/historyView.js @@ -16,7 +16,7 @@ export const WATCH_HISTORY_EMPTY_COPY = "Your watch history is empty. Watch some videos to populate this list."; export const WATCH_HISTORY_DISABLED_COPY = - "Encrypted watch history sync is disabled by this server. Local history stays on this device only."; + "Watch history sync is unavailable. Connect a NIP-07 extension or log in to enable encrypted syncing."; const WATCH_HISTORY_METADATA_PREF_KEY = "bitvid:watch-history:metadata-preference"; @@ -739,6 +739,19 @@ export function createWatchHistoryRenderer(config = {}) { }, } = config; + const syncEnabled = + typeof watchHistoryService.isEnabled === "function" + ? watchHistoryService.isEnabled() === true + : false; + const localSupported = + typeof watchHistoryService.supportsLocalHistory === "function" + ? watchHistoryService.supportsLocalHistory() === true + : false; + const localOnly = + typeof watchHistoryService.isLocalOnly === "function" + ? watchHistoryService.isLocalOnly() === true + : false; + const state = { initialized: false, actor: null, @@ -763,7 +776,10 @@ export function createWatchHistoryRenderer(config = {}) { ? watchHistoryService.shouldStoreMetadata() !== false : true, sessionFallbackActive: false, - featureEnabled: watchHistoryService.isEnabled?.() === true, + syncEnabled, + localSupported, + localOnly, + featureEnabled: syncEnabled || localSupported, }; let elements = { @@ -895,18 +911,44 @@ export function createWatchHistoryRenderer(config = {}) { } function updateFeatureBanner() { - const enabled = watchHistoryService.isEnabled?.() === true; - state.featureEnabled = enabled; + const actor = typeof state.actor === "string" ? state.actor : undefined; + const syncEnabled = + typeof watchHistoryService.isEnabled === "function" + ? watchHistoryService.isEnabled(actor) === true + : false; + const localSupported = + typeof watchHistoryService.supportsLocalHistory === "function" + ? watchHistoryService.supportsLocalHistory(actor) === true + : false; + const localOnly = + typeof watchHistoryService.isLocalOnly === "function" + ? watchHistoryService.isLocalOnly(actor) === true + : false; + state.syncEnabled = syncEnabled; + state.localSupported = localSupported; + state.localOnly = localOnly; + state.featureEnabled = syncEnabled || localSupported; if (!(elements.featureBanner instanceof HTMLElement)) { return; } - if (enabled) { + if (syncEnabled) { elements.featureBanner.textContent = ""; setHidden(elements.featureBanner, true); return; } - elements.featureBanner.textContent = - "Watch history sync is disabled on this server. Local history only."; + if (localOnly) { + elements.featureBanner.textContent = + "Watch history sync requires a logged-in Nostr account. This guest session is stored locally only."; + setHidden(elements.featureBanner, false); + return; + } + if (localSupported) { + elements.featureBanner.textContent = + "Watch history sync is disabled right now. We'll keep a local copy on this device."; + } else { + elements.featureBanner.textContent = + "Watch history is disabled on this server."; + } setHidden(elements.featureBanner, false); } diff --git a/js/nostr.js b/js/nostr.js index 2b6511f7..621fb452 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -2319,6 +2319,19 @@ class NostrClient { relays = Array.from(RELAY_URLS); } + console.info( + "[nostr] Preparing to publish watch history snapshot.", + { + actor: actorKey, + snapshotId, + itemCount: canonicalItems.length, + chunkCount: chunks.length, + relaysRequested: relays, + attempt: options.attempt || 0, + source: options.source || "unknown", + } + ); + const createdAtBase = Math.max( Math.floor(Date.now() / 1000), this.watchHistoryLastCreatedAt + 1, @@ -2404,6 +2417,17 @@ class NostrClient { return tag; }); + console.info( + "[nostr] Publishing watch history chunk.", + { + actor: actorKey, + snapshotId, + chunkIndex: index, + chunkSize: chunkItems.length, + relays, + } + ); + const plaintext = JSON.stringify({ version: 2, snapshot: snapshotId, @@ -2504,6 +2528,15 @@ class NostrClient { return { ok: false, error: "signing-failed", retryable: false }; } + console.info( + "[nostr] Publishing watch history pointer event.", + { + actor: actorKey, + snapshotId, + relays, + } + ); + const pointerResults = await publishEventToRelays( this.pool, relays, @@ -2588,6 +2621,16 @@ class NostrClient { result.error = errorCode; } + console.info("[nostr] Watch history snapshot publish result.", { + actor: actorKey, + snapshotId, + success, + partialAcceptance, + error: result.error || null, + pointerAcceptedCount, + chunkAcceptedCounts: chunkResults.map((entry) => entry.acceptedCount), + }); + return result; } @@ -2628,6 +2671,13 @@ class NostrClient { canonicalItems, ); + console.info("[nostr] Updating watch history list.", { + actor: resolvedActor, + incomingItemCount: incomingItems.length, + finalItemCount: canonicalItems.length, + replace: options.replace === true, + }); + const publishResult = await this.publishWatchHistorySnapshot( canonicalItems, { @@ -2637,6 +2687,13 @@ class NostrClient { }, ); + console.info("[nostr] Watch history list publish attempt finished.", { + actor: resolvedActor, + snapshotId: publishResult.snapshotId || null, + success: !!publishResult.ok, + retryable: !!publishResult.retryable, + }); + const metadata = sanitizeWatchHistoryMetadata(cachedEntry.metadata); metadata.updatedAt = Date.now(); metadata.status = publishResult.ok ? "ok" : "error"; @@ -2734,6 +2791,11 @@ class NostrClient { return { pointerEvent: null, items: [], snapshotId: "" }; } + console.info("[nostr] Fetching watch history from relays.", { + actor: resolvedActor, + forceRefresh: options.forceRefresh === true, + }); + const extension = window?.nostr; const existingEntry = this.watchHistoryCache.get(actorKey); @@ -2746,6 +2808,11 @@ class NostrClient { Number.isFinite(existingEntry.savedAt) && now - existingEntry.savedAt < ttl ) { + console.info("[nostr] Using cached watch history entry.", { + actor: resolvedActor, + itemCount: Array.isArray(existingEntry.items) ? existingEntry.items.length : 0, + cacheAgeMs: now - existingEntry.savedAt, + }); return { pointerEvent: existingEntry.pointerEvent || null, items: existingEntry.items || [], @@ -2754,6 +2821,7 @@ class NostrClient { } if (!this.pool) { + console.warn("[nostr] Cannot fetch watch history because relay pool is unavailable. Returning cached values."); return { pointerEvent: existingEntry?.pointerEvent || null, items: existingEntry?.items || [], @@ -2820,6 +2888,12 @@ class NostrClient { }, null); if (!pointerEvent) { + console.info( + "[nostr] No watch history pointer event found on relays. Falling back to storage.", + { + actor: resolvedActor, + } + ); const storageEntry = this.getWatchHistoryStorage().actors?.[actorKey]; const items = Array.isArray(storageEntry?.items) ? canonicalizeWatchHistoryItems(storageEntry.items, WATCH_HISTORY_MAX_ITEMS) @@ -3080,6 +3154,11 @@ class NostrClient { return []; } + console.info("[nostr] Resolving watch history for actor.", { + actor: resolvedActor, + forceRefresh: options.forceRefresh === true, + }); + const storage = this.getWatchHistoryStorage(); const fallbackItems = Array.isArray(storage.actors?.[actorKey]?.items) ? canonicalizeWatchHistoryItems( @@ -3123,6 +3202,19 @@ class NostrClient { canonicalItems, ); + console.info("[nostr] Watch history fetch complete.", { + actor: resolvedActor, + snapshotId: fetchResult.snapshotId || null, + pointerFound: !!fetchResult.pointerEvent, + itemCount: canonicalItems.length, + }); + + console.info("[nostr] Watch history resolved and cached.", { + actor: resolvedActor, + itemCount: canonicalItems.length, + snapshotId: fetchResult.snapshotId || null, + }); + const entry = { actor: resolvedActor, items: canonicalItems, diff --git a/js/watchHistoryService.js b/js/watchHistoryService.js index 1aeb3bef..94002c05 100644 --- a/js/watchHistoryService.js +++ b/js/watchHistoryService.js @@ -36,10 +36,10 @@ const state = { }, }; -function isFeatureEnabled() { +function resolveFlagEnabled() { try { if (typeof getWatchHistoryV2Enabled === "function") { - return getWatchHistoryV2Enabled(); + return getWatchHistoryV2Enabled() === true; } } catch (error) { if (isDevMode) { @@ -52,6 +52,115 @@ function isFeatureEnabled() { return FEATURE_WATCH_HISTORY_V2 === true; } +function getLoggedInActorKey() { + const direct = normalizeActorKey(nostrClient?.pubkey); + if (direct) { + return direct; + } + + if (typeof window !== "undefined") { + const appCandidate = + (window.bitvid && window.bitvid.app) || window.app || null; + if (appCandidate && typeof appCandidate === "object") { + if (typeof appCandidate.normalizeHexPubkey === "function") { + try { + const normalized = appCandidate.normalizeHexPubkey( + appCandidate.pubkey + ); + if (normalized) { + return normalizeActorKey(normalized); + } + } catch (error) { + if (isDevMode) { + console.warn( + "[watchHistoryService] Failed to normalize app login pubkey:", + error + ); + } + } + } + + if (typeof appCandidate.pubkey === "string" && appCandidate.pubkey) { + const fallback = normalizeActorKey(appCandidate.pubkey); + if (fallback) { + return fallback; + } + } + } + } + + return ""; +} + +function getSessionActorKey() { + const logged = getLoggedInActorKey(); + if (logged) { + return ""; + } + return normalizeActorKey(nostrClient?.sessionActor?.pubkey); +} + +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 baseEnabled = resolveFlagEnabled(); + const actorKey = resolveEffectiveActorKey(actorInput); + const logged = getLoggedInActorKey(); + const session = getSessionActorKey(); + + if (actorKey && logged && actorKey === logged) { + return true; + } + + if (actorKey && session && actorKey === session) { + return false; + } + + if (!actorKey) { + if (logged) { + return true; + } + if (session) { + return false; + } + } + + return baseEnabled; +} + +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 getSessionStorage() { try { if (typeof window !== "undefined" && window.sessionStorage) { @@ -454,9 +563,6 @@ function getOrCreateQueue(actorKey) { } function restoreQueueState() { - if (!isFeatureEnabled()) { - return; - } if (state.restored) { return; } @@ -560,16 +666,13 @@ function restoreQueueState() { queue.lastSnapshotReason = null; } - if (queue.pendingSnapshotId && isFeatureEnabled()) { + if (queue.pendingSnapshotId && isFeatureEnabled(actorKey)) { scheduleRepublishForQueue(actorKey); } } } function ensureQueue(actorKey) { - if (!isFeatureEnabled()) { - return null; - } if (!actorKey) { return null; } @@ -580,9 +683,6 @@ function ensureQueue(actorKey) { } function persistQueueState() { - if (!isFeatureEnabled()) { - return; - } const storage = getSessionStorage(); if (!storage) { return; @@ -647,9 +747,6 @@ function resolveActorKey(actorInput) { } function collectQueueItems(actorKey) { - if (!isFeatureEnabled()) { - return []; - } const queue = state.queues.get(actorKey); if (!queue) { return []; @@ -675,9 +772,6 @@ function collectQueueItems(actorKey) { } function notifyQueueChange(actorKey) { - if (!isFeatureEnabled()) { - return; - } emit("queue-changed", { actor: actorKey, items: collectQueueItems(actorKey), @@ -685,9 +779,6 @@ function notifyQueueChange(actorKey) { } function clearQueue(actorKey) { - if (!isFeatureEnabled()) { - return; - } const queue = state.queues.get(actorKey); if (!queue) { return; @@ -720,7 +811,7 @@ function resolveWatchedAt(...candidates) { } function scheduleRepublishForQueue(actorKey) { - if (!isFeatureEnabled()) { + if (!isFeatureEnabled(actorKey)) { return; } const queue = state.queues.get(actorKey); @@ -825,12 +916,21 @@ async function publishView(pointerInput, createdAt, metadata = {}) { const viewResult = await nostrClient.recordVideoView(pointerInput, recordOptions); - if (!isFeatureEnabled()) { - return viewResult; - } + console.info( + "[watchHistoryService] Video view recorded. Preparing watch list update.", + { + pointer: pointerInput, + createdAt, + hasMetadata: !!metadata, + } + ); const pointer = normalizePointerInput(pointerInput); if (!pointer) { + console.warn( + "[watchHistoryService] Skipping watch list update because pointer normalization failed.", + { pointerInput } + ); return viewResult; } @@ -842,16 +942,30 @@ async function publishView(pointerInput, createdAt, metadata = {}) { ""; const actorKey = normalizeActorKey(actorCandidate); if (!actorKey) { + console.warn( + "[watchHistoryService] Unable to resolve actor for watch list update.", + { actorCandidate } + ); return viewResult; } const queue = ensureQueue(actorKey); if (!queue) { + console.warn( + "[watchHistoryService] Failed to resolve queue for actor.", + { actorKey } + ); return viewResult; } + const remoteEnabled = isFeatureEnabled(actorKey); + const normalizedPointer = normalizePointerInput(pointer); if (!normalizedPointer) { + console.warn( + "[watchHistoryService] Pointer normalization failed during queue preparation.", + { pointerInput } + ); return viewResult; } @@ -860,6 +974,18 @@ async function publishView(pointerInput, createdAt, metadata = {}) { viewResult?.event?.created_at ); + const key = pointerKey(normalizedPointer); + console.info( + "[watchHistoryService] Watch list process triggered by video view.", + { + actor: actorKey, + pointerKey: key, + watchedAt: normalizedPointer.watchedAt, + sessionEvent: normalizedPointer.session === true, + remoteSyncEnabled: remoteEnabled, + } + ); + const normalizedLogged = normalizeActorKey(nostrClient?.pubkey); const normalizedEventActor = normalizeActorKey( viewResult?.event?.pubkey || actorCandidate @@ -868,8 +994,11 @@ async function publishView(pointerInput, createdAt, metadata = {}) { normalizedPointer.session = true; } - const key = pointerKey(normalizedPointer); if (!key) { + console.warn( + "[watchHistoryService] Pointer key generation failed. Skipping queue update.", + { pointer: normalizedPointer } + ); return viewResult; } @@ -896,6 +1025,14 @@ async function publishView(pointerInput, createdAt, metadata = {}) { queue.throttle.set(key, now); persistQueueState(); notifyQueueChange(actorKey); + console.info( + "[watchHistoryService] Updated existing watch list entry.", + { + actor: actorKey, + pointerKey: key, + watchedAt: existing.pointer.watchedAt, + } + ); return viewResult; } @@ -918,19 +1055,48 @@ async function publishView(pointerInput, createdAt, metadata = {}) { persistQueueState(); notifyQueueChange(actorKey); + console.info( + "[watchHistoryService] Added pointer to pending watch list snapshot queue.", + { + actor: actorKey, + pointerKey: key, + queueSize: queue.items.size, + } + ); + + if (!remoteEnabled) { + console.info( + "[watchHistoryService] Watch history sync unavailable for this actor; retaining pointer in local session queue only.", + { + actor: actorKey, + pointerKey: key, + watchedAt: normalizedPointer.watchedAt, + } + ); + } + return viewResult; } async function snapshot(items, options = {}) { - if (!isFeatureEnabled()) { - return { ok: true, skipped: true, reason: "feature-disabled" }; - } const reason = typeof options.reason === "string" ? options.reason : "manual"; const actorKey = resolveActorKey(options.actor); if (!actorKey) { return { ok: false, error: "missing-actor" }; } + if (!isFeatureEnabled(actorKey)) { + console.info( + "[watchHistoryService] Snapshot skipped because watch history sync is unavailable for this actor.", + { + actor: actorKey, + requestedItems: Array.isArray(items) ? items.length : 0, + reason, + } + ); + return { ok: true, skipped: true, reason: "feature-disabled" }; + } + if (state.inflightSnapshots.has(actorKey)) { return state.inflightSnapshots.get(actorKey); } @@ -952,6 +1118,16 @@ async function snapshot(items, options = {}) { const queue = ensureQueue(actorKey); + console.info( + "[watchHistoryService] Initiating watch list snapshot publish.", + { + actor: actorKey, + reason, + itemCount: payloadItems.length, + queued: queue?.items?.size ?? 0, + } + ); + const run = (async () => { emit("snapshot-start", { actor: actorKey, reason, items: payloadItems }); const publishResult = await nostrClient.publishWatchHistorySnapshot( @@ -962,6 +1138,17 @@ async function snapshot(items, options = {}) { } ); + console.info( + "[watchHistoryService] Watch list snapshot publish completed.", + { + actor: actorKey, + reason, + success: !!publishResult?.ok, + snapshotId: publishResult?.snapshotId || null, + retryable: !!publishResult?.retryable, + } + ); + if (!publishResult?.ok) { if (publishResult?.retryable && publishResult?.snapshotId) { if (queue) { @@ -988,6 +1175,14 @@ async function snapshot(items, options = {}) { await updateFingerprintCache(actorKey, publishResult.items || payloadItems); emit("snapshot-complete", { actor: actorKey, reason, result: publishResult }); + console.info( + "[watchHistoryService] Watch list snapshot processed and fingerprint cache updated.", + { + actor: actorKey, + reason, + fingerprintUpdated: true, + } + ); return publishResult; })() .catch((error) => { @@ -1005,26 +1200,59 @@ async function snapshot(items, options = {}) { } async function loadLatest(actorInput) { - if (!isFeatureEnabled()) { - return []; - } const actorKey = resolveActorKey(actorInput); if (!actorKey) { return []; } + if (!isFeatureEnabled(actorKey)) { + if (!state.restored) { + restoreQueueState(); + } + const items = collectQueueItems(actorKey); + console.info( + "[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); + console.info( + "[watchHistoryService] loadLatest invoked.", + { + actor: actorKey, + cacheHasItems: Array.isArray(cacheEntry?.items), + cacheExpiresAt: cacheEntry?.expiresAt || null, + } + ); if ( cacheEntry?.items && cacheEntry.expiresAt && cacheEntry.expiresAt > now && Array.isArray(cacheEntry.items) ) { + console.info( + "[watchHistoryService] Returning cached watch list items.", + { + actor: actorKey, + itemCount: cacheEntry.items.length, + } + ); return cacheEntry.items; } if (cacheEntry?.promise) { + console.info( + "[watchHistoryService] Awaiting in-flight watch list refresh.", + { + actor: actorKey, + } + ); return cacheEntry.promise; } @@ -1048,6 +1276,13 @@ async function loadLatest(actorInput) { } }); + console.info( + "[watchHistoryService] Triggered nostr watch list refresh.", + { + actor: actorKey, + } + ); + state.fingerprintCache.set(actorKey, { ...(cacheEntry || {}), promise, @@ -1057,13 +1292,13 @@ async function loadLatest(actorInput) { } async function getFingerprint(actorInput) { - if (!isFeatureEnabled()) { - return ""; - } const actorKey = resolveActorKey(actorInput); if (!actorKey) { return ""; } + if (!isFeatureEnabled(actorKey)) { + return ""; + } const now = Date.now(); const cacheEntry = state.fingerprintCache.get(actorKey); if ( @@ -1086,9 +1321,6 @@ async function getFingerprint(actorInput) { } function resetProgress(actorInput) { - if (!isFeatureEnabled()) { - return; - } const actorKey = normalizeActorKey(actorInput); if (actorKey) { state.queues.delete(actorKey); @@ -1106,9 +1338,6 @@ function resetProgress(actorInput) { } function getQueuedPointers(actorInput) { - if (!isFeatureEnabled()) { - return []; - } const actorKey = normalizeActorKey(actorInput); if (!actorKey) { return []; @@ -1120,9 +1349,6 @@ function getQueuedPointers(actorInput) { } function getAllQueues() { - if (!isFeatureEnabled()) { - return {}; - } if (!state.restored) { restoreQueueState(); } @@ -1149,6 +1375,8 @@ function getSettings() { const watchHistoryService = { isEnabled: isFeatureEnabled, + supportsLocalHistory, + isLocalOnly, publishView, snapshot, loadLatest, diff --git a/tests/watch-history.test.mjs b/tests/watch-history.test.mjs index 8558c243..b9fae06a 100644 --- a/tests/watch-history.test.mjs +++ b/tests/watch-history.test.mjs @@ -48,6 +48,9 @@ const originalWatchHistoryCache = nostrClient.watchHistoryCache; const originalWatchHistoryStorage = nostrClient.watchHistoryStorage; const originalScheduleWatchHistoryRepublish = nostrClient.scheduleWatchHistoryRepublish; +const originalResolveWatchHistory = nostrClient.resolveWatchHistory; +const originalGetWatchHistoryFingerprint = + nostrClient.getWatchHistoryFingerprint; nostrClient.watchHistoryCache = new Map(); nostrClient.watchHistoryStorage = { @@ -628,7 +631,7 @@ async function testWatchHistoryPartialRelayRetry() { const originalRecordView = nostrClient.recordVideoView; try { - nostrClient.pubkey = ""; + nostrClient.pubkey = actor; nostrClient.sessionActor = { pubkey: actor, privateKey: "partial-priv" }; nostrClient.ensureSessionActor = async () => actor; @@ -980,12 +983,226 @@ async function testWatchHistoryServiceIntegration() { } } +async function testWatchHistoryLocalFallbackWhenDisabled() { + console.log("Running watch history local fallback test..."); + + const actor = "local-fallback-actor"; + const originalRecordView = nostrClient.recordVideoView; + const originalPub = nostrClient.pubkey; + const originalSession = nostrClient.sessionActor; + + try { + setWatchHistoryV2Enabled(false); + localStorage.clear(); + watchHistoryService.resetProgress(); + nostrClient.pubkey = ""; + nostrClient.sessionActor = { pubkey: actor, privateKey: "local-priv" }; + nostrClient.recordVideoView = async (_pointer, options = {}) => ({ + ok: true, + event: { + id: "local-view", + pubkey: actor, + created_at: options.created_at || 1_700_500_000, + }, + }); + + const supported = + typeof watchHistoryService.supportsLocalHistory === "function" + ? watchHistoryService.supportsLocalHistory(actor) + : true; + const localOnly = + typeof watchHistoryService.isLocalOnly === "function" + ? watchHistoryService.isLocalOnly(actor) + : true; + const enabled = + typeof watchHistoryService.isEnabled === "function" + ? watchHistoryService.isEnabled(actor) + : false; + assert.equal( + supported, + true, + "service should report local history support while sync disabled", + ); + assert.equal(localOnly, true, "guest session should be treated as local only"); + assert.equal( + enabled, + false, + "sync should be disabled for session actors when feature flag is off", + ); + + const createdAt = 1_700_500_000; + await watchHistoryService.publishView( + { type: "e", value: "local-pointer" }, + createdAt, + { actor }, + ); + + const queued = watchHistoryService.getQueuedPointers(actor); + assert.equal( + queued.length, + 1, + "local queue should retain pointer when sync is disabled", + ); + + const latest = await watchHistoryService.loadLatest(actor); + assert.equal( + latest.length, + 1, + "loadLatest should surface session queue entries when sync disabled", + ); + assert.equal(latest[0]?.value, "local-pointer"); + assert.equal(latest[0]?.type, "e"); + assert( + Number.isFinite(latest[0]?.watchedAt) && latest[0].watchedAt > 0, + "local fallback entries should carry watchedAt timestamps", + ); + } finally { + setWatchHistoryV2Enabled(true); + watchHistoryService.resetProgress(); + nostrClient.recordVideoView = originalRecordView; + nostrClient.pubkey = originalPub; + nostrClient.sessionActor = originalSession; + } +} + +async function testWatchHistorySyncEnabledForLoggedInUsers() { + console.log("Running watch history logged-in sync override test..."); + + const actor = "npub-logged-sync"; + const originalPub = nostrClient.pubkey; + const originalSession = nostrClient.sessionActor; + const originalResolve = nostrClient.resolveWatchHistory; + const originalFingerprint = nostrClient.getWatchHistoryFingerprint; + + try { + setWatchHistoryV2Enabled(false); + localStorage.clear(); + watchHistoryService.resetProgress(); + nostrClient.pubkey = actor; + nostrClient.sessionActor = null; + + let resolveCalls = 0; + nostrClient.resolveWatchHistory = async (requestedActor, options = {}) => { + resolveCalls += 1; + assert.equal( + requestedActor, + actor, + "resolveWatchHistory should target the logged-in actor", + ); + assert.equal( + options?.forceRefresh, + true, + "loadLatest should force a relay refresh for logged-in actors", + ); + return [ + { + type: "e", + value: "remote-pointer", + watchedAt: 1_700_600_000, + }, + ]; + }; + + let fingerprintCalls = 0; + nostrClient.getWatchHistoryFingerprint = async (requestedActor, items) => { + fingerprintCalls += 1; + assert.equal( + requestedActor, + actor, + "fingerprint lookup should use the logged-in actor", + ); + assert(Array.isArray(items), "fingerprint helper expects item array input"); + return "fingerprint-logged"; + }; + + const enabled = + typeof watchHistoryService.isEnabled === "function" + ? watchHistoryService.isEnabled(actor) + : false; + assert.equal( + enabled, + true, + "sync should remain enabled for logged-in actors even when the flag is disabled", + ); + + const items = await watchHistoryService.loadLatest(actor); + assert.equal( + resolveCalls > 0, + true, + "loadLatest should fetch from relays for logged-in actors", + ); + assert.equal(items.length, 1, "loadLatest should return relay data for logged-in actors"); + assert.equal(items[0]?.value, "remote-pointer"); + assert.equal( + fingerprintCalls > 0, + true, + "fingerprint cache should update for logged-in actors", + ); + } finally { + setWatchHistoryV2Enabled(true); + watchHistoryService.resetProgress(); + nostrClient.resolveWatchHistory = originalResolve; + nostrClient.getWatchHistoryFingerprint = originalFingerprint; + nostrClient.pubkey = originalPub; + nostrClient.sessionActor = originalSession; + } +} + +async function testWatchHistoryAppLoginFallback() { + console.log("Running watch history app login fallback test..."); + + const actor = "f".repeat(64); + const originalPub = nostrClient.pubkey; + const originalSession = nostrClient.sessionActor; + const originalApp = typeof window.app === "undefined" ? undefined : window.app; + + try { + setWatchHistoryV2Enabled(false); + localStorage.clear(); + watchHistoryService.resetProgress(); + nostrClient.pubkey = ""; + nostrClient.sessionActor = null; + window.app = { + pubkey: actor, + normalizeHexPubkey(value) { + if (typeof value === "string" && value.trim()) { + return value.trim().toLowerCase(); + } + return null; + }, + }; + + const enabled = + typeof watchHistoryService.isEnabled === "function" + ? watchHistoryService.isEnabled(actor) + : false; + assert.equal( + enabled, + true, + "sync should be enabled when the app reports a logged-in pubkey", + ); + } finally { + setWatchHistoryV2Enabled(true); + watchHistoryService.resetProgress(); + nostrClient.pubkey = originalPub; + nostrClient.sessionActor = originalSession; + if (typeof originalApp === "undefined") { + delete window.app; + } else { + window.app = originalApp; + } + } +} + await testPublishSnapshotCanonicalizationAndChunking(); await testPublishSnapshotUsesExtensionCrypto(); await testPublishSnapshotFailureRetry(); await testWatchHistoryPartialRelayRetry(); await testResolveWatchHistoryBatchingWindow(); await testWatchHistoryServiceIntegration(); +await testWatchHistoryLocalFallbackWhenDisabled(); +await testWatchHistorySyncEnabledForLoggedInUsers(); +await testWatchHistoryAppLoginFallback(); console.log("watch-history.test.mjs completed successfully"); @@ -1004,6 +1221,9 @@ nostrClient.watchHistoryCache = originalWatchHistoryCache; nostrClient.watchHistoryStorage = originalWatchHistoryStorage; nostrClient.scheduleWatchHistoryRepublish = originalScheduleWatchHistoryRepublish; +nostrClient.resolveWatchHistory = originalResolveWatchHistory; +nostrClient.getWatchHistoryFingerprint = + originalGetWatchHistoryFingerprint; if (!originalFlag) { setWatchHistoryV2Enabled(false);