+ Session actor in use. Connect a NIP-07 extension to publish as your main profile.
+
+
this.pubkey || window.app?.pubkey || undefined,
+ getActor: async () => {
+ if (this.pubkey) {
+ return this.pubkey;
+ }
+ if (
+ typeof nostrClient?.sessionActor?.pubkey === "string" &&
+ nostrClient.sessionActor.pubkey
+ ) {
+ return nostrClient.sessionActor.pubkey;
+ }
+ return window.app?.pubkey || undefined;
+ },
});
}
this.adminModeratorsSection =
@@ -4640,6 +4662,13 @@ class bitvidApp {
if (this.boundProfileModalFocusIn) {
document.removeEventListener("focusin", this.boundProfileModalFocusIn);
}
+ if (this.boundProfileHistoryVisibility) {
+ document.removeEventListener(
+ "visibilitychange",
+ this.boundProfileHistoryVisibility
+ );
+ this.boundProfileHistoryVisibility = null;
+ }
this.closeAllMoreMenus();
@@ -5030,9 +5059,40 @@ class bitvidApp {
return;
}
+ let primaryActor =
+ typeof this.pubkey === "string" && this.pubkey ? this.pubkey : undefined;
+ if (
+ !primaryActor &&
+ typeof nostrClient?.sessionActor?.pubkey === "string" &&
+ nostrClient.sessionActor.pubkey
+ ) {
+ primaryActor = nostrClient.sessionActor.pubkey;
+ }
+
try {
- await this.profileHistoryRenderer.ensureInitialLoad();
- this.profileHistoryRenderer.resume();
+ await this.profileHistoryRenderer.ensureInitialLoad({ actor: primaryActor });
+ await this.profileHistoryRenderer.refresh({ actor: primaryActor, force: true });
+ if (!this.boundProfileHistoryVisibility) {
+ this.boundProfileHistoryVisibility = () => {
+ if (!this.profileHistoryRenderer) {
+ return;
+ }
+ if (document.visibilityState === "visible") {
+ this.profileHistoryRenderer.resume();
+ } else {
+ this.profileHistoryRenderer.pause();
+ }
+ };
+ document.addEventListener(
+ "visibilitychange",
+ this.boundProfileHistoryVisibility
+ );
+ }
+ if (document.visibilityState === "hidden") {
+ this.profileHistoryRenderer.pause();
+ } else {
+ this.profileHistoryRenderer.resume();
+ }
} catch (error) {
console.error(
"[profileModal] Failed to populate watch history pane:",
diff --git a/js/historyView.js b/js/historyView.js
index 6fe9d2d2..88f3481e 100644
--- a/js/historyView.js
+++ b/js/historyView.js
@@ -677,6 +677,13 @@ export function createWatchHistoryRenderer(config = {}) {
infoSelector = "#watchHistoryInfo",
errorBannerSelector = "#watchHistoryError",
scrollContainerSelector = null,
+ featureBannerSelector = "#profileHistoryFeatureBanner",
+ toastRegionSelector = "#profileHistoryToastRegion",
+ sessionWarningSelector = "#profileHistorySessionWarning",
+ metadataToggleSelector = "#profileHistoryMetadataToggle",
+ metadataThumbSelector = "#profileHistoryMetadataThumb",
+ metadataLabelSelector = "#profileHistoryMetadataLabel",
+ metadataDescriptionSelector = "#profileHistoryMetadataDescription",
emptyCopy = WATCH_HISTORY_EMPTY_COPY,
batchSize = WATCH_HISTORY_BATCH_SIZE,
} = config;
@@ -700,6 +707,11 @@ export function createWatchHistoryRenderer(config = {}) {
activeVideos: null,
catalogPromise: null,
},
+ metadataStorageEnabled:
+ typeof watchHistoryService.shouldStoreMetadata === "function"
+ ? watchHistoryService.shouldStoreMetadata() !== false
+ : true,
+ sessionFallbackActive: false,
};
let elements = {
@@ -719,6 +731,13 @@ export function createWatchHistoryRenderer(config = {}) {
info: null,
errorBanner: null,
scrollContainer: null,
+ featureBanner: null,
+ toastRegion: null,
+ sessionWarning: null,
+ metadataToggle: null,
+ metadataThumb: null,
+ metadataLabel: null,
+ metadataDescription: null,
};
let boundGridClickHandler = null;
@@ -727,6 +746,10 @@ export function createWatchHistoryRenderer(config = {}) {
let boundRepublishHandler = null;
let boundPrivacyToggleHandler = null;
let boundPrivacyDismissHandler = null;
+ let boundMetadataToggleHandler = null;
+
+ const subscriptions = new Set();
+ const toastTimers = new Set();
function refreshElements() {
elements = {
@@ -746,9 +769,176 @@ export function createWatchHistoryRenderer(config = {}) {
info: resolveElement(infoSelector),
errorBanner: resolveElement(errorBannerSelector),
scrollContainer: resolveElement(scrollContainerSelector),
+ featureBanner: resolveElement(featureBannerSelector),
+ toastRegion: resolveElement(toastRegionSelector),
+ sessionWarning: resolveElement(sessionWarningSelector),
+ metadataToggle: resolveElement(metadataToggleSelector),
+ metadataThumb: resolveElement(metadataThumbSelector),
+ metadataLabel: resolveElement(metadataLabelSelector),
+ metadataDescription: resolveElement(metadataDescriptionSelector),
};
}
+ function clearSubscriptions() {
+ for (const unsubscribe of subscriptions) {
+ try {
+ unsubscribe();
+ } catch (error) {
+ if (isDevEnv) {
+ console.warn(
+ "[historyView] Failed to unsubscribe from watch history event:",
+ error,
+ );
+ }
+ }
+ }
+ subscriptions.clear();
+ }
+
+ function clearToasts() {
+ for (const timer of toastTimers) {
+ clearTimeout(timer);
+ }
+ toastTimers.clear();
+ if (elements.toastRegion instanceof HTMLElement) {
+ elements.toastRegion.innerHTML = "";
+ }
+ }
+
+ function pushToast({ message, variant = "info", duration = 8000 }) {
+ if (!(elements.toastRegion instanceof HTMLElement)) {
+ return;
+ }
+ const text = typeof message === "string" ? message.trim() : "";
+ if (!text) {
+ return;
+ }
+ const variantClass = {
+ info: "border-blue-500/40 bg-blue-500/10 text-blue-100",
+ warning: "border-amber-500/40 bg-amber-500/10 text-amber-100",
+ error: "border-red-500/40 bg-red-500/10 text-red-100",
+ }[variant] || "border-gray-600 bg-gray-800/70 text-gray-100";
+
+ if (elements.toastRegion.childElementCount >= 3) {
+ const firstChild = elements.toastRegion.firstElementChild;
+ if (firstChild instanceof HTMLElement) {
+ firstChild.remove();
+ }
+ }
+
+ const toast = document.createElement("div");
+ toast.className = `rounded-md border px-4 py-2 text-sm ${variantClass}`;
+ toast.setAttribute("role", "status");
+ toast.textContent = text;
+ elements.toastRegion.appendChild(toast);
+
+ const timeout = Number.isFinite(duration) ? Math.max(0, duration) : 8000;
+ if (timeout > 0) {
+ const timer = setTimeout(() => {
+ toast.remove();
+ toastTimers.delete(timer);
+ }, timeout);
+ toastTimers.add(timer);
+ }
+ }
+
+ function updateFeatureBanner() {
+ if (!(elements.featureBanner instanceof HTMLElement)) {
+ return;
+ }
+ if (watchHistoryService.isEnabled?.() === true) {
+ elements.featureBanner.textContent = "";
+ setHidden(elements.featureBanner, true);
+ return;
+ }
+ elements.featureBanner.textContent =
+ "Watch history sync is disabled on this server. Local history only.";
+ setHidden(elements.featureBanner, false);
+ }
+
+ function updateMetadataToggle() {
+ if (!(elements.metadataToggle instanceof HTMLElement)) {
+ return;
+ }
+ const enabled = state.metadataStorageEnabled;
+ elements.metadataToggle.setAttribute("aria-checked", enabled ? "true" : "false");
+ elements.metadataToggle.classList.toggle("bg-blue-600", enabled);
+ elements.metadataToggle.classList.toggle("border-blue-500", enabled);
+ elements.metadataToggle.classList.toggle("bg-gray-700", !enabled);
+ elements.metadataToggle.classList.toggle("border-gray-600", !enabled);
+ if (elements.metadataThumb instanceof HTMLElement) {
+ elements.metadataThumb.classList.toggle("translate-x-5", enabled);
+ elements.metadataThumb.classList.toggle("translate-x-1", !enabled);
+ }
+ }
+
+ function updateSessionFallbackWarning() {
+ if (!(elements.sessionWarning instanceof HTMLElement)) {
+ return;
+ }
+ const nip07Pubkey =
+ typeof nostrClient?.pubkey === "string" ? nostrClient.pubkey : "";
+ const sessionPubkey =
+ typeof nostrClient?.sessionActor?.pubkey === "string"
+ ? nostrClient.sessionActor.pubkey
+ : "";
+ const activeActor = typeof state.actor === "string" ? state.actor : "";
+ const fallbackActive = !nip07Pubkey && sessionPubkey && sessionPubkey === activeActor;
+ state.sessionFallbackActive = Boolean(fallbackActive);
+ setHidden(elements.sessionWarning, !fallbackActive);
+ }
+
+ function subscribeToMetadataPreference() {
+ if (typeof watchHistoryService.subscribe !== "function") {
+ return;
+ }
+ const unsubscribe = watchHistoryService.subscribe(
+ "metadata-preference",
+ (payload) => {
+ const enabled = payload?.enabled !== false;
+ state.metadataStorageEnabled = enabled;
+ updateMetadataToggle();
+ }
+ );
+ if (typeof unsubscribe === "function") {
+ subscriptions.add(unsubscribe);
+ }
+ }
+
+ function subscribeToRepublishEvents() {
+ if (typeof watchHistoryService.subscribe !== "function") {
+ return;
+ }
+ const unsubscribe = watchHistoryService.subscribe(
+ "republish-scheduled",
+ (payload) => {
+ if (!payload) {
+ return;
+ }
+ if (
+ payload.actor &&
+ state.actor &&
+ payload.actor !== state.actor
+ ) {
+ return;
+ }
+ let message = "Republish retry scheduled.";
+ const delayMs = Number.isFinite(payload.delayMs)
+ ? Math.max(0, Math.floor(payload.delayMs))
+ : null;
+ if (delayMs != null) {
+ const seconds = Math.max(1, Math.round(delayMs / 1000));
+ const suffix = seconds === 1 ? "" : "s";
+ message = `Republish retry scheduled in about ${seconds} second${suffix}.`;
+ }
+ pushToast({ message, variant: "warning" });
+ }
+ );
+ if (typeof unsubscribe === "function") {
+ subscriptions.add(unsubscribe);
+ }
+ }
+
function setLoadingState(loading) {
state.isLoading = loading;
if (elements.loading) {
@@ -927,17 +1117,74 @@ export function createWatchHistoryRenderer(config = {}) {
hydrated.push({ ...item, metadata: state.metadataCache.get(item.pointerKey) });
continue;
}
+ const pointerKeyValue = item.pointerKey;
let video = null;
+ let profile = null;
+ if (
+ pointerKeyValue &&
+ typeof watchHistoryService.getLocalMetadata === "function"
+ ) {
+ try {
+ const stored = watchHistoryService.getLocalMetadata(pointerKeyValue);
+ if (stored) {
+ video = stored.video || null;
+ profile = stored.profile || null;
+ }
+ } catch (error) {
+ if (isDevEnv) {
+ console.warn(
+ "[historyView] Failed to read stored metadata for pointer:",
+ pointerKeyValue,
+ error,
+ );
+ }
+ }
+ }
try {
- video = await resolveVideoFromPointer(item.pointer, caches);
+ const resolvedVideo = await resolveVideoFromPointer(item.pointer, caches);
+ if (resolvedVideo) {
+ video = resolvedVideo;
+ }
} catch (error) {
if (isDevEnv) {
- console.warn("[historyView] Failed to resolve video for pointer:", error);
+ console.warn(
+ "[historyView] Failed to resolve video for pointer:",
+ error,
+ );
+ }
+ }
+ if (video?.pubkey) {
+ profile = resolveProfileForPubkey(video.pubkey) || profile;
+ }
+ const metadata = { video: video || null, profile: profile || null };
+ state.metadataCache.set(pointerKeyValue, metadata);
+ if (pointerKeyValue) {
+ if (state.metadataStorageEnabled) {
+ try {
+ watchHistoryService.setLocalMetadata?.(pointerKeyValue, metadata);
+ } catch (error) {
+ if (isDevEnv) {
+ console.warn(
+ "[historyView] Failed to persist metadata for pointer:",
+ pointerKeyValue,
+ error,
+ );
+ }
+ }
+ } else {
+ try {
+ watchHistoryService.removeLocalMetadata?.(pointerKeyValue);
+ } catch (error) {
+ if (isDevEnv) {
+ console.warn(
+ "[historyView] Failed to remove cached metadata for pointer:",
+ pointerKeyValue,
+ error,
+ );
+ }
+ }
}
}
- const profile = video?.pubkey ? resolveProfileForPubkey(video.pubkey) : null;
- const metadata = { video, profile };
- state.metadataCache.set(item.pointerKey, metadata);
hydrated.push({ ...item, metadata });
}
return hydrated;
@@ -1124,6 +1371,16 @@ export function createWatchHistoryRenderer(config = {}) {
state.cursor = 0;
state.hasMore = false;
state.metadataCache.clear();
+ try {
+ watchHistoryService.clearLocalMetadata?.();
+ } catch (error) {
+ if (isDevEnv) {
+ console.warn(
+ "[historyView] Failed to clear stored metadata while resetting progress:",
+ error,
+ );
+ }
+ }
showEmptyState();
const app = getAppInstance();
app?.showSuccess?.("Local watch history cache cleared.");
@@ -1209,13 +1466,62 @@ export function createWatchHistoryRenderer(config = {}) {
}
}
- async function loadHistory({ force = false } = {}) {
+ function bindMetadataToggle() {
+ if (!(elements.metadataToggle instanceof HTMLElement)) {
+ return;
+ }
+ if (boundMetadataToggleHandler) {
+ elements.metadataToggle.removeEventListener(
+ "click",
+ boundMetadataToggleHandler
+ );
+ }
+ boundMetadataToggleHandler = (event) => {
+ event.preventDefault();
+ const nextValue = !state.metadataStorageEnabled;
+ try {
+ if (typeof watchHistoryService.setMetadataPreference === "function") {
+ watchHistoryService.setMetadataPreference(nextValue);
+ }
+ } catch (error) {
+ if (isDevEnv) {
+ console.warn("[historyView] Failed to set metadata preference:", error);
+ }
+ }
+ state.metadataStorageEnabled =
+ typeof watchHistoryService.shouldStoreMetadata === "function"
+ ? watchHistoryService.shouldStoreMetadata() !== false
+ : nextValue;
+ updateMetadataToggle();
+ if (!state.metadataStorageEnabled) {
+ try {
+ watchHistoryService.clearLocalMetadata?.();
+ } catch (error) {
+ if (isDevEnv) {
+ console.warn(
+ "[historyView] Failed to clear stored metadata after disabling preference:",
+ error,
+ );
+ }
+ }
+ state.metadataCache.clear();
+ void renderer.render();
+ }
+ };
+ elements.metadataToggle.addEventListener("click", boundMetadataToggleHandler);
+ }
+
+ async function loadHistory({ force = false, actorOverride } = {}) {
if (state.isLoading) {
return false;
}
setLoadingState(true);
- const actor = await resolveActorKey();
+ const actor =
+ typeof actorOverride === "string" && actorOverride.trim()
+ ? actorOverride.trim()
+ : await resolveActorKey();
state.actor = actor;
+ updateSessionFallbackWarning();
let items = [];
try {
items = await fetchHistory(actor);
@@ -1264,17 +1570,28 @@ export function createWatchHistoryRenderer(config = {}) {
}
const renderer = {
- async init() {
+ async init(options = {}) {
if (typeof document === "undefined") {
return;
}
+ clearSubscriptions();
+ clearToasts();
refreshElements();
+ updateFeatureBanner();
ensureObserver();
bindGridEvents();
bindLoadMore();
bindActions();
bindPrivacyControls();
+ bindMetadataToggle();
+ subscribeToMetadataPreference();
+ subscribeToRepublishEvents();
updateInfoCallout();
+ state.metadataStorageEnabled =
+ typeof watchHistoryService.shouldStoreMetadata === "function"
+ ? watchHistoryService.shouldStoreMetadata() !== false
+ : true;
+ updateMetadataToggle();
const storedPreference = readPreference(
WATCH_HISTORY_METADATA_PREF_KEY,
"encrypted-only"
@@ -1287,18 +1604,28 @@ export function createWatchHistoryRenderer(config = {}) {
) === "true";
updatePrivacyBanner();
state.initialized = true;
- await this.refresh();
+ await this.refresh({ ...options, force: true });
},
- async ensureInitialLoad() {
+ async ensureInitialLoad(options = {}) {
if (!state.initialized) {
- await this.init();
+ await this.init(options);
return;
}
refreshElements();
- await this.refresh();
+ updateFeatureBanner();
+ bindMetadataToggle();
+ state.metadataStorageEnabled =
+ typeof watchHistoryService.shouldStoreMetadata === "function"
+ ? watchHistoryService.shouldStoreMetadata() !== false
+ : true;
+ updateMetadataToggle();
},
- async refresh() {
- const changed = await loadHistory({ force: true });
+ async refresh(options = {}) {
+ const { actor, force = true } = options;
+ const changed = await loadHistory({
+ force,
+ actorOverride: actor,
+ });
if (!changed && state.items.length) {
updateLoadMoreVisibility();
return;
@@ -1354,6 +1681,15 @@ export function createWatchHistoryRenderer(config = {}) {
);
boundPrivacyDismissHandler = null;
}
+ if (elements.metadataToggle && boundMetadataToggleHandler) {
+ elements.metadataToggle.removeEventListener(
+ "click",
+ boundMetadataToggleHandler
+ );
+ boundMetadataToggleHandler = null;
+ }
+ clearSubscriptions();
+ clearToasts();
if (elements.grid) {
elements.grid.innerHTML = "";
}
@@ -1384,6 +1720,18 @@ export function createWatchHistoryRenderer(config = {}) {
if (card) {
card.remove();
}
+ state.metadataCache.delete(pointerKeyValue);
+ try {
+ watchHistoryService.removeLocalMetadata?.(pointerKeyValue);
+ } catch (error) {
+ if (isDevEnv) {
+ console.warn(
+ "[historyView] Failed to drop cached metadata for pointer:",
+ pointerKeyValue,
+ error,
+ );
+ }
+ }
removeEmptyDayContainers(elements.grid);
if (!state.items.length) {
showEmptyState();
diff --git a/js/nostr.js b/js/nostr.js
index 0d46db7e..182382a0 100644
--- a/js/nostr.js
+++ b/js/nostr.js
@@ -2022,6 +2022,8 @@ class NostrClient {
return;
}
+ const onSchedule =
+ typeof options?.onSchedule === "function" ? options.onSchedule : null;
const previous = this.watchHistoryRepublishTimers.get(key);
if (previous && typeof previous.timer === "number") {
clearTimeout(previous.timer);
@@ -2049,6 +2051,19 @@ class NostrClient {
Math.floor(cappedDelay + jitter),
);
+ if (onSchedule) {
+ try {
+ onSchedule({ snapshotId: key, attempt: attempt + 1, delay });
+ } catch (error) {
+ if (isDevMode) {
+ console.warn(
+ `[nostr] Failed to notify watch history republish schedule for ${key}:`,
+ error,
+ );
+ }
+ }
+ }
+
const timer = setTimeout(async () => {
this.watchHistoryRepublishTimers.delete(key);
try {
@@ -2057,6 +2072,7 @@ class NostrClient {
if (attempt + 1 <= WATCH_HISTORY_REPUBLISH_MAX_ATTEMPTS) {
this.scheduleWatchHistoryRepublish(key, operation, {
attempt: attempt + 1,
+ onSchedule,
});
} else if (isDevMode) {
console.warn(
@@ -2073,6 +2089,7 @@ class NostrClient {
if (attempt + 1 <= WATCH_HISTORY_REPUBLISH_MAX_ATTEMPTS) {
this.scheduleWatchHistoryRepublish(key, operation, {
attempt: attempt + 1,
+ onSchedule,
});
}
}
@@ -2083,6 +2100,8 @@ class NostrClient {
attempt,
operation,
});
+
+ return { attempt: attempt + 1, delay };
}
async getWatchHistoryFingerprint(actorInput, itemsOverride = null) {
diff --git a/js/watchHistoryService.js b/js/watchHistoryService.js
index 6b9605de..e882d856 100644
--- a/js/watchHistoryService.js
+++ b/js/watchHistoryService.js
@@ -16,6 +16,9 @@ import { isDevMode } from "./config.js";
const SESSION_STORAGE_KEY = "bitvid:watch-history:session:v1";
const SESSION_STORAGE_VERSION = 1;
const POINTER_THROTTLE_MS = 60 * 1000;
+const METADATA_STORAGE_KEY = "bitvid:watch-history:metadata-cache:v1";
+const METADATA_STORAGE_VERSION = 1;
+const METADATA_PREFERENCE_KEY = "bitvid:watch-history:metadata:store-locally";
const state = {
restored: false,
@@ -23,6 +26,11 @@ const state = {
inflightSnapshots: new Map(),
fingerprintCache: new Map(),
listeners: new Map(),
+ metadata: {
+ restored: false,
+ cache: new Map(),
+ preference: null,
+ },
};
function isFeatureEnabled() {
@@ -42,6 +50,313 @@ function getSessionStorage() {
return null;
}
+function getLocalStorage() {
+ try {
+ if (typeof window !== "undefined" && window.localStorage) {
+ return window.localStorage;
+ }
+ } catch (error) {
+ if (isDevMode) {
+ console.warn("[watchHistoryService] localStorage unavailable:", error);
+ }
+ }
+ return null;
+}
+
+function ensureMetadataPreference() {
+ if (typeof state.metadata.preference === "boolean") {
+ return state.metadata.preference;
+ }
+ const storage = getLocalStorage();
+ if (!storage) {
+ state.metadata.preference = true;
+ return state.metadata.preference;
+ }
+ try {
+ const stored = storage.getItem(METADATA_PREFERENCE_KEY);
+ if (stored === null) {
+ state.metadata.preference = true;
+ } else {
+ state.metadata.preference = stored === "true";
+ }
+ } catch (error) {
+ if (isDevMode) {
+ console.warn(
+ "[watchHistoryService] Failed to read metadata preference:",
+ error,
+ );
+ }
+ state.metadata.preference = true;
+ }
+ return state.metadata.preference;
+}
+
+function persistMetadataPreference(value) {
+ const storage = getLocalStorage();
+ if (!storage) {
+ return;
+ }
+ try {
+ if (value === true) {
+ storage.setItem(METADATA_PREFERENCE_KEY, "true");
+ } else {
+ storage.setItem(METADATA_PREFERENCE_KEY, "false");
+ }
+ } catch (error) {
+ if (isDevMode) {
+ console.warn(
+ "[watchHistoryService] Failed to persist metadata preference:",
+ error,
+ );
+ }
+ }
+}
+
+function sanitizeVideoForStorage(video) {
+ if (!video || typeof video !== "object") {
+ return null;
+ }
+ const createdAt = Number.isFinite(video.created_at)
+ ? Math.floor(Number(video.created_at))
+ : null;
+ return {
+ id: typeof video.id === "string" ? video.id : "",
+ title: typeof video.title === "string" ? video.title : "",
+ thumbnail: typeof video.thumbnail === "string" ? video.thumbnail : "",
+ url: typeof video.url === "string" ? video.url : "",
+ magnet: typeof video.magnet === "string" ? video.magnet : "",
+ pubkey: typeof video.pubkey === "string" ? video.pubkey : "",
+ created_at: createdAt,
+ infoHash: typeof video.infoHash === "string" ? video.infoHash : "",
+ mode: typeof video.mode === "string" ? video.mode : "",
+ isPrivate: video?.isPrivate === true,
+ description:
+ typeof video.description === "string" ? video.description : "",
+ };
+}
+
+function sanitizeProfileForStorage(profile) {
+ if (!profile || typeof profile !== "object") {
+ return null;
+ }
+ return {
+ pubkey: typeof profile.pubkey === "string" ? profile.pubkey : "",
+ name: typeof profile.name === "string" ? profile.name : "",
+ display_name:
+ typeof profile.display_name === "string" ? profile.display_name : "",
+ picture: typeof profile.picture === "string" ? profile.picture : "",
+ nip05: typeof profile.nip05 === "string" ? profile.nip05 : "",
+ };
+}
+
+function restoreMetadataCache() {
+ if (state.metadata.restored) {
+ return;
+ }
+ state.metadata.restored = true;
+
+ const storage = getLocalStorage();
+ if (!storage) {
+ return;
+ }
+
+ let raw = null;
+ try {
+ raw = storage.getItem(METADATA_STORAGE_KEY);
+ } catch (error) {
+ if (isDevMode) {
+ console.warn(
+ "[watchHistoryService] Failed to read metadata cache:",
+ error,
+ );
+ }
+ return;
+ }
+
+ if (!raw || typeof raw !== "string") {
+ return;
+ }
+
+ let parsed = null;
+ try {
+ parsed = JSON.parse(raw);
+ } catch (error) {
+ if (isDevMode) {
+ console.warn(
+ "[watchHistoryService] Failed to parse metadata cache:",
+ error,
+ );
+ }
+ try {
+ storage.removeItem(METADATA_STORAGE_KEY);
+ } catch (cleanupError) {
+ if (isDevMode) {
+ console.warn(
+ "[watchHistoryService] Failed to clear corrupt metadata cache:",
+ cleanupError,
+ );
+ }
+ }
+ return;
+ }
+
+ if (!parsed || parsed.version !== METADATA_STORAGE_VERSION) {
+ try {
+ storage.removeItem(METADATA_STORAGE_KEY);
+ } catch (cleanupError) {
+ if (isDevMode) {
+ console.warn(
+ "[watchHistoryService] Failed to clear outdated metadata cache:",
+ cleanupError,
+ );
+ }
+ }
+ return;
+ }
+
+ state.metadata.cache.clear();
+ const entries = parsed.entries && typeof parsed.entries === "object"
+ ? parsed.entries
+ : {};
+ for (const [key, entry] of Object.entries(entries)) {
+ if (typeof key !== "string" || !key) {
+ continue;
+ }
+ const sanitizedVideo = sanitizeVideoForStorage(entry?.video);
+ const sanitizedProfile = sanitizeProfileForStorage(entry?.profile);
+ state.metadata.cache.set(key, {
+ video: sanitizedVideo,
+ profile: sanitizedProfile,
+ storedAt: Number.isFinite(entry?.storedAt) ? entry.storedAt : Date.now(),
+ });
+ }
+}
+
+function persistMetadataCache() {
+ const storage = getLocalStorage();
+ if (!storage) {
+ return;
+ }
+ const shouldStore = shouldStoreMetadataLocally();
+ if (!shouldStore || !state.metadata.cache.size) {
+ try {
+ storage.removeItem(METADATA_STORAGE_KEY);
+ } catch (error) {
+ if (isDevMode) {
+ console.warn(
+ "[watchHistoryService] Failed to clear metadata cache:",
+ error,
+ );
+ }
+ }
+ return;
+ }
+
+ const payload = { version: METADATA_STORAGE_VERSION, entries: {} };
+ for (const [key, entry] of state.metadata.cache.entries()) {
+ payload.entries[key] = {
+ video: sanitizeVideoForStorage(entry?.video) || null,
+ profile: sanitizeProfileForStorage(entry?.profile) || null,
+ storedAt: Number.isFinite(entry?.storedAt) ? entry.storedAt : Date.now(),
+ };
+ }
+ try {
+ storage.setItem(METADATA_STORAGE_KEY, JSON.stringify(payload));
+ } catch (error) {
+ if (isDevMode) {
+ console.warn(
+ "[watchHistoryService] Failed to persist metadata cache:",
+ error,
+ );
+ }
+ }
+}
+
+function shouldStoreMetadataLocally() {
+ return ensureMetadataPreference() === true;
+}
+
+function setMetadataPreference(enabled) {
+ const normalized = enabled === false ? false : true;
+ const previous = ensureMetadataPreference();
+ state.metadata.preference = normalized;
+ persistMetadataPreference(normalized);
+ if (!normalized) {
+ state.metadata.cache.clear();
+ persistMetadataCache();
+ } else {
+ persistMetadataCache();
+ }
+ if (previous !== normalized) {
+ emit("metadata-preference", { enabled: normalized });
+ }
+}
+
+function getMetadataPreference() {
+ return ensureMetadataPreference();
+}
+
+function getLocalMetadata(pointerKeyValue) {
+ restoreMetadataCache();
+ const key = typeof pointerKeyValue === "string" ? pointerKeyValue : "";
+ if (!key) {
+ return null;
+ }
+ const stored = state.metadata.cache.get(key);
+ if (!stored) {
+ return null;
+ }
+ return {
+ video: stored.video ? { ...stored.video } : null,
+ profile: stored.profile ? { ...stored.profile } : null,
+ storedAt: stored.storedAt,
+ };
+}
+
+function setLocalMetadata(pointerKeyValue, metadata) {
+ if (!shouldStoreMetadataLocally()) {
+ return;
+ }
+ const key = typeof pointerKeyValue === "string" ? pointerKeyValue : "";
+ if (!key) {
+ return;
+ }
+ restoreMetadataCache();
+ if (!metadata || typeof metadata !== "object") {
+ state.metadata.cache.delete(key);
+ persistMetadataCache();
+ return;
+ }
+ const video = sanitizeVideoForStorage(metadata.video);
+ const profile = sanitizeProfileForStorage(metadata.profile);
+ state.metadata.cache.set(key, {
+ video,
+ profile,
+ storedAt: Date.now(),
+ });
+ persistMetadataCache();
+}
+
+function removeLocalMetadata(pointerKeyValue) {
+ const key = typeof pointerKeyValue === "string" ? pointerKeyValue : "";
+ if (!key) {
+ return;
+ }
+ restoreMetadataCache();
+ if (state.metadata.cache.delete(key)) {
+ persistMetadataCache();
+ }
+}
+
+function clearLocalMetadata() {
+ restoreMetadataCache();
+ if (!state.metadata.cache.size) {
+ return;
+ }
+ state.metadata.cache.clear();
+ persistMetadataCache();
+}
+
function emit(eventName, payload) {
const listeners = state.listeners.get(eventName);
if (listeners && listeners.size) {
@@ -378,41 +693,57 @@ function scheduleRepublishForQueue(actorKey) {
}
const snapshotId = queue.pendingSnapshotId;
queue.republishScheduled = true;
- nostrClient.scheduleWatchHistoryRepublish(snapshotId, async (attempt) => {
- const latestItems = collectQueueItems(actorKey);
- if (!latestItems.length) {
- queue.pendingSnapshotId = null;
- queue.republishScheduled = false;
- persistQueueState();
- return {
- ok: true,
- skipped: true,
- actor: actorKey,
- snapshotId,
- };
- }
- const publishResult = await nostrClient.publishWatchHistorySnapshot(
- latestItems,
- {
- actorPubkey: actorKey,
- snapshotId,
- attempt,
- source: `${queue.lastSnapshotReason || "session"}-retry`,
+ nostrClient.scheduleWatchHistoryRepublish(
+ snapshotId,
+ async (attempt) => {
+ const latestItems = collectQueueItems(actorKey);
+ if (!latestItems.length) {
+ queue.pendingSnapshotId = null;
+ queue.republishScheduled = false;
+ persistQueueState();
+ return {
+ ok: true,
+ skipped: true,
+ actor: actorKey,
+ snapshotId,
+ };
}
- );
- if (publishResult?.ok) {
- queue.pendingSnapshotId = null;
- queue.republishScheduled = false;
- await updateFingerprintCache(actorKey, publishResult.items || latestItems);
- persistQueueState();
- notifyQueueChange(actorKey);
- } else if (!publishResult?.retryable) {
- queue.pendingSnapshotId = publishResult?.snapshotId || queue.pendingSnapshotId;
- queue.republishScheduled = false;
- persistQueueState();
+ const publishResult = await nostrClient.publishWatchHistorySnapshot(
+ latestItems,
+ {
+ actorPubkey: actorKey,
+ snapshotId,
+ attempt,
+ source: `${queue.lastSnapshotReason || "session"}-retry`,
+ }
+ );
+ if (publishResult?.ok) {
+ queue.pendingSnapshotId = null;
+ queue.republishScheduled = false;
+ await updateFingerprintCache(
+ actorKey,
+ publishResult.items || latestItems,
+ );
+ persistQueueState();
+ notifyQueueChange(actorKey);
+ } else if (!publishResult?.retryable) {
+ queue.pendingSnapshotId =
+ publishResult?.snapshotId || queue.pendingSnapshotId;
+ queue.republishScheduled = false;
+ persistQueueState();
+ }
+ return publishResult;
+ },
+ {
+ onSchedule: ({ delay }) => {
+ emit("republish-scheduled", {
+ actor: actorKey,
+ snapshotId,
+ delayMs: Number.isFinite(delay) ? delay : null,
+ });
+ },
}
- return publishResult;
- });
+ );
}
async function updateFingerprintCache(actorKey, items) {
@@ -764,6 +1095,13 @@ const watchHistoryService = {
resetProgress,
getQueuedPointers,
getAllQueues,
+ getMetadataPreference,
+ setMetadataPreference,
+ shouldStoreMetadata: shouldStoreMetadataLocally,
+ getLocalMetadata,
+ setLocalMetadata,
+ removeLocalMetadata,
+ clearLocalMetadata,
subscribe,
};