mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 16:02:26 +00:00
1963 lines
58 KiB
JavaScript
1963 lines
58 KiB
JavaScript
// js/historyView.js
|
|
|
|
import watchHistoryService from "./watchHistoryService.js";
|
|
import {
|
|
pointerKey,
|
|
normalizePointerInput,
|
|
nostrClient,
|
|
updateWatchHistoryList,
|
|
} from "./nostr.js";
|
|
import {
|
|
WATCH_HISTORY_BATCH_RESOLVE,
|
|
WATCH_HISTORY_BATCH_PAGE_SIZE,
|
|
} from "./config.js";
|
|
import { getApplication } from "./applicationContext.js";
|
|
|
|
export const WATCH_HISTORY_EMPTY_COPY =
|
|
"Your watch history is empty. Watch some videos to populate this list.";
|
|
|
|
export const WATCH_HISTORY_DISABLED_COPY =
|
|
"Watch history sync is unavailable. Connect a NIP-07 extension or log in to enable encrypted syncing.";
|
|
|
|
const WATCH_HISTORY_METADATA_PREF_KEY =
|
|
"bitvid:watch-history:metadata-preference";
|
|
const WATCH_HISTORY_PRIVACY_DISMISSED_KEY =
|
|
"bitvid:watch-history:privacy-banner-dismissed";
|
|
const DEFAULT_WATCH_HISTORY_BATCH_SIZE = 12;
|
|
const WATCH_HISTORY_BATCH_SIZE = (() => {
|
|
const raw = Number(WATCH_HISTORY_BATCH_PAGE_SIZE);
|
|
if (!Number.isFinite(raw) || raw <= 0) {
|
|
return DEFAULT_WATCH_HISTORY_BATCH_SIZE;
|
|
}
|
|
return Math.floor(raw);
|
|
})();
|
|
const FALLBACK_THUMBNAIL = "assets/svg/default-thumbnail.svg";
|
|
const FALLBACK_AVATAR = "assets/svg/default-profile.svg";
|
|
const isDevEnv =
|
|
typeof process !== "undefined" &&
|
|
process?.env?.NODE_ENV !== "production";
|
|
|
|
function escapeSelector(value) {
|
|
if (typeof value !== "string") {
|
|
return "";
|
|
}
|
|
if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
|
|
return CSS.escape(value);
|
|
}
|
|
return value.replace(/([^a-zA-Z0-9_-])/g, "\\$1");
|
|
}
|
|
|
|
function resolveElement(selector) {
|
|
if (!selector || typeof selector !== "string") {
|
|
return null;
|
|
}
|
|
try {
|
|
return document.querySelector(selector);
|
|
} catch (error) {
|
|
console.warn("[historyView] Failed to query selector:", selector, error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function setHidden(element, hidden) {
|
|
if (!(element instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
element.classList.toggle("hidden", hidden);
|
|
if (hidden) {
|
|
element.setAttribute("aria-hidden", "true");
|
|
} else {
|
|
element.removeAttribute("aria-hidden");
|
|
}
|
|
}
|
|
|
|
function setTextContent(element, text) {
|
|
if (!(element instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
element.textContent = text;
|
|
}
|
|
|
|
function getAppInstance() {
|
|
return getApplication();
|
|
}
|
|
|
|
function buildWatchHistoryFeedRuntime({ actor, cursor = 0 } = {}) {
|
|
const app = getAppInstance();
|
|
const normalizedActor =
|
|
typeof actor === "string" && actor.trim() ? actor.trim() : "";
|
|
|
|
const blacklist =
|
|
app?.blacklistedEventIds instanceof Set
|
|
? new Set(app.blacklistedEventIds)
|
|
: new Set();
|
|
|
|
const runtime = {
|
|
blacklistedEventIds: blacklist,
|
|
isAuthorBlocked: (pubkey) =>
|
|
(typeof app?.isAuthorBlocked === "function" && app.isAuthorBlocked(pubkey)) ||
|
|
false,
|
|
watchHistory: {
|
|
actor: normalizedActor,
|
|
cursor: Number.isFinite(cursor) ? cursor : 0,
|
|
},
|
|
};
|
|
|
|
if (normalizedActor) {
|
|
runtime.actor = normalizedActor;
|
|
}
|
|
|
|
return runtime;
|
|
}
|
|
|
|
function safeLocaleDate(date) {
|
|
try {
|
|
return date.toLocaleDateString(undefined, {
|
|
weekday: "long",
|
|
month: "long",
|
|
day: "numeric",
|
|
});
|
|
} catch (error) {
|
|
return date.toDateString();
|
|
}
|
|
}
|
|
|
|
function formatDayLabel(timestampSeconds) {
|
|
if (!Number.isFinite(timestampSeconds)) {
|
|
return "Unknown day";
|
|
}
|
|
const eventDate = new Date(timestampSeconds * 1000);
|
|
if (Number.isNaN(eventDate.getTime())) {
|
|
return "Unknown day";
|
|
}
|
|
const today = new Date();
|
|
const yesterday = new Date();
|
|
yesterday.setDate(today.getDate() - 1);
|
|
|
|
const targetDay = eventDate.toDateString();
|
|
if (targetDay === today.toDateString()) {
|
|
return "Today";
|
|
}
|
|
if (targetDay === yesterday.toDateString()) {
|
|
return "Yesterday";
|
|
}
|
|
return safeLocaleDate(eventDate);
|
|
}
|
|
|
|
function formatRelativeTime(timestampSeconds) {
|
|
if (!Number.isFinite(timestampSeconds) || timestampSeconds <= 0) {
|
|
return "Unknown time";
|
|
}
|
|
const app = getAppInstance();
|
|
if (app && typeof app.formatTimeAgo === "function") {
|
|
return app.formatTimeAgo(timestampSeconds);
|
|
}
|
|
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
const delta = Math.max(0, nowSeconds - Math.floor(timestampSeconds));
|
|
const units = [
|
|
{ label: "year", seconds: 31536000 },
|
|
{ label: "month", seconds: 2592000 },
|
|
{ label: "week", seconds: 604800 },
|
|
{ label: "day", seconds: 86400 },
|
|
{ label: "hour", seconds: 3600 },
|
|
{ label: "minute", seconds: 60 },
|
|
];
|
|
for (const unit of units) {
|
|
const count = Math.floor(delta / unit.seconds);
|
|
if (count >= 1) {
|
|
return `${count} ${unit.label}${count > 1 ? "s" : ""} ago`;
|
|
}
|
|
}
|
|
return "just now";
|
|
}
|
|
|
|
function computeFingerprint(items) {
|
|
if (!Array.isArray(items) || !items.length) {
|
|
return "";
|
|
}
|
|
const pieces = [];
|
|
for (const entry of items) {
|
|
const pointer = normalizePointerInput(entry);
|
|
if (!pointer) {
|
|
continue;
|
|
}
|
|
const key = pointerKey(pointer);
|
|
if (!key) {
|
|
continue;
|
|
}
|
|
const watchedAt = Number.isFinite(entry?.watchedAt) ? entry.watchedAt : 0;
|
|
pieces.push(`${key}:${watchedAt}`);
|
|
}
|
|
return pieces.join("|");
|
|
}
|
|
|
|
function normalizeHistoryItems(rawItems) {
|
|
if (!Array.isArray(rawItems)) {
|
|
return [];
|
|
}
|
|
const normalized = [];
|
|
for (const candidate of rawItems) {
|
|
const pointer = normalizePointerInput(candidate);
|
|
if (!pointer) {
|
|
continue;
|
|
}
|
|
const key = pointerKey(pointer);
|
|
if (!key) {
|
|
continue;
|
|
}
|
|
const watchedAtRaw = Number.isFinite(candidate?.watchedAt)
|
|
? candidate.watchedAt
|
|
: Number.isFinite(candidate?.timestamp)
|
|
? candidate.timestamp
|
|
: null;
|
|
const watchedAt =
|
|
watchedAtRaw !== null ? Math.max(0, Math.floor(watchedAtRaw)) : 0;
|
|
normalized.push({
|
|
pointer,
|
|
pointerKey: key,
|
|
watchedAt,
|
|
raw: candidate,
|
|
});
|
|
}
|
|
normalized.sort((a, b) => {
|
|
if (a.watchedAt !== b.watchedAt) {
|
|
return b.watchedAt - a.watchedAt;
|
|
}
|
|
return a.pointerKey.localeCompare(b.pointerKey);
|
|
});
|
|
return normalized;
|
|
}
|
|
|
|
async function defaultRemoveHandler({
|
|
actor,
|
|
items,
|
|
snapshot,
|
|
reason = "remove-item",
|
|
} = {}) {
|
|
const sanitized = Array.isArray(items)
|
|
? items
|
|
.map((entry) => {
|
|
const pointer = normalizePointerInput(entry?.pointer || entry);
|
|
if (!pointer) {
|
|
return null;
|
|
}
|
|
if (Number.isFinite(entry?.watchedAt)) {
|
|
pointer.watchedAt = entry.watchedAt;
|
|
}
|
|
if (entry?.pointer?.session === true || entry?.session === true) {
|
|
pointer.session = true;
|
|
}
|
|
return pointer;
|
|
})
|
|
.filter(Boolean)
|
|
: [];
|
|
if (typeof snapshot === "function") {
|
|
await snapshot(sanitized, { actor, reason });
|
|
}
|
|
try {
|
|
if (typeof updateWatchHistoryList === "function") {
|
|
await updateWatchHistoryList(sanitized, {
|
|
actorPubkey: actor,
|
|
replace: true,
|
|
source: reason,
|
|
});
|
|
}
|
|
} catch (error) {
|
|
if (isDevEnv) {
|
|
console.warn("[historyView] Failed to update watch history list:", error);
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function getStorage() {
|
|
if (typeof window === "undefined") {
|
|
return null;
|
|
}
|
|
try {
|
|
return window.localStorage || null;
|
|
} catch (error) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function readPreference(key, fallback) {
|
|
const storage = getStorage();
|
|
if (!storage) {
|
|
return fallback;
|
|
}
|
|
try {
|
|
const stored = storage.getItem(key);
|
|
return stored === null ? fallback : stored;
|
|
} catch (error) {
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
function writePreference(key, value) {
|
|
const storage = getStorage();
|
|
if (!storage) {
|
|
return;
|
|
}
|
|
try {
|
|
if (value === null || typeof value === "undefined") {
|
|
storage.removeItem(key);
|
|
return;
|
|
}
|
|
storage.setItem(key, value);
|
|
} catch (error) {
|
|
if (isDevEnv) {
|
|
console.warn("[historyView] Failed to persist preference:", error);
|
|
}
|
|
}
|
|
}
|
|
|
|
function removeEmptyDayContainers(grid) {
|
|
if (!(grid instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
const daySections = grid.querySelectorAll("[data-history-day]");
|
|
daySections.forEach((section) => {
|
|
if (!(section instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
const list = section.querySelector("[data-history-day-list]");
|
|
if (list instanceof HTMLElement && list.childElementCount === 0) {
|
|
section.remove();
|
|
}
|
|
});
|
|
}
|
|
|
|
function getPointerVideoId(video, pointer) {
|
|
if (video && typeof video.id === "string" && video.id) {
|
|
return video.id;
|
|
}
|
|
if (pointer?.type === "e" && typeof pointer.value === "string") {
|
|
return pointer.value;
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function buildHistoryCard({
|
|
item,
|
|
video,
|
|
profile,
|
|
metadataPreference,
|
|
}) {
|
|
const article = document.createElement("article");
|
|
article.className = "watch-history-card";
|
|
article.dataset.pointerKey = item.pointerKey;
|
|
if (video?.isPrivate) {
|
|
article.classList.add("watch-history-card--private");
|
|
}
|
|
|
|
const primary = document.createElement("div");
|
|
primary.className = "watch-history-card__primary";
|
|
|
|
const playbackData = (() => {
|
|
if (!video) {
|
|
return { url: "", magnet: "" };
|
|
}
|
|
const url = typeof video.url === "string" ? video.url.trim() : "";
|
|
const magnetRaw =
|
|
typeof video.magnet === "string"
|
|
? video.magnet.trim()
|
|
: typeof video.infoHash === "string"
|
|
? video.infoHash.trim()
|
|
: "";
|
|
return { url, magnet: magnetRaw };
|
|
})();
|
|
|
|
const pointerVideoId = getPointerVideoId(video, item.pointer);
|
|
|
|
const thumbnailLink = document.createElement("a");
|
|
thumbnailLink.className = "watch-history-card__thumbnail";
|
|
thumbnailLink.href = "#";
|
|
thumbnailLink.dataset.historyAction = "play";
|
|
thumbnailLink.dataset.pointerKey = item.pointerKey;
|
|
if (pointerVideoId) {
|
|
thumbnailLink.dataset.videoId = pointerVideoId;
|
|
}
|
|
if (playbackData.url) {
|
|
thumbnailLink.dataset.playUrl = encodeURIComponent(playbackData.url);
|
|
}
|
|
if (playbackData.magnet) {
|
|
thumbnailLink.dataset.playMagnet = playbackData.magnet;
|
|
}
|
|
|
|
const thumbnailInner = document.createElement("div");
|
|
thumbnailInner.className = "watch-history-card__thumbnailInner";
|
|
const thumbnailImg = document.createElement("img");
|
|
thumbnailImg.alt = video?.title || "Video thumbnail";
|
|
const thumbnailSrc =
|
|
(video && typeof video.thumbnail === "string" && video.thumbnail.trim())
|
|
? video.thumbnail.trim()
|
|
: FALLBACK_THUMBNAIL;
|
|
thumbnailImg.src = thumbnailSrc;
|
|
thumbnailImg.loading = "lazy";
|
|
thumbnailImg.decoding = "async";
|
|
thumbnailInner.appendChild(thumbnailImg);
|
|
thumbnailLink.appendChild(thumbnailInner);
|
|
|
|
const details = document.createElement("div");
|
|
details.className = "watch-history-card__details";
|
|
|
|
const titleLink = document.createElement("a");
|
|
titleLink.className = "watch-history-card__title";
|
|
titleLink.href = "#";
|
|
titleLink.dataset.historyAction = "play";
|
|
titleLink.dataset.pointerKey = item.pointerKey;
|
|
if (pointerVideoId) {
|
|
titleLink.dataset.videoId = pointerVideoId;
|
|
}
|
|
if (playbackData.url) {
|
|
titleLink.dataset.playUrl = encodeURIComponent(playbackData.url);
|
|
}
|
|
if (playbackData.magnet) {
|
|
titleLink.dataset.playMagnet = playbackData.magnet;
|
|
}
|
|
titleLink.textContent = video?.title || "Untitled video";
|
|
|
|
const created = document.createElement("p");
|
|
created.className = "watch-history-card__created";
|
|
const createdAt = Number.isFinite(video?.created_at) ? video.created_at : null;
|
|
created.textContent = createdAt
|
|
? `Published ${formatRelativeTime(createdAt)}`
|
|
: "Published date unavailable";
|
|
|
|
details.appendChild(titleLink);
|
|
details.appendChild(created);
|
|
|
|
primary.appendChild(thumbnailLink);
|
|
primary.appendChild(details);
|
|
|
|
const meta = document.createElement("div");
|
|
meta.className = "watch-history-card__meta";
|
|
|
|
const watched = document.createElement("p");
|
|
watched.className = "watch-history-card__watched";
|
|
watched.textContent = item.watchedAt
|
|
? `Watched ${formatRelativeTime(item.watchedAt)}`
|
|
: "Watched time unavailable";
|
|
meta.appendChild(watched);
|
|
|
|
const creator = document.createElement("div");
|
|
creator.className = "watch-history-card__creator";
|
|
|
|
const creatorAvatarButton = document.createElement("button");
|
|
creatorAvatarButton.type = "button";
|
|
creatorAvatarButton.className = "watch-history-card__creatorAvatar";
|
|
creatorAvatarButton.dataset.historyAction = "channel";
|
|
creatorAvatarButton.dataset.pointerKey = item.pointerKey;
|
|
if (video?.pubkey) {
|
|
creatorAvatarButton.dataset.author = video.pubkey;
|
|
}
|
|
const avatarImg = document.createElement("img");
|
|
const avatarSrc =
|
|
(profile && typeof profile.picture === "string" && profile.picture.trim())
|
|
? profile.picture.trim()
|
|
: FALLBACK_AVATAR;
|
|
avatarImg.src = avatarSrc;
|
|
avatarImg.alt = profile?.name || profile?.display_name || "Creator avatar";
|
|
creatorAvatarButton.appendChild(avatarImg);
|
|
|
|
const creatorNameButton = document.createElement("button");
|
|
creatorNameButton.type = "button";
|
|
creatorNameButton.className = "watch-history-card__creatorName";
|
|
creatorNameButton.dataset.historyAction = "channel";
|
|
creatorNameButton.dataset.pointerKey = item.pointerKey;
|
|
if (video?.pubkey) {
|
|
creatorNameButton.dataset.author = video.pubkey;
|
|
}
|
|
const app = getAppInstance();
|
|
let creatorLabel =
|
|
profile?.display_name || profile?.name || profile?.username || "Unknown";
|
|
if ((!creatorLabel || creatorLabel === "Unknown") && video?.pubkey) {
|
|
const encoded = app?.safeEncodeNpub?.(video.pubkey) || "";
|
|
creatorLabel = encoded || video.pubkey.slice(0, 8).concat("…");
|
|
}
|
|
creatorNameButton.textContent = creatorLabel;
|
|
|
|
creator.appendChild(creatorAvatarButton);
|
|
creator.appendChild(creatorNameButton);
|
|
meta.appendChild(creator);
|
|
|
|
const actions = document.createElement("div");
|
|
actions.className = "watch-history-card__actions";
|
|
|
|
const playButton = document.createElement("button");
|
|
playButton.type = "button";
|
|
playButton.className =
|
|
"watch-history-card__action watch-history-card__action--primary";
|
|
playButton.dataset.historyAction = "play";
|
|
playButton.dataset.pointerKey = item.pointerKey;
|
|
if (pointerVideoId) {
|
|
playButton.dataset.videoId = pointerVideoId;
|
|
}
|
|
if (playbackData.url) {
|
|
playButton.dataset.playUrl = encodeURIComponent(playbackData.url);
|
|
}
|
|
if (playbackData.magnet) {
|
|
playButton.dataset.playMagnet = playbackData.magnet;
|
|
}
|
|
playButton.textContent = "Play";
|
|
|
|
const channelButton = document.createElement("button");
|
|
channelButton.type = "button";
|
|
channelButton.className = "watch-history-card__action";
|
|
channelButton.dataset.historyAction = "channel";
|
|
channelButton.dataset.pointerKey = item.pointerKey;
|
|
if (video?.pubkey) {
|
|
channelButton.dataset.author = video.pubkey;
|
|
}
|
|
channelButton.textContent = "Open channel";
|
|
|
|
const removeButton = document.createElement("button");
|
|
removeButton.type = "button";
|
|
removeButton.className =
|
|
"watch-history-card__action watch-history-card__action--danger";
|
|
removeButton.dataset.historyAction = "remove";
|
|
removeButton.dataset.pointerKey = item.pointerKey;
|
|
removeButton.textContent = "Remove from history";
|
|
if (item.pointer?.type) {
|
|
removeButton.dataset.pointerType = item.pointer.type;
|
|
}
|
|
if (item.pointer?.value) {
|
|
removeButton.dataset.pointerValue = item.pointer.value;
|
|
}
|
|
if (item.pointer?.relay) {
|
|
removeButton.dataset.pointerRelay = item.pointer.relay;
|
|
}
|
|
if (Number.isFinite(item.watchedAt)) {
|
|
removeButton.dataset.pointerWatchedAt = String(item.watchedAt);
|
|
}
|
|
if (item.pointer?.session === true) {
|
|
removeButton.dataset.pointerSession = "true";
|
|
}
|
|
removeButton.setAttribute(
|
|
"aria-label",
|
|
"Remove this encrypted history entry (sync may take a moment).",
|
|
);
|
|
removeButton.title =
|
|
"Removes this entry from encrypted history. Relay sync may take a moment.";
|
|
|
|
actions.appendChild(playButton);
|
|
actions.appendChild(channelButton);
|
|
actions.appendChild(removeButton);
|
|
|
|
meta.appendChild(actions);
|
|
|
|
if (metadataPreference === "relay-opt-in") {
|
|
const hint = document.createElement("p");
|
|
hint.className = "text-xs text-blue-200";
|
|
hint.textContent = "Metadata is shared with relays for this entry.";
|
|
meta.appendChild(hint);
|
|
}
|
|
|
|
article.appendChild(primary);
|
|
article.appendChild(meta);
|
|
|
|
return article;
|
|
}
|
|
|
|
export function createWatchHistoryRenderer(config = {}) {
|
|
const {
|
|
fetchHistory = async (actorInput, { cursor = 0 } = {}) => {
|
|
const app = getAppInstance();
|
|
const engine = app?.feedEngine;
|
|
if (engine && typeof engine.run === "function") {
|
|
const runtime = buildWatchHistoryFeedRuntime({
|
|
actor: actorInput,
|
|
cursor,
|
|
});
|
|
return engine.run("watch-history", { runtime });
|
|
}
|
|
const items = await watchHistoryService.loadLatest(actorInput, {
|
|
allowStale: true,
|
|
});
|
|
const normalized = normalizeHistoryItems(items);
|
|
return { items: normalized, metadata: { engine: "service-fallback" } };
|
|
},
|
|
snapshot = watchHistoryService.snapshot.bind(watchHistoryService),
|
|
getActor,
|
|
viewSelector = "#watchHistoryView",
|
|
gridSelector = "#watchHistoryGrid",
|
|
loadingSelector = "#watchHistoryLoading",
|
|
statusSelector = "#watchHistoryStatus",
|
|
emptySelector = "#watchHistoryEmpty",
|
|
sentinelSelector = "#watchHistorySentinel",
|
|
loadMoreSelector = "#watchHistoryLoadMore",
|
|
clearButtonSelector = "[data-history-action=\"clear-cache\"]",
|
|
republishButtonSelector = "[data-history-action=\"republish\"]",
|
|
privacyBannerSelector = "#watchHistoryPrivacyBanner",
|
|
privacyMessageSelector = "#watchHistoryPrivacyMessage",
|
|
privacyToggleSelector = "#watchHistoryPrivacyToggle",
|
|
privacyDismissSelector = "#watchHistoryPrivacyDismiss",
|
|
infoSelector = "#watchHistoryInfo",
|
|
errorBannerSelector = "#watchHistoryError",
|
|
scrollContainerSelector = null,
|
|
featureBannerSelector = "#profileHistoryFeatureBanner",
|
|
toastRegionSelector = "#profileHistoryToastRegion",
|
|
sessionWarningSelector = "#profileHistorySessionWarning",
|
|
metadataToggleSelector = "#profileHistoryMetadataToggle",
|
|
metadataThumbSelector = "#profileHistoryMetadataThumb",
|
|
metadataLabelSelector = "#profileHistoryMetadataLabel",
|
|
metadataDescriptionSelector = "#profileHistoryMetadataDescription",
|
|
emptyCopy = WATCH_HISTORY_EMPTY_COPY,
|
|
disabledCopy = WATCH_HISTORY_DISABLED_COPY,
|
|
batchSize = WATCH_HISTORY_BATCH_SIZE,
|
|
remove = (payload) => {
|
|
const app = getAppInstance();
|
|
const controller = app?.watchHistoryController;
|
|
if (controller?.handleWatchHistoryRemoval) {
|
|
return controller.handleWatchHistoryRemoval(payload);
|
|
}
|
|
return defaultRemoveHandler(payload);
|
|
},
|
|
} = config;
|
|
|
|
const syncEnabled =
|
|
typeof watchHistoryService.isEnabled === "function"
|
|
? watchHistoryService.isEnabled() === true
|
|
: false;
|
|
const localSupported =
|
|
typeof watchHistoryService.supportsLocalHistory === "function"
|
|
? watchHistoryService.supportsLocalHistory() === true
|
|
: false;
|
|
const localOnly =
|
|
typeof watchHistoryService.isLocalOnly === "function"
|
|
? watchHistoryService.isLocalOnly() === true
|
|
: false;
|
|
|
|
const state = {
|
|
initialized: false,
|
|
actor: null,
|
|
items: [],
|
|
fingerprint: "",
|
|
cursor: 0,
|
|
hasMore: false,
|
|
isLoading: false,
|
|
isRendering: false,
|
|
lastError: null,
|
|
observer: null,
|
|
observerAttached: false,
|
|
metadataPreference: "encrypted-only",
|
|
privacyDismissed: false,
|
|
metadataCache: new Map(),
|
|
feedMetadata: null,
|
|
metadataStorageEnabled:
|
|
typeof watchHistoryService.shouldStoreMetadata === "function"
|
|
? watchHistoryService.shouldStoreMetadata() !== false
|
|
: true,
|
|
sessionFallbackActive: false,
|
|
syncEnabled,
|
|
localSupported,
|
|
localOnly,
|
|
featureEnabled: syncEnabled || localSupported,
|
|
};
|
|
|
|
let rendererRef = null;
|
|
let fingerprintRefreshQueued = false;
|
|
const scheduleTask =
|
|
typeof queueMicrotask === "function"
|
|
? queueMicrotask
|
|
: (callback) => Promise.resolve().then(callback);
|
|
let elements = {
|
|
view: null,
|
|
grid: null,
|
|
loading: null,
|
|
status: null,
|
|
empty: null,
|
|
sentinel: null,
|
|
loadMore: null,
|
|
clearButton: null,
|
|
republishButton: null,
|
|
privacyBanner: null,
|
|
privacyMessage: null,
|
|
privacyToggle: null,
|
|
privacyDismiss: null,
|
|
info: null,
|
|
errorBanner: null,
|
|
scrollContainer: null,
|
|
featureBanner: null,
|
|
toastRegion: null,
|
|
sessionWarning: null,
|
|
metadataToggle: null,
|
|
metadataThumb: null,
|
|
metadataLabel: null,
|
|
metadataDescription: null,
|
|
};
|
|
|
|
let boundGridClickHandler = null;
|
|
let boundLoadMoreHandler = null;
|
|
let boundClearHandler = null;
|
|
let boundRepublishHandler = null;
|
|
let boundPrivacyToggleHandler = null;
|
|
let boundPrivacyDismissHandler = null;
|
|
let boundMetadataToggleHandler = null;
|
|
|
|
const subscriptions = new Set();
|
|
const toastTimers = new Set();
|
|
|
|
function refreshElements() {
|
|
elements = {
|
|
view: resolveElement(viewSelector),
|
|
grid: resolveElement(gridSelector),
|
|
loading: resolveElement(loadingSelector),
|
|
status: resolveElement(statusSelector),
|
|
empty: resolveElement(emptySelector),
|
|
sentinel: resolveElement(sentinelSelector),
|
|
loadMore: resolveElement(loadMoreSelector),
|
|
clearButton: resolveElement(clearButtonSelector),
|
|
republishButton: resolveElement(republishButtonSelector),
|
|
privacyBanner: resolveElement(privacyBannerSelector),
|
|
privacyMessage: resolveElement(privacyMessageSelector),
|
|
privacyToggle: resolveElement(privacyToggleSelector),
|
|
privacyDismiss: resolveElement(privacyDismissSelector),
|
|
info: resolveElement(infoSelector),
|
|
errorBanner: resolveElement(errorBannerSelector),
|
|
scrollContainer: resolveElement(scrollContainerSelector),
|
|
featureBanner: resolveElement(featureBannerSelector),
|
|
toastRegion: resolveElement(toastRegionSelector),
|
|
sessionWarning: resolveElement(sessionWarningSelector),
|
|
metadataToggle: resolveElement(metadataToggleSelector),
|
|
metadataThumb: resolveElement(metadataThumbSelector),
|
|
metadataLabel: resolveElement(metadataLabelSelector),
|
|
metadataDescription: resolveElement(metadataDescriptionSelector),
|
|
};
|
|
}
|
|
|
|
function clearSubscriptions() {
|
|
for (const unsubscribe of subscriptions) {
|
|
try {
|
|
unsubscribe();
|
|
} catch (error) {
|
|
if (isDevEnv) {
|
|
console.warn(
|
|
"[historyView] Failed to unsubscribe from watch history event:",
|
|
error,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
subscriptions.clear();
|
|
fingerprintRefreshQueued = false;
|
|
}
|
|
|
|
function clearToasts() {
|
|
for (const timer of toastTimers) {
|
|
clearTimeout(timer);
|
|
}
|
|
toastTimers.clear();
|
|
if (elements.toastRegion instanceof HTMLElement) {
|
|
elements.toastRegion.innerHTML = "";
|
|
}
|
|
}
|
|
|
|
function pushToast({ message, variant = "info", duration = 8000 }) {
|
|
if (!(elements.toastRegion instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
const text = typeof message === "string" ? message.trim() : "";
|
|
if (!text) {
|
|
return;
|
|
}
|
|
const variantClass = {
|
|
info: "border-blue-500/40 bg-blue-500/10 text-blue-100",
|
|
warning: "border-amber-500/40 bg-amber-500/10 text-amber-100",
|
|
error: "border-red-500/40 bg-red-500/10 text-red-100",
|
|
}[variant] || "border-gray-600 bg-gray-800/70 text-gray-100";
|
|
|
|
if (elements.toastRegion.childElementCount >= 3) {
|
|
const firstChild = elements.toastRegion.firstElementChild;
|
|
if (firstChild instanceof HTMLElement) {
|
|
firstChild.remove();
|
|
}
|
|
}
|
|
|
|
const toast = document.createElement("div");
|
|
toast.className = `rounded-md border px-4 py-2 text-sm ${variantClass}`;
|
|
toast.setAttribute("role", "status");
|
|
toast.textContent = text;
|
|
elements.toastRegion.appendChild(toast);
|
|
|
|
const timeout = Number.isFinite(duration) ? Math.max(0, duration) : 8000;
|
|
if (timeout > 0) {
|
|
const timer = setTimeout(() => {
|
|
toast.remove();
|
|
toastTimers.delete(timer);
|
|
}, timeout);
|
|
toastTimers.add(timer);
|
|
}
|
|
}
|
|
|
|
function updateFeatureBanner() {
|
|
const actor = typeof state.actor === "string" ? state.actor : undefined;
|
|
const syncEnabled =
|
|
typeof watchHistoryService.isEnabled === "function"
|
|
? watchHistoryService.isEnabled(actor) === true
|
|
: false;
|
|
const localSupported =
|
|
typeof watchHistoryService.supportsLocalHistory === "function"
|
|
? watchHistoryService.supportsLocalHistory(actor) === true
|
|
: false;
|
|
const localOnly =
|
|
typeof watchHistoryService.isLocalOnly === "function"
|
|
? watchHistoryService.isLocalOnly(actor) === true
|
|
: false;
|
|
state.syncEnabled = syncEnabled;
|
|
state.localSupported = localSupported;
|
|
state.localOnly = localOnly;
|
|
state.featureEnabled = syncEnabled || localSupported;
|
|
if (!(elements.featureBanner instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
if (syncEnabled) {
|
|
elements.featureBanner.textContent = "";
|
|
setHidden(elements.featureBanner, true);
|
|
return;
|
|
}
|
|
if (localOnly) {
|
|
elements.featureBanner.textContent =
|
|
"Watch history sync requires a logged-in Nostr account. This guest session is stored locally only.";
|
|
setHidden(elements.featureBanner, false);
|
|
return;
|
|
}
|
|
if (localSupported) {
|
|
elements.featureBanner.textContent =
|
|
"Watch history sync is disabled right now. We'll keep a local copy on this device.";
|
|
} else {
|
|
elements.featureBanner.textContent =
|
|
"Watch history is disabled on this server.";
|
|
}
|
|
setHidden(elements.featureBanner, false);
|
|
}
|
|
|
|
function updateMetadataToggle() {
|
|
if (!(elements.metadataToggle instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
const enabled = state.metadataStorageEnabled;
|
|
elements.metadataToggle.setAttribute("aria-checked", enabled ? "true" : "false");
|
|
elements.metadataToggle.classList.toggle("bg-blue-600", enabled);
|
|
elements.metadataToggle.classList.toggle("border-blue-500", enabled);
|
|
elements.metadataToggle.classList.toggle("bg-gray-700", !enabled);
|
|
elements.metadataToggle.classList.toggle("border-gray-600", !enabled);
|
|
if (elements.metadataThumb instanceof HTMLElement) {
|
|
elements.metadataThumb.classList.toggle("translate-x-5", enabled);
|
|
elements.metadataThumb.classList.toggle("translate-x-1", !enabled);
|
|
}
|
|
}
|
|
|
|
function updateSessionFallbackWarning() {
|
|
if (!(elements.sessionWarning instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
const nip07Pubkey =
|
|
typeof nostrClient?.pubkey === "string" ? nostrClient.pubkey : "";
|
|
const sessionPubkey =
|
|
typeof nostrClient?.sessionActor?.pubkey === "string"
|
|
? nostrClient.sessionActor.pubkey
|
|
: "";
|
|
const activeActor = typeof state.actor === "string" ? state.actor : "";
|
|
const fallbackActive = !nip07Pubkey && sessionPubkey && sessionPubkey === activeActor;
|
|
state.sessionFallbackActive = Boolean(fallbackActive);
|
|
setHidden(elements.sessionWarning, !fallbackActive);
|
|
}
|
|
|
|
function subscribeToMetadataPreference() {
|
|
if (typeof watchHistoryService.subscribe !== "function") {
|
|
return;
|
|
}
|
|
const unsubscribe = watchHistoryService.subscribe(
|
|
"metadata-preference",
|
|
(payload) => {
|
|
const enabled = payload?.enabled !== false;
|
|
state.metadataStorageEnabled = enabled;
|
|
updateMetadataToggle();
|
|
}
|
|
);
|
|
if (typeof unsubscribe === "function") {
|
|
subscriptions.add(unsubscribe);
|
|
}
|
|
}
|
|
|
|
function subscribeToRepublishEvents() {
|
|
if (typeof watchHistoryService.subscribe !== "function") {
|
|
return;
|
|
}
|
|
const unsubscribe = watchHistoryService.subscribe(
|
|
"republish-scheduled",
|
|
(payload) => {
|
|
if (!payload) {
|
|
return;
|
|
}
|
|
if (
|
|
payload.actor &&
|
|
state.actor &&
|
|
payload.actor !== state.actor
|
|
) {
|
|
return;
|
|
}
|
|
let message = "Republish retry scheduled.";
|
|
const delayMs = Number.isFinite(payload.delayMs)
|
|
? Math.max(0, Math.floor(payload.delayMs))
|
|
: null;
|
|
if (delayMs != null) {
|
|
const seconds = Math.max(1, Math.round(delayMs / 1000));
|
|
const suffix = seconds === 1 ? "" : "s";
|
|
message = `Republish retry scheduled in about ${seconds} second${suffix}.`;
|
|
}
|
|
pushToast({ message, variant: "warning" });
|
|
}
|
|
);
|
|
if (typeof unsubscribe === "function") {
|
|
subscriptions.add(unsubscribe);
|
|
}
|
|
}
|
|
|
|
function queueFingerprintRefresh() {
|
|
if (!fingerprintRefreshQueued) {
|
|
return;
|
|
}
|
|
if (state.isLoading) {
|
|
return;
|
|
}
|
|
if (!rendererRef) {
|
|
return;
|
|
}
|
|
fingerprintRefreshQueued = false;
|
|
void rendererRef.refresh({ force: false });
|
|
}
|
|
|
|
function subscribeToFingerprintUpdates() {
|
|
if (typeof watchHistoryService.subscribe !== "function") {
|
|
return;
|
|
}
|
|
const unsubscribe = watchHistoryService.subscribe(
|
|
"fingerprint",
|
|
(payload) => {
|
|
if (!payload) {
|
|
return;
|
|
}
|
|
if (
|
|
payload.actor &&
|
|
state.actor &&
|
|
payload.actor !== state.actor
|
|
) {
|
|
return;
|
|
}
|
|
fingerprintRefreshQueued = true;
|
|
if (!state.isLoading) {
|
|
scheduleTask(() => {
|
|
queueFingerprintRefresh();
|
|
});
|
|
}
|
|
}
|
|
);
|
|
if (typeof unsubscribe === "function") {
|
|
subscriptions.add(unsubscribe);
|
|
}
|
|
}
|
|
|
|
function setLoadingState(loading) {
|
|
state.isLoading = loading;
|
|
if (elements.loading) {
|
|
setHidden(elements.loading, !loading);
|
|
}
|
|
if (elements.status) {
|
|
setHidden(elements.status, !loading);
|
|
}
|
|
}
|
|
|
|
function showErrorBanner(message) {
|
|
if (!(elements.errorBanner instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
elements.errorBanner.textContent = message;
|
|
setHidden(elements.errorBanner, false);
|
|
}
|
|
|
|
function hideErrorBanner() {
|
|
if (!(elements.errorBanner instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
elements.errorBanner.textContent = "";
|
|
setHidden(elements.errorBanner, true);
|
|
}
|
|
|
|
function showEmptyState() {
|
|
if (elements.grid) {
|
|
elements.grid.innerHTML = "";
|
|
setHidden(elements.grid, true);
|
|
}
|
|
if (elements.empty) {
|
|
setTextContent(elements.empty, emptyCopy);
|
|
setHidden(elements.empty, false);
|
|
}
|
|
setHidden(elements.loadMore, true);
|
|
if (elements.sentinel) {
|
|
setHidden(elements.sentinel, true);
|
|
}
|
|
}
|
|
|
|
function showFeatureDisabledState() {
|
|
hideErrorBanner();
|
|
if (elements.loading) {
|
|
setHidden(elements.loading, true);
|
|
}
|
|
if (elements.status) {
|
|
setHidden(elements.status, true);
|
|
}
|
|
detachObserver();
|
|
if (elements.grid) {
|
|
elements.grid.innerHTML = "";
|
|
setHidden(elements.grid, true);
|
|
}
|
|
if (elements.empty) {
|
|
setTextContent(elements.empty, disabledCopy || emptyCopy);
|
|
setHidden(elements.empty, false);
|
|
}
|
|
if (elements.loadMore) {
|
|
setHidden(elements.loadMore, true);
|
|
}
|
|
if (elements.sentinel) {
|
|
setHidden(elements.sentinel, true);
|
|
}
|
|
}
|
|
|
|
function ensureObserver() {
|
|
if (!elements.sentinel || !WATCH_HISTORY_BATCH_RESOLVE) {
|
|
return;
|
|
}
|
|
if (state.observer) {
|
|
return;
|
|
}
|
|
if (typeof IntersectionObserver !== "function") {
|
|
return;
|
|
}
|
|
state.observer = new IntersectionObserver(
|
|
(entries) => {
|
|
for (const entry of entries) {
|
|
if (entry.isIntersecting) {
|
|
void renderer.loadMore();
|
|
}
|
|
}
|
|
},
|
|
{
|
|
root: elements.scrollContainer || null,
|
|
rootMargin: "0px 0px 320px 0px",
|
|
threshold: 0.1,
|
|
}
|
|
);
|
|
}
|
|
|
|
function attachObserver() {
|
|
if (!state.observer || !elements.sentinel) {
|
|
return;
|
|
}
|
|
if (!state.hasMore) {
|
|
return;
|
|
}
|
|
if (!state.observerAttached) {
|
|
state.observer.observe(elements.sentinel);
|
|
state.observerAttached = true;
|
|
setHidden(elements.sentinel, false);
|
|
}
|
|
}
|
|
|
|
function detachObserver() {
|
|
if (state.observer && state.observerAttached && elements.sentinel) {
|
|
state.observer.unobserve(elements.sentinel);
|
|
state.observerAttached = false;
|
|
setHidden(elements.sentinel, true);
|
|
}
|
|
}
|
|
|
|
function updateLoadMoreVisibility() {
|
|
if (WATCH_HISTORY_BATCH_RESOLVE) {
|
|
if (state.hasMore) {
|
|
attachObserver();
|
|
} else {
|
|
detachObserver();
|
|
}
|
|
if (elements.loadMore) {
|
|
setHidden(elements.loadMore, true);
|
|
}
|
|
return;
|
|
}
|
|
detachObserver();
|
|
if (elements.loadMore) {
|
|
setHidden(elements.loadMore, !state.hasMore);
|
|
elements.loadMore.disabled = !state.hasMore;
|
|
}
|
|
}
|
|
|
|
function updatePrivacyBanner() {
|
|
if (!(elements.privacyBanner instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
const preference = state.metadataPreference;
|
|
const dismissed = state.privacyDismissed;
|
|
if (dismissed && preference !== "relay-opt-in") {
|
|
setHidden(elements.privacyBanner, true);
|
|
return;
|
|
}
|
|
let message =
|
|
"Your history stays encrypted. Share metadata with relays to sync thumbnails across devices?";
|
|
let toggleLabel = "Share metadata";
|
|
if (preference === "relay-opt-in") {
|
|
message =
|
|
"You are sharing metadata with relays so your thumbnails and titles stay in sync.";
|
|
toggleLabel = "Keep encrypted only";
|
|
}
|
|
if (elements.privacyMessage) {
|
|
elements.privacyMessage.textContent = message;
|
|
}
|
|
if (elements.privacyToggle) {
|
|
elements.privacyToggle.textContent = toggleLabel;
|
|
}
|
|
setHidden(elements.privacyBanner, false);
|
|
}
|
|
|
|
function updateInfoCallout() {
|
|
if (!(elements.info instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
const message =
|
|
"Thumbnails and titles are hydrated locally from your device caches. Nothing is published unless you opt in.";
|
|
elements.info.textContent = message;
|
|
}
|
|
|
|
async function resolveActorKey() {
|
|
if (typeof getActor === "function") {
|
|
try {
|
|
const result = await getActor();
|
|
if (typeof result === "string" && result.trim()) {
|
|
return result.trim();
|
|
}
|
|
} catch (error) {
|
|
console.warn("[historyView] Failed to resolve actor via getActor:", error);
|
|
}
|
|
}
|
|
if (watchHistoryService?.publishView) {
|
|
const pubkey = nostrClient?.pubkey || window?.app?.pubkey || "";
|
|
if (typeof pubkey === "string" && pubkey.trim()) {
|
|
return pubkey.trim();
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
async function hydrateBatch(batch) {
|
|
const hydrated = [];
|
|
for (const item of batch) {
|
|
if (!item) {
|
|
continue;
|
|
}
|
|
|
|
const pointerKeyValue = item.pointerKey || null;
|
|
const baseMetadata =
|
|
item && typeof item === "object" && item.metadata && typeof item.metadata === "object"
|
|
? { ...item.metadata }
|
|
: {};
|
|
|
|
let cached = pointerKeyValue ? state.metadataCache.get(pointerKeyValue) : null;
|
|
if (!cached && pointerKeyValue && typeof watchHistoryService.getLocalMetadata === "function") {
|
|
try {
|
|
cached = watchHistoryService.getLocalMetadata(pointerKeyValue) || null;
|
|
} catch (error) {
|
|
if (isDevEnv) {
|
|
console.warn(
|
|
"[historyView] Failed to read stored metadata for pointer:",
|
|
pointerKeyValue,
|
|
error,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
let video = item.video || baseMetadata.video || null;
|
|
let profile = baseMetadata.profile || null;
|
|
|
|
if (cached) {
|
|
video = video || cached.video || null;
|
|
profile = profile || cached.profile || null;
|
|
}
|
|
|
|
const metadata = {
|
|
...baseMetadata,
|
|
video: video || null,
|
|
profile: profile || null,
|
|
};
|
|
|
|
if (pointerKeyValue) {
|
|
state.metadataCache.set(pointerKeyValue, {
|
|
video: metadata.video,
|
|
profile: metadata.profile,
|
|
});
|
|
if (state.metadataStorageEnabled) {
|
|
try {
|
|
watchHistoryService.setLocalMetadata?.(pointerKeyValue, {
|
|
video: metadata.video,
|
|
profile: metadata.profile,
|
|
});
|
|
} catch (error) {
|
|
if (isDevEnv) {
|
|
console.warn(
|
|
"[historyView] Failed to persist metadata for pointer:",
|
|
pointerKeyValue,
|
|
error,
|
|
);
|
|
}
|
|
}
|
|
} else if (typeof watchHistoryService.removeLocalMetadata === "function") {
|
|
try {
|
|
watchHistoryService.removeLocalMetadata(pointerKeyValue);
|
|
} catch (error) {
|
|
if (isDevEnv) {
|
|
console.warn(
|
|
"[historyView] Failed to remove cached metadata for pointer:",
|
|
pointerKeyValue,
|
|
error,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
hydrated.push({ ...item, video: metadata.video, metadata });
|
|
}
|
|
return hydrated;
|
|
}
|
|
|
|
async function renderNextBatch() {
|
|
if (!Array.isArray(state.items) || !state.items.length) {
|
|
showEmptyState();
|
|
return;
|
|
}
|
|
if (!(elements.grid instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
if (state.cursor >= state.items.length) {
|
|
state.hasMore = false;
|
|
updateLoadMoreVisibility();
|
|
return;
|
|
}
|
|
if (state.isRendering) {
|
|
return;
|
|
}
|
|
state.isRendering = true;
|
|
const start = state.cursor;
|
|
const end = Math.min(state.items.length, start + Math.max(1, batchSize));
|
|
const slice = state.items.slice(start, end);
|
|
let hydrated;
|
|
try {
|
|
hydrated = await hydrateBatch(slice);
|
|
} catch (error) {
|
|
state.isRendering = false;
|
|
if (isDevEnv) {
|
|
console.warn("[historyView] Failed to hydrate batch:", error);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const fragment = document.createDocumentFragment();
|
|
const dayContainers = new Map();
|
|
|
|
const ensureDayContainer = (dayKey, label) => {
|
|
if (dayContainers.has(dayKey)) {
|
|
return dayContainers.get(dayKey);
|
|
}
|
|
let section = elements.grid.querySelector(
|
|
`[data-history-day="${escapeSelector(dayKey)}"]`
|
|
);
|
|
if (!(section instanceof HTMLElement)) {
|
|
section = document.createElement("section");
|
|
section.dataset.historyDay = dayKey;
|
|
section.className = "space-y-4";
|
|
const header = document.createElement("h3");
|
|
header.className = "text-sm font-semibold uppercase tracking-wide text-gray-300";
|
|
header.textContent = label;
|
|
const list = document.createElement("div");
|
|
list.dataset.historyDayList = "true";
|
|
list.className = "space-y-4";
|
|
section.appendChild(header);
|
|
section.appendChild(list);
|
|
fragment.appendChild(section);
|
|
}
|
|
const list = section.querySelector("[data-history-day-list]");
|
|
const record = { section, list };
|
|
dayContainers.set(dayKey, record);
|
|
return record;
|
|
};
|
|
|
|
hydrated.forEach((entry) => {
|
|
if (!entry) {
|
|
return;
|
|
}
|
|
const dayLabel = formatDayLabel(entry.watchedAt);
|
|
const dayKey = `${dayLabel}`;
|
|
const container = ensureDayContainer(dayKey, dayLabel);
|
|
if (!(container?.list instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
const card = buildHistoryCard({
|
|
item: entry,
|
|
video: entry.metadata?.video || null,
|
|
profile: entry.metadata?.profile || null,
|
|
metadataPreference: state.metadataPreference,
|
|
});
|
|
container.list.appendChild(card);
|
|
});
|
|
|
|
elements.grid.appendChild(fragment);
|
|
setHidden(elements.grid, false);
|
|
setHidden(elements.empty, true);
|
|
|
|
state.cursor = end;
|
|
state.hasMore = state.cursor < state.items.length;
|
|
updateLoadMoreVisibility();
|
|
state.isRendering = false;
|
|
}
|
|
|
|
function bindGridEvents() {
|
|
if (!(elements.grid instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
if (boundGridClickHandler) {
|
|
elements.grid.removeEventListener("click", boundGridClickHandler);
|
|
}
|
|
boundGridClickHandler = async (event) => {
|
|
const trigger = event.target instanceof HTMLElement
|
|
? event.target.closest("[data-history-action]")
|
|
: null;
|
|
if (!(trigger instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
const action = trigger.dataset.historyAction || "";
|
|
const pointerKeyAttr = trigger.dataset.pointerKey || (trigger.closest("[data-pointer-key]")?.dataset.pointerKey ?? "");
|
|
if (!action) {
|
|
return;
|
|
}
|
|
switch (action) {
|
|
case "play": {
|
|
event.preventDefault();
|
|
const videoId = trigger.dataset.videoId || pointerKeyAttr;
|
|
const rawUrlAttr = trigger.dataset.playUrl || "";
|
|
const magnetAttr = trigger.dataset.playMagnet || "";
|
|
let url = "";
|
|
if (rawUrlAttr) {
|
|
try {
|
|
url = decodeURIComponent(rawUrlAttr);
|
|
} catch (error) {
|
|
url = rawUrlAttr;
|
|
}
|
|
}
|
|
|
|
const app = getAppInstance();
|
|
if (videoId && app?.playVideoByEventId) {
|
|
app.playVideoByEventId(videoId, { url, magnet: magnetAttr });
|
|
return;
|
|
}
|
|
|
|
if (app?.playVideoWithFallback) {
|
|
app.playVideoWithFallback({
|
|
url,
|
|
magnet: magnetAttr,
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
case "channel": {
|
|
event.preventDefault();
|
|
const author = trigger.dataset.author || "";
|
|
const app = getAppInstance();
|
|
if (author && app?.goToProfile) {
|
|
app.goToProfile(author);
|
|
} else if (app?.showError) {
|
|
app.showError("No creator info available.");
|
|
}
|
|
break;
|
|
}
|
|
case "remove": {
|
|
event.preventDefault();
|
|
if (pointerKeyAttr) {
|
|
await renderer.handleRemove(pointerKeyAttr);
|
|
}
|
|
break;
|
|
}
|
|
default:
|
|
break;
|
|
}
|
|
};
|
|
elements.grid.addEventListener("click", boundGridClickHandler);
|
|
}
|
|
|
|
function bindLoadMore() {
|
|
if (!(elements.loadMore instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
if (boundLoadMoreHandler) {
|
|
elements.loadMore.removeEventListener("click", boundLoadMoreHandler);
|
|
}
|
|
boundLoadMoreHandler = (event) => {
|
|
event.preventDefault();
|
|
void renderer.loadMore();
|
|
};
|
|
elements.loadMore.addEventListener("click", boundLoadMoreHandler);
|
|
}
|
|
|
|
function bindActions() {
|
|
if (elements.clearButton) {
|
|
if (boundClearHandler) {
|
|
elements.clearButton.removeEventListener("click", boundClearHandler);
|
|
}
|
|
boundClearHandler = async (event) => {
|
|
event.preventDefault();
|
|
const actor = await resolveActorKey();
|
|
try {
|
|
await watchHistoryService.resetProgress(actor);
|
|
state.items = [];
|
|
state.cursor = 0;
|
|
state.hasMore = false;
|
|
state.metadataCache.clear();
|
|
try {
|
|
watchHistoryService.clearLocalMetadata?.();
|
|
} catch (error) {
|
|
if (isDevEnv) {
|
|
console.warn(
|
|
"[historyView] Failed to clear stored metadata while resetting progress:",
|
|
error,
|
|
);
|
|
}
|
|
}
|
|
showEmptyState();
|
|
const app = getAppInstance();
|
|
app?.showSuccess?.("Local watch history cache cleared.");
|
|
} catch (error) {
|
|
const message =
|
|
error && typeof error.message === "string"
|
|
? error.message
|
|
: "Failed to clear local watch history cache.";
|
|
const app = getAppInstance();
|
|
app?.showError?.(message);
|
|
}
|
|
};
|
|
elements.clearButton.addEventListener("click", boundClearHandler);
|
|
}
|
|
|
|
if (elements.republishButton) {
|
|
if (boundRepublishHandler) {
|
|
elements.republishButton.removeEventListener("click", boundRepublishHandler);
|
|
}
|
|
boundRepublishHandler = async (event) => {
|
|
event.preventDefault();
|
|
const actor = await resolveActorKey();
|
|
try {
|
|
const payload = state.items.map((entry) => ({
|
|
...entry.pointer,
|
|
watchedAt: entry.watchedAt,
|
|
}));
|
|
await snapshot(payload, { actor, reason: "manual-republish" });
|
|
const app = getAppInstance();
|
|
app?.showSuccess?.("Watch history snapshot queued for publish.");
|
|
} catch (error) {
|
|
const app = getAppInstance();
|
|
app?.showError?.("Failed to publish watch history. Try again later.");
|
|
if (isDevEnv) {
|
|
console.warn("[historyView] Republish failed:", error);
|
|
}
|
|
}
|
|
};
|
|
elements.republishButton.addEventListener("click", boundRepublishHandler);
|
|
}
|
|
}
|
|
|
|
function bindPrivacyControls() {
|
|
if (elements.privacyToggle) {
|
|
if (boundPrivacyToggleHandler) {
|
|
elements.privacyToggle.removeEventListener(
|
|
"click",
|
|
boundPrivacyToggleHandler
|
|
);
|
|
}
|
|
boundPrivacyToggleHandler = (event) => {
|
|
event.preventDefault();
|
|
if (state.metadataPreference === "relay-opt-in") {
|
|
state.metadataPreference = "encrypted-only";
|
|
} else {
|
|
state.metadataPreference = "relay-opt-in";
|
|
state.privacyDismissed = false;
|
|
}
|
|
writePreference(
|
|
WATCH_HISTORY_METADATA_PREF_KEY,
|
|
state.metadataPreference
|
|
);
|
|
updatePrivacyBanner();
|
|
renderer.render();
|
|
};
|
|
elements.privacyToggle.addEventListener("click", boundPrivacyToggleHandler);
|
|
}
|
|
|
|
if (elements.privacyDismiss) {
|
|
if (boundPrivacyDismissHandler) {
|
|
elements.privacyDismiss.removeEventListener(
|
|
"click",
|
|
boundPrivacyDismissHandler
|
|
);
|
|
}
|
|
boundPrivacyDismissHandler = (event) => {
|
|
event.preventDefault();
|
|
state.privacyDismissed = true;
|
|
writePreference(WATCH_HISTORY_PRIVACY_DISMISSED_KEY, "true");
|
|
updatePrivacyBanner();
|
|
};
|
|
elements.privacyDismiss.addEventListener("click", boundPrivacyDismissHandler);
|
|
}
|
|
}
|
|
|
|
function bindMetadataToggle() {
|
|
if (!(elements.metadataToggle instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
if (boundMetadataToggleHandler) {
|
|
elements.metadataToggle.removeEventListener(
|
|
"click",
|
|
boundMetadataToggleHandler
|
|
);
|
|
}
|
|
boundMetadataToggleHandler = (event) => {
|
|
event.preventDefault();
|
|
const nextValue = !state.metadataStorageEnabled;
|
|
try {
|
|
if (typeof watchHistoryService.setMetadataPreference === "function") {
|
|
watchHistoryService.setMetadataPreference(nextValue);
|
|
}
|
|
} catch (error) {
|
|
if (isDevEnv) {
|
|
console.warn("[historyView] Failed to set metadata preference:", error);
|
|
}
|
|
}
|
|
state.metadataStorageEnabled =
|
|
typeof watchHistoryService.shouldStoreMetadata === "function"
|
|
? watchHistoryService.shouldStoreMetadata() !== false
|
|
: nextValue;
|
|
updateMetadataToggle();
|
|
if (!state.metadataStorageEnabled) {
|
|
try {
|
|
watchHistoryService.clearLocalMetadata?.();
|
|
} catch (error) {
|
|
if (isDevEnv) {
|
|
console.warn(
|
|
"[historyView] Failed to clear stored metadata after disabling preference:",
|
|
error,
|
|
);
|
|
}
|
|
}
|
|
state.metadataCache.clear();
|
|
void renderer.render();
|
|
}
|
|
};
|
|
elements.metadataToggle.addEventListener("click", boundMetadataToggleHandler);
|
|
}
|
|
|
|
async function loadHistory({ force = false, actorOverride } = {}) {
|
|
if (state.isLoading) {
|
|
return false;
|
|
}
|
|
if (!state.featureEnabled) {
|
|
state.actor = null;
|
|
updateSessionFallbackWarning();
|
|
state.items = [];
|
|
state.fingerprint = "";
|
|
state.cursor = 0;
|
|
state.hasMore = false;
|
|
state.lastError = null;
|
|
showFeatureDisabledState();
|
|
return false;
|
|
}
|
|
setLoadingState(true);
|
|
const actor =
|
|
typeof actorOverride === "string" && actorOverride.trim()
|
|
? actorOverride.trim()
|
|
: await resolveActorKey();
|
|
state.actor = actor;
|
|
updateSessionFallbackWarning();
|
|
let result = null;
|
|
try {
|
|
result = await fetchHistory(actor, { cursor: 0 });
|
|
state.lastError = null;
|
|
hideErrorBanner();
|
|
} catch (error) {
|
|
state.lastError = error;
|
|
const message =
|
|
error && typeof error.message === "string"
|
|
? error.message
|
|
: "Failed to load watch history from relays.";
|
|
showErrorBanner(message);
|
|
result = null;
|
|
}
|
|
setLoadingState(false);
|
|
if (fingerprintRefreshQueued) {
|
|
scheduleTask(() => {
|
|
queueFingerprintRefresh();
|
|
});
|
|
}
|
|
const feedItems = Array.isArray(result?.items) ? result.items : [];
|
|
const normalized = [];
|
|
state.metadataCache.clear();
|
|
for (const entry of feedItems) {
|
|
const pointer = normalizePointerInput(entry?.pointer || entry);
|
|
if (!pointer) {
|
|
continue;
|
|
}
|
|
const pointerKeyValue =
|
|
typeof entry?.pointerKey === "string" && entry.pointerKey
|
|
? entry.pointerKey
|
|
: pointerKey(pointer);
|
|
if (!pointerKeyValue) {
|
|
continue;
|
|
}
|
|
const watchedAtRaw = Number.isFinite(entry?.watchedAt)
|
|
? entry.watchedAt
|
|
: Number.isFinite(entry?.metadata?.watchedAt)
|
|
? entry.metadata.watchedAt
|
|
: 0;
|
|
const watchedAt = Math.max(0, Math.floor(Number(watchedAtRaw) || 0));
|
|
const baseMetadata =
|
|
entry && typeof entry === "object" && entry.metadata && typeof entry.metadata === "object"
|
|
? { ...entry.metadata }
|
|
: {};
|
|
const video = entry?.video || baseMetadata.video || null;
|
|
const profile = baseMetadata.profile || null;
|
|
const metadata = {
|
|
...baseMetadata,
|
|
pointerKey: pointerKeyValue,
|
|
watchedAt: Number.isFinite(baseMetadata.watchedAt)
|
|
? baseMetadata.watchedAt
|
|
: watchedAt || null,
|
|
video: video || null,
|
|
profile: profile || null,
|
|
};
|
|
state.metadataCache.set(pointerKeyValue, {
|
|
video: metadata.video,
|
|
profile: metadata.profile,
|
|
});
|
|
normalized.push({
|
|
pointer,
|
|
pointerKey: pointerKeyValue,
|
|
watchedAt,
|
|
video: metadata.video,
|
|
metadata,
|
|
});
|
|
}
|
|
|
|
const fingerprint = computeFingerprint(normalized);
|
|
if (!force && fingerprint && fingerprint === state.fingerprint) {
|
|
return false;
|
|
}
|
|
state.items = normalized;
|
|
state.feedMetadata =
|
|
result && typeof result.metadata === "object" && result.metadata
|
|
? { ...result.metadata }
|
|
: null;
|
|
state.fingerprint = fingerprint;
|
|
state.cursor = 0;
|
|
state.hasMore = state.items.length > 0;
|
|
return true;
|
|
}
|
|
|
|
async function renderInitial() {
|
|
if (!(elements.grid instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
state.cursor = 0;
|
|
state.hasMore = state.items.length > 0;
|
|
elements.grid.innerHTML = "";
|
|
removeEmptyDayContainers(elements.grid);
|
|
if (!state.items.length) {
|
|
showEmptyState();
|
|
return;
|
|
}
|
|
setHidden(elements.empty, true);
|
|
setHidden(elements.grid, false);
|
|
await renderNextBatch();
|
|
}
|
|
|
|
const renderer = {
|
|
async init(options = {}) {
|
|
if (typeof document === "undefined") {
|
|
return;
|
|
}
|
|
clearSubscriptions();
|
|
clearToasts();
|
|
refreshElements();
|
|
updateFeatureBanner();
|
|
ensureObserver();
|
|
bindGridEvents();
|
|
bindLoadMore();
|
|
bindActions();
|
|
bindPrivacyControls();
|
|
bindMetadataToggle();
|
|
subscribeToMetadataPreference();
|
|
subscribeToRepublishEvents();
|
|
subscribeToFingerprintUpdates();
|
|
updateInfoCallout();
|
|
state.metadataStorageEnabled =
|
|
typeof watchHistoryService.shouldStoreMetadata === "function"
|
|
? watchHistoryService.shouldStoreMetadata() !== false
|
|
: true;
|
|
updateMetadataToggle();
|
|
const storedPreference = readPreference(
|
|
WATCH_HISTORY_METADATA_PREF_KEY,
|
|
"encrypted-only"
|
|
);
|
|
state.metadataPreference =
|
|
storedPreference === "relay-opt-in" ? "relay-opt-in" : "encrypted-only";
|
|
state.privacyDismissed = readPreference(
|
|
WATCH_HISTORY_PRIVACY_DISMISSED_KEY,
|
|
"false"
|
|
) === "true";
|
|
updatePrivacyBanner();
|
|
state.initialized = true;
|
|
if (!state.featureEnabled) {
|
|
state.items = [];
|
|
state.fingerprint = "";
|
|
state.cursor = 0;
|
|
state.hasMore = false;
|
|
state.lastError = null;
|
|
showFeatureDisabledState();
|
|
return;
|
|
}
|
|
await this.refresh({ ...options, force: true });
|
|
},
|
|
async ensureInitialLoad(options = {}) {
|
|
if (!state.initialized) {
|
|
await this.init(options);
|
|
return;
|
|
}
|
|
refreshElements();
|
|
updateFeatureBanner();
|
|
if (!state.featureEnabled) {
|
|
state.items = [];
|
|
state.fingerprint = "";
|
|
state.cursor = 0;
|
|
state.hasMore = false;
|
|
state.lastError = null;
|
|
showFeatureDisabledState();
|
|
return;
|
|
}
|
|
bindMetadataToggle();
|
|
state.metadataStorageEnabled =
|
|
typeof watchHistoryService.shouldStoreMetadata === "function"
|
|
? watchHistoryService.shouldStoreMetadata() !== false
|
|
: true;
|
|
updateMetadataToggle();
|
|
},
|
|
async refresh(options = {}) {
|
|
updateFeatureBanner();
|
|
if (!state.featureEnabled) {
|
|
state.items = [];
|
|
state.fingerprint = "";
|
|
state.cursor = 0;
|
|
state.hasMore = false;
|
|
state.lastError = null;
|
|
showFeatureDisabledState();
|
|
return;
|
|
}
|
|
const { actor, force = true } = options;
|
|
const changed = await loadHistory({
|
|
force,
|
|
actorOverride: actor,
|
|
});
|
|
if (!changed && state.items.length) {
|
|
updateLoadMoreVisibility();
|
|
return;
|
|
}
|
|
await renderInitial();
|
|
},
|
|
async loadMore() {
|
|
if (!state.featureEnabled) {
|
|
return [];
|
|
}
|
|
if (!state.items.length) {
|
|
return [];
|
|
}
|
|
await renderNextBatch();
|
|
return state.items.slice(0, state.cursor);
|
|
},
|
|
resume() {
|
|
updateLoadMoreVisibility();
|
|
},
|
|
pause() {
|
|
detachObserver();
|
|
},
|
|
destroy() {
|
|
detachObserver();
|
|
if (state.observer) {
|
|
state.observer.disconnect();
|
|
state.observer = null;
|
|
}
|
|
if (elements.grid && boundGridClickHandler) {
|
|
elements.grid.removeEventListener("click", boundGridClickHandler);
|
|
boundGridClickHandler = null;
|
|
}
|
|
if (elements.loadMore && boundLoadMoreHandler) {
|
|
elements.loadMore.removeEventListener("click", boundLoadMoreHandler);
|
|
boundLoadMoreHandler = null;
|
|
}
|
|
if (elements.clearButton && boundClearHandler) {
|
|
elements.clearButton.removeEventListener("click", boundClearHandler);
|
|
boundClearHandler = null;
|
|
}
|
|
if (elements.republishButton && boundRepublishHandler) {
|
|
elements.republishButton.removeEventListener("click", boundRepublishHandler);
|
|
boundRepublishHandler = null;
|
|
}
|
|
if (elements.privacyToggle && boundPrivacyToggleHandler) {
|
|
elements.privacyToggle.removeEventListener(
|
|
"click",
|
|
boundPrivacyToggleHandler
|
|
);
|
|
boundPrivacyToggleHandler = null;
|
|
}
|
|
if (elements.privacyDismiss && boundPrivacyDismissHandler) {
|
|
elements.privacyDismiss.removeEventListener(
|
|
"click",
|
|
boundPrivacyDismissHandler
|
|
);
|
|
boundPrivacyDismissHandler = null;
|
|
}
|
|
if (elements.metadataToggle && boundMetadataToggleHandler) {
|
|
elements.metadataToggle.removeEventListener(
|
|
"click",
|
|
boundMetadataToggleHandler
|
|
);
|
|
boundMetadataToggleHandler = null;
|
|
}
|
|
clearSubscriptions();
|
|
clearToasts();
|
|
if (elements.grid) {
|
|
elements.grid.innerHTML = "";
|
|
}
|
|
state.initialized = false;
|
|
state.items = [];
|
|
state.cursor = 0;
|
|
state.hasMore = false;
|
|
state.metadataCache.clear();
|
|
rendererRef = null;
|
|
fingerprintRefreshQueued = false;
|
|
},
|
|
async handleRemove(pointerKeyValue) {
|
|
if (!pointerKeyValue) {
|
|
return;
|
|
}
|
|
const index = state.items.findIndex(
|
|
(entry) => entry.pointerKey === pointerKeyValue
|
|
);
|
|
if (index === -1) {
|
|
return;
|
|
}
|
|
const [removed] = state.items.splice(index, 1);
|
|
state.fingerprint = computeFingerprint(state.items);
|
|
state.cursor = Math.min(state.cursor, state.items.length);
|
|
state.hasMore = state.cursor < state.items.length;
|
|
const card = elements.grid?.querySelector(
|
|
`[data-pointer-key="${escapeSelector(pointerKeyValue)}"]`
|
|
);
|
|
const parentDay = card?.closest("[data-history-day]");
|
|
if (card) {
|
|
card.remove();
|
|
}
|
|
state.metadataCache.delete(pointerKeyValue);
|
|
try {
|
|
watchHistoryService.removeLocalMetadata?.(pointerKeyValue);
|
|
} catch (error) {
|
|
if (isDevEnv) {
|
|
console.warn(
|
|
"[historyView] Failed to drop cached metadata for pointer:",
|
|
pointerKeyValue,
|
|
error,
|
|
);
|
|
}
|
|
}
|
|
removeEmptyDayContainers(elements.grid);
|
|
if (!state.items.length) {
|
|
showEmptyState();
|
|
} else {
|
|
updateLoadMoreVisibility();
|
|
}
|
|
const remainingItems = state.items.slice();
|
|
const actor = await resolveActorKey();
|
|
let refreshNeeded = true;
|
|
try {
|
|
const handler =
|
|
typeof remove === "function"
|
|
? remove
|
|
: (payload) => defaultRemoveHandler(payload);
|
|
const result = await handler({
|
|
actor,
|
|
items: remainingItems,
|
|
removed,
|
|
pointerKey: pointerKeyValue,
|
|
snapshot,
|
|
reason: "remove-item",
|
|
});
|
|
const app = getAppInstance();
|
|
if (!result?.handledToasts) {
|
|
app?.showSuccess?.("Removed from watch history.");
|
|
}
|
|
} catch (error) {
|
|
const app = getAppInstance();
|
|
if (!error?.handled) {
|
|
app?.showError?.("Failed to remove from history. Reloading list.");
|
|
}
|
|
if (isDevEnv) {
|
|
console.warn("[historyView] Removal failed:", error);
|
|
}
|
|
} finally {
|
|
if (parentDay instanceof HTMLElement) {
|
|
removeEmptyDayContainers(elements.grid);
|
|
}
|
|
if (refreshNeeded) {
|
|
try {
|
|
await this.refresh({ force: true });
|
|
} catch (error) {
|
|
if (isDevEnv) {
|
|
console.warn(
|
|
"[historyView] Failed to refresh watch history after removal:",
|
|
error,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
async render() {
|
|
await renderInitial();
|
|
},
|
|
getState() {
|
|
return {
|
|
...state,
|
|
items: state.items.slice(),
|
|
};
|
|
},
|
|
};
|
|
|
|
rendererRef = renderer;
|
|
return renderer;
|
|
}
|
|
|
|
export const watchHistoryRenderer = createWatchHistoryRenderer();
|
|
|
|
export async function initHistoryView() {
|
|
await watchHistoryRenderer.init();
|
|
}
|
|
|
|
if (typeof window !== "undefined") {
|
|
window.bitvid = window.bitvid || {};
|
|
window.bitvid.initHistoryView = initHistoryView;
|
|
window.bitvid.watchHistoryRenderer = watchHistoryRenderer;
|
|
}
|