From 80fcb22f89328e59bb7baa290a88713124bbdd3d Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 30 Sep 2025 10:59:33 -0400 Subject: [PATCH] Add profile watch history pane to modal --- components/profile-modal.html | 39 +++ js/app.js | 66 +++++ js/historyView.js | 483 ++++++++++++++++++++++------------ 3 files changed, 413 insertions(+), 175 deletions(-) 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. + + + + + + + { + 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; 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 = {}; }
+ Your recently watched videos appear here so you can jump back into favorites. +