Merge pull request #255 from PR0M3TH3AN/codex/extend-profile-modal-with-new-controls

Enhance profile watch history controls
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2025-10-02 14:42:54 -04:00
commit 6e4323d9f6
5 files changed
+895 -50

No files matched your search

+80
View File
@@ -290,7 +290,81 @@
<p class="text-sm text-gray-400">
Your recently watched videos appear here so you can jump back into favorites.
</p>
<div
id="profileHistoryFeatureBanner"
class="hidden rounded-md border border-amber-500/40 bg-amber-500/10 px-4 py-3 text-sm text-amber-100"
role="status"
aria-live="polite"
></div>
<div class="space-y-4">
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div
class="flex flex-wrap gap-2"
role="group"
aria-label="Watch history actions"
>
<button
id="profileHistoryClear"
type="button"
class="inline-flex items-center gap-2 rounded-md bg-gray-800 px-4 py-2 text-sm font-medium text-gray-200 transition hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-gray-900"
>
Clear local cache
</button>
<button
id="profileHistoryRepublish"
type="button"
class="inline-flex items-center gap-2 rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 focus:ring-offset-gray-900"
>
Republish now
</button>
</div>
<div class="flex items-start gap-3">
<div class="flex flex-col">
<span
id="profileHistoryMetadataLabel"
class="text-sm font-medium text-gray-200"
>
Store titles/thumbnails locally
</span>
<span
id="profileHistoryMetadataDescription"
class="text-xs text-gray-400"
>
When disabled, BitVid skips saving rich metadata to this device.
</span>
</div>
<button
id="profileHistoryMetadataToggle"
type="button"
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer items-center rounded-full border border-gray-600 bg-gray-700 transition focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-gray-900"
role="switch"
aria-checked="true"
aria-labelledby="profileHistoryMetadataLabel"
aria-describedby="profileHistoryMetadataDescription"
>
<span class="sr-only">Toggle local metadata storage</span>
<span
id="profileHistoryMetadataThumb"
class="inline-block h-5 w-5 translate-x-5 transform rounded-full bg-white transition"
aria-hidden="true"
></span>
</button>
</div>
</div>
<div
id="profileHistoryToastRegion"
class="space-y-2"
aria-live="polite"
aria-atomic="false"
></div>
<div
id="profileHistorySessionWarning"
class="hidden inline-flex items-center gap-2 rounded-full border border-amber-500/50 bg-amber-500/10 px-3 py-1 text-xs font-medium text-amber-100"
role="status"
aria-live="polite"
>
Session actor in use. Connect a NIP-07 extension to publish as your main profile.
</div>
<div id="profileHistoryLoading" class="py-6">
<div id="profileHistoryStatus">
<div
@@ -310,6 +384,12 @@
</div>
</div>
</div>
<div
id="profileHistoryError"
class="hidden rounded-md border border-red-500/40 bg-red-500/10 px-4 py-3 text-sm text-red-100"
role="alert"
aria-live="assertive"
></div>
<div
id="profileHistoryEmpty"
class="hidden rounded-lg border border-dashed border-gray-700 p-6 text-center text-sm text-gray-400"
+63 -3
View File
@@ -592,6 +592,7 @@ class bitvidApp {
this.lastFocusedBeforeProfileModal = null;
this.boundProfileModalKeydown = null;
this.boundProfileModalFocusIn = null;
this.boundProfileHistoryVisibility = null;
this.profileModalFocusables = [];
this.profileSwitcherList = null;
this.profileAddAccountBtn = null;
@@ -4207,8 +4208,29 @@ class bitvidApp {
emptySelector: "#profileHistoryEmpty",
sentinelSelector: "#profileHistorySentinel",
scrollContainerSelector: "#profileHistoryScroll",
errorBannerSelector: "#profileHistoryError",
clearButtonSelector: "#profileHistoryClear",
republishButtonSelector: "#profileHistoryRepublish",
featureBannerSelector: "#profileHistoryFeatureBanner",
toastRegionSelector: "#profileHistoryToastRegion",
sessionWarningSelector: "#profileHistorySessionWarning",
metadataToggleSelector: "#profileHistoryMetadataToggle",
metadataThumbSelector: "#profileHistoryMetadataThumb",
metadataLabelSelector: "#profileHistoryMetadataLabel",
metadataDescriptionSelector: "#profileHistoryMetadataDescription",
emptyCopy: "You haven’t watched any videos yet.",
getActor: async () => 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:",
+362 -14
View File
@@ -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();
+19
View File
@@ -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) {
+371 -33
View File
@@ -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,
};