mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-03 19:35:28 +00:00
Merge pull request #255 from PR0M3TH3AN/codex/extend-profile-modal-with-new-controls
Enhance profile watch history controls
This commit is contained in:
5 files changed
+895
-50
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user