mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 16:02:26 +00:00
- Rename "Clear local cache" button to "Clear local history" - Update info text to remove references to hydrating from session/caches - Simplify copy to remove "encrypted" references in favor of "syncing" or "history" - Update success/error messages to match new terminology
2545 lines
74 KiB
JavaScript
2545 lines
74 KiB
JavaScript
// js/historyView.js
|
|
|
|
import watchHistoryService from "./watchHistoryService.js";
|
|
import { nostrClient } from "./nostrClientFacade.js";
|
|
import {
|
|
updateWatchHistoryListWithDefaultClient as updateWatchHistoryList,
|
|
} from "./nostrWatchHistoryFacade.js";
|
|
import { pointerKey, normalizePointerInput } from "./nostr/watchHistory.js";
|
|
import {
|
|
WATCH_HISTORY_BATCH_RESOLVE,
|
|
WATCH_HISTORY_BATCH_PAGE_SIZE
|
|
} from "./config.js";
|
|
import { getApplication } from "./applicationContext.js";
|
|
import { userLogger } from "./utils/logger.js";
|
|
import {
|
|
normalizeVideoModerationContext,
|
|
applyModerationContextDatasets,
|
|
getModerationOverrideActionLabels,
|
|
} from "./ui/moderationUiHelpers.js";
|
|
import { buildModerationBadgeText } from "./ui/moderationCopy.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 syncing.";
|
|
|
|
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) {
|
|
userLogger.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
|
|
}
|
|
};
|
|
|
|
const preferenceSource =
|
|
typeof app?.getHashtagPreferences === "function"
|
|
? app.getHashtagPreferences()
|
|
: {};
|
|
runtime.tagPreferences = {
|
|
interests: Array.isArray(preferenceSource?.interests)
|
|
? [...preferenceSource.interests]
|
|
: [],
|
|
disinterests: Array.isArray(preferenceSource?.disinterests)
|
|
? [...preferenceSource.disinterests]
|
|
: []
|
|
};
|
|
|
|
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) {
|
|
userLogger.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) {
|
|
userLogger.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 "";
|
|
}
|
|
|
|
const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
|
|
const historyCardRegistry = new Map();
|
|
|
|
function sanitizeIdToken(value) {
|
|
if (typeof value !== "string") {
|
|
return "";
|
|
}
|
|
const token = value.trim().replace(/[^a-zA-Z0-9_-]/g, "");
|
|
return token;
|
|
}
|
|
|
|
function getHistoryCardModerationId(pointerKey) {
|
|
const token = sanitizeIdToken(pointerKey);
|
|
return token ? `watch-history-${token}-moderation` : "";
|
|
}
|
|
|
|
function getHistoryCardModerationState(context) {
|
|
if (!context) {
|
|
return "";
|
|
}
|
|
if (context.overrideActive) {
|
|
return "override";
|
|
}
|
|
if (context.activeHidden && !context.overrideActive) {
|
|
return "hidden";
|
|
}
|
|
if (context.trustedMuted) {
|
|
return "trusted-mute";
|
|
}
|
|
return "blocked";
|
|
}
|
|
|
|
function shouldShowHistoryCardBlockButton(context) {
|
|
if (!context || !context.trustedMuted) {
|
|
return false;
|
|
}
|
|
if (context.activeHidden && !context.overrideActive) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function getModerationBadgeIconShape(state) {
|
|
if (state === "override") {
|
|
return {
|
|
d: "M10 18a8 8 0 100-16 8 8 0 000 16zm3.78-9.72a.75.75 0 00-1.06-1.06L9 11.94l-1.72-1.72a.75.75 0 10-1.06 1.06l2.25 2.25a.75.75 0 001.06 0l3.25-3.25z",
|
|
fillRule: "evenodd",
|
|
clipRule: "evenodd"
|
|
};
|
|
}
|
|
|
|
return {
|
|
d: "M10 18a8 8 0 100-16 8 8 0 000 16zm-.75-11.75a.75.75 0 011.5 0v4.5a.75.75 0 01-1.5 0v-4.5zm.75 8.5a1 1 0 100-2 1 1 0 000 2z",
|
|
fillRule: "evenodd",
|
|
clipRule: "evenodd"
|
|
};
|
|
}
|
|
|
|
function updateHistoryCardBadgeIcon(svg, state) {
|
|
if (!svg) {
|
|
return;
|
|
}
|
|
svg.dataset.iconState = state;
|
|
const path = svg.firstElementChild;
|
|
if (!path) {
|
|
return;
|
|
}
|
|
const { d, fillRule, clipRule } = getModerationBadgeIconShape(state);
|
|
path.setAttribute("d", d);
|
|
if (fillRule) {
|
|
path.setAttribute("fill-rule", fillRule);
|
|
} else {
|
|
path.removeAttribute("fill-rule");
|
|
}
|
|
if (clipRule) {
|
|
path.setAttribute("clip-rule", clipRule);
|
|
} else {
|
|
path.removeAttribute("clip-rule");
|
|
}
|
|
}
|
|
|
|
function createHistoryCardBadgeIcon(doc, state) {
|
|
const wrapper = doc.createElement("span");
|
|
wrapper.className = "moderation-badge__icon";
|
|
wrapper.setAttribute("aria-hidden", "true");
|
|
const svg = doc.createElementNS(SVG_NAMESPACE, "svg");
|
|
svg.setAttribute("viewBox", "0 0 20 20");
|
|
svg.setAttribute("focusable", "false");
|
|
svg.setAttribute("aria-hidden", "true");
|
|
svg.classList.add("moderation-badge__icon-mark");
|
|
const path = doc.createElementNS(SVG_NAMESPACE, "path");
|
|
path.setAttribute("fill", "currentColor");
|
|
svg.appendChild(path);
|
|
wrapper.appendChild(svg);
|
|
updateHistoryCardBadgeIcon(svg, state);
|
|
return { wrapper, svg };
|
|
}
|
|
|
|
function cleanupHistoryCard(pointerKey) {
|
|
if (!pointerKey || !historyCardRegistry.has(pointerKey)) {
|
|
return;
|
|
}
|
|
const ref = historyCardRegistry.get(pointerKey);
|
|
historyCardRegistry.delete(pointerKey);
|
|
if (!ref || typeof ref !== "object") {
|
|
return;
|
|
}
|
|
if (ref.overrideButton && typeof ref.overrideButton.removeEventListener === "function" && typeof ref.boundOverride === "function") {
|
|
ref.overrideButton.removeEventListener("click", ref.boundOverride);
|
|
}
|
|
if (ref.blockButton && typeof ref.blockButton.removeEventListener === "function" && typeof ref.boundBlock === "function") {
|
|
ref.blockButton.removeEventListener("click", ref.boundBlock);
|
|
}
|
|
if (ref.badgeEl && ref.badgeEl.parentNode) {
|
|
ref.badgeEl.parentNode.removeChild(ref.badgeEl);
|
|
}
|
|
if (ref.hiddenContainer && ref.hiddenContainer.parentNode) {
|
|
ref.hiddenContainer.parentNode.removeChild(ref.hiddenContainer);
|
|
}
|
|
}
|
|
|
|
function registerHistoryCard(pointerKey, ref) {
|
|
if (!pointerKey || !ref) {
|
|
return;
|
|
}
|
|
cleanupHistoryCard(pointerKey);
|
|
historyCardRegistry.set(pointerKey, ref);
|
|
}
|
|
|
|
function clearHistoryCardRegistry() {
|
|
const keys = Array.from(historyCardRegistry.keys());
|
|
keys.forEach((key) => {
|
|
cleanupHistoryCard(key);
|
|
});
|
|
historyCardRegistry.clear();
|
|
}
|
|
|
|
function ensureHiddenSummaryContainer(ref) {
|
|
const article = ref?.article;
|
|
if (!article || typeof article.insertBefore !== "function") {
|
|
return null;
|
|
}
|
|
let container = ref.hiddenContainer;
|
|
const doc = article.ownerDocument || document;
|
|
if (!container) {
|
|
container = doc.createElement("div");
|
|
container.className = "watch-history-card__hidden bv-stack bv-stack--tight p-md";
|
|
container.dataset.moderationHiddenContainer = "true";
|
|
container.setAttribute("role", "group");
|
|
container.setAttribute("aria-live", "polite");
|
|
ref.hiddenContainer = container;
|
|
}
|
|
container.hidden = false;
|
|
container.removeAttribute("aria-hidden");
|
|
if (container.parentNode !== article) {
|
|
article.insertBefore(container, article.firstChild || null);
|
|
}
|
|
return container;
|
|
}
|
|
|
|
function updateHistoryCardHiddenState(ref, context) {
|
|
const hiddenActive = Boolean(context?.activeHidden && !context?.overrideActive);
|
|
const primary = ref.primary;
|
|
const meta = ref.meta;
|
|
|
|
if (hiddenActive) {
|
|
if (primary && typeof primary.setAttribute === "function") {
|
|
primary.setAttribute("hidden", "");
|
|
primary.setAttribute("aria-hidden", "true");
|
|
}
|
|
if (meta && typeof meta.setAttribute === "function") {
|
|
meta.setAttribute("hidden", "");
|
|
meta.setAttribute("aria-hidden", "true");
|
|
}
|
|
const container = ensureHiddenSummaryContainer(ref);
|
|
if (container) {
|
|
const description = buildModerationBadgeText(context, { variant: "card" });
|
|
if (description) {
|
|
container.setAttribute("aria-label", description);
|
|
} else {
|
|
container.removeAttribute("aria-label");
|
|
}
|
|
}
|
|
} else {
|
|
if (primary && typeof primary.removeAttribute === "function") {
|
|
primary.removeAttribute("hidden");
|
|
primary.removeAttribute("aria-hidden");
|
|
}
|
|
if (meta && typeof meta.removeAttribute === "function") {
|
|
meta.removeAttribute("hidden");
|
|
meta.removeAttribute("aria-hidden");
|
|
}
|
|
const container = ref.hiddenContainer;
|
|
if (container) {
|
|
container.hidden = true;
|
|
container.setAttribute("aria-hidden", "true");
|
|
container.removeAttribute("aria-label");
|
|
container.removeAttribute("aria-labelledby");
|
|
if (container.parentNode) {
|
|
container.parentNode.removeChild(container);
|
|
}
|
|
ref.hiddenContainer = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
function createHistoryCardBadge(ref, context) {
|
|
const article = ref?.article;
|
|
const doc = (article && article.ownerDocument) || (typeof document !== "undefined" ? document : null);
|
|
if (!doc) {
|
|
return null;
|
|
}
|
|
const badge = doc.createElement("div");
|
|
badge.className = "moderation-badge";
|
|
badge.dataset.moderationBadge = "true";
|
|
const label = doc.createElement("span");
|
|
label.className = "moderation-badge__label inline-flex items-center gap-xs";
|
|
const { wrapper, svg } = createHistoryCardBadgeIcon(doc, getHistoryCardModerationState(context));
|
|
label.appendChild(wrapper);
|
|
const text = doc.createElement("span");
|
|
text.className = "moderation-badge__text";
|
|
label.appendChild(text);
|
|
badge.appendChild(label);
|
|
ref.badgeEl = badge;
|
|
ref.badgeLabelEl = label;
|
|
ref.badgeTextEl = text;
|
|
ref.badgeIconSvg = svg;
|
|
ref.badgeIconWrapper = wrapper;
|
|
if (typeof ref.boundOverride !== "function") {
|
|
ref.boundOverride = (event) => handleHistoryCardModerationOverride(event, ref);
|
|
}
|
|
if (typeof ref.boundHide !== "function") {
|
|
ref.boundHide = (event) => handleHistoryCardModerationHide(event, ref);
|
|
}
|
|
if (typeof ref.boundBlock !== "function") {
|
|
ref.boundBlock = (event) => handleHistoryCardModerationBlock(event, ref);
|
|
}
|
|
if (!ref.overrideButton) {
|
|
const { text: overrideLabel, ariaLabel: overrideAria } =
|
|
getModerationOverrideActionLabels({ overrideActive: false });
|
|
const overrideButton = doc.createElement("button");
|
|
overrideButton.type = "button";
|
|
overrideButton.className = "moderation-badge__action flex-shrink-0";
|
|
overrideButton.dataset.moderationAction = "override";
|
|
overrideButton.textContent = overrideLabel;
|
|
overrideButton.setAttribute("aria-label", overrideAria);
|
|
overrideButton.addEventListener("click", ref.boundOverride);
|
|
ref.overrideButton = overrideButton;
|
|
}
|
|
if (!ref.hideButton) {
|
|
const { text: restoreLabel, ariaLabel: restoreAria } =
|
|
getModerationOverrideActionLabels({ overrideActive: true });
|
|
const hideButton = doc.createElement("button");
|
|
hideButton.type = "button";
|
|
hideButton.className = "moderation-badge__action flex-shrink-0";
|
|
hideButton.dataset.moderationAction = "hide";
|
|
hideButton.textContent = restoreLabel;
|
|
hideButton.setAttribute("aria-label", restoreAria);
|
|
hideButton.addEventListener("click", ref.boundHide);
|
|
ref.hideButton = hideButton;
|
|
}
|
|
if (!ref.blockButton) {
|
|
const blockButton = doc.createElement("button");
|
|
blockButton.type = "button";
|
|
blockButton.className = "moderation-badge__action flex-shrink-0";
|
|
blockButton.dataset.moderationAction = "block";
|
|
blockButton.textContent = "Block";
|
|
blockButton.addEventListener("click", ref.boundBlock);
|
|
ref.blockButton = blockButton;
|
|
}
|
|
return badge;
|
|
}
|
|
|
|
function updateHistoryCardBadge(ref, context) {
|
|
const hiddenActive = Boolean(context?.activeHidden && !context?.overrideActive);
|
|
if (!context?.shouldShow) {
|
|
if (ref.overrideButton && typeof ref.overrideButton.removeEventListener === "function" && typeof ref.boundOverride === "function") {
|
|
ref.overrideButton.removeEventListener("click", ref.boundOverride);
|
|
}
|
|
if (ref.hideButton && typeof ref.hideButton.removeEventListener === "function" && typeof ref.boundHide === "function") {
|
|
ref.hideButton.removeEventListener("click", ref.boundHide);
|
|
}
|
|
if (ref.blockButton && typeof ref.blockButton.removeEventListener === "function" && typeof ref.boundBlock === "function") {
|
|
ref.blockButton.removeEventListener("click", ref.boundBlock);
|
|
}
|
|
if (ref.badgeEl && ref.badgeEl.parentNode) {
|
|
ref.badgeEl.parentNode.removeChild(ref.badgeEl);
|
|
}
|
|
ref.badgeEl = null;
|
|
ref.badgeLabelEl = null;
|
|
ref.badgeTextEl = null;
|
|
ref.badgeIconSvg = null;
|
|
ref.badgeIconWrapper = null;
|
|
ref.overrideButton = null;
|
|
ref.hideButton = null;
|
|
ref.blockButton = null;
|
|
return;
|
|
}
|
|
|
|
if (!ref.badgeEl) {
|
|
createHistoryCardBadge(ref, context);
|
|
}
|
|
|
|
const badge = ref.badgeEl;
|
|
if (!badge) {
|
|
return;
|
|
}
|
|
|
|
badge.dataset.variant = context.overrideActive ? "neutral" : "warning";
|
|
const state = context.overrideActive ? "override" : getHistoryCardModerationState(context);
|
|
badge.dataset.moderationState = state;
|
|
if (hiddenActive && context.effectiveHideReason) {
|
|
badge.dataset.moderationHideReason = context.effectiveHideReason;
|
|
} else if (badge.dataset.moderationHideReason) {
|
|
delete badge.dataset.moderationHideReason;
|
|
}
|
|
|
|
if (ref.badgeIconSvg) {
|
|
updateHistoryCardBadgeIcon(ref.badgeIconSvg, state);
|
|
}
|
|
|
|
const textContent = buildModerationBadgeText(context, { variant: "card" });
|
|
if (ref.badgeTextEl) {
|
|
ref.badgeTextEl.textContent = textContent;
|
|
}
|
|
|
|
const muteNames = Array.isArray(context.trustedMuteDisplayNames)
|
|
? context.trustedMuteDisplayNames
|
|
.map((name) => (typeof name === "string" ? name.trim() : ""))
|
|
.filter(Boolean)
|
|
: [];
|
|
const reporterNames = Array.isArray(context.reporterDisplayNames)
|
|
? context.reporterDisplayNames
|
|
.map((name) => (typeof name === "string" ? name.trim() : ""))
|
|
.filter(Boolean)
|
|
: [];
|
|
|
|
const allNames = [...muteNames, ...reporterNames];
|
|
const uniqueNames = [];
|
|
const seen = new Set();
|
|
for (const name of allNames) {
|
|
if (!name) {
|
|
continue;
|
|
}
|
|
const key = name.toLowerCase();
|
|
if (seen.has(key)) {
|
|
continue;
|
|
}
|
|
seen.add(key);
|
|
uniqueNames.push(name);
|
|
}
|
|
|
|
if (uniqueNames.length) {
|
|
const hasMuted = muteNames.length > 0;
|
|
const hasReporters = reporterNames.length > 0;
|
|
const prefix = hasMuted && hasReporters
|
|
? "Muted/Reported by"
|
|
: hasMuted
|
|
? "Muted by"
|
|
: "Reported by";
|
|
const joined = uniqueNames.join(", ");
|
|
badge.title = `${prefix} ${joined}`;
|
|
badge.setAttribute("aria-label", `${textContent}. ${prefix} ${joined}.`);
|
|
} else if (textContent) {
|
|
badge.removeAttribute("title");
|
|
badge.setAttribute("aria-label", `${textContent}.`);
|
|
} else {
|
|
badge.removeAttribute("title");
|
|
badge.removeAttribute("aria-label");
|
|
}
|
|
|
|
const parent = hiddenActive ? ensureHiddenSummaryContainer(ref) : ref.badgeMount;
|
|
if (parent && badge.parentNode !== parent) {
|
|
if (badge.parentNode) {
|
|
badge.parentNode.removeChild(badge);
|
|
}
|
|
parent.appendChild(badge);
|
|
}
|
|
|
|
if (!context.overrideActive && context.allowOverride && ref.overrideButton) {
|
|
const { text: overrideLabel, ariaLabel: overrideAria } =
|
|
getModerationOverrideActionLabels({ overrideActive: false });
|
|
ref.overrideButton.textContent = overrideLabel;
|
|
ref.overrideButton.setAttribute("aria-label", overrideAria);
|
|
if (ref.overrideButton.parentNode !== badge) {
|
|
badge.appendChild(ref.overrideButton);
|
|
}
|
|
ref.overrideButton.disabled = false;
|
|
ref.overrideButton.removeAttribute("aria-busy");
|
|
} else if (ref.overrideButton && ref.overrideButton.parentNode === badge) {
|
|
badge.removeChild(ref.overrideButton);
|
|
}
|
|
|
|
if (context.overrideActive && ref.hideButton) {
|
|
const { text: restoreLabel, ariaLabel: restoreAria } =
|
|
getModerationOverrideActionLabels({ overrideActive: true });
|
|
ref.hideButton.textContent = restoreLabel;
|
|
ref.hideButton.setAttribute("aria-label", restoreAria);
|
|
if (ref.hideButton.parentNode !== badge) {
|
|
badge.appendChild(ref.hideButton);
|
|
}
|
|
ref.hideButton.disabled = false;
|
|
ref.hideButton.removeAttribute("aria-busy");
|
|
} else if (ref.hideButton && ref.hideButton.parentNode === badge) {
|
|
badge.removeChild(ref.hideButton);
|
|
}
|
|
|
|
if (shouldShowHistoryCardBlockButton(context) && ref.blockButton) {
|
|
if (ref.blockButton.parentNode !== badge) {
|
|
badge.appendChild(ref.blockButton);
|
|
}
|
|
ref.blockButton.disabled = false;
|
|
ref.blockButton.removeAttribute("aria-busy");
|
|
} else if (ref.blockButton && ref.blockButton.parentNode === badge) {
|
|
badge.removeChild(ref.blockButton);
|
|
}
|
|
}
|
|
|
|
function updateHistoryCardAria(ref) {
|
|
const badge = ref.badgeEl;
|
|
const badgeId = badge ? getHistoryCardModerationId(ref.pointerKey) : "";
|
|
if (badge) {
|
|
if (badgeId) {
|
|
badge.id = badgeId;
|
|
} else {
|
|
badge.removeAttribute("id");
|
|
}
|
|
}
|
|
|
|
const targets = [
|
|
ref.thumbnailLink,
|
|
ref.titleLink,
|
|
ref.playButton,
|
|
ref.channelButton,
|
|
ref.creatorNameButton,
|
|
ref.avatarButton
|
|
].filter((el) => el && typeof el.getAttribute === "function");
|
|
|
|
targets.forEach((el) => {
|
|
const existing = el.getAttribute("aria-describedby") || "";
|
|
const tokens = existing.split(/\s+/).filter(Boolean);
|
|
if (ref.badgeId) {
|
|
for (let index = tokens.length - 1; index >= 0; index -= 1) {
|
|
if (tokens[index] === ref.badgeId) {
|
|
tokens.splice(index, 1);
|
|
}
|
|
}
|
|
}
|
|
if (badgeId) {
|
|
tokens.push(badgeId);
|
|
}
|
|
if (tokens.length) {
|
|
el.setAttribute("aria-describedby", Array.from(new Set(tokens)).join(" "));
|
|
} else {
|
|
el.removeAttribute("aria-describedby");
|
|
}
|
|
});
|
|
|
|
if (ref.overrideButton) {
|
|
if (badgeId) {
|
|
ref.overrideButton.setAttribute("aria-describedby", badgeId);
|
|
} else {
|
|
ref.overrideButton.removeAttribute("aria-describedby");
|
|
}
|
|
}
|
|
|
|
if (ref.hideButton) {
|
|
if (badgeId) {
|
|
ref.hideButton.setAttribute("aria-describedby", badgeId);
|
|
} else {
|
|
ref.hideButton.removeAttribute("aria-describedby");
|
|
}
|
|
}
|
|
|
|
if (ref.blockButton) {
|
|
if (badgeId) {
|
|
ref.blockButton.setAttribute("aria-describedby", badgeId);
|
|
} else {
|
|
ref.blockButton.removeAttribute("aria-describedby");
|
|
}
|
|
}
|
|
|
|
if (ref.hiddenContainer) {
|
|
if (badgeId) {
|
|
ref.hiddenContainer.setAttribute("aria-labelledby", badgeId);
|
|
} else {
|
|
ref.hiddenContainer.removeAttribute("aria-labelledby");
|
|
}
|
|
}
|
|
|
|
ref.badgeId = badgeId;
|
|
}
|
|
|
|
function applyHistoryCardModeration(ref, context) {
|
|
if (!ref) {
|
|
return;
|
|
}
|
|
const normalizedContext = context || normalizeVideoModerationContext(ref.video?.moderation);
|
|
applyModerationContextDatasets(normalizedContext, {
|
|
root: ref.article,
|
|
thumbnail: ref.thumbnailInner || ref.thumbnailLink,
|
|
avatar: ref.avatarButton
|
|
});
|
|
updateHistoryCardHiddenState(ref, normalizedContext);
|
|
updateHistoryCardBadge(ref, normalizedContext);
|
|
updateHistoryCardAria(ref);
|
|
}
|
|
|
|
function handleHistoryCardModerationOverride(event, ref) {
|
|
if (event) {
|
|
if (typeof event.preventDefault === "function") {
|
|
event.preventDefault();
|
|
}
|
|
if (typeof event.stopPropagation === "function") {
|
|
event.stopPropagation();
|
|
}
|
|
}
|
|
|
|
const button = ref.overrideButton;
|
|
if (button) {
|
|
button.disabled = true;
|
|
button.setAttribute("aria-busy", "true");
|
|
}
|
|
|
|
const video = ref.video;
|
|
if (!video || typeof video !== "object" || !video.id) {
|
|
if (button) {
|
|
button.disabled = false;
|
|
button.removeAttribute("aria-busy");
|
|
}
|
|
return;
|
|
}
|
|
|
|
const app = getAppInstance();
|
|
let result;
|
|
try {
|
|
if (typeof app?.handleModerationOverride === "function") {
|
|
result = app.handleModerationOverride({ video });
|
|
} else {
|
|
const doc = (ref.article && ref.article.ownerDocument) || (typeof document !== "undefined" ? document : null);
|
|
if (doc && typeof doc.dispatchEvent === "function") {
|
|
doc.dispatchEvent(
|
|
new CustomEvent("video:moderation-override", { detail: { video } })
|
|
);
|
|
}
|
|
result = true;
|
|
}
|
|
} catch (error) {
|
|
if (button) {
|
|
button.disabled = false;
|
|
button.removeAttribute("aria-busy");
|
|
}
|
|
if (isDevEnv) {
|
|
userLogger.warn("[historyView] Moderation override handler threw:", error);
|
|
}
|
|
return;
|
|
}
|
|
|
|
Promise.resolve(result)
|
|
.then((handled) => {
|
|
if (handled === false) {
|
|
if (button) {
|
|
button.disabled = false;
|
|
button.removeAttribute("aria-busy");
|
|
}
|
|
return;
|
|
}
|
|
const context = normalizeVideoModerationContext(ref.video?.moderation);
|
|
applyHistoryCardModeration(ref, context);
|
|
})
|
|
.catch((error) => {
|
|
if (isDevEnv) {
|
|
userLogger.warn("[historyView] Moderation override failed:", error);
|
|
}
|
|
if (button) {
|
|
button.disabled = false;
|
|
button.removeAttribute("aria-busy");
|
|
}
|
|
});
|
|
}
|
|
|
|
function handleHistoryCardModerationHide(event, ref) {
|
|
if (event) {
|
|
if (typeof event.preventDefault === "function") {
|
|
event.preventDefault();
|
|
}
|
|
if (typeof event.stopPropagation === "function") {
|
|
event.stopPropagation();
|
|
}
|
|
}
|
|
|
|
const button = ref.hideButton;
|
|
if (button) {
|
|
button.disabled = true;
|
|
button.setAttribute("aria-busy", "true");
|
|
}
|
|
|
|
const video = ref.video;
|
|
if (!video || typeof video !== "object" || !video.id) {
|
|
if (button) {
|
|
button.disabled = false;
|
|
button.removeAttribute("aria-busy");
|
|
}
|
|
return;
|
|
}
|
|
|
|
const app = getAppInstance();
|
|
let result;
|
|
try {
|
|
if (typeof app?.handleModerationHide === "function") {
|
|
result = app.handleModerationHide({ video });
|
|
} else {
|
|
const doc =
|
|
(ref.article && ref.article.ownerDocument) ||
|
|
(typeof document !== "undefined" ? document : null);
|
|
if (doc && typeof doc.dispatchEvent === "function") {
|
|
doc.dispatchEvent(new CustomEvent("video:moderation-hide", { detail: { video } }));
|
|
}
|
|
result = true;
|
|
}
|
|
} catch (error) {
|
|
if (button) {
|
|
button.disabled = false;
|
|
button.removeAttribute("aria-busy");
|
|
}
|
|
if (isDevEnv) {
|
|
userLogger.warn("[historyView] Moderation hide handler threw:", error);
|
|
}
|
|
return;
|
|
}
|
|
|
|
Promise.resolve(result)
|
|
.then((handled) => {
|
|
if (handled === false) {
|
|
if (button) {
|
|
button.disabled = false;
|
|
button.removeAttribute("aria-busy");
|
|
}
|
|
return;
|
|
}
|
|
const context = normalizeVideoModerationContext(ref.video?.moderation);
|
|
applyHistoryCardModeration(ref, context);
|
|
})
|
|
.catch((error) => {
|
|
if (isDevEnv) {
|
|
userLogger.warn("[historyView] Moderation hide failed:", error);
|
|
}
|
|
if (button) {
|
|
button.disabled = false;
|
|
button.removeAttribute("aria-busy");
|
|
}
|
|
});
|
|
}
|
|
|
|
function handleHistoryCardModerationBlock(event, ref) {
|
|
if (event) {
|
|
if (typeof event.preventDefault === "function") {
|
|
event.preventDefault();
|
|
}
|
|
if (typeof event.stopPropagation === "function") {
|
|
event.stopPropagation();
|
|
}
|
|
}
|
|
|
|
const button = ref.blockButton;
|
|
if (button) {
|
|
button.disabled = true;
|
|
button.setAttribute("aria-busy", "true");
|
|
}
|
|
|
|
const video = ref.video;
|
|
if (!video || typeof video !== "object" || !video.id) {
|
|
if (button) {
|
|
button.disabled = false;
|
|
button.removeAttribute("aria-busy");
|
|
}
|
|
return;
|
|
}
|
|
|
|
const app = getAppInstance();
|
|
let result;
|
|
try {
|
|
if (typeof app?.handleModerationBlock === "function") {
|
|
result = app.handleModerationBlock({ video });
|
|
} else {
|
|
const doc = (ref.article && ref.article.ownerDocument) || (typeof document !== "undefined" ? document : null);
|
|
if (doc && typeof doc.dispatchEvent === "function") {
|
|
doc.dispatchEvent(
|
|
new CustomEvent("video:moderation-block", { detail: { video } })
|
|
);
|
|
document.dispatchEvent(
|
|
new CustomEvent("video:moderation-hide", { detail: { video } })
|
|
);
|
|
}
|
|
result = true;
|
|
}
|
|
} catch (error) {
|
|
if (button) {
|
|
button.disabled = false;
|
|
button.removeAttribute("aria-busy");
|
|
}
|
|
if (isDevEnv) {
|
|
userLogger.warn("[historyView] Moderation block handler threw:", error);
|
|
}
|
|
return;
|
|
}
|
|
|
|
Promise.resolve(result)
|
|
.then((handled) => {
|
|
if (handled === false) {
|
|
if (button) {
|
|
button.disabled = false;
|
|
button.removeAttribute("aria-busy");
|
|
}
|
|
return;
|
|
}
|
|
const context = normalizeVideoModerationContext(ref.video?.moderation);
|
|
applyHistoryCardModeration(ref, context);
|
|
})
|
|
.catch((error) => {
|
|
if (isDevEnv) {
|
|
userLogger.warn("[historyView] Moderation block failed:", error);
|
|
}
|
|
if (button) {
|
|
button.disabled = false;
|
|
button.removeAttribute("aria-busy");
|
|
}
|
|
});
|
|
}
|
|
|
|
function refreshRegisteredHistoryCards(video, context, pointerKeys = null) {
|
|
const filterSet = Array.isArray(pointerKeys) && pointerKeys.length ? new Set(pointerKeys) : null;
|
|
const videoId = video && typeof video.id === "string" ? video.id : "";
|
|
historyCardRegistry.forEach((ref, pointerKey) => {
|
|
if (!ref) {
|
|
return;
|
|
}
|
|
if (filterSet && !filterSet.has(pointerKey)) {
|
|
return;
|
|
}
|
|
if (videoId) {
|
|
ref.video = video;
|
|
}
|
|
applyHistoryCardModeration(ref, context);
|
|
});
|
|
}
|
|
|
|
export function buildHistoryCard({ item, video, profile }) {
|
|
const article = document.createElement("article");
|
|
article.className = "watch-history-card";
|
|
article.dataset.pointerKey = item.pointerKey;
|
|
article.dataset.historyCard = "true";
|
|
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);
|
|
const moderationMount = document.createElement("div");
|
|
moderationMount.className = "watch-history-card__moderation flex flex-wrap items-center gap-sm";
|
|
details.appendChild(moderationMount);
|
|
|
|
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.variant = "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 history entry (sync may take a moment)."
|
|
);
|
|
removeButton.title =
|
|
"Removes this entry from history. Relay sync may take a moment.";
|
|
|
|
actions.appendChild(playButton);
|
|
actions.appendChild(channelButton);
|
|
actions.appendChild(removeButton);
|
|
|
|
meta.appendChild(actions);
|
|
|
|
article.appendChild(primary);
|
|
article.appendChild(meta);
|
|
|
|
if (pointerVideoId) {
|
|
article.dataset.videoId = pointerVideoId;
|
|
}
|
|
|
|
const moderationContext = normalizeVideoModerationContext(video?.moderation);
|
|
const cardRef = {
|
|
pointerKey: item.pointerKey,
|
|
article,
|
|
primary,
|
|
meta,
|
|
details,
|
|
badgeMount: moderationMount,
|
|
thumbnailInner,
|
|
thumbnailLink,
|
|
titleLink,
|
|
playButton,
|
|
channelButton,
|
|
removeButton,
|
|
creatorNameButton,
|
|
avatarButton: creatorAvatarButton,
|
|
video: video || null,
|
|
hiddenContainer: null,
|
|
badgeEl: null,
|
|
badgeLabelEl: null,
|
|
badgeTextEl: null,
|
|
badgeIconSvg: null,
|
|
badgeIconWrapper: null,
|
|
overrideButton: null,
|
|
blockButton: null,
|
|
badgeId: "",
|
|
boundOverride: null,
|
|
boundBlock: null
|
|
};
|
|
|
|
registerHistoryCard(item.pointerKey, cardRef);
|
|
applyHistoryCardModeration(cardRef, moderationContext);
|
|
|
|
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",
|
|
sessionWarningSelector = "#profileHistorySessionWarning",
|
|
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,
|
|
privacyDismissed: false,
|
|
feedMetadata: null,
|
|
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,
|
|
sessionWarning: null
|
|
};
|
|
|
|
let boundGridClickHandler = null;
|
|
let boundLoadMoreHandler = null;
|
|
let boundClearHandler = null;
|
|
let boundRepublishHandler = null;
|
|
let boundPrivacyDismissHandler = null;
|
|
|
|
const subscriptions = 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),
|
|
sessionWarning: resolveElement(sessionWarningSelector)
|
|
};
|
|
}
|
|
|
|
function clearSubscriptions() {
|
|
for (const unsubscribe of subscriptions) {
|
|
try {
|
|
unsubscribe();
|
|
} catch (error) {
|
|
if (isDevEnv) {
|
|
userLogger.warn(
|
|
"[historyView] Failed to unsubscribe from watch history event:",
|
|
error
|
|
);
|
|
}
|
|
}
|
|
}
|
|
subscriptions.clear();
|
|
fingerprintRefreshQueued = false;
|
|
}
|
|
|
|
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 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 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 handleModerationUpdate(video) {
|
|
if (!video || typeof video !== "object" || !video.id) {
|
|
return;
|
|
}
|
|
|
|
let workingVideo = video;
|
|
if (typeof structuredClone === "function") {
|
|
try {
|
|
workingVideo = structuredClone(video);
|
|
} catch (error) {
|
|
workingVideo = video;
|
|
}
|
|
} else {
|
|
try {
|
|
workingVideo = JSON.parse(JSON.stringify(video));
|
|
} catch (error) {
|
|
workingVideo = { ...video };
|
|
if (video.moderation && typeof video.moderation === "object") {
|
|
workingVideo.moderation = { ...video.moderation };
|
|
}
|
|
}
|
|
}
|
|
|
|
const app = getAppInstance();
|
|
if (typeof app?.decorateVideoModeration === "function") {
|
|
try {
|
|
const decorated = app.decorateVideoModeration(workingVideo);
|
|
if (decorated) {
|
|
workingVideo = decorated;
|
|
}
|
|
} catch (error) {
|
|
if (isDevEnv) {
|
|
userLogger.warn(
|
|
"[historyView] Failed to decorate moderation update:",
|
|
error
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
const context = normalizeVideoModerationContext(workingVideo?.moderation);
|
|
const affectedPointers = [];
|
|
state.items.forEach((entry) => {
|
|
if (!entry) {
|
|
return;
|
|
}
|
|
const entryVideo = entry.metadata?.video || entry.video;
|
|
if (entryVideo && entryVideo.id === workingVideo.id) {
|
|
entry.metadata.video = workingVideo;
|
|
entry.video = workingVideo;
|
|
if (entry.pointerKey) {
|
|
affectedPointers.push(entry.pointerKey);
|
|
}
|
|
}
|
|
});
|
|
|
|
refreshRegisteredHistoryCards(
|
|
workingVideo,
|
|
context,
|
|
affectedPointers.length ? affectedPointers : null
|
|
);
|
|
}
|
|
|
|
function subscribeToModerationEvents() {
|
|
if (typeof document === "undefined" || typeof document.addEventListener !== "function") {
|
|
return;
|
|
}
|
|
const handler = (event) => {
|
|
const video = event?.detail?.video;
|
|
if (!video) {
|
|
return;
|
|
}
|
|
handleModerationUpdate(video);
|
|
};
|
|
document.addEventListener("video:moderation-override", handler);
|
|
document.addEventListener("video:moderation-block", handler);
|
|
subscriptions.add(() => {
|
|
document.removeEventListener("video:moderation-override", handler);
|
|
document.removeEventListener("video:moderation-block", handler);
|
|
});
|
|
}
|
|
|
|
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);
|
|
}
|
|
clearHistoryCardRegistry();
|
|
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 dismissed = state.privacyDismissed;
|
|
if (dismissed) {
|
|
setHidden(elements.privacyBanner, true);
|
|
return;
|
|
}
|
|
if (elements.privacyMessage) {
|
|
elements.privacyMessage.textContent =
|
|
"Watch history only stores the event IDs of videos you've played. Titles and thumbnails reload from relays when available.";
|
|
}
|
|
if (elements.privacyToggle) {
|
|
setHidden(elements.privacyToggle, true);
|
|
}
|
|
setHidden(elements.privacyBanner, false);
|
|
}
|
|
|
|
function updateInfoCallout() {
|
|
if (!(elements.info instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
const message =
|
|
"Watch history keeps a lean list of event IDs. Details load on demand from relays when you open this page. Nothing is published from this view.";
|
|
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) {
|
|
userLogger.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 baseMetadata =
|
|
item &&
|
|
typeof item === "object" &&
|
|
item.metadata &&
|
|
typeof item.metadata === "object"
|
|
? { ...item.metadata }
|
|
: {};
|
|
|
|
const metadata = {
|
|
...baseMetadata,
|
|
video: item.video || baseMetadata.video || null,
|
|
profile: baseMetadata.profile || null
|
|
};
|
|
|
|
if (metadata.video && typeof metadata.video === "object") {
|
|
const app = getAppInstance();
|
|
if (typeof app?.decorateVideoModeration === "function") {
|
|
try {
|
|
const decorated = app.decorateVideoModeration(metadata.video);
|
|
if (decorated) {
|
|
metadata.video = decorated;
|
|
}
|
|
} catch (error) {
|
|
if (isDevEnv) {
|
|
userLogger.warn(
|
|
"[historyView] Failed to decorate video moderation:",
|
|
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) {
|
|
userLogger.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-text";
|
|
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
|
|
});
|
|
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;
|
|
showEmptyState();
|
|
const app = getAppInstance();
|
|
app?.showSuccess?.("Local watch history reset.");
|
|
} catch (error) {
|
|
const message =
|
|
error && typeof error.message === "string"
|
|
? error.message
|
|
: "Failed to reset local watch history.";
|
|
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) {
|
|
userLogger.warn("[historyView] Republish failed:", error);
|
|
}
|
|
}
|
|
};
|
|
elements.republishButton.addEventListener("click", boundRepublishHandler);
|
|
}
|
|
}
|
|
|
|
function bindPrivacyControls() {
|
|
if (elements.privacyToggle) {
|
|
setHidden(elements.privacyToggle, true);
|
|
}
|
|
|
|
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
|
|
);
|
|
}
|
|
}
|
|
|
|
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 = [];
|
|
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
|
|
};
|
|
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 = "";
|
|
clearHistoryCardRegistry();
|
|
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();
|
|
refreshElements();
|
|
updateFeatureBanner();
|
|
ensureObserver();
|
|
bindGridEvents();
|
|
bindLoadMore();
|
|
bindActions();
|
|
bindPrivacyControls();
|
|
subscribeToFingerprintUpdates();
|
|
subscribeToModerationEvents();
|
|
updateInfoCallout();
|
|
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;
|
|
}
|
|
},
|
|
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.privacyDismiss && boundPrivacyDismissHandler) {
|
|
elements.privacyDismiss.removeEventListener(
|
|
"click",
|
|
boundPrivacyDismissHandler
|
|
);
|
|
boundPrivacyDismissHandler = null;
|
|
}
|
|
clearSubscriptions();
|
|
if (elements.grid) {
|
|
elements.grid.innerHTML = "";
|
|
}
|
|
clearHistoryCardRegistry();
|
|
state.initialized = false;
|
|
state.items = [];
|
|
state.cursor = 0;
|
|
state.hasMore = false;
|
|
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]");
|
|
cleanupHistoryCard(pointerKeyValue);
|
|
if (card) {
|
|
card.remove();
|
|
}
|
|
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) {
|
|
userLogger.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) {
|
|
userLogger.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;
|
|
}
|