// js/historyView.js import { nostrClient } from "./nostr.js"; import { WATCH_HISTORY_BATCH_RESOLVE } from "./config.js"; import { subscriptions } from "./subscriptions.js"; import { accessControl } from "./accessControl.js"; import { getSidebarLoadingMarkup } from "./sidebarLoading.js"; import { toNumber, getWatchedAtScore, compareByWatchedAtDesc, } from "./watchHistorySort.js"; const DEFAULT_BATCH_SIZE = 20; const BATCH_SIZE = WATCH_HISTORY_BATCH_RESOLVE ? DEFAULT_BATCH_SIZE : 1; export const WATCH_HISTORY_EMPTY_COPY = "Your watch history is empty. Watch some videos to populate this list."; const WATCH_HISTORY_LOADING_STATUS = "Fetching watch history from relays…"; const WATCH_HISTORY_EMPTY_STATUS = "No watch history yet."; const WATCH_HISTORY_SHARED_UI_CACHE = new Map(); function normalizeActorCacheKey(actor) { if (typeof actor !== "string") { return ""; } const trimmed = actor.trim(); if (!trimmed) { return ""; } if (/^[0-9a-fA-F]{64}$/.test(trimmed)) { return trimmed.toLowerCase(); } return trimmed; } function cloneWatchHistoryVideosForCache(videos) { if (!Array.isArray(videos)) { return []; } if (typeof structuredClone === "function") { try { return structuredClone(videos); } catch (error) { // Fall back to a shallow clone below if structuredClone is unavailable // or throws (for example, when encountering unsupported values). } } return videos.map((video) => { if (!video || typeof video !== "object") { return video; } const clone = { ...video }; if (video.watchHistory && typeof video.watchHistory === "object") { clone.watchHistory = { ...video.watchHistory }; } if (Array.isArray(video.tags)) { clone.tags = video.tags.map((tag) => Array.isArray(tag) ? tag.slice() : tag ); } return clone; }); } function getSharedUiSnapshot(actor) { const key = normalizeActorCacheKey(actor); if (!key) { return null; } const entry = WATCH_HISTORY_SHARED_UI_CACHE.get(key); if (!entry) { return null; } return { resolvedVideos: cloneWatchHistoryVideosForCache(entry.resolvedVideos), hasMore: entry.hasMore !== false, snapshotFingerprint: typeof entry.snapshotFingerprint === "string" && entry.snapshotFingerprint ? entry.snapshotFingerprint : null, updatedAt: entry.updatedAt || 0, }; } function setSharedUiSnapshot(actor, snapshot) { const key = normalizeActorCacheKey(actor); if (!key) { return; } const normalized = { resolvedVideos: cloneWatchHistoryVideosForCache(snapshot.resolvedVideos), hasMore: snapshot.hasMore !== false, snapshotFingerprint: typeof snapshot.snapshotFingerprint === "string" && snapshot.snapshotFingerprint ? snapshot.snapshotFingerprint : null, updatedAt: Date.now(), }; WATCH_HISTORY_SHARED_UI_CACHE.set(key, normalized); } function getVideoAuthorIdentifiers(video) { if (!video || typeof video !== "object") { return { pubkey: "", npub: "" }; } const extractString = (candidate) => typeof candidate === "string" && candidate.trim() ? candidate.trim() : ""; const candidateNpubs = [ extractString(video.npub), extractString(video.authorNpub), extractString(video?.author?.npub), extractString(video?.watchHistory?.npub), extractString(video?.watchHistory?.npubEncoded), extractString(video?.watchHistory?.authorNpub), extractString(video?.watchHistory?.ownerNpub), ]; let pubkey = extractString(video.pubkey); if (!pubkey && video?.author && typeof video.author === "object") { pubkey = extractString(video.author.pubkey); } let npub = candidateNpubs.find((value) => value) || ""; if (!npub) { if (pubkey && pubkey.startsWith("npub")) { npub = pubkey; } else if (pubkey && window?.NostrTools?.nip19?.npubEncode) { try { npub = window.NostrTools.nip19.npubEncode(pubkey); } catch (error) { npub = ""; } } } if (!pubkey && npub && window?.NostrTools?.nip19?.decode) { try { const decoded = window.NostrTools.nip19.decode(npub); if (decoded?.type === "npub") { if (typeof decoded?.data === "string" && decoded.data) { pubkey = decoded.data; } else if ( decoded?.data && typeof decoded.data === "object" && typeof decoded.data.hex === "string" ) { pubkey = decoded.data.hex; } } } catch (error) { pubkey = ""; } } return { pubkey, npub }; } function isVideoBlocked(video) { const { pubkey, npub } = getVideoAuthorIdentifiers(video); if (pubkey && typeof window?.app?.isAuthorBlocked === "function") { try { if (window.app.isAuthorBlocked(pubkey)) { return true; } } catch (error) { console.warn("[historyView] Failed to check personal block list:", error); } } if (npub && typeof accessControl?.isBlacklisted === "function") { try { if (accessControl.isBlacklisted(npub)) { return true; } } catch (error) { console.warn("[historyView] Failed to check access blacklist:", error); } } return false; } function filterAccessibleVideos(videos) { if (!Array.isArray(videos)) { return []; } return videos.filter((video) => { if (!video || typeof video !== "object") { return false; } return !isVideoBlocked(video); }); } function getAbsoluteShareUrl(nevent) { if (!nevent) { return ""; } if (window.app?.buildShareUrlFromNevent) { const candidate = window.app.buildShareUrlFromNevent(nevent); if (candidate) { return candidate; } } const origin = window.location?.origin || ""; const pathname = window.location?.pathname || ""; let base = origin || pathname ? `${origin}${pathname}` : ""; if (!base) { const href = window.location?.href || ""; base = href ? href.split(/[?#]/)[0] : ""; } if (!base) { return `?v=${encodeURIComponent(nevent)}`; } return `${base}?v=${encodeURIComponent(nevent)}`; } function renderWatchHistoryGrid(videos, containerOrElement) { let container = null; if (typeof containerOrElement === "string") { container = document.getElementById(containerOrElement); } else if (containerOrElement instanceof Element) { container = containerOrElement; } if (!container) { return; } const safeVideos = Array.isArray(videos) ? videos : []; const dedupedVideos = window.app?.dedupeVideosByRoot?.(safeVideos) ?? subscriptions.dedupeToNewestByRoot(safeVideos); const filteredVideos = filterAccessibleVideos(dedupedVideos); if (!filteredVideos.length) { container.innerHTML = `

No videos available yet.

`; return; } const fullAllEventsArray = Array.from(nostrClient.allEvents.values()); const fragment = document.createDocumentFragment(); const localAuthorSet = new Set(); filteredVideos.forEach((video, index) => { if (!video.id || !video.title) { console.error("Missing ID or title:", video); return; } window.app?.videosMap?.set(video.id, video); localAuthorSet.add(video.pubkey); const nevent = window.NostrTools.nip19.neventEncode({ id: video.id }); const shareUrl = getAbsoluteShareUrl(nevent); const canEdit = window.app?.pubkey === video.pubkey; const highlightClass = video.isPrivate && canEdit ? "watch-history-card--private" : ""; const timeAgo = window.app?.formatTimeAgo ? window.app.formatTimeAgo(video.created_at) : new Date(video.created_at * 1000).toLocaleString(); const watchedAtTimestamp = toNumber( video?.watchHistory?.watchedAt, null ); let watchedAtLabel = "Watch date unavailable"; if (Number.isFinite(watchedAtTimestamp) && watchedAtTimestamp > 0) { const watchedDate = new Date(watchedAtTimestamp); if (!Number.isNaN(watchedDate.getTime())) { try { watchedAtLabel = `Watched on ${watchedDate.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short", })}`; } catch (err) { watchedAtLabel = `Watched on ${watchedDate.toLocaleString()}`; } } } const safeTitle = window.app?.escapeHTML(video.title) || "Untitled"; const safeThumb = window.app?.escapeHTML(video.thumbnail) || ""; const playbackUrl = typeof video.url === "string" ? video.url : ""; const trimmedUrl = playbackUrl ? playbackUrl.trim() : ""; const trimmedMagnet = typeof video.magnet === "string" ? video.magnet.trim() : ""; const legacyInfoHash = typeof video.infoHash === "string" ? video.infoHash.trim() : ""; const magnetCandidate = trimmedMagnet || legacyInfoHash; const playbackMagnet = magnetCandidate; const magnetProvided = magnetCandidate.length > 0; const watchHistoryKey = video?.watchHistory?.key || ""; const cardHtml = `
${safeTitle}

${safeTitle}

${timeAgo}

${watchedAtLabel}

View
`; const t = document.createElement("template"); t.innerHTML = cardHtml.trim(); const cardEl = t.content.firstElementChild; if (cardEl) { cardEl.dataset.ownerIsViewer = canEdit ? "true" : "false"; if (typeof video.pubkey === "string" && video.pubkey) { cardEl.dataset.ownerPubkey = video.pubkey; } else if (cardEl.dataset.ownerPubkey) { delete cardEl.dataset.ownerPubkey; } const interactiveEls = cardEl.querySelectorAll("[data-video-id]"); interactiveEls.forEach((el) => { if (!el.dataset) return; if (trimmedUrl) { el.dataset.playUrl = encodeURIComponent(trimmedUrl); } else { delete el.dataset.playUrl; } el.dataset.playMagnet = playbackMagnet || ""; }); if (magnetProvided) { cardEl.dataset.magnet = playbackMagnet; } else if (cardEl.dataset.magnet) { delete cardEl.dataset.magnet; } } fragment.appendChild(cardEl); }); container.innerHTML = ""; container.appendChild(fragment); window.app?.attachVideoListHandler?.(); const lazyEls = container.querySelectorAll("[data-lazy]"); lazyEls.forEach((el) => window.app?.mediaLoader.observe(el)); const authorPics = container.querySelectorAll(".author-pic"); const authorNames = container.querySelectorAll(".author-name"); const creatorButtons = container.querySelectorAll( ".watch-history-card__creatorAvatar" ); authorPics.forEach((pic) => { localAuthorSet.add(pic.getAttribute("data-pubkey")); }); authorNames.forEach((nameEl) => { localAuthorSet.add(nameEl.getAttribute("data-pubkey")); }); creatorButtons.forEach((btn) => { localAuthorSet.add(btn.getAttribute("data-pubkey")); }); if (window.app?.batchFetchProfiles && localAuthorSet.size > 0) { window.app.batchFetchProfiles(localAuthorSet); } authorPics.forEach((pic) => { if (!pic.dataset.watchHistoryClickAttached) { pic.dataset.watchHistoryClickAttached = "true"; pic.style.cursor = "pointer"; pic.addEventListener("click", (ev) => { ev.preventDefault(); ev.stopPropagation(); const pubkey = pic.getAttribute("data-pubkey"); window.app?.goToProfile(pubkey); }); } }); creatorButtons.forEach((btn) => { if (!btn.dataset.watchHistoryClickAttached) { btn.dataset.watchHistoryClickAttached = "true"; btn.addEventListener("click", (ev) => { ev.preventDefault(); ev.stopPropagation(); const pubkey = btn.getAttribute("data-pubkey"); window.app?.goToProfile(pubkey); }); } }); authorNames.forEach((nameEl) => { if (!nameEl.dataset.watchHistoryClickAttached) { nameEl.dataset.watchHistoryClickAttached = "true"; nameEl.style.cursor = "pointer"; nameEl.addEventListener("click", (ev) => { ev.preventDefault(); ev.stopPropagation(); const pubkey = nameEl.getAttribute("data-pubkey"); window.app?.goToProfile(pubkey); }); } }); } 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", statusSelector: "#watchHistoryStatus", 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, gridSelector, loadingSelector, statusSelector, emptySelector, sentinelSelector, scrollContainerSelector, emptyCopy, batchSize, getActor, resolveBatch, renderGrid, } = mergedConfig; const providedBeforeInitialLoad = mergedConfig.beforeInitialLoad; const providedGetSnapshotFingerprint = mergedConfig.getSnapshotFingerprint; let authChangeListener = null; let authRefreshPromise = null; let scheduleAuthRefresh = null; const normalizeActorHint = (actorHint) => { if (typeof actorHint === "string") { const trimmed = actorHint.trim(); if (trimmed) { return trimmed; } } return null; }; const attachAuthChangeListener = () => { if (authChangeListener || typeof window === "undefined") { return; } authChangeListener = (event) => { const detail = event?.detail || {}; const actorHint = typeof detail.pubkey === "string" ? detail.pubkey : null; try { scheduleAuthRefresh?.(actorHint ?? null); } catch (error) { console.warn("[historyView] Failed to schedule auth refresh:", error); } }; window.addEventListener("bitvid:auth-changed", authChangeListener); }; const detachAuthChangeListener = () => { if (!authChangeListener || typeof window === "undefined") { return; } window.removeEventListener("bitvid:auth-changed", authChangeListener); authChangeListener = null; }; const selectors = { view: viewSelector, grid: gridSelector, loading: loadingSelector, status: statusSelector, empty: emptySelector, sentinel: sentinelSelector, scrollContainer: scrollContainerSelector, }; const LOG_PREFIX = "[historyView]"; const debugLog = (...args) => { if (typeof console !== "undefined") { if (typeof console.debug === "function") { console.debug(LOG_PREFIX, ...args); return; } if (typeof console.log === "function") { console.log(LOG_PREFIX, ...args); } } }; 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, observer: null, scrollListener: null, resolvedVideos: [], initialized: false, emptyCopy, batchSize: Math.max(1, toNumber(batchSize, BATCH_SIZE)), actor: null, snapshotFingerprint: null, gridEventHandler: null, }; const applyAccessFilters = () => { if (!Array.isArray(state.resolvedVideos)) { state.resolvedVideos = []; return state.resolvedVideos; } const beforeLength = state.resolvedVideos.length; const filtered = filterAccessibleVideos(state.resolvedVideos); if (filtered.length !== beforeLength) { debugLog("filtered resolved videos due to block settings", { before: beforeLength, after: filtered.length, }); } state.resolvedVideos = filtered; return state.resolvedVideos; }; const query = (selector) => { if (!selector) { return null; } try { return document.querySelector(selector); } catch (error) { console.warn("[historyView] Failed to query selector:", selector, error); return null; } }; const getElements = () => ({ view: query(selectors.view), grid: query(selectors.grid), loadingEl: query(selectors.loading), statusEl: query(selectors.status), emptyEl: query(selectors.empty), sentinel: query(selectors.sentinel), scrollContainer: query(selectors.scrollContainer), }); const setStatusMessage = (message = "") => { const { statusEl } = getElements(); if (!statusEl) { return; } const nextMessage = typeof message === "string" ? message.trim() : ""; if (nextMessage) { const showSpinner = nextMessage === WATCH_HISTORY_LOADING_STATUS; statusEl.innerHTML = getSidebarLoadingMarkup(nextMessage, { showSpinner, }); statusEl.classList.remove("hidden"); } else { statusEl.textContent = ""; statusEl.innerHTML = ""; statusEl.classList.add("hidden"); } }; const clearStatusMessage = () => { setStatusMessage(""); }; const setLoadingVisible = (visible, { statusMessage } = {}) => { const { loadingEl } = getElements(); if (loadingEl) { if (visible) { loadingEl.classList.remove("hidden"); } else { loadingEl.classList.add("hidden"); } } if (visible) { const message = typeof statusMessage === "string" && statusMessage.trim() ? statusMessage : WATCH_HISTORY_LOADING_STATUS; setStatusMessage(message); return; } if (typeof statusMessage === "string") { setStatusMessage(statusMessage); } else { clearStatusMessage(); } }; const resetUiState = () => { const { grid, emptyEl } = getElements(); if (grid) { grid.innerHTML = ""; grid.classList.add("hidden"); } if (emptyEl) { emptyEl.textContent = state.emptyCopy; emptyEl.classList.add("hidden"); } setLoadingVisible(true); }; const cleanupObservers = () => { if (state.observer) { debugLog("disconnecting intersection observer"); state.observer.disconnect(); state.observer = null; } if (state.scrollListener?.target && state.scrollListener?.handler) { debugLog("removing scroll listener"); state.scrollListener.target.removeEventListener( "scroll", state.scrollListener.handler ); state.scrollListener = null; } }; const cleanupGridHandler = () => { if (!state.gridEventHandler) { return; } const { grid } = getElements(); if (grid) { grid.removeEventListener("click", state.gridEventHandler); } state.gridEventHandler = null; }; const showEmptyState = (message = state.emptyCopy) => { const { grid, emptyEl } = getElements(); if (grid) { grid.innerHTML = ""; grid.classList.add("hidden"); } if (emptyEl) { emptyEl.textContent = message; emptyEl.classList.remove("hidden"); } const shouldShowEmptyStatus = message === state.emptyCopy; const statusMessage = shouldShowEmptyStatus ? WATCH_HISTORY_EMPTY_STATUS : ""; setLoadingVisible(false, { statusMessage }); state.hasMore = false; cleanupObservers(); debugLog("showing empty state", { message }); persistSharedUiState(); }; const mergeResolvedVideos = (nextVideos) => { const ingest = (map, video) => { if (!video || typeof video !== "object" || !video.id) { return; } const existing = map.get(video.id); if (!existing) { map.set(video.id, video); return; } const existingScore = getWatchedAtScore(existing); const nextScore = getWatchedAtScore(video); if (nextScore > existingScore) { map.set(video.id, video); return; } if (nextScore === existingScore) { const existingCreated = toNumber( existing?.created_at, Number.NEGATIVE_INFINITY ); const nextCreated = toNumber( video?.created_at, Number.NEGATIVE_INFINITY ); if (nextCreated > existingCreated) { map.set(video.id, video); } } }; if (!Array.isArray(state.resolvedVideos)) { state.resolvedVideos = []; } if (!Array.isArray(nextVideos) || !nextVideos.length) { applyAccessFilters(); return; } const dedupeMap = new Map(); state.resolvedVideos.forEach((video) => ingest(dedupeMap, video)); nextVideos.forEach((video) => ingest(dedupeMap, video)); state.resolvedVideos = Array.from(dedupeMap.values()).sort( compareByWatchedAtDesc ); applyAccessFilters(); persistSharedUiState(); }; const ensureGridEventHandlers = () => { const { grid } = getElements(); if (!grid || state.gridEventHandler) { return; } const showRemoveSpinner = (button) => { if (!button || !button.dataset) { return; } if (button.dataset.originalHtml === undefined) { button.dataset.originalHtml = button.innerHTML; } if (button.dataset.originalAriaLabel === undefined) { button.dataset.originalAriaLabel = button.getAttribute("aria-label") || ""; } button.innerHTML = ''; button.setAttribute("aria-label", "Removing from watch history"); }; const restoreRemoveButton = (button) => { if (!button || !button.dataset) { return; } const originalHtml = button.dataset.originalHtml; if (typeof originalHtml === "string") { button.innerHTML = originalHtml; } const originalAria = button.dataset.originalAriaLabel || ""; if (originalAria) { button.setAttribute("aria-label", originalAria); } else { button.removeAttribute("aria-label"); } delete button.dataset.originalHtml; delete button.dataset.originalAriaLabel; }; const handler = async (event) => { const removeTrigger = event.target.closest( "[data-history-remove-key]" ); if (removeTrigger) { event.preventDefault(); event.stopPropagation(); if (removeTrigger.disabled) { return; } const targetKey = removeTrigger .getAttribute("data-history-remove-key") ?.trim(); const targetEventId = removeTrigger .getAttribute("data-history-remove-event-id") ?.trim(); if (!targetKey) { window.app?.showError?.( "Unable to remove this video from watch history." ); return; } removeTrigger.disabled = true; removeTrigger.setAttribute("aria-busy", "true"); showRemoveSpinner(removeTrigger); try { const result = await nostrClient.removeWatchHistoryItem(targetKey); if (!result?.ok) { const errorMessage = typeof result?.error === "string" && result.error ? result.error : "remove-failed"; throw new Error(errorMessage); } state.resolvedVideos = state.resolvedVideos.filter((video) => { const key = video?.watchHistory?.key || ""; if (key) { return key !== targetKey; } if (targetEventId) { return video?.id !== targetEventId; } return true; }); applyAccessFilters(); setLoadingVisible(false); renderResolvedVideos(); persistSharedUiState(); window.app?.showSuccess?.("Removed from watch history."); } catch (error) { console.error( "[historyView] Failed to remove watch history item:", error ); removeTrigger.disabled = false; window.app?.showError?.( "Failed to remove this video from watch history." ); } finally { if (removeTrigger.isConnected) { restoreRemoveButton(removeTrigger); removeTrigger.removeAttribute("aria-busy"); } } return; } const shareTrigger = event.target.closest("[data-history-share]"); if (shareTrigger) { event.preventDefault(); event.stopPropagation(); const shareUrl = shareTrigger.getAttribute("data-share-url") || ""; const shareTitle = shareTrigger.getAttribute("data-share-title") || ""; const eventId = shareTrigger.getAttribute("data-event-id") || ""; const normalizedTitle = shareTitle || "Watch on Bitvid"; try { if (shareUrl && navigator.share) { await navigator.share({ title: normalizedTitle, url: shareUrl }); return; } if (window.app?.handleMoreMenuAction) { await window.app.handleMoreMenuAction("copy-link", { eventId }); return; } if (shareUrl && navigator.clipboard?.writeText) { await navigator.clipboard.writeText(shareUrl); window.app?.showSuccess?.("Video link copied to clipboard!"); return; } if (shareUrl) { window.open(shareUrl, "_blank", "noopener,noreferrer"); return; } window.app?.showError?.("Unable to share this video right now."); } catch (error) { if (error?.name === "AbortError") { return; } console.error( "[historyView] Failed to share watch history item:", error ); window.app?.showError?.("Unable to share this video right now."); } } }; grid.addEventListener("click", handler); state.gridEventHandler = handler; }; const renderResolvedVideos = () => { const { grid, emptyEl } = getElements(); if (!grid) { return; } const accessibleVideos = applyAccessFilters(); if (!accessibleVideos.length) { if (emptyEl) { emptyEl.textContent = state.emptyCopy; emptyEl.classList.remove("hidden"); } grid.classList.add("hidden"); return; } if (typeof renderGrid === "function") { renderGrid(accessibleVideos, grid); } else { renderWatchHistoryGrid(accessibleVideos, grid); } ensureGridEventHandlers(); grid.classList.remove("hidden"); if (emptyEl) { emptyEl.classList.add("hidden"); } }; const persistSharedUiState = () => { if (!state.actor) { return; } setSharedUiSnapshot(state.actor, { resolvedVideos: state.resolvedVideos, hasMore: state.hasMore, snapshotFingerprint: state.snapshotFingerprint, }); }; const hydrateFromSharedUiCache = () => { if (!state.actor) { return { hydrated: false, sharedHasMore: false, fingerprintMatches: false, }; } const snapshot = getSharedUiSnapshot(state.actor); if (!snapshot) { return { hydrated: false, sharedHasMore: false, fingerprintMatches: false, }; } state.resolvedVideos = Array.isArray(snapshot.resolvedVideos) ? snapshot.resolvedVideos : []; state.hasMore = snapshot.hasMore !== false; applyAccessFilters(); const nextFingerprint = typeof snapshot.snapshotFingerprint === "string" && snapshot.snapshotFingerprint ? snapshot.snapshotFingerprint : null; let fingerprintMatches = true; if ( typeof state.snapshotFingerprint === "string" && state.snapshotFingerprint && nextFingerprint && nextFingerprint !== state.snapshotFingerprint ) { fingerprintMatches = false; } if (!state.snapshotFingerprint && nextFingerprint) { state.snapshotFingerprint = nextFingerprint; } if (state.resolvedVideos.length) { setLoadingVisible(false); renderResolvedVideos(); } return { hydrated: true, sharedHasMore: state.hasMore, fingerprintMatches, }; }; const loadNextBatch = async ({ initial = false } = {}) => { if (state.isLoading || !state.hasMore) { debugLog("skipping loadNextBatch", { reason: state.isLoading ? "already loading" : "no more items", initial, resolvedCount: state.resolvedVideos.length, }); if (initial && !state.isLoading && state.resolvedVideos.length === 0) { setLoadingVisible(false); } return; } setLoadingVisible(true); state.isLoading = true; debugLog("loading next batch", { initial, batchSize: state.batchSize, resolvedCount: state.resolvedVideos.length, }); try { const videos = await resolveBatch(state.batchSize); debugLog("resolveBatch completed", { initial, received: Array.isArray(videos) ? videos.length : "non-array", }); if (!Array.isArray(videos) || videos.length === 0) { if (initial && state.resolvedVideos.length === 0) { showEmptyState(); } else { state.hasMore = false; cleanupObservers(); setLoadingVisible(false); debugLog("no videos returned", { initial, resolvedCount: state.resolvedVideos.length, }); persistSharedUiState(); } return; } mergeResolvedVideos(videos); renderResolvedVideos(); setLoadingVisible(false); persistSharedUiState(); debugLog("rendered videos", { totalResolved: state.resolvedVideos.length, initial, }); } catch (error) { console.error("[historyView] Failed to resolve watch history:", error); if (initial && state.resolvedVideos.length === 0) { showEmptyState( "We couldn't load your watch history. Please try again later." ); } } finally { state.isLoading = false; debugLog("loadNextBatch finished", { initial, hasMore: state.hasMore, resolvedCount: state.resolvedVideos.length, }); } }; const attachObservers = () => { const { sentinel, scrollContainer } = getElements(); if (!sentinel || !state.hasMore) { debugLog("skipping observer attachment", { hasSentinel: Boolean(sentinel), hasMore: state.hasMore, }); return; } if ("IntersectionObserver" in window) { debugLog("attaching intersection observer", { useScrollContainer: Boolean(scrollContainer), }); state.observer = new IntersectionObserver( (entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { debugLog("sentinel intersected"); loadNextBatch(); } }); }, { root: scrollContainer || null, rootMargin: scrollContainer ? "200px 0px" : "300px 0px", } ); state.observer.observe(sentinel); return; } const target = scrollContainer || window; const handler = () => { const rect = sentinel.getBoundingClientRect(); const threshold = scrollContainer ? scrollContainer.clientHeight : window.innerHeight; if (rect.top <= threshold + 200) { debugLog("scroll threshold met"); loadNextBatch(); } }; target.addEventListener("scroll", handler, { passive: true }); state.scrollListener = { target, handler }; handler(); debugLog("attached scroll listener", { useScrollContainer: Boolean(scrollContainer), }); }; scheduleAuthRefresh = (actorHint) => { const normalizedActor = normalizeActorHint(actorHint); const perform = async () => { const nextActor = normalizedActor || null; try { const { view } = getElements(); if (!view) { state.actor = nextActor; state.snapshotFingerprint = null; state.initialized = false; state.resolvedVideos = []; state.hasMore = true; state.isLoading = false; cleanupObservers(); return; } await runInitialLoad({ actor: nextActor }); } catch (error) { console.error( "[historyView] Failed to refresh watch history after auth change:", error ); } }; const pendingRefresh = authRefreshPromise !== null ? authRefreshPromise.then(perform, perform) : perform(); let currentPromise; const finalize = () => { if (authRefreshPromise === currentPromise) { authRefreshPromise = null; } }; currentPromise = pendingRefresh.finally(finalize); authRefreshPromise = currentPromise; return currentPromise; }; 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", { actor: state.actor }); 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(); } if (state.actor) { nostrClient.resetWatchHistoryProgress(state.actor); } 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; } let sharedHydration = null; try { sharedHydration = hydrateFromSharedUiCache(); } catch (error) { debugLog("failed to hydrate shared watch history state", error); } if (sharedHydration?.hydrated) { state.hasMore = true; } try { await accessControl.ensureReady(); } catch (error) { console.warn( "[historyView] Access control not ready, proceeding with cached lists:", error ); } await loadNextBatch({ initial: true }); if (state.hasMore) { attachObservers(); } state.initialized = true; debugLog("initial load complete", { hasMore: state.hasMore, resolvedCount: state.resolvedVideos.length, actor: state.actor, }); }; attachAuthChangeListener(); return { async init() { debugLog("init called"); await runInitialLoad(); }, async ensureInitialLoad() { const { view } = getElements(); if (!view) { debugLog("ensureInitialLoad aborted: view not found"); return; } if (!state.initialized) { debugLog("ensureInitialLoad rerunning initial load (not initialized)"); await runInitialLoad(); 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"); state.hasMore = true; } debugLog("ensureInitialLoad requesting new batch", { hasMore: state.hasMore, }); await loadNextBatch({ initial: true }); if (state.hasMore && !state.observer && !state.scrollListener) { debugLog("ensureInitialLoad reattaching observers"); attachObservers(); } } else { debugLog("ensureInitialLoad no action", { resolvedCount: state.resolvedVideos.length, isLoading: state.isLoading, hasMore: state.hasMore, }); } }, async loadMore() { debugLog("loadMore called"); await loadNextBatch(); }, resume() { cleanupObservers(); if (state.hasMore) { debugLog("resume attaching observers"); attachObservers(); } }, pause() { debugLog("pause called"); cleanupObservers(); }, destroy() { debugLog("destroy called"); cleanupObservers(); cleanupGridHandler(); detachAuthChangeListener(); state.initialized = false; state.resolvedVideos = []; state.hasMore = true; state.isLoading = false; state.actor = null; state.snapshotFingerprint = null; resetUiState(); setLoadingVisible(false); }, async refresh(actorHint = null) { await scheduleAuthRefresh(actorHint); }, render() { renderResolvedVideos(); }, getState() { return { ...state, resolvedVideos: state.resolvedVideos.slice(), }; }, }; } export const watchHistoryRenderer = createWatchHistoryRenderer(); export async function initHistoryView() { await watchHistoryRenderer.init(); } if (typeof window !== "undefined") { if (!window.bitvid) { window.bitvid = {}; } window.bitvid.initHistoryView = initHistoryView; window.bitvid.watchHistoryRenderer = watchHistoryRenderer; }