diff --git a/config/instance-config.js b/config/instance-config.js index 4c7f4917..12eca0cc 100644 --- a/config/instance-config.js +++ b/config/instance-config.js @@ -76,6 +76,17 @@ export const WATCH_HISTORY_LEGACY_LIST_IDENTIFIERS = Object.freeze([ "watch-history:v2:index", ]); +/** + * Whether to enable the V2 encrypted watch-history service. + * + * The runtime flag that controls this feature defaults to `false` so that new + * deployments stick with the analytics-only view flow until operators opt in. + * To enable V2, set `window.__BITVID_RUNTIME_FLAGS__.FEATURE_WATCH_HISTORY_V2 = true` + * in a bootstrap script (or override the value before the app loads). When the + * flag stays off, BitVid still emits legacy view events and will honor + * existing watch-history reads per plan ยง12, but the sync UI will surface a + * disabled banner instead of querying relays. + */ /** * Maximum number of watch-history entries to retain per user. * diff --git a/js/app.js b/js/app.js index 1b7e4ddb..54162d5b 100644 --- a/js/app.js +++ b/js/app.js @@ -7585,7 +7585,16 @@ class bitvidApp { (async () => { let viewResult; try { - viewResult = await watchHistoryService.publishView(thresholdPointer); + const watchHistoryEnabled = + watchHistoryService?.isEnabled?.() === true && + typeof watchHistoryService.publishView === "function"; + if (watchHistoryEnabled) { + viewResult = await watchHistoryService.publishView(thresholdPointer); + } else if (typeof nostrClient?.recordVideoView === "function") { + viewResult = await nostrClient.recordVideoView(thresholdPointer); + } else { + viewResult = { ok: false, error: "view-logging-unavailable" }; + } } catch (error) { if (isDevMode) { console.warn( diff --git a/js/constants.js b/js/constants.js index 831d3e3d..c13c4ab2 100644 --- a/js/constants.js +++ b/js/constants.js @@ -1,6 +1,6 @@ const DEFAULT_FLAGS = Object.freeze({ - URL_FIRST_ENABLED: true, // try URL before magnet in the player - ACCEPT_LEGACY_V1: true, // accept v1 magnet-only notes + URL_FIRST_ENABLED: true, // try URL before magnet in the player + ACCEPT_LEGACY_V1: true, // accept v1 magnet-only notes VIEW_FILTER_INCLUDE_LEGACY_VIDEO: false, FEATURE_WATCH_HISTORY_V2: false, WSS_TRACKERS: Object.freeze([ @@ -195,6 +195,10 @@ export function setViewFilterIncludeLegacyVideo(next) { return VIEW_FILTER_INCLUDE_LEGACY_VIDEO; } +export function getWatchHistoryV2Enabled() { + return FEATURE_WATCH_HISTORY_V2 === true; +} + export function setWssTrackers(next) { runtimeFlags.WSS_TRACKERS = next; return WSS_TRACKERS; diff --git a/js/historyView.js b/js/historyView.js index 20832cbe..46a3e5d6 100644 --- a/js/historyView.js +++ b/js/historyView.js @@ -12,6 +12,9 @@ import { WATCH_HISTORY_BATCH_RESOLVE } from "./config.js"; 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."; + const WATCH_HISTORY_METADATA_PREF_KEY = "bitvid:watch-history:metadata-preference"; const WATCH_HISTORY_PRIVACY_DISMISSED_KEY = @@ -716,6 +719,7 @@ export function createWatchHistoryRenderer(config = {}) { metadataLabelSelector = "#profileHistoryMetadataLabel", metadataDescriptionSelector = "#profileHistoryMetadataDescription", emptyCopy = WATCH_HISTORY_EMPTY_COPY, + disabledCopy = WATCH_HISTORY_DISABLED_COPY, batchSize = WATCH_HISTORY_BATCH_SIZE, remove = (payload) => { const app = getAppInstance(); @@ -750,6 +754,7 @@ export function createWatchHistoryRenderer(config = {}) { ? watchHistoryService.shouldStoreMetadata() !== false : true, sessionFallbackActive: false, + featureEnabled: watchHistoryService.isEnabled?.() === true, }; let elements = { @@ -881,10 +886,12 @@ export function createWatchHistoryRenderer(config = {}) { } function updateFeatureBanner() { + const enabled = watchHistoryService.isEnabled?.() === true; + state.featureEnabled = enabled; if (!(elements.featureBanner instanceof HTMLElement)) { return; } - if (watchHistoryService.isEnabled?.() === true) { + if (enabled) { elements.featureBanner.textContent = ""; setHidden(elements.featureBanner, true); return; @@ -1018,6 +1025,31 @@ export function createWatchHistoryRenderer(config = {}) { } } + function showFeatureDisabledState() { + hideErrorBanner(); + if (elements.loading) { + setHidden(elements.loading, true); + } + if (elements.status) { + setHidden(elements.status, true); + } + detachObserver(); + if (elements.grid) { + elements.grid.innerHTML = ""; + setHidden(elements.grid, true); + } + if (elements.empty) { + setTextContent(elements.empty, disabledCopy || emptyCopy); + setHidden(elements.empty, false); + } + if (elements.loadMore) { + setHidden(elements.loadMore, true); + } + if (elements.sentinel) { + setHidden(elements.sentinel, true); + } + } + function ensureObserver() { if (!elements.sentinel || !WATCH_HISTORY_BATCH_RESOLVE) { return; @@ -1553,6 +1585,17 @@ export function createWatchHistoryRenderer(config = {}) { if (state.isLoading) { return false; } + if (!state.featureEnabled) { + state.actor = null; + updateSessionFallbackWarning(); + state.items = []; + state.fingerprint = ""; + state.cursor = 0; + state.hasMore = false; + state.lastError = null; + showFeatureDisabledState(); + return false; + } setLoadingState(true); const actor = typeof actorOverride === "string" && actorOverride.trim() @@ -1642,6 +1685,15 @@ export function createWatchHistoryRenderer(config = {}) { ) === "true"; updatePrivacyBanner(); state.initialized = true; + if (!state.featureEnabled) { + state.items = []; + state.fingerprint = ""; + state.cursor = 0; + state.hasMore = false; + state.lastError = null; + showFeatureDisabledState(); + return; + } await this.refresh({ ...options, force: true }); }, async ensureInitialLoad(options = {}) { @@ -1651,6 +1703,15 @@ export function createWatchHistoryRenderer(config = {}) { } refreshElements(); updateFeatureBanner(); + if (!state.featureEnabled) { + state.items = []; + state.fingerprint = ""; + state.cursor = 0; + state.hasMore = false; + state.lastError = null; + showFeatureDisabledState(); + return; + } bindMetadataToggle(); state.metadataStorageEnabled = typeof watchHistoryService.shouldStoreMetadata === "function" @@ -1659,6 +1720,16 @@ export function createWatchHistoryRenderer(config = {}) { updateMetadataToggle(); }, async refresh(options = {}) { + updateFeatureBanner(); + if (!state.featureEnabled) { + state.items = []; + state.fingerprint = ""; + state.cursor = 0; + state.hasMore = false; + state.lastError = null; + showFeatureDisabledState(); + return; + } const { actor, force = true } = options; const changed = await loadHistory({ force, @@ -1671,6 +1742,9 @@ export function createWatchHistoryRenderer(config = {}) { await renderInitial(); }, async loadMore() { + if (!state.featureEnabled) { + return []; + } if (!state.items.length) { return []; } diff --git a/js/watchHistoryService.js b/js/watchHistoryService.js index 8990b5d6..cbbcdcba 100644 --- a/js/watchHistoryService.js +++ b/js/watchHistoryService.js @@ -10,7 +10,10 @@ import { WATCH_HISTORY_CACHE_TTL_MS, WATCH_HISTORY_MAX_ITEMS, } from "./config.js"; -import { FEATURE_WATCH_HISTORY_V2 } from "./constants.js"; +import { + FEATURE_WATCH_HISTORY_V2, + getWatchHistoryV2Enabled, +} from "./constants.js"; import { isDevMode } from "./config.js"; const SESSION_STORAGE_KEY = "bitvid:watch-history:session:v1"; @@ -34,6 +37,18 @@ const state = { }; function isFeatureEnabled() { + try { + if (typeof getWatchHistoryV2Enabled === "function") { + return getWatchHistoryV2Enabled(); + } + } catch (error) { + if (isDevMode) { + console.warn( + "[watchHistoryService] Failed to read FEATURE_WATCH_HISTORY_V2 flag:", + error, + ); + } + } return FEATURE_WATCH_HISTORY_V2 === true; } @@ -439,6 +454,9 @@ function getOrCreateQueue(actorKey) { } function restoreQueueState() { + if (!isFeatureEnabled()) { + return; + } if (state.restored) { return; } @@ -549,6 +567,9 @@ function restoreQueueState() { } function ensureQueue(actorKey) { + if (!isFeatureEnabled()) { + return null; + } if (!actorKey) { return null; } @@ -559,6 +580,9 @@ function ensureQueue(actorKey) { } function persistQueueState() { + if (!isFeatureEnabled()) { + return; + } const storage = getSessionStorage(); if (!storage) { return; @@ -623,6 +647,9 @@ function resolveActorKey(actorInput) { } function collectQueueItems(actorKey) { + if (!isFeatureEnabled()) { + return []; + } const queue = state.queues.get(actorKey); if (!queue) { return []; @@ -648,6 +675,9 @@ function collectQueueItems(actorKey) { } function notifyQueueChange(actorKey) { + if (!isFeatureEnabled()) { + return; + } emit("queue-changed", { actor: actorKey, items: collectQueueItems(actorKey), @@ -655,6 +685,9 @@ function notifyQueueChange(actorKey) { } function clearQueue(actorKey) { + if (!isFeatureEnabled()) { + return; + } const queue = state.queues.get(actorKey); if (!queue) { return; @@ -687,6 +720,9 @@ function resolveWatchedAt(...candidates) { } function scheduleRepublishForQueue(actorKey) { + if (!isFeatureEnabled()) { + return; + } const queue = state.queues.get(actorKey); if (!queue || !queue.pendingSnapshotId || queue.republishScheduled) { return; @@ -1045,6 +1081,9 @@ async function getFingerprint(actorInput) { } function resetProgress(actorInput) { + if (!isFeatureEnabled()) { + return; + } const actorKey = normalizeActorKey(actorInput); if (actorKey) { state.queues.delete(actorKey); @@ -1062,6 +1101,9 @@ function resetProgress(actorInput) { } function getQueuedPointers(actorInput) { + if (!isFeatureEnabled()) { + return []; + } const actorKey = normalizeActorKey(actorInput); if (!actorKey) { return []; @@ -1073,6 +1115,9 @@ function getQueuedPointers(actorInput) { } function getAllQueues() { + if (!isFeatureEnabled()) { + return {}; + } if (!state.restored) { restoreQueueState(); }