diff --git a/js/app.js b/js/app.js index 6a16a6d6..3a954fb3 100644 --- a/js/app.js +++ b/js/app.js @@ -3955,10 +3955,7 @@ class bitvidApp { sentinelSelector: "#profileHistorySentinel", scrollContainerSelector: "#profileHistoryScroll", emptyCopy: "You haven’t watched any videos yet.", - beforeInitialLoad: async () => { - const actor = this.pubkey || undefined; - await nostrClient.fetchWatchHistory(actor); - }, + getActor: async () => this.pubkey || window.app?.pubkey || undefined, }); } this.adminModeratorsSection = diff --git a/js/historyView.js b/js/historyView.js index 7917b948..0b508d7f 100644 --- a/js/historyView.js +++ b/js/historyView.js @@ -16,22 +16,47 @@ function toNumber(value, fallback) { } export function createWatchHistoryRenderer(config = {}) { + const fallbackGetActor = async () => { + const candidate = + typeof window?.app?.pubkey === "string" && window.app.pubkey.trim() + ? window.app.pubkey.trim() + : ""; + return candidate || undefined; + }; + + const mergedConfig = { + viewSelector: "#watchHistoryView", + gridSelector: "#watchHistoryGrid", + loadingSelector: "#watchHistoryLoading", + emptySelector: "#watchHistoryEmpty", + sentinelSelector: "#watchHistorySentinel", + scrollContainerSelector: null, + emptyCopy: WATCH_HISTORY_EMPTY_COPY, + batchSize: BATCH_SIZE, + getActor: fallbackGetActor, + beforeInitialLoad: null, + getSnapshotFingerprint: null, + resolveBatch: (size) => nostrClient.resolveWatchHistory(size), + renderGrid: null, + ...config, + }; + const { - viewSelector = "#watchHistoryView", - gridSelector = "#watchHistoryGrid", - loadingSelector = "#watchHistoryLoading", - emptySelector = "#watchHistoryEmpty", - sentinelSelector = "#watchHistorySentinel", - scrollContainerSelector = null, - emptyCopy = WATCH_HISTORY_EMPTY_COPY, - beforeInitialLoad = async () => { - const actor = window.app?.pubkey || undefined; - await nostrClient.fetchWatchHistory(actor); - }, - resolveBatch = (size) => nostrClient.resolveWatchHistory(size), - renderGrid = null, - batchSize = BATCH_SIZE, - } = config; + viewSelector, + gridSelector, + loadingSelector, + emptySelector, + sentinelSelector, + scrollContainerSelector, + emptyCopy, + batchSize, + getActor, + resolveBatch, + renderGrid, + } = mergedConfig; + + const providedBeforeInitialLoad = mergedConfig.beforeInitialLoad; + const providedGetSnapshotFingerprint = mergedConfig.getSnapshotFingerprint; const selectors = { view: viewSelector, @@ -56,6 +81,64 @@ export function createWatchHistoryRenderer(config = {}) { } }; + const resolveActor = async (hint = null) => { + if (typeof hint === "string" && hint.trim()) { + return hint.trim(); + } + + if (typeof getActor === "function") { + try { + const candidate = await getActor(); + if (typeof candidate === "string" && candidate.trim()) { + return candidate.trim(); + } + } catch (error) { + debugLog("failed to resolve actor via getActor", error); + } + } + + return null; + }; + + const callBeforeInitialLoad = async (context = {}) => { + let actor = await resolveActor(context.actor ?? null); + const finalContext = { ...context, actor }; + + if (typeof providedBeforeInitialLoad === "function") { + return providedBeforeInitialLoad(finalContext); + } + + const snapshot = await nostrClient.fetchWatchHistory(actor || undefined); + + if (!actor) { + try { + const ensured = await nostrClient.ensureSessionActor(); + if (typeof ensured === "string" && ensured.trim()) { + actor = ensured.trim(); + } + } catch (error) { + debugLog("failed to ensure session actor while resolving history", error); + } + } + + return { actor: actor || null, snapshot }; + }; + + const computeSnapshotFingerprint = async (context = {}) => { + const actor = await resolveActor(context.actor ?? null); + const finalContext = { ...context, actor }; + + if (typeof providedGetSnapshotFingerprint === "function") { + return providedGetSnapshotFingerprint(finalContext); + } + + if (finalContext.refresh) { + await nostrClient.fetchWatchHistory(actor || undefined); + } + + return nostrClient.getWatchHistoryFingerprint(actor || undefined); + }; + const state = { isLoading: false, hasMore: true, @@ -65,6 +148,8 @@ export function createWatchHistoryRenderer(config = {}) { initialized: false, emptyCopy, batchSize: Math.max(1, toNumber(batchSize, BATCH_SIZE)), + actor: null, + snapshotFingerprint: null, }; const query = (selector) => { @@ -313,29 +398,75 @@ export function createWatchHistoryRenderer(config = {}) { }); }; - const runInitialLoad = async () => { + const runInitialLoad = async (options = {}) => { + const { actor: actorHint = null, prefetched = null, fingerprintOverride } = + typeof options === "object" && options !== null ? options : {}; + const { view } = getElements(); if (!view) { debugLog("runInitialLoad aborted: view not found"); return; } + const resolvedActor = await resolveActor(actorHint ?? state.actor); + state.actor = resolvedActor || null; + cleanupObservers(); state.resolvedVideos = []; state.hasMore = true; state.isLoading = false; state.initialized = false; + state.snapshotFingerprint = null; resetUiState(); - debugLog("starting initial load"); + debugLog("starting initial load", { actor: state.actor }); - try { - await beforeInitialLoad?.(); - } catch (error) { - console.error("[historyView] Failed to fetch watch history list:", error); - showEmptyState( - "We couldn't load your watch history. Please try again later." - ); - return; + let beforeResult; + const hasPrefetched = + prefetched && + Object.prototype.hasOwnProperty.call(prefetched, "beforeResult"); + + if (hasPrefetched) { + beforeResult = prefetched.beforeResult; + } else { + try { + beforeResult = await callBeforeInitialLoad({ actor: state.actor }); + } catch (error) { + console.error("[historyView] Failed to fetch watch history list:", error); + showEmptyState( + "We couldn't load your watch history. Please try again later." + ); + return; + } + } + + if ( + beforeResult && + typeof beforeResult === "object" && + typeof beforeResult.actor === "string" && + beforeResult.actor.trim() + ) { + state.actor = beforeResult.actor.trim(); + } + + let fingerprint; + if (typeof fingerprintOverride !== "undefined") { + fingerprint = fingerprintOverride; + } else { + try { + fingerprint = await computeSnapshotFingerprint({ + actor: state.actor, + beforeResult, + refresh: false, + }); + } catch (error) { + debugLog("failed to compute snapshot fingerprint", error); + } + } + + if (typeof fingerprint === "string") { + state.snapshotFingerprint = fingerprint; + } else { + state.snapshotFingerprint = null; } await loadNextBatch({ initial: true }); @@ -348,6 +479,7 @@ export function createWatchHistoryRenderer(config = {}) { debugLog("initial load complete", { hasMore: state.hasMore, resolvedCount: state.resolvedVideos.length, + actor: state.actor, }); }; @@ -369,6 +501,56 @@ export function createWatchHistoryRenderer(config = {}) { return; } + state.actor = (await resolveActor(state.actor)) || null; + + if (!state.isLoading) { + try { + const beforeResult = await callBeforeInitialLoad({ + actor: state.actor, + reason: "ensureInitialLoad", + }); + if ( + beforeResult && + typeof beforeResult === "object" && + typeof beforeResult.actor === "string" && + beforeResult.actor.trim() + ) { + state.actor = beforeResult.actor.trim(); + } + const fingerprint = await computeSnapshotFingerprint({ + actor: state.actor, + beforeResult, + refresh: false, + }); + + if (typeof fingerprint === "string") { + if (fingerprint !== state.snapshotFingerprint) { + debugLog("ensureInitialLoad detected new snapshot", { + previous: state.snapshotFingerprint, + next: fingerprint, + actor: state.actor, + }); + await runInitialLoad({ + actor: state.actor, + prefetched: { beforeResult }, + fingerprintOverride: fingerprint, + }); + return; + } + state.snapshotFingerprint = fingerprint; + } + } catch (error) { + console.warn( + "[historyView] Failed to refresh watch history snapshot:", + error + ); + } + } else { + debugLog("ensureInitialLoad skipping snapshot refresh (still loading)", { + actor: state.actor, + }); + } + if (!state.resolvedVideos.length && !state.isLoading) { if (!state.hasMore) { debugLog("ensureInitialLoad resetting hasMore before retry"); @@ -413,6 +595,8 @@ export function createWatchHistoryRenderer(config = {}) { state.resolvedVideos = []; state.hasMore = true; state.isLoading = false; + state.actor = null; + state.snapshotFingerprint = null; resetUiState(); setLoadingVisible(false); }, diff --git a/js/nostr.js b/js/nostr.js index 9c03fb3f..e0aca71c 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -969,6 +969,45 @@ class NostrClient { } } + getWatchHistoryFingerprint(pubkeyOrSession) { + let actor = + typeof pubkeyOrSession === "string" && pubkeyOrSession.trim() + ? pubkeyOrSession.trim() + : ""; + + if (!actor) { + if (this.sessionActor?.pubkey) { + actor = this.sessionActor.pubkey.trim(); + } else if (typeof this.pubkey === "string" && this.pubkey.trim()) { + actor = this.pubkey.trim(); + } + } + + if (!actor) { + return ""; + } + + const entry = this.watchHistoryCache.get(actor); + if (!entry) { + return ""; + } + + const pointerId = + entry.pointerEvent && typeof entry.pointerEvent.id === "string" + ? entry.pointerEvent.id + : ""; + const pointerCreated = + entry.pointerEvent && Number.isFinite(entry.pointerEvent.created_at) + ? entry.pointerEvent.created_at + : 0; + const itemKeys = entry.items + .map((item) => pointerKey(item)) + .filter(Boolean) + .join("|"); + + return `${pointerId}:${pointerCreated}:${itemKeys}`; + } + async encryptWatchHistoryPayload(actorPubkey, payload) { const normalizedActor = typeof actorPubkey === "string" && actorPubkey.trim()