diff --git a/README.md b/README.md index 4e8dee7c..efba1995 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,13 @@ latest styles. - **Magnet helpers**: - Use `safeDecodeMagnet()` and `normalizeAndAugmentMagnet()` from `js/magnetUtils.js` to preserve hashes and add `ws=` / `xs=` hints safely. +### Relay compatibility + +Bitvid now requests per-video discussion counts using the NIP-45 `COUNT` frame. The bundled client opens each relay via +`this.pool.ensureRelay(url)` and streams a raw `COUNT` message, so your relay stack must understand that verb (nostr-tools ≥ 1.8 +or any relay advertising NIP-45 support). Relays that do not implement `COUNT` are skipped gracefully—the UI keeps the count +placeholder at “—” and development builds log a warning—so mixed deployments remain usable while you phase in compatible relays. + ### Adding Features 1. **Fork the repository** and create a new branch for your feature. diff --git a/assets/svg/history-icon.svg b/assets/svg/history-icon.svg new file mode 100644 index 00000000..c224ce68 --- /dev/null +++ b/assets/svg/history-icon.svg @@ -0,0 +1,22 @@ + + + + + + + + + diff --git a/components/profile-modal.html b/components/profile-modal.html index b473123d..86b844ba 100644 --- a/components/profile-modal.html +++ b/components/profile-modal.html @@ -88,6 +88,15 @@ > Blocked + + + +
+ + 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; + + 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) { + if (nextMessage === WATCH_HISTORY_LOADING_STATUS) { + statusEl.innerHTML = + '' + + WATCH_HISTORY_LOADING_STATUS + + ""; + } else { + statusEl.textContent = nextMessage; + } + 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 }); + }; + + 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()); + applyAccessFilters(); + }; + + 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(); + 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 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, + }); + } + return; + } + + mergeResolvedVideos(videos); + renderResolvedVideos(); + setLoadingVisible(false); + 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), + }); + }; + + 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; + } + + 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, + }); + }; + + 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(); + state.initialized = false; + state.resolvedVideos = []; + state.hasMore = true; + state.isLoading = false; + state.actor = null; + state.snapshotFingerprint = null; + resetUiState(); + setLoadingVisible(false); + }, + render() { + renderResolvedVideos(); + }, + getState() { + return { + ...state, + resolvedVideos: state.resolvedVideos.slice(), + }; + }, + }; +} + +export const watchHistoryRenderer = createWatchHistoryRenderer(); + +export async function initHistoryView() { + await watchHistoryRenderer.init(); +} + +if (!window.bitvid) { + window.bitvid = {}; +} +window.bitvid.initHistoryView = initHistoryView; diff --git a/js/index.js b/js/index.js index 6ad7302f..b6ac042c 100644 --- a/js/index.js +++ b/js/index.js @@ -378,7 +378,6 @@ function handleHashChange() { if (typeof initFn === "function") { initFn(); } - recordView("most-recent-videos"); }); }); return; diff --git a/js/nostr.js b/js/nostr.js index 126658c0..a12fc112 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -1,6 +1,15 @@ // js/nostr.js -import { isDevMode } from "./config.js"; +import { + isDevMode, + WATCH_HISTORY_KIND, + WATCH_HISTORY_LIST_IDENTIFIER, + WATCH_HISTORY_MAX_ITEMS, + WATCH_HISTORY_BATCH_RESOLVE, + WATCH_HISTORY_PAYLOAD_MAX_BYTES, + WATCH_HISTORY_FETCH_EVENT_LIMIT, + WATCH_HISTORY_CACHE_TTL_MS, +} from "./config.js"; import { ACCEPT_LEGACY_V1 } from "./constants.js"; import { accessControl } from "./accessControl.js"; // 🔧 merged conflicting changes from codex/update-video-publishing-and-parsing-logic vs unstable @@ -22,6 +31,10 @@ const EVENTS_CACHE_STORAGE_KEY = "bitvid:eventsCache:v1"; const LEGACY_EVENTS_STORAGE_KEY = "bitvidEvents"; const EVENTS_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes const NIP07_LOGIN_TIMEOUT_MS = 15_000; // 15 seconds +const NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE = + "Timed out waiting for the NIP-07 extension. Check the extension prompt and try again."; +const SESSION_ACTOR_STORAGE_KEY = "bitvid:sessionActor:v1"; +const WATCH_HISTORY_CACHE_STORAGE_KEY = "bitvid:watchHistoryCache:v1"; // To limit error spam let errorLogCount = 0; @@ -41,6 +54,505 @@ function logErrorOnce(message, eventContent = null) { } } +function withNip07Timeout(operation) { + let timeoutId; + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + reject(new Error(NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE)); + }, NIP07_LOGIN_TIMEOUT_MS); + }); + + let operationResult; + try { + operationResult = operation(); + } catch (err) { + if (timeoutId) { + clearTimeout(timeoutId); + } + throw err; + } + + const operationPromise = Promise.resolve(operationResult); + + return Promise.race([operationPromise, timeoutPromise]).finally(() => { + if (timeoutId) { + clearTimeout(timeoutId); + } + }); +} + +function withRequestTimeout(promise, timeoutMs, onTimeout, message = "Request timed out") { + const resolvedTimeout = Number(timeoutMs); + const effectiveTimeout = + Number.isFinite(resolvedTimeout) && resolvedTimeout > 0 + ? Math.floor(resolvedTimeout) + : 4000; + + let timeoutId = null; + let settled = false; + + return new Promise((resolve, reject) => { + timeoutId = setTimeout(() => { + if (settled) { + return; + } + settled = true; + if (typeof onTimeout === "function") { + try { + onTimeout(); + } catch (cleanupError) { + if (isDevMode) { + console.warn("[nostr] COUNT timeout cleanup failed:", cleanupError); + } + } + } + reject(new Error(message)); + }, effectiveTimeout); + + Promise.resolve(promise) + .then((value) => { + if (settled) { + return; + } + settled = true; + if (timeoutId) { + clearTimeout(timeoutId); + } + resolve(value); + }) + .catch((error) => { + if (settled) { + return; + } + settled = true; + if (timeoutId) { + clearTimeout(timeoutId); + } + reject(error); + }); + }); +} + +function pointerKey(pointer) { + if (!pointer || typeof pointer !== "object") { + return ""; + } + + const type = pointer.type === "a" ? "a" : "e"; + const value = + typeof pointer.value === "string" ? pointer.value.trim().toLowerCase() : ""; + + if (!value) { + return ""; + } + + return `${type}:${value}`; +} + +function clonePointerItem(pointer) { + if (!pointer || typeof pointer !== "object") { + return null; + } + + const type = pointer.type === "a" ? "a" : "e"; + const value = typeof pointer.value === "string" ? pointer.value.trim() : ""; + if (!value) { + return null; + } + + const relay = + typeof pointer.relay === "string" && pointer.relay.trim() + ? pointer.relay.trim() + : null; + const watchedAtRaw = Number.isFinite(pointer.watchedAt) + ? pointer.watchedAt + : null; + const watchedAt = + watchedAtRaw !== null ? Math.max(0, Math.floor(watchedAtRaw)) : null; + + const cloned = { type, value, relay }; + if (watchedAt !== null) { + cloned.watchedAt = watchedAt; + } + + return cloned; +} + +function normalizePointerTag(tag) { + if (!Array.isArray(tag) || tag.length < 2) { + return null; + } + + const type = tag[0] === "a" ? "a" : tag[0] === "e" ? "e" : ""; + if (!type) { + return null; + } + + const value = typeof tag[1] === "string" ? tag[1].trim() : ""; + if (!value) { + return null; + } + + const relay = + tag.length > 2 && typeof tag[2] === "string" && tag[2].trim() + ? tag[2].trim() + : null; + + return { type, value, relay }; +} + +function normalizePointerInput(pointer) { + if (!pointer) { + return null; + } + + if (Array.isArray(pointer)) { + return normalizePointerTag(pointer); + } + + if (typeof pointer === "object") { + if (typeof pointer.type === "string" && typeof pointer.value === "string") { + return clonePointerItem(pointer); + } + + if (Array.isArray(pointer.tag)) { + return normalizePointerTag(pointer.tag); + } + } + + if (typeof pointer !== "string") { + return null; + } + + const trimmed = pointer.trim(); + if (!trimmed) { + return null; + } + + if (trimmed.startsWith("naddr") || trimmed.startsWith("nevent")) { + try { + const decoder = window?.NostrTools?.nip19?.decode; + if (typeof decoder === "function") { + const decoded = decoder(trimmed); + if (decoded?.type === "naddr" && decoded.data) { + const { kind, pubkey, identifier, relays } = decoded.data; + if ( + typeof kind === "number" && + typeof pubkey === "string" && + typeof identifier === "string" + ) { + const relay = + Array.isArray(relays) && relays.length && typeof relays[0] === "string" + ? relays[0] + : null; + return { + type: "a", + value: `${kind}:${pubkey}:${identifier}`, + relay, + }; + } + } + if (decoded?.type === "nevent" && decoded.data) { + const { id, relays } = decoded.data; + if (typeof id === "string" && id.trim()) { + const relay = + Array.isArray(relays) && relays.length && typeof relays[0] === "string" + ? relays[0] + : null; + return { + type: "e", + value: id.trim(), + relay, + }; + } + } + } + } catch (err) { + if (isDevMode) { + console.warn(`[nostr] Failed to decode pointer ${trimmed}:`, err); + } + } + } + + const type = trimmed.includes(":") ? "a" : "e"; + return { type, value: trimmed, relay: null }; +} + +function extractPointerItemsFromEvent(event) { + if (!event || typeof event !== "object") { + return []; + } + + const tags = Array.isArray(event.tags) ? event.tags : []; + const seen = new Set(); + const items = []; + + for (const tag of tags) { + const pointer = normalizePointerTag(tag); + if (!pointer) { + continue; + } + + const key = pointerKey(pointer); + if (!key || seen.has(key)) { + continue; + } + + seen.add(key); + items.push(pointer); + } + + return items; +} + +function normalizePointersFromPayload(payload) { + const normalized = []; + const seen = new Set(); + + if (!payload || typeof payload !== "object") { + return normalized; + } + + const source = Array.isArray(payload.items) ? payload.items : []; + for (const candidate of source) { + const pointer = normalizePointerInput(candidate); + if (!pointer) { + continue; + } + const key = pointerKey(pointer); + if (!key || seen.has(key)) { + continue; + } + seen.add(key); + normalized.push(pointer); + } + + return normalized; +} + +function parseWatchHistoryPayload(plaintext) { + if (typeof plaintext !== "string") { + return { + version: 0, + items: [], + snapshot: "", + chunkIndex: 0, + totalChunks: 1, + }; + } + + try { + const parsed = JSON.parse(plaintext); + if (!parsed || typeof parsed !== "object") { + return { + version: 0, + items: [], + snapshot: "", + chunkIndex: 0, + totalChunks: 1, + }; + } + + const version = Number.isFinite(parsed.version) ? parsed.version : 0; + const items = normalizePointersFromPayload(parsed); + const snapshot = + typeof parsed.snapshot === "string" ? parsed.snapshot : ""; + const chunkIndex = Number.isFinite(parsed.chunkIndex) + ? Math.max(0, Math.floor(parsed.chunkIndex)) + : 0; + const totalChunks = Number.isFinite(parsed.totalChunks) + ? Math.max(1, Math.floor(parsed.totalChunks)) + : 1; + return { version, items, snapshot, chunkIndex, totalChunks }; + } catch (error) { + if (isDevMode) { + console.warn("[nostr] Failed to parse watch history payload:", error); + } + return { + version: 0, + items: [], + snapshot: "", + chunkIndex: 0, + totalChunks: 1, + }; + } +} + +function chunkWatchHistoryPayloadItems(payloadItems, snapshotId, maxBytes) { + const items = Array.isArray(payloadItems) ? payloadItems : []; + const safeMax = Math.max(128, Math.floor(maxBytes || 0)); + const measurementLimit = Math.max(64, safeMax - 32); + const normalizedSnapshot = + typeof snapshotId === "string" ? snapshotId : ""; + + const chunks = []; + const skipped = []; + let current = []; + + const estimateLength = (chunkItems, chunkIndex, totalGuess) => + JSON.stringify({ + version: 2, + snapshot: normalizedSnapshot, + chunkIndex, + totalChunks: totalGuess, + items: chunkItems, + }).length; + + for (const item of items) { + if (!item || typeof item !== "object") { + continue; + } + + if (!current.length) { + const candidate = [item]; + const size = estimateLength(candidate, chunks.length, chunks.length + 1); + if (size <= measurementLimit) { + current = candidate; + } else { + skipped.push(item); + } + continue; + } + + const chunkIndex = chunks.length; + const candidate = [...current, item]; + const size = estimateLength(candidate, chunkIndex, chunkIndex + 1); + if (size <= measurementLimit) { + current = candidate; + continue; + } + + chunks.push(current); + current = []; + + const nextIndex = chunks.length; + const soloSize = estimateLength([item], nextIndex, nextIndex + 1); + if (soloSize <= measurementLimit) { + current = [item]; + } else { + skipped.push(item); + } + } + + if (current.length || chunks.length === 0) { + chunks.push(current); + } + + let needsRebalance = true; + while (needsRebalance) { + needsRebalance = false; + for (let index = 0; index < chunks.length; index++) { + let chunkItems = chunks[index]; + if (!Array.isArray(chunkItems)) { + chunks[index] = []; + chunkItems = chunks[index]; + } + + let payloadSize = JSON.stringify({ + version: 2, + snapshot: normalizedSnapshot, + chunkIndex: index, + totalChunks: chunks.length, + items: chunkItems, + }).length; + + while (chunkItems.length && payloadSize > safeMax) { + const overflow = chunkItems.pop(); + if (!overflow) { + break; + } + if (!chunks[index + 1]) { + chunks[index + 1] = []; + } + chunks[index + 1].unshift(overflow); + needsRebalance = true; + chunkItems = chunks[index]; + payloadSize = JSON.stringify({ + version: 2, + snapshot: normalizedSnapshot, + chunkIndex: index, + totalChunks: chunks.length, + items: chunkItems, + }).length; + } + } + } + + while (chunks.length > 1 && chunks[chunks.length - 1].length === 0) { + chunks.pop(); + } + + if (!chunks.length) { + chunks.push([]); + } + + return { chunks, skipped }; +} + +function eventToAddressPointer(event) { + if (!event || typeof event !== "object") { + return ""; + } + + const kind = typeof event.kind === "number" ? event.kind : null; + const pubkey = typeof event.pubkey === "string" ? event.pubkey : ""; + if (!kind || !pubkey) { + return ""; + } + + const tags = Array.isArray(event.tags) ? event.tags : []; + for (const tag of tags) { + if (Array.isArray(tag) && tag[0] === "d" && typeof tag[1] === "string") { + return `${kind}:${pubkey}:${tag[1]}`; + } + } + + return ""; +} + +function signEventWithPrivateKey(event, privateKey) { + if ( + !privateKey || + typeof privateKey !== "string" || + !window?.NostrTools?.getEventHash || + typeof window.NostrTools.signEvent !== "function" + ) { + throw new Error("Missing signing primitives"); + } + + const tags = Array.isArray(event.tags) + ? event.tags.map((tag) => (Array.isArray(tag) ? [...tag] : tag)) + : []; + + const prepared = { + kind: event.kind, + pubkey: event.pubkey, + created_at: event.created_at, + tags, + content: typeof event.content === "string" ? event.content : "", + }; + + const id = window.NostrTools.getEventHash(prepared); + const sig = window.NostrTools.signEvent(prepared, privateKey); + + return { ...prepared, id, sig }; +} + +function cloneEventForCache(event) { + if (!event || typeof event !== "object") { + return null; + } + + const cloned = { ...event }; + if (Array.isArray(event.tags)) { + cloned.tags = event.tags.map((tag) => + Array.isArray(tag) ? [...tag] : tag + ); + } + + return cloned; +} + /** * Example "encryption" that just reverses strings. * In real usage, replace with actual crypto. @@ -435,6 +947,13 @@ class NostrClient { this.activeMap = new Map(); this.hasRestoredLocalData = false; + + this.sessionActor = null; + this.watchHistoryCache = new Map(); + this.watchHistoryStorage = null; + this.watchHistoryRepublishTimers = new Map(); + this.watchHistoryCacheTtlMs = WATCH_HISTORY_CACHE_TTL_MS; + this.countRequestCounter = 0; } restoreLocalData() { @@ -536,6 +1055,2057 @@ class NostrClient { return this.allEvents.size > 0; } + /** + * Resolve the effective TTL for watch-history cache entries. + */ + getWatchHistoryCacheTtlMs() { + const candidate = this.watchHistoryCacheTtlMs; + if (Number.isFinite(candidate) && candidate >= 0) { + return candidate; + } + return WATCH_HISTORY_CACHE_TTL_MS; + } + + /** + * Load watch history cache payload from localStorage. + */ + getWatchHistoryStorage() { + const base = { version: 1, actors: {} }; + + if (!this.watchHistoryStorage) { + if (typeof localStorage === "undefined") { + this.watchHistoryStorage = base; + return this.watchHistoryStorage; + } + + let parsed = base; + try { + const raw = localStorage.getItem(WATCH_HISTORY_CACHE_STORAGE_KEY); + if (raw) { + const candidate = JSON.parse(raw); + if ( + candidate && + typeof candidate === "object" && + candidate.version === 1 && + candidate.actors && + typeof candidate.actors === "object" + ) { + parsed = { version: 1, actors: candidate.actors }; + } + } + } catch (error) { + if (isDevMode) { + console.warn("[nostr] Failed to parse watch history cache:", error); + } + } + + const actors = { ...(parsed.actors || {}) }; + this.watchHistoryStorage = { version: 1, actors }; + } + + const now = Date.now(); + const ttl = this.getWatchHistoryCacheTtlMs(); + let mutated = false; + const actors = this.watchHistoryStorage.actors || {}; + for (const actor of Object.keys(actors)) { + const info = actors[actor]; + if ( + !info || + typeof info !== "object" || + typeof info.savedAt !== "number" || + now - info.savedAt > ttl + ) { + delete actors[actor]; + mutated = true; + } + } + + if (mutated && typeof localStorage !== "undefined") { + try { + localStorage.setItem( + WATCH_HISTORY_CACHE_STORAGE_KEY, + JSON.stringify(this.watchHistoryStorage) + ); + } catch (error) { + if (isDevMode) { + console.warn( + "[nostr] Failed to persist cleaned watch history cache:", + error + ); + } + } + } + + return this.watchHistoryStorage; + } + + createWatchHistoryEntry(pointerEvent, items, savedAt, previousEntry = null) { + const normalizedItems = []; + const seen = new Set(); + + if (Array.isArray(items)) { + for (const raw of items) { + const pointer = normalizePointerInput(raw); + if (!pointer) { + continue; + } + const key = pointerKey(pointer); + if (!key || seen.has(key)) { + continue; + } + seen.add(key); + const watchedAtRaw = Number.isFinite(pointer.watchedAt) + ? pointer.watchedAt + : null; + const watchedAt = + watchedAtRaw !== null ? Math.max(0, Math.floor(watchedAtRaw)) : 0; + normalizedItems.push({ ...pointer, watchedAt }); + } + } + + const entry = { + pointerEvent: pointerEvent ? cloneEventForCache(pointerEvent) : null, + items: normalizedItems, + savedAt: typeof savedAt === "number" ? savedAt : Date.now(), + resolved: previousEntry?.resolved + ? new Map(previousEntry.resolved) + : new Map(), + resolvedVideos: previousEntry?.resolvedVideos + ? new Map(previousEntry.resolvedVideos) + : new Map(), + resolving: previousEntry?.resolving + ? new Set(previousEntry.resolving) + : new Set(), + delivered: previousEntry?.delivered instanceof Set + ? new Set(previousEntry.delivered) + : new Set(), + }; + + const validKeys = new Set(entry.items.map((item) => pointerKey(item))); + for (const key of entry.resolved.keys()) { + if (!validKeys.has(key)) { + entry.resolved.delete(key); + } + } + for (const key of entry.resolvedVideos.keys()) { + if (!validKeys.has(key)) { + entry.resolvedVideos.delete(key); + } + } + for (const key of [...entry.resolving]) { + if (!validKeys.has(key)) { + entry.resolving.delete(key); + } + } + for (const key of [...entry.delivered]) { + if (!validKeys.has(key)) { + entry.delivered.delete(key); + } + } + + const previousChunkPointers = previousEntry?.chunkPointers; + const headPointer = previousChunkPointers?.head + ? clonePointerItem(previousChunkPointers.head) + : null; + const chunkPointerList = Array.isArray(previousChunkPointers?.chunks) + ? previousChunkPointers.chunks + .map((pointer) => clonePointerItem(pointer)) + .filter(Boolean) + : []; + + entry.chunkPointers = { + head: headPointer, + chunks: chunkPointerList, + }; + + entry.chunkEvents = new Map(); + if (previousEntry?.chunkEvents instanceof Map) { + for (const [key, value] of previousEntry.chunkEvents.entries()) { + const clonedEvent = cloneEventForCache(value); + if (clonedEvent) { + entry.chunkEvents.set(key, clonedEvent); + } + } + } + + return entry; + } + + persistWatchHistoryEntry(actor, entry) { + if (typeof localStorage === "undefined" || !actor) { + return; + } + + const storage = this.getWatchHistoryStorage(); + storage.actors[actor] = { + pointerEvent: entry.pointerEvent ? cloneEventForCache(entry.pointerEvent) : null, + items: entry.items.map((item) => ({ + type: item.type, + value: item.value, + relay: item.relay || null, + watchedAt: Number.isFinite(item.watchedAt) + ? Math.max(0, Math.floor(item.watchedAt)) + : 0, + })), + savedAt: entry.savedAt, + }; + + try { + localStorage.setItem( + WATCH_HISTORY_CACHE_STORAGE_KEY, + JSON.stringify(storage) + ); + } catch (error) { + if (isDevMode) { + console.warn("[nostr] Failed to persist watch history cache:", error); + } + } + } + + 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}`; + } + + resetWatchHistoryProgress(pubkeyOrSession = null) { + 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; + } + + if (!(entry.delivered instanceof Set)) { + entry.delivered = new Set(); + } else { + entry.delivered.clear(); + } + + if (entry.resolving instanceof Set) { + entry.resolving.clear(); + } + } + + async encryptWatchHistoryPayload(actorPubkey, payload) { + const normalizedActor = + typeof actorPubkey === "string" && actorPubkey.trim() + ? actorPubkey.trim().toLowerCase() + : ""; + if (!normalizedActor) { + return { ok: false, error: "invalid-actor" }; + } + + let normalizedPayload = payload; + if (!normalizedPayload || typeof normalizedPayload !== "object") { + normalizedPayload = {}; + } + + const items = Array.isArray(normalizedPayload.items) + ? normalizedPayload.items + : []; + + const version = Number.isFinite(normalizedPayload.version) + ? normalizedPayload.version + : 1; + + const prepared = { + version, + items, + }; + + if (version >= 2) { + prepared.snapshot = + typeof normalizedPayload.snapshot === "string" + ? normalizedPayload.snapshot + : ""; + prepared.chunkIndex = Number.isFinite(normalizedPayload.chunkIndex) + ? Math.max(0, Math.floor(normalizedPayload.chunkIndex)) + : 0; + prepared.totalChunks = Number.isFinite(normalizedPayload.totalChunks) + ? Math.max(1, Math.floor(normalizedPayload.totalChunks)) + : 1; + } + + const plaintext = JSON.stringify(prepared); + + let ciphertext = ""; + const normalizedLogged = + typeof this.pubkey === "string" ? this.pubkey.toLowerCase() : ""; + + if ( + normalizedActor && + normalizedActor === normalizedLogged && + window?.nostr?.nip04?.encrypt + ) { + try { + ciphertext = await window.nostr.nip04.encrypt( + normalizedActor, + plaintext + ); + } catch (error) { + if (isDevMode) { + console.warn( + "[nostr] Failed to encrypt watch history with extension:", + error + ); + } + } + } + + if (!ciphertext) { + try { + if (!this.sessionActor || this.sessionActor.pubkey !== normalizedActor) { + await this.ensureSessionActor(true); + } + } catch (error) { + if (isDevMode) { + console.warn( + "[nostr] Failed to ensure session actor while encrypting watch history:", + error + ); + } + } + + const privateKey = this.sessionActor?.privateKey; + if (privateKey && window?.NostrTools?.nip04?.encrypt) { + try { + ciphertext = await window.NostrTools.nip04.encrypt( + privateKey, + normalizedActor, + plaintext + ); + } catch (error) { + if (isDevMode) { + console.warn( + "[nostr] Failed to encrypt watch history with session key:", + error + ); + } + } + } + } + + if (!ciphertext) { + return { ok: false, error: "encryption-unavailable" }; + } + + return { ok: true, ciphertext }; + } + + async decryptWatchHistoryEvent(pointerEvent, actorPubkey) { + const fallbackItems = extractPointerItemsFromEvent(pointerEvent); + const fallbackPayload = { + version: 0, + items: fallbackItems, + snapshot: "", + chunkIndex: 0, + totalChunks: 1, + }; + + const normalizedActor = + typeof actorPubkey === "string" && actorPubkey.trim() + ? actorPubkey.trim().toLowerCase() + : ""; + if (!normalizedActor) { + return fallbackPayload; + } + + const ciphertext = + pointerEvent && typeof pointerEvent.content === "string" + ? pointerEvent.content.trim() + : ""; + + if (!ciphertext) { + return fallbackPayload; + } + + const normalizedLogged = + typeof this.pubkey === "string" ? this.pubkey.toLowerCase() : ""; + + if ( + normalizedActor && + normalizedActor === normalizedLogged && + window?.nostr?.nip04?.decrypt + ) { + try { + const plaintext = await window.nostr.nip04.decrypt( + normalizedActor, + ciphertext + ); + const parsed = parseWatchHistoryPayload(plaintext); + if (parsed.items.length || fallbackItems.length === 0) { + return parsed; + } + return { ...parsed, items: fallbackItems }; + } catch (error) { + if (isDevMode) { + console.warn( + "[nostr] Failed to decrypt watch history with extension:", + error + ); + } + } + } + + try { + if (!this.sessionActor || this.sessionActor.pubkey !== normalizedActor) { + await this.ensureSessionActor(true); + } + } catch (error) { + if (isDevMode) { + console.warn( + "[nostr] Failed to ensure session actor while decrypting watch history:", + error + ); + } + } + + const privateKey = this.sessionActor?.privateKey; + if (privateKey && window?.NostrTools?.nip04?.decrypt) { + try { + const plaintext = await window.NostrTools.nip04.decrypt( + privateKey, + normalizedActor, + ciphertext + ); + const parsed = parseWatchHistoryPayload(plaintext); + if (parsed.items.length || fallbackItems.length === 0) { + return parsed; + } + return { ...parsed, items: fallbackItems }; + } catch (error) { + if (isDevMode) { + console.warn( + "[nostr] Failed to decrypt watch history with session key:", + error + ); + } + } + } + + return fallbackPayload; + } + + cancelWatchHistoryRepublish(actor) { + const key = + typeof actor === "string" && actor.trim() + ? actor.trim().toLowerCase() + : ""; + if (!key) { + return; + } + + const timer = this.watchHistoryRepublishTimers.get(key); + if (timer) { + clearTimeout(timer); + this.watchHistoryRepublishTimers.delete(key); + } + } + + scheduleWatchHistoryRepublish(actor, items) { + if (!this.pool) { + return; + } + + const key = + typeof actor === "string" && actor.trim() + ? actor.trim().toLowerCase() + : ""; + if (!key || this.watchHistoryRepublishTimers.has(key)) { + return; + } + + const clonedItems = Array.isArray(items) + ? items + .map((item) => clonePointerItem(item)) + .filter((candidate) => !!candidate) + : []; + + if (!clonedItems.length) { + return; + } + + const timer = setTimeout(() => { + this.watchHistoryRepublishTimers.delete(key); + this.publishWatchHistorySnapshot(actor, clonedItems, this.watchHistoryCache.get(actor) || null, { + allowRetry: false, + }).catch((error) => { + if (isDevMode) { + console.warn( + `[nostr] Failed to republish watch history list for ${key}:`, + error + ); + } + }); + }, 2000); + + this.watchHistoryRepublishTimers.set(key, timer); + } + + async publishWatchHistorySnapshot( + actorPubkey, + candidateItems, + existingEntry = null, + options = {} + ) { + if (!this.pool) { + return { ok: false, error: "nostr-uninitialized" }; + } + + const normalizedActor = + typeof actorPubkey === "string" && actorPubkey.trim() + ? actorPubkey.trim() + : ""; + if (!normalizedActor) { + return { ok: false, error: "missing-actor" }; + } + + const allowRetry = options?.allowRetry !== false; + + const dedupe = new Map(); + const normalizedItems = []; + const pushItem = (item) => { + const pointer = normalizePointerInput(item); + if (!pointer) { + return; + } + const key = pointerKey(pointer); + if (!key || dedupe.has(key)) { + return; + } + dedupe.set(key, true); + normalizedItems.push(pointer); + }; + + if (Array.isArray(candidateItems)) { + candidateItems.forEach((item) => pushItem(item)); + } else if (candidateItems) { + pushItem(candidateItems); + } + + const trimmedItems = + normalizedItems.length > WATCH_HISTORY_MAX_ITEMS + ? normalizedItems.slice(0, WATCH_HISTORY_MAX_ITEMS) + : normalizedItems; + + const payloadItems = trimmedItems.map((item) => ({ + type: item.type, + value: item.value, + relay: item.relay || null, + watchedAt: Number.isFinite(item.watchedAt) + ? Math.max(0, Math.floor(item.watchedAt)) + : 0, + })); + + const maxBytesCandidate = Number.isFinite(this.watchHistoryPayloadMaxBytes) + ? this.watchHistoryPayloadMaxBytes + : WATCH_HISTORY_PAYLOAD_MAX_BYTES; + const maxPayloadBytes = Math.max(64, Math.floor(maxBytesCandidate)); + const snapshotId = `${Math.floor(Date.now() / 1000)}-${Math.random() + .toString(36) + .slice(2, 10)}`; + + const chunkInfo = chunkWatchHistoryPayloadItems( + payloadItems, + snapshotId, + maxPayloadBytes + ); + const chunkItemsList = chunkInfo.chunks || []; + const skippedKeys = new Set( + (chunkInfo.skipped || []) + .map((item) => pointerKey(item)) + .filter((key) => !!key) + ); + + const persistedItems = trimmedItems.filter((item) => { + const key = pointerKey(item); + return key && !skippedKeys.has(key); + }); + + if (chunkInfo.skipped?.length && isDevMode) { + console.warn( + `[nostr] Skipped ${chunkInfo.skipped.length} oversized watch history entries while chunking.` + ); + } + + const relays = + Array.isArray(this.relays) && this.relays.length ? this.relays : RELAY_URLS; + + const normalizedLogged = + typeof this.pubkey === "string" ? this.pubkey.toLowerCase() : ""; + const normalizedActorLower = normalizedActor.toLowerCase(); + const useExtension = + normalizedActorLower === normalizedLogged && + window?.nostr && + typeof window.nostr.signEvent === "function"; + + if (!useExtension) { + try { + if (!this.sessionActor || this.sessionActor.pubkey !== normalizedActor) { + await this.ensureSessionActor(true); + } + if (!this.sessionActor || this.sessionActor.pubkey !== normalizedActor) { + throw new Error("session-actor-mismatch"); + } + } catch (error) { + console.warn( + "[nostr] Failed to prepare signing key for watch history chunks:", + error + ); + return { ok: false, error: "signing-failed", details: error }; + } + } + + const totalChunks = chunkItemsList.length || 1; + const chunkIdentifiers = chunkItemsList.map((_, index) => + index === 0 + ? WATCH_HISTORY_LIST_IDENTIFIER + : `watch-history:${snapshotId}:${index}` + ); + const chunkAddresses = chunkIdentifiers.map( + (identifier) => `${WATCH_HISTORY_KIND}:${normalizedActor}:${identifier}` + ); + + const nowSeconds = Math.floor(Date.now() / 1000); + let lastCreatedAt = 0; + + const pointerCandidates = []; + if (existingEntry?.pointerEvent) { + pointerCandidates.push(existingEntry.pointerEvent); + } + + const cachedPointerEvent = this.watchHistoryCache.get(normalizedActor)?.pointerEvent; + if (cachedPointerEvent) { + pointerCandidates.push(cachedPointerEvent); + } + + for (const candidate of pointerCandidates) { + if (!candidate || typeof candidate !== "object") { + continue; + } + const createdAt = Number.isFinite(candidate.created_at) + ? Math.floor(candidate.created_at) + : null; + if (createdAt !== null && createdAt > lastCreatedAt) { + lastCreatedAt = createdAt; + } + } + + const baseTimestamp = Math.max(nowSeconds, lastCreatedAt + totalChunks); + const chunkResults = []; + let overallSuccess = true; + + for (let index = 0; index < totalChunks; index++) { + const chunkItems = Array.isArray(chunkItemsList[index]) + ? chunkItemsList[index] + : []; + + const payload = { + version: 2, + snapshot: snapshotId, + chunkIndex: index, + totalChunks, + items: chunkItems, + }; + + const encryptionResult = await this.encryptWatchHistoryPayload( + normalizedActor, + payload + ); + if (!encryptionResult.ok) { + if (isDevMode) { + console.warn( + "[nostr] Unable to encrypt watch history chunk:", + encryptionResult.error + ); + } + return encryptionResult; + } + + const pointerTags = chunkItems.map((item) => { + const tag = [item.type, item.value]; + if (item.relay) { + tag.push(item.relay); + } + return tag; + }); + + const chunkIdentifier = chunkIdentifiers[index] || WATCH_HISTORY_LIST_IDENTIFIER; + + const tags = [ + ["d", chunkIdentifier], + ["encrypted", "nip04"], + ["snapshot", snapshotId], + ["chunk", String(index), String(totalChunks)], + ...pointerTags, + ]; + if (index === 0) { + tags.splice(2, 0, ["head", "1"]); + const pointerTagsForChunks = chunkAddresses + .map((address) => + typeof address === "string" && address + ? ["a", address] + : null + ) + .filter(Boolean); + tags.push(...pointerTagsForChunks); + } + + // Ensure created_at stays monotonic so chunk 0 always outranks prior snapshots + // even when multiple publishes land within the same wall-clock second. + const event = { + kind: WATCH_HISTORY_KIND, + pubkey: normalizedActor, + created_at: baseTimestamp + (totalChunks - index - 1), + tags, + content: encryptionResult.ciphertext, + }; + + let signedEvent; + if (useExtension) { + try { + signedEvent = await window.nostr.signEvent(event); + } catch (error) { + console.warn( + "[nostr] Failed to sign watch history chunk with extension:", + error + ); + return { ok: false, error: "signing-failed", details: error }; + } + } else { + try { + signedEvent = signEventWithPrivateKey( + event, + this.sessionActor.privateKey + ); + } catch (error) { + console.warn( + "[nostr] Failed to sign watch history chunk with session key:", + error + ); + return { ok: false, error: "signing-failed", details: error }; + } + } + + const publishResults = await Promise.all( + relays.map((url) => publishEventToRelay(this.pool, url, signedEvent)) + ); + + const chunkSuccess = publishResults.some((result) => result.success); + if (!chunkSuccess) { + overallSuccess = false; + console.warn( + "[nostr] Failed to publish watch history chunk:", + publishResults + ); + } + + chunkResults.push({ + chunkIndex: index, + event: signedEvent, + results: publishResults, + success: chunkSuccess, + }); + } + + if (overallSuccess) { + this.cancelWatchHistoryRepublish(normalizedActor); + } else if (allowRetry && persistedItems.length) { + this.scheduleWatchHistoryRepublish(normalizedActor, persistedItems); + } + + const baselineEntry = + existingEntry || this.watchHistoryCache.get(normalizedActor) || null; + + const headEvent = chunkResults[0]?.event || null; + const newEntry = this.createWatchHistoryEntry( + headEvent, + persistedItems, + Date.now(), + baselineEntry + ); + + this.watchHistoryCache.set(normalizedActor, newEntry); + this.persistWatchHistoryEntry(normalizedActor, newEntry); + + return { + ok: overallSuccess, + event: headEvent, + events: chunkResults.map((chunk) => chunk.event), + chunks: chunkResults, + results: chunkResults.map((chunk) => chunk.results), + snapshot: snapshotId, + items: newEntry.items + .map((item) => clonePointerItem(item)) + .filter(Boolean), + }; + } + + async ensureSessionActor(forceSession = false) { + const loggedInPubkey = + typeof this.pubkey === "string" && this.pubkey.trim() + ? this.pubkey.trim() + : ""; + + if (!forceSession && loggedInPubkey) { + return loggedInPubkey; + } + + if ( + this.sessionActor && + typeof this.sessionActor.pubkey === "string" && + this.sessionActor.pubkey + ) { + return this.sessionActor.pubkey; + } + + if (typeof localStorage !== "undefined") { + try { + const raw = localStorage.getItem(SESSION_ACTOR_STORAGE_KEY); + if (raw) { + const parsed = JSON.parse(raw); + if ( + parsed && + typeof parsed === "object" && + typeof parsed.pubkey === "string" && + parsed.pubkey && + typeof parsed.privkey === "string" && + parsed.privkey + ) { + this.sessionActor = { + pubkey: parsed.pubkey, + privateKey: parsed.privkey, + }; + return this.sessionActor.pubkey; + } + } + } catch (error) { + if (isDevMode) { + console.warn("[nostr] Failed to restore session actor:", error); + } + } + } + + const nostrTools = window?.NostrTools || {}; + const keyGenerator = + typeof nostrTools.generatePrivateKey === "function" + ? nostrTools.generatePrivateKey + : typeof nostrTools.generateSecretKey === "function" + ? nostrTools.generateSecretKey + : null; + const pubkeyDeriver = + typeof nostrTools.getPublicKey === "function" + ? nostrTools.getPublicKey + : null; + + if (!keyGenerator || !pubkeyDeriver) { + if (isDevMode) { + console.warn( + "[nostr] Unable to generate session actor: missing NostrTools helpers." + ); + } + return ""; + } + + let privateKey = keyGenerator(); + if (privateKey && typeof privateKey !== "string") { + const hasLength = + typeof privateKey === "object" && + privateKey !== null && + typeof privateKey.length === "number"; + if (nostrTools.utils?.bytesToHex && hasLength) { + try { + privateKey = nostrTools.utils.bytesToHex(privateKey); + } catch (error) { + if (isDevMode) { + console.warn( + "[nostr] Failed to normalize generated private key:", + error + ); + } + privateKey = ""; + } + } else { + privateKey = ""; + } + } + + if (typeof privateKey !== "string" || !privateKey) { + if (isDevMode) { + console.warn( + "[nostr] Unable to generate session actor: invalid private key output." + ); + } + return ""; + } + + let pubkey = pubkeyDeriver(privateKey); + if (pubkey && typeof pubkey !== "string") { + const hasLength = + typeof pubkey === "object" && + pubkey !== null && + typeof pubkey.length === "number"; + if (nostrTools.utils?.bytesToHex && hasLength) { + try { + pubkey = nostrTools.utils.bytesToHex(pubkey); + } catch (error) { + if (isDevMode) { + console.warn( + "[nostr] Failed to normalize generated public key:", + error + ); + } + pubkey = ""; + } + } else { + pubkey = ""; + } + } + + if (typeof pubkey !== "string" || !pubkey) { + if (isDevMode) { + console.warn( + "[nostr] Unable to generate session actor: invalid public key output." + ); + } + return ""; + } + + this.sessionActor = { pubkey, privateKey }; + + if (typeof localStorage !== "undefined") { + try { + localStorage.setItem( + SESSION_ACTOR_STORAGE_KEY, + JSON.stringify({ pubkey, privkey: privateKey, savedAt: Date.now() }) + ); + } catch (error) { + if (isDevMode) { + console.warn("[nostr] Failed to persist session actor:", error); + } + } + } + + return pubkey; + } + + async publishViewEvent(videoPointer, options = {}) { + if (!this.pool) { + return { ok: false, error: "nostr-uninitialized" }; + } + + const pointer = normalizePointerInput(videoPointer); + if (!pointer) { + return { ok: false, error: "invalid-pointer" }; + } + + const actorPubkey = await this.ensureSessionActor(); + if (!actorPubkey) { + return { ok: false, error: "missing-actor" }; + } + + const createdAt = + typeof options.created_at === "number" && options.created_at > 0 + ? Math.floor(options.created_at) + : Math.floor(Date.now() / 1000); + + const normalizedActor = + typeof actorPubkey === "string" ? actorPubkey.toLowerCase() : ""; + const normalizedLogged = + typeof this.pubkey === "string" ? this.pubkey.toLowerCase() : ""; + const usingSessionActor = + normalizedActor && normalizedActor !== normalizedLogged; + + const additionalTags = Array.isArray(options.additionalTags) + ? options.additionalTags.filter( + (tag) => Array.isArray(tag) && typeof tag[0] === "string" + ) + : []; + + const tags = [ + ["t", "view"], + ["video", pointer.value], + ...additionalTags, + ]; + + if ( + usingSessionActor && + !tags.some((tag) => tag[0] === "session" && tag[1] === "true") + ) { + tags.push(["session", "true"]); + } + const content = + typeof options.content === "string" ? options.content : ""; + + const event = { + kind: WATCH_HISTORY_KIND, + pubkey: actorPubkey, + created_at: createdAt, + tags, + content, + }; + + let signedEvent = null; + + if ( + normalizedActor && + normalizedActor === normalizedLogged && + window?.nostr && + typeof window.nostr.signEvent === "function" + ) { + try { + signedEvent = await window.nostr.signEvent(event); + } catch (error) { + console.warn("[nostr] Failed to sign view event with extension:", error); + return { ok: false, error: "signing-failed", details: error }; + } + } else { + try { + if (!this.sessionActor || this.sessionActor.pubkey !== actorPubkey) { + await this.ensureSessionActor(true); + } + if (!this.sessionActor || this.sessionActor.pubkey !== actorPubkey) { + throw new Error("session-actor-mismatch"); + } + const privateKey = this.sessionActor.privateKey; + signedEvent = signEventWithPrivateKey(event, privateKey); + } catch (error) { + console.warn("[nostr] Failed to sign view event with session key:", error); + return { ok: false, error: "signing-failed", details: error }; + } + } + + const relayList = + Array.isArray(options.relays) && options.relays.length + ? options.relays + : this.relays; + const relays = + Array.isArray(relayList) && relayList.length ? relayList : RELAY_URLS; + + const publishResults = await Promise.all( + relays.map((url) => publishEventToRelay(this.pool, url, signedEvent)) + ); + + const success = publishResults.some((result) => result.success); + if (!success) { + console.warn("[nostr] View event rejected by relays:", publishResults); + } + + return { ok: success, event: signedEvent, results: publishResults }; + } + + async updateWatchHistoryList(pointer) { + if (!this.pool) { + return { ok: false, error: "nostr-uninitialized" }; + } + + const normalizedPointer = normalizePointerInput(pointer); + if (!normalizedPointer) { + return { ok: false, error: "invalid-pointer" }; + } + + if (Number.isFinite(normalizedPointer.watchedAt)) { + normalizedPointer.watchedAt = Math.max( + 0, + Math.floor(normalizedPointer.watchedAt) + ); + } else { + normalizedPointer.watchedAt = Date.now(); + } + + const actorPubkey = await this.ensureSessionActor(); + if (!actorPubkey) { + return { ok: false, error: "missing-actor" }; + } + + await this.fetchWatchHistory(actorPubkey); + + const existingEntry = + this.watchHistoryCache.get(actorPubkey) || + this.createWatchHistoryEntry(null, [], Date.now()); + + const candidates = [normalizedPointer, ...(existingEntry.items || [])]; + return this.publishWatchHistorySnapshot(actorPubkey, candidates, existingEntry); + } + + async removeWatchHistoryItem(pointerOrKey) { + if (!this.pool) { + return { ok: false, error: "nostr-uninitialized" }; + } + + const actorPubkey = await this.ensureSessionActor(); + if (!actorPubkey) { + return { ok: false, error: "missing-actor" }; + } + + await this.fetchWatchHistory(actorPubkey); + + const entry = this.watchHistoryCache.get(actorPubkey); + if (!entry) { + return { ok: false, error: "missing-entry" }; + } + + let targetKey = ""; + if (typeof pointerOrKey === "string" && pointerOrKey.trim()) { + targetKey = pointerOrKey.trim().toLowerCase(); + } else if (pointerOrKey) { + const normalizedPointer = normalizePointerInput(pointerOrKey); + targetKey = pointerKey(normalizedPointer); + } + + if (!targetKey) { + return { ok: false, error: "invalid-pointer" }; + } + + const filteredItems = entry.items.filter((item) => { + const key = pointerKey(item); + return key && key !== targetKey; + }); + + if (filteredItems.length === entry.items.length) { + return { ok: false, error: "pointer-not-found" }; + } + + const result = await this.publishWatchHistorySnapshot( + actorPubkey, + filteredItems, + entry + ); + + if (!result.ok) { + return result; + } + + return { ...result, removedKey: targetKey }; + } + + async fetchWatchHistory(pubkeyOrSession) { + let actor = + typeof pubkeyOrSession === "string" && pubkeyOrSession.trim() + ? pubkeyOrSession.trim() + : ""; + + if (!actor) { + actor = await this.ensureSessionActor(); + } + + if (!actor) { + return { pointerEvent: null, items: [] }; + } + + const now = Date.now(); + const ttl = this.getWatchHistoryCacheTtlMs(); + const cached = this.watchHistoryCache.get(actor); + if (cached) { + if (now - cached.savedAt < ttl) { + return { + pointerEvent: cached.pointerEvent + ? cloneEventForCache(cached.pointerEvent) + : null, + items: cached.items + .map((item) => clonePointerItem(item)) + .filter(Boolean), + }; + } + this.watchHistoryCache.delete(actor); + } + + let storage = null; + let stored = null; + if (typeof localStorage !== "undefined") { + storage = this.getWatchHistoryStorage(); + stored = storage.actors?.[actor]; + if ( + stored && + typeof stored.savedAt === "number" && + now - stored.savedAt < ttl + ) { + const entry = this.createWatchHistoryEntry( + stored.pointerEvent, + Array.isArray(stored.items) ? stored.items : [], + stored.savedAt, + this.watchHistoryCache.get(actor) || null + ); + this.watchHistoryCache.set(actor, entry); + return { + pointerEvent: entry.pointerEvent + ? cloneEventForCache(entry.pointerEvent) + : null, + items: entry.items + .map((item) => clonePointerItem(item)) + .filter(Boolean), + }; + } + } + + if (!this.pool) { + const fallbackEntry = this.createWatchHistoryEntry( + null, + [], + Date.now(), + this.watchHistoryCache.get(actor) || null + ); + this.watchHistoryCache.set(actor, fallbackEntry); + return { pointerEvent: null, items: [] }; + } + + const relayList = + Array.isArray(this.relays) && this.relays.length ? this.relays : RELAY_URLS; + + const fetchLimitCandidate = Number.isFinite(this.watchHistoryFetchEventLimit) + ? this.watchHistoryFetchEventLimit + : WATCH_HISTORY_FETCH_EVENT_LIMIT; + const fetchLimit = Math.max(1, Math.floor(fetchLimitCandidate)); + + const filter = { + kinds: [WATCH_HISTORY_KIND], + authors: [actor], + "#d": [WATCH_HISTORY_LIST_IDENTIFIER], + limit: fetchLimit, + }; + + let fetchedHeadEvents = []; + try { + const events = await this.pool.list(relayList, [filter]); + if (Array.isArray(events)) { + const dedupe = new Map(); + for (const evt of events) { + if (!evt || typeof evt !== "object") { + continue; + } + const id = typeof evt.id === "string" ? evt.id : null; + if (!id) { + continue; + } + const existing = dedupe.get(id); + if ( + !existing || + (typeof evt.created_at === "number" ? evt.created_at : 0) > + (typeof existing.created_at === "number" ? existing.created_at : 0) + ) { + dedupe.set(id, evt); + } + } + fetchedHeadEvents = Array.from(dedupe.values()); + } + } catch (error) { + if (isDevMode) { + console.warn("[nostr] Failed to fetch watch history list:", error); + } + } + + fetchedHeadEvents.sort((a, b) => { + const aCreated = typeof a?.created_at === "number" ? a.created_at : 0; + const bCreated = typeof b?.created_at === "number" ? b.created_at : 0; + return bCreated - aCreated; + }); + + const chunkIdentifiers = new Set(); + const snapshotIds = new Set(); + + for (const event of fetchedHeadEvents) { + if (!event || typeof event !== "object") { + continue; + } + + const tags = Array.isArray(event.tags) ? event.tags : []; + for (const tag of tags) { + if (!Array.isArray(tag) || tag.length < 2) { + continue; + } + if (tag[0] === "snapshot" && typeof tag[1] === "string" && tag[1]) { + snapshotIds.add(tag[1]); + } else if (tag[0] === "a") { + const pointer = normalizePointerTag(tag); + if (pointer?.type === "a") { + const [kindStr, pubkey, identifier] = pointer.value.split(":"); + const kind = Number.parseInt(kindStr, 10); + if ( + Number.isFinite(kind) && + kind === WATCH_HISTORY_KIND && + typeof pubkey === "string" && + pubkey === actor && + typeof identifier === "string" && + identifier && + identifier !== WATCH_HISTORY_LIST_IDENTIFIER + ) { + chunkIdentifiers.add(identifier); + } + } + } + } + } + + const chunkFilters = []; + const chunkFetchLimit = Math.max( + fetchLimit, + chunkIdentifiers.size ? chunkIdentifiers.size + 1 : 0, + snapshotIds.size ? snapshotIds.size * 2 : 0 + ); + + if (chunkIdentifiers.size) { + chunkFilters.push({ + kinds: [WATCH_HISTORY_KIND], + authors: [actor], + "#d": Array.from(chunkIdentifiers), + limit: chunkFetchLimit, + }); + } + + if (snapshotIds.size) { + chunkFilters.push({ + kinds: [WATCH_HISTORY_KIND], + authors: [actor], + "#snapshot": Array.from(snapshotIds), + limit: chunkFetchLimit, + }); + } + + let fetchedChunkEvents = []; + if (chunkFilters.length) { + try { + const events = await this.pool.list(relayList, chunkFilters); + if (Array.isArray(events)) { + fetchedChunkEvents = events; + } + } catch (error) { + if (isDevMode) { + console.warn( + "[nostr] Failed to fetch watch history chunks:", + error + ); + } + } + } + + const eventDedupe = new Map(); + const registerEvent = (evt) => { + if (!evt || typeof evt !== "object") { + return; + } + const id = typeof evt.id === "string" ? evt.id : null; + if (!id) { + return; + } + const existing = eventDedupe.get(id); + if ( + !existing || + (typeof evt.created_at === "number" ? evt.created_at : 0) > + (typeof existing.created_at === "number" ? existing.created_at : 0) + ) { + eventDedupe.set(id, evt); + } + }; + + fetchedHeadEvents.forEach(registerEvent); + fetchedChunkEvents.forEach(registerEvent); + + const fetchedEvents = Array.from(eventDedupe.values()); + + fetchedEvents.sort((a, b) => { + const aCreated = typeof a?.created_at === "number" ? a.created_at : 0; + const bCreated = typeof b?.created_at === "number" ? b.created_at : 0; + return bCreated - aCreated; + }); + + const snapshotMap = new Map(); + + for (const event of fetchedEvents) { + if (!event || typeof event !== "object") { + continue; + } + + const tags = Array.isArray(event.tags) ? event.tags : []; + let taggedSnapshotId = ""; + let taggedChunkIndex = null; + let taggedTotalChunks = null; + + for (const tag of tags) { + if (!Array.isArray(tag) || tag.length < 2) { + continue; + } + if (tag[0] === "snapshot" && typeof tag[1] === "string") { + taggedSnapshotId = tag[1]; + } else if (tag[0] === "chunk") { + const index = Number.parseInt(tag[1], 10); + const total = Number.parseInt(tag[2], 10); + if (Number.isFinite(index)) { + taggedChunkIndex = Math.max(0, index); + } + if (Number.isFinite(total) && total > 0) { + taggedTotalChunks = total; + } + } + } + + const payload = await this.decryptWatchHistoryEvent(event, actor); + let snapshotId = + (typeof payload.snapshot === "string" && payload.snapshot) || + taggedSnapshotId; + if (!snapshotId) { + const createdAt = + typeof event.created_at === "number" ? event.created_at : 0; + const fallbackId = + typeof event.id === "string" && event.id + ? event.id + : `${createdAt}:${Math.random().toString(36).slice(2, 10)}`; + snapshotId = fallbackId; + } + + let chunkIndex = Number.isFinite(payload.chunkIndex) + ? Math.max(0, Math.floor(payload.chunkIndex)) + : 0; + let totalChunks = Number.isFinite(payload.totalChunks) + ? Math.max(1, Math.floor(payload.totalChunks)) + : 1; + + if (Number.isFinite(taggedChunkIndex)) { + chunkIndex = Math.max(0, Math.floor(taggedChunkIndex)); + } + if (Number.isFinite(taggedTotalChunks)) { + totalChunks = Math.max(1, Math.floor(taggedTotalChunks)); + } + + const bucket = snapshotMap.get(snapshotId) || { + snapshotId, + chunks: new Map(), + expectedChunks: totalChunks, + latestCreatedAt: + typeof event.created_at === "number" ? event.created_at : 0, + version: payload.version || 0, + }; + + bucket.chunks.set(chunkIndex, { + event, + payload: { + ...payload, + snapshot: snapshotId, + chunkIndex, + totalChunks, + }, + created_at: typeof event.created_at === "number" ? event.created_at : 0, + }); + bucket.expectedChunks = Math.max(bucket.expectedChunks, totalChunks); + const createdAt = + typeof event.created_at === "number" ? event.created_at : 0; + if (createdAt > bucket.latestCreatedAt) { + bucket.latestCreatedAt = createdAt; + } + bucket.version = Math.max(bucket.version, payload.version || 0); + snapshotMap.set(snapshotId, bucket); + } + + const snapshotBuckets = Array.from(snapshotMap.values()).sort( + (a, b) => b.latestCreatedAt - a.latestCreatedAt + ); + + let selectedSnapshot = null; + let fallbackSnapshot = null; + + for (const bucket of snapshotBuckets) { + const chunkIndices = Array.from(bucket.chunks.keys()).sort( + (a, b) => a - b + ); + const seenKeys = new Set(); + const combinedItems = []; + let pointerCandidate = null; + + for (const index of chunkIndices) { + const chunk = bucket.chunks.get(index); + if (!chunk) { + continue; + } + if (!pointerCandidate && chunk.payload.chunkIndex === 0) { + pointerCandidate = chunk.event; + } + const items = Array.isArray(chunk.payload.items) + ? chunk.payload.items + : []; + for (const pointer of items) { + const key = pointerKey(pointer); + if (!key || seenKeys.has(key)) { + continue; + } + seenKeys.add(key); + combinedItems.push(pointer); + } + } + + if (!pointerCandidate && chunkIndices.length) { + const firstChunk = bucket.chunks.get(chunkIndices[0]); + pointerCandidate = firstChunk?.event || null; + } + + const snapshot = { + snapshotId: bucket.snapshotId, + items: combinedItems, + pointerEvent: pointerCandidate || null, + version: bucket.version, + chunkCount: chunkIndices.length, + expectedChunks: Math.max(bucket.expectedChunks, chunkIndices.length || 1), + }; + + if (!fallbackSnapshot) { + fallbackSnapshot = snapshot; + } + + if (snapshot.chunkCount >= snapshot.expectedChunks) { + selectedSnapshot = snapshot; + break; + } + + if (!selectedSnapshot) { + selectedSnapshot = snapshot; + } + } + + const effectiveSnapshot = selectedSnapshot || fallbackSnapshot || null; + + const previousEntry = this.watchHistoryCache.get(actor) || null; + const storedItems = Array.isArray(stored?.items) ? stored.items : []; + const storedPointerEvent = stored?.pointerEvent || null; + + const remoteItems = Array.isArray(effectiveSnapshot?.items) + ? effectiveSnapshot.items + : []; + + let pointerForEntry = effectiveSnapshot?.pointerEvent || null; + if (!pointerForEntry) { + if (previousEntry?.pointerEvent) { + pointerForEntry = previousEntry.pointerEvent; + } else if (storedPointerEvent) { + pointerForEntry = storedPointerEvent; + } + } + + let items = remoteItems; + let usedFallback = false; + const remoteVersion = effectiveSnapshot?.version ?? 0; + + if (!effectiveSnapshot || (remoteVersion === 0 && !remoteItems.length)) { + if (previousEntry?.items?.length) { + items = previousEntry.items; + usedFallback = true; + } else if (storedItems.length) { + items = storedItems; + usedFallback = true; + } + } + + const entry = this.createWatchHistoryEntry( + pointerForEntry, + items, + Date.now(), + previousEntry + ); + + if (!(entry.chunkEvents instanceof Map)) { + entry.chunkEvents = new Map(); + } else { + entry.chunkEvents.clear(); + } + + const chunkPointerItemsForEntry = []; + let headPointerForEntry = null; + const chunkPointerKeys = new Set(); + + if (effectiveSnapshot?.snapshotId) { + const bucketForEntry = snapshotMap.get(effectiveSnapshot.snapshotId); + if (bucketForEntry) { + const chunkEntries = Array.from(bucketForEntry.chunks.values()).sort( + (a, b) => a.payload.chunkIndex - b.payload.chunkIndex + ); + for (const chunk of chunkEntries) { + if (!chunk?.event) { + continue; + } + const addressValue = eventToAddressPointer(chunk.event); + if (!addressValue) { + continue; + } + const pointer = clonePointerItem({ + type: "a", + value: addressValue, + relay: null, + }); + if (!pointer) { + continue; + } + const key = pointerKey(pointer); + if (!key) { + continue; + } + const clonedChunkEvent = cloneEventForCache(chunk.event); + if (clonedChunkEvent) { + entry.chunkEvents.set(key, clonedChunkEvent); + } + chunkPointerKeys.add(key); + if (chunk.payload?.chunkIndex === 0) { + headPointerForEntry = pointer; + } else { + chunkPointerItemsForEntry.push(pointer); + } + } + } + } + + entry.chunkPointers = { + head: headPointerForEntry, + chunks: chunkPointerItemsForEntry, + }; + + const headPointerKey = headPointerForEntry ? pointerKey(headPointerForEntry) : ""; + if (headPointerKey) { + chunkPointerKeys.add(headPointerKey); + if (entry.pointerEvent) { + entry.chunkEvents.set(headPointerKey, cloneEventForCache(entry.pointerEvent)); + } + } + + for (const key of Array.from(entry.resolved.keys())) { + if ( + !key || + !key.startsWith(`a:${WATCH_HISTORY_KIND}:`) + ) { + continue; + } + if (!chunkPointerKeys.has(key)) { + entry.resolved.delete(key); + } + } + + for (const key of chunkPointerKeys) { + const eventForKey = entry.chunkEvents.get(key); + if (eventForKey) { + entry.resolved.set(key, cloneEventForCache(eventForKey)); + } + } + + this.watchHistoryCache.set(actor, entry); + this.persistWatchHistoryEntry(actor, entry); + + if ( + (!effectiveSnapshot || !effectiveSnapshot.pointerEvent) && + usedFallback && + entry.items.length + ) { + this.scheduleWatchHistoryRepublish(actor, entry.items); + } + + return { + pointerEvent: entry.pointerEvent + ? cloneEventForCache(entry.pointerEvent) + : null, + items: entry.items + .map((item) => clonePointerItem(item)) + .filter(Boolean), + }; + } + + async resolveWatchHistory(batchSize = 20) { + if (!this.pool) { + return []; + } + + const actor = await this.ensureSessionActor(); + if (!actor) { + return []; + } + + await this.fetchWatchHistory(actor); + const entry = this.watchHistoryCache.get(actor); + if (!entry) { + return []; + } + + if (!(entry.resolvedVideos instanceof Map)) { + entry.resolvedVideos = new Map(); + } + if (!(entry.delivered instanceof Set)) { + entry.delivered = new Set(); + } + + const parsedBatchSize = Number(batchSize); + const normalizedBatchSize = + Number.isFinite(parsedBatchSize) && parsedBatchSize > 0 + ? Math.floor(parsedBatchSize) + : 20; + const effectiveBatchSize = WATCH_HISTORY_BATCH_RESOLVE + ? Math.max(1, normalizedBatchSize) + : 1; + + const classifyWatchHistoryPointer = (pointer, ownerPubkey) => { + if (!pointer || pointer.type !== "a") { + return "none"; + } + const value = + typeof pointer.value === "string" ? pointer.value.trim() : ""; + if (!value) { + return "none"; + } + const parts = value.split(":"); + if (parts.length < 3) { + return "none"; + } + const [kindStr, pubkeyRaw, ...identifierParts] = parts; + const kind = Number.parseInt(kindStr, 10); + if (!Number.isFinite(kind) || kind !== WATCH_HISTORY_KIND) { + return "none"; + } + const identifier = identifierParts.join(":"); + const normalizedIdentifier = + typeof identifier === "string" ? identifier.trim() : ""; + if (!normalizedIdentifier) { + return "none"; + } + const normalizedIdentifierLower = normalizedIdentifier.toLowerCase(); + if (normalizedIdentifierLower === WATCH_HISTORY_LIST_IDENTIFIER) { + return "head"; + } + if (!normalizedIdentifierLower.startsWith("watch-history:")) { + return "none"; + } + const normalizedOwner = + typeof ownerPubkey === "string" && ownerPubkey.trim() + ? ownerPubkey.trim().toLowerCase() + : ""; + const normalizedPubkey = + typeof pubkeyRaw === "string" && pubkeyRaw.trim() + ? pubkeyRaw.trim().toLowerCase() + : ""; + if (!normalizedOwner || !normalizedPubkey || normalizedOwner === normalizedPubkey) { + return "chunk"; + } + return "none"; + }; + + const addPointerToMap = (pointer, map) => { + const normalized = clonePointerItem(pointer); + if (!normalized) { + return null; + } + const key = pointerKey(normalized); + if (!key) { + return null; + } + map.set(key, normalized); + return normalized; + }; + + const headPointerMap = new Map(); + const chunkPointerMap = new Map(); + const videoCandidates = []; + + if (entry.chunkPointers?.head) { + addPointerToMap(entry.chunkPointers.head, headPointerMap); + } + if (Array.isArray(entry.chunkPointers?.chunks)) { + for (const chunkPointer of entry.chunkPointers.chunks) { + addPointerToMap(chunkPointer, chunkPointerMap); + } + } + + for (const item of entry.items) { + const pointer = clonePointerItem(item); + if (!pointer) { + continue; + } + const pointerType = classifyWatchHistoryPointer(pointer, actor); + if (pointerType === "head") { + addPointerToMap(pointer, headPointerMap); + continue; + } + if (pointerType === "chunk") { + addPointerToMap(pointer, chunkPointerMap); + continue; + } + videoCandidates.push(pointer); + } + + const pointerEventClone = entry.pointerEvent + ? cloneEventForCache(entry.pointerEvent) + : null; + for (const pointer of headPointerMap.values()) { + const key = pointerKey(pointer); + if (!key) { + continue; + } + if (pointerEventClone) { + entry.resolved.set(key, pointerEventClone); + if (entry.chunkEvents instanceof Map) { + entry.chunkEvents.set(key, pointerEventClone); + } + } else if (!entry.resolved.has(key)) { + entry.resolved.set(key, null); + } + entry.resolving.delete(key); + } + + const available = videoCandidates.filter((item) => { + const key = pointerKey(item); + if (!key) { + return false; + } + if (entry.delivered.has(key)) { + return false; + } + if (entry.resolving.has(key)) { + return false; + } + if (entry.resolvedVideos.has(key)) { + const cachedVideo = entry.resolvedVideos.get(key); + if (cachedVideo && typeof cachedVideo === "object" && cachedVideo.id) { + return false; + } + } + return true; + }); + + const chunkAvailable = []; + for (const [key, pointer] of chunkPointerMap.entries()) { + if (!key) { + continue; + } + if (entry.resolved.has(key)) { + continue; + } + if (entry.resolving.has(key)) { + continue; + } + const cachedChunk = entry.chunkEvents?.get?.(key); + if (cachedChunk) { + const cloned = cloneEventForCache(cachedChunk); + if (cloned) { + entry.chunkEvents.set(key, cloned); + entry.resolved.set(key, cloned); + } else { + entry.resolved.set(key, cachedChunk); + } + continue; + } + chunkAvailable.push(pointer); + } + + const videoBatch = available.slice(0, effectiveBatchSize); + const batch = [...videoBatch, ...chunkAvailable]; + + const relaySet = new Set( + Array.isArray(this.relays) && this.relays.length ? this.relays : RELAY_URLS + ); + const filters = []; + + if (batch.length) { + batch.forEach((item) => { + const key = pointerKey(item); + if (key) { + entry.resolving.add(key); + } + }); + + for (const pointer of batch) { + if (pointer.relay) { + relaySet.add(pointer.relay); + } + if (pointer.type === "e") { + filters.push({ ids: [pointer.value] }); + } else if (pointer.type === "a") { + const [kindStr, pubkey, identifier] = pointer.value.split(":"); + const kind = Number(kindStr); + if (Number.isFinite(kind) && pubkey) { + const filter = { kinds: [kind], authors: [pubkey] }; + if (identifier) { + filter["#d"] = [identifier]; + } + filters.push(filter); + } + } + } + } + + let events = []; + if (filters.length) { + try { + events = await this.pool.list(Array.from(relaySet), filters); + } catch (error) { + if (isDevMode) { + console.warn("[nostr] Failed to resolve watch history batch:", error); + } + } + } else if (batch.length) { + batch.forEach((item) => entry.resolving.delete(pointerKey(item))); + } + + const pointerMatches = new Map(); + + for (const evt of events || []) { + if (!evt || typeof evt !== "object") { + continue; + } + if (typeof evt.id === "string" && evt.id) { + pointerMatches.set( + pointerKey({ type: "e", value: evt.id }), + evt + ); + } + const addressPointer = eventToAddressPointer(evt); + if (addressPointer) { + pointerMatches.set( + pointerKey({ type: "a", value: addressPointer }), + evt + ); + } + } + + for (const pointer of batch) { + const key = pointerKey(pointer); + if (!key) { + continue; + } + const pointerType = classifyWatchHistoryPointer(pointer, actor); + const event = pointerMatches.get(key); + if (pointerType === "chunk" || pointerType === "head") { + if (event) { + const cloned = cloneEventForCache(event) || event; + if (!entry.chunkEvents) { + entry.chunkEvents = new Map(); + } + entry.chunkEvents.set(key, cloned); + entry.resolved.set(key, cloned); + } + entry.resolving.delete(key); + continue; + } + + if (event) { + try { + const video = convertEventToVideo(event); + if (!video.invalid) { + this.allEvents.set(event.id, video); + if (!video.deleted) { + const activeKey = getActiveKey(video); + const existing = this.activeMap.get(activeKey); + if (!existing || video.created_at > existing.created_at) { + this.activeMap.set(activeKey, video); + } + } + entry.resolvedVideos.set(key, video); + } + } catch (err) { + if (isDevMode) { + console.warn("[nostr] Failed to convert watch history event:", err); + } + } + } + entry.resolving.delete(key); + } + + const deliverableVideos = []; + for (const pointer of videoCandidates) { + if (deliverableVideos.length >= effectiveBatchSize) { + break; + } + const key = pointerKey(pointer); + if (!key || entry.delivered.has(key)) { + continue; + } + const cachedVideo = entry.resolvedVideos.get(key); + if (cachedVideo && typeof cachedVideo === "object" && cachedVideo.id) { + const pointerClone = clonePointerItem(pointer); + const watchedAt = Number.isFinite(pointerClone?.watchedAt) + ? pointerClone.watchedAt + : 0; + const videoWithHistory = { + ...cachedVideo, + watchHistory: { + key, + watchedAt, + pointer: + pointerClone || { + type: pointer.type, + value: pointer.value, + relay: pointer.relay || null, + watchedAt, + }, + }, + }; + deliverableVideos.push(videoWithHistory); + entry.delivered.add(key); + } + } + + return deliverableVideos; + } + + async recordVideoView(videoPointer, options = {}) { + const pointer = normalizePointerInput(videoPointer); + if (!pointer) { + return { + ok: false, + error: "invalid-pointer", + view: { ok: false, error: "invalid-pointer" }, + history: { ok: false, error: "invalid-pointer" }, + }; + } + + const view = await this.publishViewEvent(pointer, options); + const history = await this.updateWatchHistoryList(pointer); + + return { + ok: view.ok && history.ok, + view, + history, + }; + } + /** * Connect to the configured relays */ @@ -621,7 +3191,7 @@ class NostrClient { console.log("Requesting permissions from NIP-07 extension..."); } try { - await extension.enable(); + await withNip07Timeout(() => extension.enable()); } catch (enableErr) { throw new Error( enableErr && enableErr.message @@ -633,8 +3203,8 @@ class NostrClient { if (allowAccountSelection && typeof extension.selectAccounts === "function") { try { - const selection = await extension.selectAccounts( - expectPubkey ? [expectPubkey] : undefined + const selection = await withNip07Timeout(() => + extension.selectAccounts(expectPubkey ? [expectPubkey] : undefined) ); const didCancelSelection = @@ -654,24 +3224,7 @@ class NostrClient { throw new Error(message); } } - - let timeoutId; - const pubkey = await Promise.race([ - extension.getPublicKey(), - new Promise((_, reject) => { - timeoutId = setTimeout(() => { - reject( - new Error( - "Timed out waiting for the NIP-07 extension. Check the extension prompt and try again." - ) - ); - }, NIP07_LOGIN_TIMEOUT_MS); - }), - ]).finally(() => { - if (timeoutId) { - clearTimeout(timeoutId); - } - }); + const pubkey = await withNip07Timeout(() => extension.getPublicKey()); if (!pubkey || typeof pubkey !== "string") { throw new Error( "The NIP-07 extension did not return a public key. Please try again." @@ -715,6 +3268,10 @@ class NostrClient { logout() { this.pubkey = null; + for (const timer of this.watchHistoryRepublishTimers.values()) { + clearTimeout(timer); + } + this.watchHistoryRepublishTimers.clear(); if (isDevMode) console.log("User logged out."); } @@ -1543,6 +4100,310 @@ class NostrClient { return null; } + getRequestTimeoutMs(timeoutMs) { + const candidate = Number(timeoutMs); + if (Number.isFinite(candidate) && candidate > 0) { + return Math.floor(candidate); + } + const poolTimeout = Number(this.pool?.getTimeout); + if (Number.isFinite(poolTimeout) && poolTimeout > 0) { + return Math.floor(poolTimeout); + } + return 3400; + } + + normalizeCountFilter(filter) { + if (!filter || typeof filter !== "object") { + return null; + } + + const normalized = {}; + + const toStringArray = (value) => { + if (value === undefined || value === null) { + return []; + } + const source = Array.isArray(value) ? value : [value]; + const collected = []; + for (const item of source) { + if (typeof item !== "string") { + continue; + } + const trimmed = item.trim(); + if (!trimmed || collected.includes(trimmed)) { + continue; + } + collected.push(trimmed); + } + return collected; + }; + + if (filter.kinds !== undefined) { + const kindsSource = Array.isArray(filter.kinds) + ? filter.kinds + : [filter.kinds]; + const normalizedKinds = []; + const seenKinds = new Set(); + for (const candidate of kindsSource) { + const parsed = Number(candidate); + if (!Number.isFinite(parsed)) { + continue; + } + const normalizedValue = Math.floor(parsed); + if (seenKinds.has(normalizedValue)) { + continue; + } + seenKinds.add(normalizedValue); + normalizedKinds.push(normalizedValue); + } + if (normalizedKinds.length) { + normalized.kinds = normalizedKinds; + } + } + + const ids = toStringArray(filter.ids); + if (ids.length) { + normalized.ids = ids; + } + + const authors = toStringArray(filter.authors); + if (authors.length) { + normalized.authors = authors; + } + + for (const [key, value] of Object.entries(filter)) { + if (!key.startsWith("#")) { + continue; + } + const tagValues = toStringArray(value); + if (tagValues.length) { + normalized[key] = tagValues; + } + } + + if (filter.since !== undefined) { + const parsedSince = Number(filter.since); + if (Number.isFinite(parsedSince)) { + normalized.since = Math.floor(parsedSince); + } + } + + if (filter.until !== undefined) { + const parsedUntil = Number(filter.until); + if (Number.isFinite(parsedUntil)) { + normalized.until = Math.floor(parsedUntil); + } + } + + if (filter.limit !== undefined) { + const parsedLimit = Number(filter.limit); + if (Number.isFinite(parsedLimit) && parsedLimit >= 0) { + normalized.limit = Math.floor(parsedLimit); + } + } + + return Object.keys(normalized).length ? normalized : null; + } + + normalizeCountFilters(filters) { + if (!filters) { + return []; + } + + const list = Array.isArray(filters) ? filters : [filters]; + const normalized = []; + + for (const candidate of list) { + const normalizedFilter = this.normalizeCountFilter(candidate); + if (normalizedFilter) { + normalized.push(normalizedFilter); + } + } + + return normalized; + } + + generateCountRequestId(prefix = "count") { + this.countRequestCounter += 1; + if (this.countRequestCounter > Number.MAX_SAFE_INTEGER - 1) { + this.countRequestCounter = 1; + } + const normalizedPrefix = + typeof prefix === "string" && prefix.trim() ? prefix.trim() : "count"; + const timestamp = Date.now().toString(36); + const counter = this.countRequestCounter.toString(36); + const random = Math.random().toString(36).slice(2, 8); + return `${normalizedPrefix}:${timestamp}:${counter}${random}`; + } + + extractCountValue(payload) { + if (typeof payload === "number") { + const value = Math.floor(payload); + return value >= 0 ? value : 0; + } + + if (payload && typeof payload === "object") { + const candidate = + typeof payload.count === "number" + ? payload.count + : Number(payload.count); + if (Number.isFinite(candidate)) { + const value = Math.floor(candidate); + return value >= 0 ? value : 0; + } + } + + const parsed = Number(payload); + if (Number.isFinite(parsed)) { + const value = Math.floor(parsed); + return value >= 0 ? value : 0; + } + + return 0; + } + + async sendRawCountFrame(relayUrl, filters, options = {}) { + if (!this.pool) { + throw new Error( + "Nostr pool not initialized. Call nostrClient.init() before requesting counts." + ); + } + + const normalizedUrl = + typeof relayUrl === "string" ? relayUrl.trim() : ""; + if (!normalizedUrl) { + throw new Error("Invalid relay URL for COUNT request."); + } + + const normalizedFilters = this.normalizeCountFilters(filters); + if (!normalizedFilters.length) { + throw new Error("At least one filter is required for a COUNT request."); + } + + const requestId = + typeof options.subId === "string" && options.subId.trim() + ? options.subId.trim() + : this.generateCountRequestId(); + + let relay; + try { + relay = await this.pool.ensureRelay(normalizedUrl); + } catch (error) { + throw new Error(`Failed to connect to relay ${normalizedUrl}`); + } + + if (!relay) { + throw new Error( + `Relay ${normalizedUrl} is unavailable for COUNT requests.` + ); + } + + const frame = ["COUNT", requestId, ...normalizedFilters]; + let countPromise; + + if ( + relay.openCountRequests instanceof Map && + typeof relay.send === "function" + ) { + countPromise = new Promise((resolve, reject) => { + const cleanup = () => { + if (relay.openCountRequests instanceof Map) { + relay.openCountRequests.delete(requestId); + } + }; + + relay.openCountRequests.set(requestId, { + resolve: (value) => { + cleanup(); + resolve(value); + }, + reject: (error) => { + cleanup(); + reject(error); + }, + }); + + let sendResult; + try { + sendResult = relay.send(JSON.stringify(frame)); + } catch (error) { + cleanup(); + reject(error); + return; + } + + if (sendResult && typeof sendResult.catch === "function") { + sendResult.catch((error) => { + cleanup(); + reject(error); + }); + } + }); + } else if (typeof relay.count === "function") { + countPromise = relay.count(normalizedFilters, { id: requestId }); + } else { + throw new Error( + `[nostr] Relay ${normalizedUrl} does not support COUNT frames.` + ); + } + + const timeoutMs = this.getRequestTimeoutMs(options.timeoutMs); + const rawResult = await withRequestTimeout( + countPromise, + timeoutMs, + () => { + if (relay?.openCountRequests instanceof Map) { + relay.openCountRequests.delete(requestId); + } + }, + `COUNT request timed out after ${timeoutMs}ms` + ); + + const countValue = this.extractCountValue(rawResult); + return ["COUNT", requestId, { count: countValue }]; + } + + async countEventsAcrossRelays(filters, options = {}) { + const normalizedFilters = this.normalizeCountFilters(filters); + if (!normalizedFilters.length) { + return { total: 0, perRelay: [] }; + } + + const relayList = + Array.isArray(options.relays) && options.relays.length + ? options.relays + : Array.isArray(this.relays) && this.relays.length + ? this.relays + : RELAY_URLS; + + const perRelay = await Promise.all( + relayList.map(async (url) => { + try { + const frame = await this.sendRawCountFrame(url, normalizedFilters, { + timeoutMs: options.timeoutMs, + }); + const count = this.extractCountValue(frame?.[2]); + return { url, ok: true, frame, count }; + } catch (error) { + if (isDevMode) { + console.warn(`[nostr] COUNT request failed on ${url}:`, error); + } + return { url, ok: false, error }; + } + }) + ); + + const total = perRelay.reduce((sum, entry) => { + if (!entry || !entry.ok) { + return sum; + } + const value = Number(entry.count); + return Number.isFinite(value) && value > 0 ? sum + value : sum; + }, 0); + + return { total, perRelay }; + } + /** * Ensure we have every historical revision for a given video in memory and * return the complete set sorted newest-first. We primarily group revisions @@ -1679,3 +4540,12 @@ class NostrClient { } export const nostrClient = new NostrClient(); + +export const recordVideoView = (...args) => + nostrClient.recordVideoView(...args); + +export const updateWatchHistoryList = (...args) => + nostrClient.updateWatchHistoryList(...args); + +export const removeWatchHistoryItem = (...args) => + nostrClient.removeWatchHistoryItem(...args); diff --git a/js/subscriptions.js b/js/subscriptions.js index e3147a25..95392d7f 100644 --- a/js/subscriptions.js +++ b/js/subscriptions.js @@ -614,10 +614,7 @@ class SubscriptionsManager { window.app.handleUrlHealthBadge({ video, url, badgeEl }); }); - if (window.app) { - window.app.videoList = container; - window.app.attachVideoListHandler?.(); - } + window.app?.attachVideoListHandler?.(); // Lazy-load const lazyEls = container.querySelectorAll("[data-lazy]"); diff --git a/js/viewManager.js b/js/viewManager.js index 741cd0a2..30671ed8 100644 --- a/js/viewManager.js +++ b/js/viewManager.js @@ -67,6 +67,16 @@ export const viewInitRegistry = { explore: () => { console.log("Explore view loaded."); }, + history: async () => { + try { + const module = await import("./historyView.js"); + if (typeof module.initHistoryView === "function") { + await module.initHistoryView(); + } + } catch (error) { + console.error("Failed to initialize history view:", error); + } + }, /** * Subscriptions view: diff --git a/tests/watch-history.test.mjs b/tests/watch-history.test.mjs new file mode 100644 index 00000000..a7e25b40 --- /dev/null +++ b/tests/watch-history.test.mjs @@ -0,0 +1,746 @@ +import "./test-helpers/setup-localstorage.mjs"; +import assert from "node:assert/strict"; + +const { + WATCH_HISTORY_LIST_IDENTIFIER, + WATCH_HISTORY_KIND, +} = await import("../js/config.js"); + +if (typeof globalThis.window === "undefined") { + globalThis.window = {}; +} + +if (!globalThis.window.NostrTools) { + globalThis.window.NostrTools = {}; +} + +globalThis.window.NostrTools.getEventHash = (event) => { + const payload = `${event.kind}:${event.pubkey}:${event.created_at}`; + return `hash-${Buffer.from(payload).toString("hex")}`; +}; + +globalThis.window.NostrTools.signEvent = (event, privateKey) => + `sig-${privateKey.slice(0, 8)}-${event.created_at}`; + +delete globalThis.window.NostrTools.nip04; +delete globalThis.window.nostr; + +const { nostrClient } = await import("../js/nostr.js"); + +function createPublishingClient(actorPubkey) { + const client = new nostrClient.constructor(); + const publishedEvents = []; + const payloads = []; + client.pool = { + publish(urls, event) { + publishedEvents.push(event); + return { + on(eventName, handler) { + if (eventName === "ok" || eventName === "seen") { + setTimeout(handler, 0); + } + }, + }; + }, + list: async () => [], + }; + client.relays = ["wss://unit.test"]; + client.pubkey = ""; + client.sessionActor = { pubkey: actorPubkey, privateKey: "unit-secret" }; + client.ensureSessionActor = async () => actorPubkey; + client.encryptWatchHistoryPayload = async (_actor, payload) => { + payloads.push(payload); + return { ok: true, ciphertext: JSON.stringify(payload) }; + }; + client.persistWatchHistoryEntry = () => {}; + client.cancelWatchHistoryRepublish = () => {}; + client.scheduleWatchHistoryRepublish = () => {}; + return { client, publishedEvents, payloads }; +} + +function createDecryptClient(actorPubkey) { + const client = new nostrClient.constructor(); + client.sessionActor = { pubkey: actorPubkey, privateKey: "unit-secret" }; + client.ensureSessionActor = async () => actorPubkey; + return client; +} + +const ACTOR = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +const { client: publishingClient, publishedEvents, payloads } = + createPublishingClient(ACTOR); + +publishingClient.watchHistoryPayloadMaxBytes = 400; +publishingClient.watchHistoryFetchEventLimit = 10; + +const baseWatchedAt = 1_700_000_000_000; +const longPointers = Array.from({ length: 8 }, (_, index) => { + const relay = index % 3 === 0 ? "wss://relay.unit" : null; + const watchedAt = baseWatchedAt + index * 60_000; + if (index % 2 === 0) { + return { + type: "e", + value: `event-${index}-${"x".repeat(90)}`, + relay, + watchedAt, + }; + } + return { + type: "a", + value: `30078:${ACTOR}:history-${index}-${"y".repeat(60)}`, + relay, + watchedAt, + }; +}); + +const publishResult = await publishingClient.publishWatchHistorySnapshot( + ACTOR, + longPointers +); + +assert.equal(publishResult.ok, true, "publish should report success"); +assert(publishResult.events.length > 1, "snapshot should chunk into multiple events"); +assert.equal( + publishResult.events.length, + payloads.length, + "each chunk should produce an encryption payload" +); + +payloads.forEach((payload) => { + const size = JSON.stringify(payload).length; + assert( + size <= publishingClient.watchHistoryPayloadMaxBytes, + `payload size ${size} should respect cap` + ); + assert.equal(payload.version, 2, "chunk payload should use version 2"); +}); + +const snapshotIds = new Set( + publishResult.events.map((event) => + event.tags.find((tag) => Array.isArray(tag) && tag[0] === "snapshot")?.[1] + ) +); +assert.equal(snapshotIds.size, 1, "all chunks should share a snapshot id"); + +const headTag = publishResult.event.tags.find( + (tag) => Array.isArray(tag) && tag[0] === "head" +); +assert(headTag, "head chunk should include head tag"); + +const chunkIdentifiers = publishResult.events.map((event) => + event.tags.find((tag) => Array.isArray(tag) && tag[0] === "d")?.[1] +); + +assert.equal( + chunkIdentifiers.length, + publishResult.events.length, + "each chunk should expose a d tag" +); + +assert.equal( + chunkIdentifiers[0], + WATCH_HISTORY_LIST_IDENTIFIER, + "head chunk should retain canonical watch history identifier" +); + +const uniqueChunkIdentifiers = new Set(chunkIdentifiers); +assert.equal( + uniqueChunkIdentifiers.size, + publishResult.events.length, + "each chunk should have a unique d tag" +); + +chunkIdentifiers.slice(1).forEach((identifier, index) => { + assert( + identifier && identifier.startsWith("watch-history:"), + `chunk ${index + 1} should use a watch-history namespace identifier` + ); + assert.notEqual( + identifier, + WATCH_HISTORY_LIST_IDENTIFIER, + "only the head chunk should use the canonical identifier" + ); +}); + +const headATags = publishResult.event.tags + .filter((tag) => Array.isArray(tag) && tag[0] === "a") + .map((tag) => tag[1]); + +const expectedChunkAddresses = publishResult.events.map((event) => { + const identifier = event.tags.find( + (tag) => Array.isArray(tag) && tag[0] === "d" + )?.[1]; + assert(identifier, "chunk should include identifier tag"); + return `${WATCH_HISTORY_KIND}:${ACTOR}:${identifier}`; +}); + +expectedChunkAddresses.forEach((address) => { + assert( + headATags.includes(address), + `head chunk should reference ${address}` + ); +}); + +delete globalThis.window.nostr; + +if (!globalThis.window.NostrTools.nip04) { + globalThis.window.NostrTools.nip04 = {}; +} + +globalThis.window.NostrTools.nip04.decrypt = async ( + _priv, + _pub, + ciphertext +) => ciphertext; + +const decryptClient = createDecryptClient(ACTOR); +decryptClient.relays = ["wss://unit.test"]; +decryptClient.watchHistoryFetchEventLimit = 10; +decryptClient.pool = { + list: async () => publishedEvents, +}; + +const fetched = await decryptClient.fetchWatchHistory(ACTOR); + +assert.equal( + fetched.items.length, + longPointers.length, + "fetch should reassemble all chunked items" +); + +assert.deepEqual( + fetched.items.map((item) => ({ + type: item.type, + value: item.value, + relay: item.relay || null, + watchedAt: item.watchedAt, + })), + longPointers.map((item) => ({ + type: item.type, + value: item.value, + relay: item.relay || null, + watchedAt: item.watchedAt, + })), + "chunked fetch should preserve pointer order and timestamps" +); + +if (typeof localStorage !== "undefined") { + localStorage.clear(); +} + +const headEvent = publishResult.event; +const chunkEvents = publishResult.events.filter( + (event) => event.id !== headEvent.id +); + +const snapshotId = headEvent.tags.find( + (tag) => Array.isArray(tag) && tag[0] === "snapshot" +)?.[1]; + +const freshClient = createDecryptClient(ACTOR); +freshClient.relays = ["wss://unit.test"]; +freshClient.watchHistoryFetchEventLimit = 10; + +const observedFilters = []; +freshClient.pool = { + list: async (_relays, filters) => { + observedFilters.push(filters); + + const firstFilter = filters[0] || {}; + const dFilter = Array.isArray(firstFilter["#d"]) + ? firstFilter["#d"] + : []; + + if (filters.length === 1 && dFilter.includes(WATCH_HISTORY_LIST_IDENTIFIER)) { + return [headEvent]; + } + + const requestedIdentifiers = new Set(); + const requestedSnapshots = new Set(); + + for (const filter of filters) { + if (Array.isArray(filter?.["#d"])) { + for (const identifier of filter["#d"]) { + requestedIdentifiers.add(identifier); + } + } + if (Array.isArray(filter?.["#snapshot"])) { + for (const value of filter["#snapshot"]) { + requestedSnapshots.add(value); + } + } + } + + const responses = []; + + for (const event of publishResult.events) { + const identifier = event.tags.find( + (tag) => Array.isArray(tag) && tag[0] === "d" + )?.[1]; + const eventSnapshot = event.tags.find( + (tag) => Array.isArray(tag) && tag[0] === "snapshot" + )?.[1]; + + if (identifier && requestedIdentifiers.has(identifier)) { + responses.push(event); + continue; + } + if (eventSnapshot && requestedSnapshots.has(eventSnapshot)) { + responses.push(event); + } + } + + return responses; + }, +}; + +const rebuilt = await freshClient.fetchWatchHistory(ACTOR); + +assert.equal( + rebuilt.items.length, + longPointers.length, + "fresh client should rebuild full watch history list" +); + +assert.deepEqual( + rebuilt.items.map((item) => ({ + type: item.type, + value: item.value, + relay: item.relay || null, + watchedAt: item.watchedAt, + })), + longPointers.map((item) => ({ + type: item.type, + value: item.value, + relay: item.relay || null, + watchedAt: item.watchedAt, + })), + "rebuilt snapshot should preserve timestamps" +); + +assert( + observedFilters.length >= 2, + "fetch should include a follow-up query for chunk events" +); + +const followUpFilters = observedFilters[observedFilters.length - 1]; +const identifiersRequested = new Set(); +let snapshotRequested = false; + +for (const filter of followUpFilters) { + if (Array.isArray(filter?.["#d"])) { + for (const identifier of filter["#d"]) { + identifiersRequested.add(identifier); + } + } + if (Array.isArray(filter?.["#snapshot"])) { + snapshotRequested = snapshotRequested || filter["#snapshot"].includes(snapshotId); + } +} + +assert( + chunkEvents.every((event) => { + const identifier = event.tags.find( + (tag) => Array.isArray(tag) && tag[0] === "d" + )?.[1]; + return ( + identifiersRequested.has(identifier) || + (snapshotId && snapshotRequested) + ); + }), + "follow-up query should target chunk identifiers or snapshot" +); + +const MONOTONIC_ACTOR = `${ACTOR}-monotonic`; +const { client: monotonicPublishingClient, publishedEvents: monotonicEvents } = + createPublishingClient(MONOTONIC_ACTOR); + +const monotonicOriginalDateNow = Date.now; +try { + const monotonicBaseSeconds = 1_700_123_456; + Date.now = () => monotonicBaseSeconds * 1000; + + const firstMonotonicPointers = [ + { type: "e", value: "event-monotonic-1", relay: null }, + { type: "e", value: "event-monotonic-2", relay: null }, + ]; + + const firstSnapshot = await monotonicPublishingClient.publishWatchHistorySnapshot( + MONOTONIC_ACTOR, + firstMonotonicPointers + ); + + const baselineEntry = monotonicPublishingClient.createWatchHistoryEntry( + firstSnapshot.event, + firstMonotonicPointers, + Date.now() + ); + monotonicPublishingClient.watchHistoryCache.set( + MONOTONIC_ACTOR, + baselineEntry + ); + + const secondMonotonicPointers = [ + { type: "e", value: "event-monotonic-3", relay: null }, + { type: "e", value: "event-monotonic-1", relay: null }, + ]; + + const secondSnapshot = await monotonicPublishingClient.publishWatchHistorySnapshot( + MONOTONIC_ACTOR, + secondMonotonicPointers, + baselineEntry + ); + + assert( + secondSnapshot.event.created_at > firstSnapshot.event.created_at, + "new snapshot should advance created_at when reusing the same second" + ); + + const monotonicDecryptClient = createDecryptClient(MONOTONIC_ACTOR); + monotonicDecryptClient.pool = { list: async () => monotonicEvents }; + + const monotonicFetched = await monotonicDecryptClient.fetchWatchHistory( + MONOTONIC_ACTOR + ); + + assert.deepEqual( + monotonicFetched.items.map((item) => ({ + type: item.type, + value: item.value, + relay: item.relay || null, + })), + [ + { type: "e", value: "event-monotonic-3", relay: null }, + { type: "e", value: "event-monotonic-1", relay: null }, + ], + "newer snapshot should win pointer ordering" + ); +} finally { + Date.now = monotonicOriginalDateNow; +} + +localStorage.clear(); + +const UPDATE_ACTOR = `${ACTOR}-update`; +const { client: updateClient } = createPublishingClient(UPDATE_ACTOR); +const updateOriginalDateNow = Date.now; +try { + const updateTimestamp = 1_701_111_111_111; + Date.now = () => updateTimestamp; + + const updateResult = await updateClient.updateWatchHistoryList({ + type: "e", + value: "update-pointer", + relay: null, + }); + + assert.equal( + updateResult.ok, + true, + "updateWatchHistoryList should publish when adding a new pointer" + ); + const updateEntry = updateClient.watchHistoryCache.get(UPDATE_ACTOR); + assert(updateEntry, "updateWatchHistoryList should cache the new entry"); + assert.equal( + updateEntry.items[0].watchedAt, + updateTimestamp, + "cached entry should record watchedAt from Date.now()" + ); + assert.equal( + updateResult.items[0].watchedAt, + updateTimestamp, + "returned publish result should include the watchedAt timestamp" + ); +} finally { + Date.now = updateOriginalDateNow; +} + +const REMOVE_ACTOR = `${ACTOR}-remove`; +const { + client: removalClient, + payloads: removalPayloads, +} = createPublishingClient(REMOVE_ACTOR); + +const removalPointers = [ + { + type: "e", + value: "remove-first", + relay: null, + watchedAt: 1_701_222_000_000, + }, + { + type: "e", + value: "remove-second", + relay: null, + watchedAt: 1_701_222_100_000, + }, +]; + +await removalClient.publishWatchHistorySnapshot( + REMOVE_ACTOR, + removalPointers +); + +const removalKey = `e:${removalPointers[1].value.trim().toLowerCase()}`; +const removalResult = await removalClient.removeWatchHistoryItem(removalKey); + +assert.equal(removalResult.ok, true, "removeWatchHistoryItem should succeed"); +assert.equal( + removalResult.removedKey, + removalKey, + "removeWatchHistoryItem should echo the removed pointer key" +); + +const latestRemovalPayload = removalPayloads[removalPayloads.length - 1]; +assert.equal( + latestRemovalPayload.items.length, + 1, + "removal republish should trim the pointer list" +); +assert.equal( + latestRemovalPayload.items[0].value, + removalPointers[0].value, + "republished payload should only include remaining pointer" +); +assert.equal( + latestRemovalPayload.items[0].watchedAt, + removalPointers[0].watchedAt, + "republished payload should preserve watchedAt" +); + +const removalEntry = removalClient.watchHistoryCache.get(REMOVE_ACTOR); +assert.equal( + removalEntry.items.length, + 1, + "watch history cache should drop the removed pointer" +); +assert.equal( + removalEntry.items[0].value, + removalPointers[0].value, + "remaining cached pointer should match the survivor" +); +assert.equal( + removalEntry.items[0].watchedAt, + removalPointers[0].watchedAt, + "remaining cached pointer should retain watchedAt" +); + +assert.equal( + removalResult.items.map((item) => item.value).join(","), + removalPointers.slice(0, 1).map((item) => item.value).join(","), + "removeWatchHistoryItem result should reflect updated items" +); +assert.equal( + removalResult.items[0].watchedAt, + removalPointers[0].watchedAt, + "removeWatchHistoryItem result should preserve watchedAt" +); + +const VIDEO_POINTER_AUTHOR = `${ACTOR}-video-author`; +const VIDEO_POINTER_IDENTIFIER = "pointer-video-identifier"; +const videoPointer = { + type: "a", + value: `${WATCH_HISTORY_KIND}:${VIDEO_POINTER_AUTHOR}:${VIDEO_POINTER_IDENTIFIER}`, + relay: null, +}; + +const pointerClient = createDecryptClient(ACTOR); +const pointerEntry = pointerClient.createWatchHistoryEntry( + null, + [videoPointer], + Date.now() +); +pointerClient.watchHistoryCache.set(ACTOR, pointerEntry); +pointerClient.fetchWatchHistory = async () => {}; + +const observedResolveFilters = []; +const videoEvent = { + id: "video-pointer-event", + kind: WATCH_HISTORY_KIND, + pubkey: VIDEO_POINTER_AUTHOR, + created_at: 1_700_555_000, + tags: [["d", VIDEO_POINTER_IDENTIFIER]], + content: JSON.stringify({ + version: 3, + title: "Pointer Video", + url: "https://cdn.example.com/video.mp4", + }), +}; + +pointerClient.pool = { + list: async (_relays, filters) => { + observedResolveFilters.push(filters); + return [videoEvent]; + }, +}; + +const resolvedFromPointer = await pointerClient.resolveWatchHistory(1); + +const pointerKey = `a:${videoPointer.value.trim().toLowerCase()}`; + +assert.equal( + resolvedFromPointer.length, + 1, + "resolveWatchHistory should return results for 'a' style video pointers" +); +assert.equal( + resolvedFromPointer[0].id, + videoEvent.id, + "resolved video should match the pointer event id" +); +assert.equal( + resolvedFromPointer[0].title, + "Pointer Video", + "resolved video should parse title from the event payload" +); + +assert(resolvedFromPointer[0].watchHistory, "resolved video should include watchHistory metadata"); +assert.equal( + resolvedFromPointer[0].watchHistory.key, + pointerKey, + "watchHistory metadata should expose the pointer key" +); +assert.equal( + resolvedFromPointer[0].watchHistory.pointer.value, + videoPointer.value, + "watchHistory metadata should include the pointer payload" +); +assert.equal( + resolvedFromPointer[0].watchHistory.pointer.watchedAt, + pointerEntry.items[0].watchedAt, + "watchHistory metadata should preserve watchedAt" +); + +const cachedEntry = pointerClient.watchHistoryCache.get(ACTOR); +assert( + cachedEntry.resolvedVideos.has(pointerKey), + "resolved cache should retain the video" +); +assert.equal( + cachedEntry.resolvedVideos.get(pointerKey)?.id, + videoEvent.id, + "resolved cache should store the converted video result" +); +assert( + cachedEntry.delivered.has(pointerKey), + "delivery set should mark the pointer as delivered" +); + +assert( + observedResolveFilters.some((filters) => + filters.some((filter) => + Array.isArray(filter?.["#d"]) && + filter["#d"].includes(VIDEO_POINTER_IDENTIFIER) + ) + ), + "resolveWatchHistory should request the video identifier instead of treating it as a chunk" +); + +const secondResolve = await pointerClient.resolveWatchHistory(1); +assert.equal( + secondResolve.length, + 0, + "subsequent resolve calls should avoid refetching already delivered videos" +); + +pointerClient.resetWatchHistoryProgress(ACTOR); +assert.equal( + cachedEntry.delivered.size, + 0, + "resetWatchHistoryProgress should clear delivered pointers" +); + +pointerClient.pool = { + list: async () => { + throw new Error("cached resolve should not trigger network fetch"); + }, +}; + +const cachedResolve = await pointerClient.resolveWatchHistory(1); +assert.equal( + cachedResolve.length, + 1, + "reset should allow cached videos to be delivered again" +); +assert.equal( + cachedResolve[0].id, + videoEvent.id, + "cached resolve should reuse the stored video payload" +); +assert( + cachedResolve[0].watchHistory?.watchedAt === pointerEntry.items[0].watchedAt, + "cached resolve should retain watchHistory metadata" +); + +const originalDateNow = Date.now; +try { + const baseTime = 1_700_000_000_000; + const ttlActor = `${ACTOR}-ttl`; + const pointer = { type: "e", value: "event-ttl", relay: null }; + + const cachingClient = createDecryptClient(ttlActor); + cachingClient.watchHistoryCacheTtlMs = 24 * 60 * 60 * 1000; + + Date.now = () => baseTime; + + const entry = cachingClient.createWatchHistoryEntry(null, [pointer], baseTime); + cachingClient.watchHistoryCache.set(ttlActor, entry); + cachingClient.persistWatchHistoryEntry(ttlActor, entry); + + const reloadClient = createDecryptClient(ttlActor); + reloadClient.watchHistoryCacheTtlMs = cachingClient.watchHistoryCacheTtlMs; + + Date.now = () => baseTime + 60 * 60 * 1000; + + const cachedSnapshot = await reloadClient.fetchWatchHistory(ttlActor); + assert.equal( + cachedSnapshot.items.length, + 1, + "cached snapshot should survive within extended TTL" + ); + assert.equal( + cachedSnapshot.items[0].value, + pointer.value, + "cached snapshot should preserve pointer" + ); + + localStorage.clear(); + + const expiryClient = createDecryptClient(ttlActor); + expiryClient.watchHistoryCacheTtlMs = 1_000; + + Date.now = () => baseTime; + + const expiringEntry = expiryClient.createWatchHistoryEntry( + null, + [pointer], + baseTime + ); + expiryClient.watchHistoryCache.set(ttlActor, expiringEntry); + expiryClient.persistWatchHistoryEntry(ttlActor, expiringEntry); + + Date.now = () => baseTime + 1_500; + + const expiredSnapshot = await expiryClient.fetchWatchHistory(ttlActor); + assert.equal( + expiredSnapshot.items.length, + 0, + "expired snapshot should be dropped" + ); + const cachedFallback = expiryClient.watchHistoryCache.get(ttlActor); + assert.equal( + cachedFallback?.items?.length ?? 0, + 0, + "expired snapshot should not preserve stale items in memory" + ); + const storage = expiryClient.getWatchHistoryStorage(); + assert.equal( + Object.prototype.hasOwnProperty.call(storage.actors, ttlActor), + false, + "expired snapshot should be removed from localStorage" + ); +} finally { + Date.now = originalDateNow; +} + +console.log("watch history tests passed"); diff --git a/views/history.html b/views/history.html new file mode 100644 index 00000000..f5d08587 --- /dev/null +++ b/views/history.html @@ -0,0 +1,26 @@ + +
+
+

Watch History

+
+
+ + +
+ + +
+ +