diff --git a/components/profile-modal.html b/components/profile-modal.html index b473123d..3c3728e0 100644 --- a/components/profile-modal.html +++ b/components/profile-modal.html @@ -88,6 +88,15 @@ > Blocked + + Watch history + + + + Your recently watched videos appear here so you can jump back into favorites. + + + + + + + You haven’t watched any videos yet. + + + + + + + diff --git a/js/app.js b/js/app.js index bba4a79e..6a16a6d6 100644 --- a/js/app.js +++ b/js/app.js @@ -41,6 +41,7 @@ import { ensureBucketCors, } from "./storage/r2-s3.js"; import { initQuickR2Upload } from "./r2-quick.js"; +import { createWatchHistoryRenderer } from "./historyView.js"; function truncateMiddle(text, maxLength = 72) { if (!text || typeof text !== "string") { @@ -543,12 +544,14 @@ class bitvidApp { account: null, relays: null, blocked: null, + history: null, admin: null, }; this.profilePaneElements = { account: null, relays: null, blocked: null, + history: null, admin: null, }; this.profileRelayList = null; @@ -559,6 +562,8 @@ class bitvidApp { this.profileRelayInput = null; this.profileAddRelayBtn = null; this.profileRestoreRelaysBtn = null; + this.profileHistoryRenderer = null; + this.activeProfilePane = null; this.adminModeratorInput = null; this.adminAddModeratorBtn = null; this.adminModeratorList = null; @@ -3915,6 +3920,8 @@ class bitvidApp { document.getElementById("profileNavRelays") || null; this.profileNavButtons.blocked = document.getElementById("profileNavBlocked") || null; + this.profileNavButtons.history = + document.getElementById("profileNavHistory") || null; this.profileNavButtons.admin = document.getElementById("profileNavAdmin") || null; this.profilePaneElements.account = @@ -3923,6 +3930,8 @@ class bitvidApp { document.getElementById("profilePaneRelays") || null; this.profilePaneElements.blocked = document.getElementById("profilePaneBlocked") || null; + this.profilePaneElements.history = + document.getElementById("profilePaneHistory") || null; this.profilePaneElements.admin = document.getElementById("profilePaneAdmin") || null; this.profileRelayList = document.getElementById("relayList") || null; @@ -3937,6 +3946,21 @@ class bitvidApp { this.profileAddRelayBtn = document.getElementById("addRelayBtn") || null; this.profileRestoreRelaysBtn = document.getElementById("restoreRelaysBtn") || null; + if (!this.profileHistoryRenderer) { + this.profileHistoryRenderer = createWatchHistoryRenderer({ + viewSelector: "#profilePaneHistory", + gridSelector: "#profileHistoryGrid", + loadingSelector: "#profileHistoryLoading", + emptySelector: "#profileHistoryEmpty", + sentinelSelector: "#profileHistorySentinel", + scrollContainerSelector: "#profileHistoryScroll", + emptyCopy: "You haven’t watched any videos yet.", + beforeInitialLoad: async () => { + const actor = this.pubkey || undefined; + await nostrClient.fetchWatchHistory(actor); + }, + }); + } this.adminModeratorsSection = document.getElementById("adminModeratorsSection") || null; this.adminModeratorsEmpty = @@ -4138,6 +4162,7 @@ class bitvidApp { selectProfilePane(name = "account") { const normalized = typeof name === "string" ? name.toLowerCase() : "account"; + const previous = this.activeProfilePane; const availableKeys = Object.keys(this.profilePaneElements).filter((key) => { const pane = this.profilePaneElements[key]; if (!(pane instanceof HTMLElement)) { @@ -4157,6 +4182,14 @@ class bitvidApp { ? normalized : fallbackTarget; + if (previous === "history" && target !== "history") { + try { + this.profileHistoryRenderer?.pause(); + } catch (error) { + console.warn("[profileModal] Failed to pause history renderer:", error); + } + } + Object.entries(this.profilePaneElements).forEach(([key, pane]) => { if (!(pane instanceof HTMLElement)) { return; @@ -4178,6 +4211,11 @@ class bitvidApp { }); this.updateProfileModalFocusables(); + this.activeProfilePane = target; + + if (target === "history") { + void this.populateProfileWatchHistory(); + } } updateProfileModalFocusables() { @@ -4220,6 +4258,7 @@ class bitvidApp { console.error("Failed to refresh admin pane while opening profile modal:", error); } + this.activeProfilePane = null; this.selectProfilePane("account"); this.populateProfileRelays(); try { @@ -4342,6 +4381,17 @@ class bitvidApp { this.closeAllMoreMenus(); + try { + this.profileHistoryRenderer?.destroy(); + } catch (error) { + console.warn( + "[profileModal] Failed to reset watch history renderer on close:", + error + ); + } + + this.activeProfilePane = null; + if ( this.lastFocusedBeforeProfileModal && typeof this.lastFocusedBeforeProfileModal.focus === "function" @@ -4549,6 +4599,22 @@ class bitvidApp { }); } + async populateProfileWatchHistory() { + if (!this.profileHistoryRenderer) { + return; + } + + try { + await this.profileHistoryRenderer.ensureInitialLoad(); + this.profileHistoryRenderer.resume(); + } catch (error) { + console.error( + "[profileModal] Failed to populate watch history pane:", + error + ); + } + } + async handleAddProfile() { if (!this.profileAddAccountBtn) { return; @@ -5506,6 +5572,12 @@ class bitvidApp { } attachVideoListHandler() { + const nextTarget = document; + + if (this._videoListElement === nextTarget && this._videoListClickHandler) { + return; + } + if (this._videoListElement && this._videoListClickHandler) { this._videoListElement.removeEventListener( "click", @@ -5515,10 +5587,6 @@ class bitvidApp { this._videoListClickHandler = null; } - if (!this.videoList) { - return; - } - const handler = async (event) => { const trigger = event.target.closest( "[data-play-magnet],[data-play-url]" @@ -5527,7 +5595,9 @@ class bitvidApp { return; } - if (event.button === 0 && !event.ctrlKey && !event.metaKey) { + const isPrimaryClick = + typeof event.button !== "number" || event.button === 0; + if (isPrimaryClick && !event.ctrlKey && !event.metaKey) { event.preventDefault(); const rawUrlValue = @@ -5560,9 +5630,9 @@ class bitvidApp { } }; - this._videoListElement = this.videoList; + nextTarget.addEventListener("click", handler); + this._videoListElement = nextTarget; this._videoListClickHandler = handler; - this.videoList.addEventListener("click", handler); } /** diff --git a/js/channelProfile.js b/js/channelProfile.js index 3a3865d8..8e0b6dc1 100644 --- a/js/channelProfile.js +++ b/js/channelProfile.js @@ -794,7 +794,6 @@ async function loadUserVideos(pubkey) { app.handleUrlHealthBadge({ video, url, badgeEl }); }); - window.app.videoList = container; window.app.attachVideoListHandler(); // Lazy-load images diff --git a/js/historyView.js b/js/historyView.js index 9877a81f..ffbad480 100644 --- a/js/historyView.js +++ b/js/historyView.js @@ -6,214 +6,347 @@ import { subscriptions } from "./subscriptions.js"; const DEFAULT_BATCH_SIZE = 20; const BATCH_SIZE = WATCH_HISTORY_BATCH_RESOLVE ? DEFAULT_BATCH_SIZE : 1; -const EMPTY_COPY = + +export const WATCH_HISTORY_EMPTY_COPY = "Your watch history is empty. Watch some videos to populate this list."; -let isLoading = false; -let hasMore = true; -let observer = null; -let scrollHandler = null; -let resolvedVideos = []; +function toNumber(value, fallback) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; +} -function getElements() { - return { - view: document.getElementById("watchHistoryView"), - grid: document.getElementById("watchHistoryGrid"), - loadingEl: document.getElementById("watchHistoryLoading"), - emptyEl: document.getElementById("watchHistoryEmpty"), - sentinel: document.getElementById("watchHistorySentinel"), +export function createWatchHistoryRenderer(config = {}) { + const { + viewSelector = "#watchHistoryView", + gridSelector = "#watchHistoryGrid", + loadingSelector = "#watchHistoryLoading", + emptySelector = "#watchHistoryEmpty", + sentinelSelector = "#watchHistorySentinel", + scrollContainerSelector = null, + emptyCopy = WATCH_HISTORY_EMPTY_COPY, + beforeInitialLoad = async () => { + const actor = window.app?.pubkey || undefined; + await nostrClient.fetchWatchHistory(actor); + }, + resolveBatch = (size) => nostrClient.resolveWatchHistory(size), + renderGrid = null, + batchSize = BATCH_SIZE, + } = config; + + const selectors = { + view: viewSelector, + grid: gridSelector, + loading: loadingSelector, + empty: emptySelector, + sentinel: sentinelSelector, + scrollContainer: scrollContainerSelector, }; -} -function resetUiState() { - const { grid, loadingEl, emptyEl } = getElements(); - if (grid) { - grid.innerHTML = ""; - grid.classList.add("hidden"); - } - if (loadingEl) { - loadingEl.classList.remove("hidden"); - } - if (emptyEl) { - emptyEl.textContent = EMPTY_COPY; - emptyEl.classList.add("hidden"); - } -} + const state = { + isLoading: false, + hasMore: true, + observer: null, + scrollListener: null, + resolvedVideos: [], + initialized: false, + emptyCopy, + batchSize: Math.max(1, toNumber(batchSize, BATCH_SIZE)), + }; -function cleanupObservers() { - if (observer) { - observer.disconnect(); - observer = null; - } - if (scrollHandler) { - window.removeEventListener("scroll", scrollHandler); - scrollHandler = null; - } -} - -function setLoadingVisible(visible) { - const { loadingEl } = getElements(); - if (!loadingEl) { - return; - } - if (visible) { - loadingEl.classList.remove("hidden"); - } else { - loadingEl.classList.add("hidden"); - } -} - -function showEmptyState(message = EMPTY_COPY) { - const { grid, emptyEl } = getElements(); - if (grid) { - grid.innerHTML = ""; - grid.classList.add("hidden"); - } - if (emptyEl) { - emptyEl.textContent = message; - emptyEl.classList.remove("hidden"); - } - setLoadingVisible(false); - hasMore = false; - cleanupObservers(); -} - -function mergeResolvedVideos(nextVideos) { - if (!Array.isArray(nextVideos) || !nextVideos.length) { - return; - } - const dedupeMap = new Map(); - resolvedVideos.forEach((video) => { - if (video && video.id) { - dedupeMap.set(video.id, video); + const query = (selector) => { + if (!selector) { + return null; } - }); - nextVideos.forEach((video) => { - if (video && video.id) { - dedupeMap.set(video.id, video); + 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), + emptyEl: query(selectors.empty), + sentinel: query(selectors.sentinel), + scrollContainer: query(selectors.scrollContainer), }); - resolvedVideos = Array.from(dedupeMap.values()); -} -function renderResolvedVideos() { - const { grid, emptyEl } = getElements(); - if (!grid) { - return; - } + const setLoadingVisible = (visible) => { + const { loadingEl } = getElements(); + if (!loadingEl) { + return; + } + if (visible) { + loadingEl.classList.remove("hidden"); + } else { + loadingEl.classList.add("hidden"); + } + }; - if (!resolvedVideos.length) { + const resetUiState = () => { + const { grid, loadingEl, emptyEl } = getElements(); + if (grid) { + grid.innerHTML = ""; + grid.classList.add("hidden"); + } + if (loadingEl) { + loadingEl.classList.remove("hidden"); + } if (emptyEl) { - emptyEl.textContent = EMPTY_COPY; + emptyEl.textContent = state.emptyCopy; + emptyEl.classList.add("hidden"); + } + }; + + const cleanupObservers = () => { + if (state.observer) { + state.observer.disconnect(); + state.observer = null; + } + if (state.scrollListener?.target && state.scrollListener?.handler) { + state.scrollListener.target.removeEventListener( + "scroll", + state.scrollListener.handler + ); + state.scrollListener = 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"); } - grid.classList.add("hidden"); - return; - } + setLoadingVisible(false); + state.hasMore = false; + cleanupObservers(); + }; - subscriptions.renderSameGridStyle(resolvedVideos, "watchHistoryGrid"); - grid.classList.remove("hidden"); - if (emptyEl) { - emptyEl.classList.add("hidden"); - } -} - -async function loadNextBatch({ initial = false } = {}) { - if (isLoading || !hasMore) { - if (initial && !isLoading && resolvedVideos.length === 0) { - setLoadingVisible(false); + const mergeResolvedVideos = (nextVideos) => { + if (!Array.isArray(nextVideos) || !nextVideos.length) { + return; } - return; - } - isLoading = true; + const dedupeMap = new Map(); + state.resolvedVideos.forEach((video) => { + if (video && video.id) { + dedupeMap.set(video.id, video); + } + }); + nextVideos.forEach((video) => { + if (video && video.id) { + dedupeMap.set(video.id, video); + } + }); + state.resolvedVideos = Array.from(dedupeMap.values()); + }; - try { - const videos = await nostrClient.resolveWatchHistory(BATCH_SIZE); + const renderResolvedVideos = () => { + const { grid, emptyEl } = getElements(); + if (!grid) { + return; + } - if (videos.length === 0) { - if (initial && resolvedVideos.length === 0) { - showEmptyState(); - } else { - hasMore = false; - cleanupObservers(); + if (!state.resolvedVideos.length) { + if (emptyEl) { + emptyEl.textContent = state.emptyCopy; + emptyEl.classList.remove("hidden"); + } + grid.classList.add("hidden"); + return; + } + + if (typeof renderGrid === "function") { + renderGrid(state.resolvedVideos, grid); + } else if (grid.id) { + subscriptions.renderSameGridStyle(state.resolvedVideos, grid.id); + } else { + subscriptions.renderSameGridStyle(state.resolvedVideos, "watchHistoryGrid"); + } + + grid.classList.remove("hidden"); + if (emptyEl) { + emptyEl.classList.add("hidden"); + } + }; + + const loadNextBatch = async ({ initial = false } = {}) => { + if (state.isLoading || !state.hasMore) { + if (initial && !state.isLoading && state.resolvedVideos.length === 0) { + setLoadingVisible(false); } return; } - mergeResolvedVideos(videos); - renderResolvedVideos(); - setLoadingVisible(false); - } catch (error) { - console.error("[historyView] Failed to resolve watch history:", error); - if (initial && resolvedVideos.length === 0) { - showEmptyState("We couldn't load your watch history. Please try again later."); - } - } finally { - isLoading = false; - } -} + state.isLoading = true; -function attachObservers() { - const { sentinel } = getElements(); - if (!sentinel || !hasMore) { - return; - } + try { + const videos = await resolveBatch(state.batchSize); - if ("IntersectionObserver" in window) { - observer = new IntersectionObserver( - (entries) => { - entries.forEach((entry) => { - if (entry.isIntersecting) { - loadNextBatch(); - } - }); - }, - { rootMargin: "300px 0px" } - ); - observer.observe(sentinel); - return; - } + if (!Array.isArray(videos) || videos.length === 0) { + if (initial && state.resolvedVideos.length === 0) { + showEmptyState(); + } else { + state.hasMore = false; + cleanupObservers(); + setLoadingVisible(false); + } + return; + } - scrollHandler = () => { - const rect = sentinel.getBoundingClientRect(); - if (rect.top <= window.innerHeight + 200) { - loadNextBatch(); + mergeResolvedVideos(videos); + renderResolvedVideos(); + setLoadingVisible(false); + } 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; } }; - window.addEventListener("scroll", scrollHandler, { passive: true }); - scrollHandler(); + + const attachObservers = () => { + const { sentinel, scrollContainer } = getElements(); + if (!sentinel || !state.hasMore) { + return; + } + + if ("IntersectionObserver" in window) { + state.observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + 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) { + loadNextBatch(); + } + }; + + target.addEventListener("scroll", handler, { passive: true }); + state.scrollListener = { target, handler }; + handler(); + }; + + const runInitialLoad = async () => { + const { view } = getElements(); + if (!view) { + return; + } + + cleanupObservers(); + state.resolvedVideos = []; + state.hasMore = true; + state.isLoading = false; + state.initialized = false; + resetUiState(); + + try { + await beforeInitialLoad?.(); + } catch (error) { + console.error("[historyView] Failed to fetch watch history list:", error); + showEmptyState( + "We couldn't load your watch history. Please try again later." + ); + return; + } + + await loadNextBatch({ initial: true }); + + if (state.hasMore) { + attachObservers(); + } + + state.initialized = true; + }; + + return { + async init() { + await runInitialLoad(); + }, + async ensureInitialLoad() { + const { view } = getElements(); + if (!view) { + return; + } + + if (!state.initialized) { + await runInitialLoad(); + return; + } + + if (!state.resolvedVideos.length && !state.isLoading) { + await loadNextBatch({ initial: true }); + } + }, + async loadMore() { + await loadNextBatch(); + }, + resume() { + cleanupObservers(); + if (state.hasMore) { + attachObservers(); + } + }, + pause() { + cleanupObservers(); + }, + destroy() { + cleanupObservers(); + state.initialized = false; + state.resolvedVideos = []; + state.hasMore = true; + state.isLoading = false; + resetUiState(); + setLoadingVisible(false); + }, + render() { + renderResolvedVideos(); + }, + getState() { + return { + ...state, + resolvedVideos: state.resolvedVideos.slice(), + }; + }, + }; } +export const watchHistoryRenderer = createWatchHistoryRenderer(); + export async function initHistoryView() { - const { view } = getElements(); - if (!view) { - return; - } - - cleanupObservers(); - resolvedVideos = []; - hasMore = true; - isLoading = false; - resetUiState(); - - try { - const actor = window.app?.pubkey || undefined; - await nostrClient.fetchWatchHistory(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; - } - - await loadNextBatch({ initial: true }); - - if (hasMore) { - attachObservers(); - } + await watchHistoryRenderer.init(); } -// Expose init on window for debugging/manual triggers if needed. if (!window.bitvid) { window.bitvid = {}; } 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 93188edc..7533dc61 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -687,6 +687,7 @@ class NostrClient { this.sessionActor = null; this.watchHistoryCache = new Map(); this.watchHistoryStorage = null; + this.watchHistoryRepublishTimers = new Map(); } restoreLocalData() { @@ -934,6 +935,206 @@ class NostrClient { } } + 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 tags = [["d", WATCH_HISTORY_LIST_IDENTIFIER]]; + for (const item of trimmedItems) { + const tag = pointerToTag(item); + if (tag) { + tags.push(tag); + } + } + + const event = { + kind: WATCH_HISTORY_KIND, + pubkey: normalizedActor, + created_at: Math.floor(Date.now() / 1000), + tags, + content: "", + }; + + let signedEvent; + const normalizedLogged = + typeof this.pubkey === "string" ? this.pubkey.toLowerCase() : ""; + + if ( + normalizedActor.toLowerCase() === normalizedLogged && + window?.nostr && + typeof window.nostr.signEvent === "function" + ) { + try { + signedEvent = await window.nostr.signEvent(event); + } catch (error) { + console.warn( + "[nostr] Failed to sign watch history list with extension:", + error + ); + return { ok: false, error: "signing-failed", details: error }; + } + } else { + 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"); + } + signedEvent = signEventWithPrivateKey( + event, + this.sessionActor.privateKey + ); + } catch (error) { + console.warn( + "[nostr] Failed to sign watch history list with session key:", + error + ); + return { ok: false, error: "signing-failed", details: error }; + } + } + + const relays = + Array.isArray(this.relays) && this.relays.length ? this.relays : 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] Failed to publish watch history list:", + publishResults + ); + } + + if (success) { + this.cancelWatchHistoryRepublish(normalizedActor); + } else if (allowRetry) { + this.scheduleWatchHistoryRepublish(normalizedActor, trimmedItems); + } + + const baselineEntry = + existingEntry || this.watchHistoryCache.get(normalizedActor) || null; + + const newEntry = this.createWatchHistoryEntry( + signedEvent, + trimmedItems, + Date.now(), + baselineEntry + ); + + this.watchHistoryCache.set(normalizedActor, newEntry); + this.persistWatchHistoryEntry(normalizedActor, newEntry); + + return { + ok: success, + event: signedEvent, + items: newEntry.items + .map((item) => clonePointerItem(item)) + .filter(Boolean), + }; + } + async ensureSessionActor(forceSession = false) { const loggedInPubkey = typeof this.pubkey === "string" && this.pubkey.trim() @@ -979,10 +1180,19 @@ class NostrClient { } } - if ( - !window?.NostrTools?.generatePrivateKey || - typeof window.NostrTools.getPublicKey !== "function" - ) { + 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." @@ -991,8 +1201,69 @@ class NostrClient { return ""; } - const privateKey = window.NostrTools.generatePrivateKey(); - const pubkey = window.NostrTools.getPublicKey(privateKey); + 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 }; @@ -1128,118 +1399,8 @@ class NostrClient { this.watchHistoryCache.get(actorPubkey) || this.createWatchHistoryEntry(null, [], Date.now()); - const dedupe = new Map(); - const nextItems = []; - const pushPointer = (item) => { - const candidate = normalizePointerInput(item); - if (!candidate) { - return; - } - const key = pointerKey(candidate); - if (!key || dedupe.has(key)) { - return; - } - dedupe.set(key, true); - nextItems.push(candidate); - }; - - pushPointer(normalizedPointer); - for (const item of existingEntry.items) { - pushPointer(item); - if (nextItems.length >= WATCH_HISTORY_MAX_ITEMS) { - break; - } - } - - const trimmedItems = - nextItems.length > WATCH_HISTORY_MAX_ITEMS - ? nextItems.slice(0, WATCH_HISTORY_MAX_ITEMS) - : nextItems; - - const tags = [["d", WATCH_HISTORY_LIST_IDENTIFIER]]; - for (const item of trimmedItems) { - const tag = pointerToTag(item); - if (tag) { - tags.push(tag); - } - } - - const event = { - kind: WATCH_HISTORY_KIND, - pubkey: actorPubkey, - created_at: Math.floor(Date.now() / 1000), - tags, - content: "", - }; - - let signedEvent; - const normalizedActor = actorPubkey.toLowerCase(); - const normalizedLogged = - typeof this.pubkey === "string" ? this.pubkey.toLowerCase() : ""; - - 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 watch history list 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 watch history list with session key:", - error - ); - return { ok: false, error: "signing-failed", details: error }; - } - } - - const relays = - Array.isArray(this.relays) && this.relays.length ? this.relays : 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] Failed to publish watch history list:", - publishResults - ); - } - - const newEntry = this.createWatchHistoryEntry( - signedEvent, - trimmedItems, - Date.now(), - existingEntry - ); - - this.watchHistoryCache.set(actorPubkey, newEntry); - this.persistWatchHistoryEntry(actorPubkey, newEntry); - - return { - ok: success, - event: signedEvent, - items: newEntry.items.map((item) => clonePointerItem(item)).filter(Boolean), - }; + const candidates = [normalizedPointer, ...(existingEntry.items || [])]; + return this.publishWatchHistorySnapshot(actorPubkey, candidates, existingEntry); } async fetchWatchHistory(pubkeyOrSession) { @@ -1269,9 +1430,11 @@ class NostrClient { }; } + let storage = null; + let stored = null; if (typeof localStorage !== "undefined") { - const storage = this.getWatchHistoryStorage(); - const stored = storage.actors?.[actor]; + storage = this.getWatchHistoryStorage(); + stored = storage.actors?.[actor]; if ( stored && typeof stored.savedAt === "number" && @@ -1354,17 +1517,48 @@ class NostrClient { } } - const items = pointerEvent ? extractPointerItemsFromEvent(pointerEvent) : []; + const previousEntry = this.watchHistoryCache.get(actor) || null; + const remoteItems = pointerEvent + ? extractPointerItemsFromEvent(pointerEvent) + : []; + + let items = remoteItems; + let pointerForEntry = pointerEvent; + let usedFallback = false; + + const storedItems = Array.isArray(stored?.items) ? stored.items : []; + const storedPointerEvent = stored?.pointerEvent || null; + + if (!pointerEvent && previousEntry?.pointerEvent) { + pointerForEntry = previousEntry.pointerEvent; + } else if (!pointerEvent && storedPointerEvent) { + pointerForEntry = storedPointerEvent; + } + + if (!items.length) { + if (previousEntry?.items?.length) { + items = previousEntry.items; + usedFallback = true; + } else if (storedItems.length) { + items = storedItems; + usedFallback = true; + } + } + const entry = this.createWatchHistoryEntry( - pointerEvent, + pointerForEntry, items, Date.now(), - this.watchHistoryCache.get(actor) || null + previousEntry ); this.watchHistoryCache.set(actor, entry); this.persistWatchHistoryEntry(actor, entry); + if (!pointerEvent && usedFallback && entry.items.length) { + this.scheduleWatchHistoryRepublish(actor, entry.items); + } + return { pointerEvent: entry.pointerEvent ? cloneEventForCache(entry.pointerEvent) @@ -1711,6 +1905,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."); } 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]");
+ Your recently watched videos appear here so you can jump back into favorites. +