From a4eb4f2367d814b60ddf6a1f865f98f1efd0a5cf Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 2 Oct 2025 16:44:13 -0400 Subject: [PATCH 1/5] Add detailed logging for watch history flow --- js/nostr.js | 92 ++++++++++++++++++++++++++++++ js/watchHistoryService.js | 115 +++++++++++++++++++++++++++++++++++++- 2 files changed, 206 insertions(+), 1 deletion(-) 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..00eeeec6 100644 --- a/js/watchHistoryService.js +++ b/js/watchHistoryService.js @@ -825,12 +825,25 @@ async function publishView(pointerInput, createdAt, metadata = {}) { const viewResult = await nostrClient.recordVideoView(pointerInput, recordOptions); + console.info( + "[watchHistoryService] Video view recorded. Preparing watch list update.", + { + pointer: pointerInput, + createdAt, + hasMetadata: !!metadata, + } + ); + if (!isFeatureEnabled()) { return viewResult; } const pointer = normalizePointerInput(pointerInput); if (!pointer) { + console.warn( + "[watchHistoryService] Skipping watch list update because pointer normalization failed.", + { pointerInput } + ); return viewResult; } @@ -842,16 +855,28 @@ 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 normalizedPointer = normalizePointerInput(pointer); if (!normalizedPointer) { + console.warn( + "[watchHistoryService] Pointer normalization failed during queue preparation.", + { pointerInput } + ); return viewResult; } @@ -860,6 +885,17 @@ 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, + } + ); + const normalizedLogged = normalizeActorKey(nostrClient?.pubkey); const normalizedEventActor = normalizeActorKey( viewResult?.event?.pubkey || actorCandidate @@ -868,8 +904,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 +935,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,6 +965,15 @@ 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, + } + ); + return viewResult; } @@ -952,6 +1008,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 +1028,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 +1065,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) => { @@ -1015,16 +1100,37 @@ async function loadLatest(actorInput) { 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 +1154,13 @@ async function loadLatest(actorInput) { } }); + console.info( + "[watchHistoryService] Triggered nostr watch list refresh.", + { + actor: actorKey, + } + ); + state.fingerprintCache.set(actorKey, { ...(cacheEntry || {}), promise, From 84b70475d9e6707933d742919926cd2cc9514011 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 2 Oct 2025 16:53:09 -0400 Subject: [PATCH 2/5] Ensure watch history logging executes when service available --- js/app.js | 7 +++---- js/watchHistoryService.js | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/js/app.js b/js/app.js index 54162d5b..211150f2 100644 --- a/js/app.js +++ b/js/app.js @@ -7585,10 +7585,9 @@ class bitvidApp { (async () => { let viewResult; try { - const watchHistoryEnabled = - watchHistoryService?.isEnabled?.() === true && - typeof watchHistoryService.publishView === "function"; - if (watchHistoryEnabled) { + const canUseWatchHistoryService = + typeof watchHistoryService?.publishView === "function"; + if (canUseWatchHistoryService) { viewResult = await watchHistoryService.publishView(thresholdPointer); } else if (typeof nostrClient?.recordVideoView === "function") { viewResult = await nostrClient.recordVideoView(thresholdPointer); diff --git a/js/watchHistoryService.js b/js/watchHistoryService.js index 00eeeec6..ff5ca635 100644 --- a/js/watchHistoryService.js +++ b/js/watchHistoryService.js @@ -835,6 +835,13 @@ async function publishView(pointerInput, createdAt, metadata = {}) { ); if (!isFeatureEnabled()) { + console.info( + "[watchHistoryService] Watch history feature disabled; skipping queue update.", + { + pointer: pointerInput, + createdAt, + } + ); return viewResult; } @@ -979,6 +986,13 @@ async function publishView(pointerInput, createdAt, metadata = {}) { async function snapshot(items, options = {}) { if (!isFeatureEnabled()) { + console.info( + "[watchHistoryService] Snapshot skipped because watch history feature is disabled.", + { + requestedItems: Array.isArray(items) ? items.length : 0, + reason: typeof options.reason === "string" ? options.reason : "manual", + } + ); return { ok: true, skipped: true, reason: "feature-disabled" }; } const reason = typeof options.reason === "string" ? options.reason : "manual"; @@ -1091,6 +1105,10 @@ async function snapshot(items, options = {}) { async function loadLatest(actorInput) { if (!isFeatureEnabled()) { + console.info( + "[watchHistoryService] loadLatest skipped because watch history feature is disabled.", + { actor: resolveActorKey(actorInput) || null } + ); return []; } const actorKey = resolveActorKey(actorInput); From b1a493f113e8a74cbfe19427a5f237e1b3db666a Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 2 Oct 2025 17:07:21 -0400 Subject: [PATCH 3/5] Enable local watch history fallback when sync disabled --- js/historyView.js | 31 +++++++++++++--- js/watchHistoryService.js | 72 ++++++++++++++---------------------- tests/watch-history.test.mjs | 66 +++++++++++++++++++++++++++++++++ 3 files changed, 118 insertions(+), 51 deletions(-) diff --git a/js/historyView.js b/js/historyView.js index 0f9b12bc..8110b213 100644 --- a/js/historyView.js +++ b/js/historyView.js @@ -739,6 +739,12 @@ export function createWatchHistoryRenderer(config = {}) { }, } = config; + const syncEnabled = watchHistoryService.isEnabled?.() === true; + const localSupported = + typeof watchHistoryService.supportsLocalHistory === "function" + ? watchHistoryService.supportsLocalHistory() !== false + : true; + const state = { initialized: false, actor: null, @@ -763,7 +769,9 @@ export function createWatchHistoryRenderer(config = {}) { ? watchHistoryService.shouldStoreMetadata() !== false : true, sessionFallbackActive: false, - featureEnabled: watchHistoryService.isEnabled?.() === true, + syncEnabled, + localSupported, + featureEnabled: syncEnabled || localSupported, }; let elements = { @@ -895,18 +903,29 @@ export function createWatchHistoryRenderer(config = {}) { } function updateFeatureBanner() { - const enabled = watchHistoryService.isEnabled?.() === true; - state.featureEnabled = enabled; + const syncEnabled = watchHistoryService.isEnabled?.() === true; + const localSupported = + typeof watchHistoryService.supportsLocalHistory === "function" + ? watchHistoryService.supportsLocalHistory() !== false + : true; + state.syncEnabled = syncEnabled; + state.localSupported = localSupported; + 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 (localSupported) { + elements.featureBanner.textContent = + "Watch history sync is disabled on this server. Local history only."; + } else { + elements.featureBanner.textContent = + "Watch history is disabled on this server."; + } setHidden(elements.featureBanner, false); } diff --git a/js/watchHistoryService.js b/js/watchHistoryService.js index ff5ca635..13f5e0a5 100644 --- a/js/watchHistoryService.js +++ b/js/watchHistoryService.js @@ -454,9 +454,6 @@ function getOrCreateQueue(actorKey) { } function restoreQueueState() { - if (!isFeatureEnabled()) { - return; - } if (state.restored) { return; } @@ -567,9 +564,6 @@ function restoreQueueState() { } function ensureQueue(actorKey) { - if (!isFeatureEnabled()) { - return null; - } if (!actorKey) { return null; } @@ -580,9 +574,6 @@ function ensureQueue(actorKey) { } function persistQueueState() { - if (!isFeatureEnabled()) { - return; - } const storage = getSessionStorage(); if (!storage) { return; @@ -647,9 +638,6 @@ function resolveActorKey(actorInput) { } function collectQueueItems(actorKey) { - if (!isFeatureEnabled()) { - return []; - } const queue = state.queues.get(actorKey); if (!queue) { return []; @@ -675,9 +663,6 @@ function collectQueueItems(actorKey) { } function notifyQueueChange(actorKey) { - if (!isFeatureEnabled()) { - return; - } emit("queue-changed", { actor: actorKey, items: collectQueueItems(actorKey), @@ -685,9 +670,6 @@ function notifyQueueChange(actorKey) { } function clearQueue(actorKey) { - if (!isFeatureEnabled()) { - return; - } const queue = state.queues.get(actorKey); if (!queue) { return; @@ -834,17 +816,6 @@ async function publishView(pointerInput, createdAt, metadata = {}) { } ); - if (!isFeatureEnabled()) { - console.info( - "[watchHistoryService] Watch history feature disabled; skipping queue update.", - { - pointer: pointerInput, - createdAt, - } - ); - return viewResult; - } - const pointer = normalizePointerInput(pointerInput); if (!pointer) { console.warn( @@ -981,6 +952,17 @@ async function publishView(pointerInput, createdAt, metadata = {}) { } ); + if (!isFeatureEnabled()) { + console.info( + "[watchHistoryService] Watch history sync disabled; retaining pointer in local session queue only.", + { + actor: actorKey, + pointerKey: key, + watchedAt: normalizedPointer.watchedAt, + } + ); + } + return viewResult; } @@ -1104,18 +1086,26 @@ async function snapshot(items, options = {}) { } async function loadLatest(actorInput) { - if (!isFeatureEnabled()) { - console.info( - "[watchHistoryService] loadLatest skipped because watch history feature is disabled.", - { actor: resolveActorKey(actorInput) || null } - ); - return []; - } const actorKey = resolveActorKey(actorInput); if (!actorKey) { return []; } + if (!isFeatureEnabled()) { + if (!state.restored) { + restoreQueueState(); + } + const items = collectQueueItems(actorKey); + console.info( + "[watchHistoryService] loadLatest returning session watch history queue.", + { + actor: actorKey, + itemCount: items.length, + } + ); + return items; + } + const now = Date.now(); const cacheEntry = state.fingerprintCache.get(actorKey); console.info( @@ -1217,9 +1207,6 @@ async function getFingerprint(actorInput) { } function resetProgress(actorInput) { - if (!isFeatureEnabled()) { - return; - } const actorKey = normalizeActorKey(actorInput); if (actorKey) { state.queues.delete(actorKey); @@ -1237,9 +1224,6 @@ function resetProgress(actorInput) { } function getQueuedPointers(actorInput) { - if (!isFeatureEnabled()) { - return []; - } const actorKey = normalizeActorKey(actorInput); if (!actorKey) { return []; @@ -1251,9 +1235,6 @@ function getQueuedPointers(actorInput) { } function getAllQueues() { - if (!isFeatureEnabled()) { - return {}; - } if (!state.restored) { restoreQueueState(); } @@ -1280,6 +1261,7 @@ function getSettings() { const watchHistoryService = { isEnabled: isFeatureEnabled, + supportsLocalHistory: () => true, publishView, snapshot, loadLatest, diff --git a/tests/watch-history.test.mjs b/tests/watch-history.test.mjs index 8558c243..8f665191 100644 --- a/tests/watch-history.test.mjs +++ b/tests/watch-history.test.mjs @@ -980,12 +980,78 @@ 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; + + try { + setWatchHistoryV2Enabled(false); + localStorage.clear(); + watchHistoryService.resetProgress(); + nostrClient.pubkey = ""; + 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() + : true; + assert.equal( + supported, + true, + "service should report local history support while sync disabled", + ); + + 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; + } +} + await testPublishSnapshotCanonicalizationAndChunking(); await testPublishSnapshotUsesExtensionCrypto(); await testPublishSnapshotFailureRetry(); await testWatchHistoryPartialRelayRetry(); await testResolveWatchHistoryBatchingWindow(); await testWatchHistoryServiceIntegration(); +await testWatchHistoryLocalFallbackWhenDisabled(); console.log("watch-history.test.mjs completed successfully"); From c3ed4bc6ea61a0d9f0a64cb8c6857786d5e592eb Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 2 Oct 2025 18:26:43 -0400 Subject: [PATCH 4/5] Scope watch history sync to logged users --- js/historyView.js | 39 ++++++++--- js/watchHistoryService.js | 123 ++++++++++++++++++++++++++++------- tests/watch-history.test.mjs | 111 ++++++++++++++++++++++++++++++- 3 files changed, 241 insertions(+), 32 deletions(-) diff --git a/js/historyView.js b/js/historyView.js index 8110b213..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,11 +739,18 @@ export function createWatchHistoryRenderer(config = {}) { }, } = config; - const syncEnabled = watchHistoryService.isEnabled?.() === true; + const syncEnabled = + typeof watchHistoryService.isEnabled === "function" + ? watchHistoryService.isEnabled() === true + : false; const localSupported = typeof watchHistoryService.supportsLocalHistory === "function" - ? watchHistoryService.supportsLocalHistory() !== false - : true; + ? watchHistoryService.supportsLocalHistory() === true + : false; + const localOnly = + typeof watchHistoryService.isLocalOnly === "function" + ? watchHistoryService.isLocalOnly() === true + : false; const state = { initialized: false, @@ -771,6 +778,7 @@ export function createWatchHistoryRenderer(config = {}) { sessionFallbackActive: false, syncEnabled, localSupported, + localOnly, featureEnabled: syncEnabled || localSupported, }; @@ -903,13 +911,22 @@ export function createWatchHistoryRenderer(config = {}) { } function updateFeatureBanner() { - const syncEnabled = watchHistoryService.isEnabled?.() === true; + 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() !== false - : true; + ? 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; @@ -919,9 +936,15 @@ export function createWatchHistoryRenderer(config = {}) { setHidden(elements.featureBanner, true); return; } + 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 on this server. Local history only."; + "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."; diff --git a/js/watchHistoryService.js b/js/watchHistoryService.js index 13f5e0a5..6e0ca51c 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,79 @@ function isFeatureEnabled() { return FEATURE_WATCH_HISTORY_V2 === true; } +function getLoggedInActorKey() { + return normalizeActorKey(nostrClient?.pubkey); +} + +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) { @@ -557,7 +630,7 @@ function restoreQueueState() { queue.lastSnapshotReason = null; } - if (queue.pendingSnapshotId && isFeatureEnabled()) { + if (queue.pendingSnapshotId && isFeatureEnabled(actorKey)) { scheduleRepublishForQueue(actorKey); } } @@ -702,7 +775,7 @@ function resolveWatchedAt(...candidates) { } function scheduleRepublishForQueue(actorKey) { - if (!isFeatureEnabled()) { + if (!isFeatureEnabled(actorKey)) { return; } const queue = state.queues.get(actorKey); @@ -849,6 +922,8 @@ async function publishView(pointerInput, createdAt, metadata = {}) { return viewResult; } + const remoteEnabled = isFeatureEnabled(actorKey); + const normalizedPointer = normalizePointerInput(pointer); if (!normalizedPointer) { console.warn( @@ -871,6 +946,7 @@ async function publishView(pointerInput, createdAt, metadata = {}) { pointerKey: key, watchedAt: normalizedPointer.watchedAt, sessionEvent: normalizedPointer.session === true, + remoteSyncEnabled: remoteEnabled, } ); @@ -952,9 +1028,9 @@ async function publishView(pointerInput, createdAt, metadata = {}) { } ); - if (!isFeatureEnabled()) { + if (!remoteEnabled) { console.info( - "[watchHistoryService] Watch history sync disabled; retaining pointer in local session queue only.", + "[watchHistoryService] Watch history sync unavailable for this actor; retaining pointer in local session queue only.", { actor: actorKey, pointerKey: key, @@ -967,22 +1043,24 @@ async function publishView(pointerInput, createdAt, metadata = {}) { } async function snapshot(items, options = {}) { - if (!isFeatureEnabled()) { - console.info( - "[watchHistoryService] Snapshot skipped because watch history feature is disabled.", - { - requestedItems: Array.isArray(items) ? items.length : 0, - reason: typeof options.reason === "string" ? options.reason : "manual", - } - ); - 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); } @@ -1091,13 +1169,13 @@ async function loadLatest(actorInput) { return []; } - if (!isFeatureEnabled()) { + if (!isFeatureEnabled(actorKey)) { if (!state.restored) { restoreQueueState(); } const items = collectQueueItems(actorKey); console.info( - "[watchHistoryService] loadLatest returning session watch history queue.", + "[watchHistoryService] loadLatest returning local-only watch history queue.", { actor: actorKey, itemCount: items.length, @@ -1178,13 +1256,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 ( @@ -1261,7 +1339,8 @@ function getSettings() { const watchHistoryService = { isEnabled: isFeatureEnabled, - supportsLocalHistory: () => true, + supportsLocalHistory, + isLocalOnly, publishView, snapshot, loadLatest, diff --git a/tests/watch-history.test.mjs b/tests/watch-history.test.mjs index 8f665191..d8bff039 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; @@ -986,12 +989,14 @@ async function testWatchHistoryLocalFallbackWhenDisabled() { 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: { @@ -1003,13 +1008,27 @@ async function testWatchHistoryLocalFallbackWhenDisabled() { const supported = typeof watchHistoryService.supportsLocalHistory === "function" - ? watchHistoryService.supportsLocalHistory() + ? 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( @@ -1042,6 +1061,90 @@ async function testWatchHistoryLocalFallbackWhenDisabled() { 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; } } @@ -1052,6 +1155,7 @@ await testWatchHistoryPartialRelayRetry(); await testResolveWatchHistoryBatchingWindow(); await testWatchHistoryServiceIntegration(); await testWatchHistoryLocalFallbackWhenDisabled(); +await testWatchHistorySyncEnabledForLoggedInUsers(); console.log("watch-history.test.mjs completed successfully"); @@ -1070,6 +1174,9 @@ nostrClient.watchHistoryCache = originalWatchHistoryCache; nostrClient.watchHistoryStorage = originalWatchHistoryStorage; nostrClient.scheduleWatchHistoryRepublish = originalScheduleWatchHistoryRepublish; +nostrClient.resolveWatchHistory = originalResolveWatchHistory; +nostrClient.getWatchHistoryFingerprint = + originalGetWatchHistoryFingerprint; if (!originalFlag) { setWatchHistoryV2Enabled(false); From 762dbe5dcc6101bf48c75b2bf2aa6cb4a4aeeaff Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 2 Oct 2025 18:45:44 -0400 Subject: [PATCH 5/5] Fix watch history sync when logged in --- js/app.js | 65 ++++++++++++++++++++++++++++++++++-- js/watchHistoryService.js | 38 ++++++++++++++++++++- tests/watch-history.test.mjs | 47 ++++++++++++++++++++++++++ 3 files changed, 146 insertions(+), 4 deletions(-) diff --git a/js/app.js b/js/app.js index 211150f2..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); @@ -7587,8 +7596,52 @@ class bitvidApp { try { 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); + viewResult = await watchHistoryService.publishView( + thresholdPointer, + undefined, + watchMetadata + ); } else if (typeof nostrClient?.recordVideoView === "function") { viewResult = await nostrClient.recordVideoView(thresholdPointer); } else { @@ -11640,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/watchHistoryService.js b/js/watchHistoryService.js index 6e0ca51c..94002c05 100644 --- a/js/watchHistoryService.js +++ b/js/watchHistoryService.js @@ -53,7 +53,43 @@ function resolveFlagEnabled() { } function getLoggedInActorKey() { - return normalizeActorKey(nostrClient?.pubkey); + 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() { diff --git a/tests/watch-history.test.mjs b/tests/watch-history.test.mjs index d8bff039..b9fae06a 100644 --- a/tests/watch-history.test.mjs +++ b/tests/watch-history.test.mjs @@ -1148,6 +1148,52 @@ async function testWatchHistorySyncEnabledForLoggedInUsers() { } } +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(); @@ -1156,6 +1202,7 @@ await testResolveWatchHistoryBatchingWindow(); await testWatchHistoryServiceIntegration(); await testWatchHistoryLocalFallbackWhenDisabled(); await testWatchHistorySyncEnabledForLoggedInUsers(); +await testWatchHistoryAppLoginFallback(); console.log("watch-history.test.mjs completed successfully");