Add profile watch history pane to modal

This commit is contained in:
thePR0M3TH3AN committed 2025-09-30 10:59:33 -04:00
1 parent c9857839f5
commit 80fcb22f89
3 files changed
+413 -175

No files matched your search

+39
View File
@@ -88,6 +88,15 @@
>
Blocked
</button>
<button
id="profileNavHistory"
type="button"
class="profile-nav-button px-4 py-2 rounded-md text-sm font-medium text-gray-400 hover:text-white hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-gray-900"
aria-controls="profilePaneHistory"
aria-selected="false"
>
Watch history
</button>
<button
id="profileNavAdmin"
type="button"
@@ -272,6 +281,36 @@
</div>
<ul id="blockedList" class="space-y-3"></ul>
</section>
<section
id="profilePaneHistory"
class="profile-pane hidden space-y-4"
role="tabpanel"
aria-labelledby="profileNavHistory"
>
<p class="text-sm text-gray-400">
Your recently watched videos appear here so you can jump back into favorites.
</p>
<div class="space-y-4">
<div id="profileHistoryLoading" class="flex justify-center py-6">
<div class="h-8 w-8 border-4 border-blue-500 border-t-transparent rounded-full animate-spin"></div>
</div>
<div
id="profileHistoryEmpty"
class="hidden rounded-lg border border-dashed border-gray-700 p-6 text-center text-sm text-gray-400"
>
You haven’t watched any videos yet.
</div>
<div id="profileHistoryScroll" class="relative max-h-[420px] overflow-y-auto pr-1">
<div
id="profileHistoryGrid"
class="hidden"
role="list"
style="display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1.5rem;"
></div>
<div id="profileHistorySentinel" class="h-1 w-full"></div>
</div>
</div>
</section>
<section
id="profilePaneAdmin"
class="profile-pane hidden space-y-6"
+66
View File
@@ -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;
+308 -175
View File
@@ -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 = {};
}