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