mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 16:02:26 +00:00
2515 lines
73 KiB
JavaScript
2515 lines
73 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";
|
|
import { formatShortNpub } from "./utils/formatters.js";
|
|
import { attachFeedInfoPopover } from "./ui/components/FeedInfoPopover.js";
|
|
|
|
const WATCH_HISTORY_EMPTY_COPY =
|
|
"Your watch history is empty. Watch some videos to populate this list.";
|
|
|
|
const WATCH_HISTORY_DISABLED_COPY =
|
|
"Watch history sync is unavailable. Connect a NIP-07 extension or log in to enable syncing.";
|
|
|
|
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, root = document) {
|
|
if (!selector || typeof selector !== "string") {
|
|
return null;
|
|
}
|
|
const scope = root && typeof root.querySelector === "function" ? root : document;
|
|
try {
|
|
return scope.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,
|
|
forceRefresh = false,
|
|
} = {}) {
|
|
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,
|
|
forceRefresh: forceRefresh === true,
|
|
},
|
|
};
|
|
|
|
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
|
|
: Number.isFinite(pointer?.watchedAt)
|
|
? pointer.watchedAt
|
|
: 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;
|
|
}
|
|
const createdA = Number.isFinite(a?.video?.created_at)
|
|
? a.video.created_at
|
|
: 0;
|
|
const createdB = Number.isFinite(b?.video?.created_at)
|
|
? b.video.created_at
|
|
: 0;
|
|
if (createdA !== createdB) {
|
|
return createdB - createdA;
|
|
}
|
|
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, variant }) {
|
|
const isCompact = variant === "compact" || variant === "modal";
|
|
|
|
const article = document.createElement("article");
|
|
article.className =
|
|
"watch-history-card group relative flex flex-row items-start gap-4 p-3 sm:p-4 rounded-lg transition-colors";
|
|
if (!isCompact) {
|
|
article.classList.add("hover:bg-surface-panel-hover", "hover:shadow-sm");
|
|
} else {
|
|
article.classList.add("hover:bg-surface-highlight");
|
|
}
|
|
|
|
if (video?.isPrivate) {
|
|
article.classList.add(
|
|
"border",
|
|
"border-status-info",
|
|
"border-opacity-40",
|
|
"bg-surface-alt"
|
|
);
|
|
}
|
|
|
|
article.dataset.pointerKey = item.pointerKey;
|
|
article.dataset.historyCard = "true";
|
|
|
|
const pointerVideoId = getPointerVideoId(video, item.pointer);
|
|
if (pointerVideoId) {
|
|
article.dataset.videoId = pointerVideoId;
|
|
}
|
|
|
|
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 };
|
|
})();
|
|
|
|
// --- Thumbnail Column ---
|
|
const thumbnailLink = document.createElement("a");
|
|
const thumbnailSizeClass = isCompact ? "w-28 sm:w-32" : "w-32 sm:w-48";
|
|
thumbnailLink.className = `relative block flex-shrink-0 aspect-video rounded-md overflow-hidden bg-surface-alt hover:opacity-90 transition-opacity ${thumbnailSizeClass}`;
|
|
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 thumbnailImg = document.createElement("img");
|
|
thumbnailImg.className = "w-full h-full object-cover";
|
|
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";
|
|
thumbnailLink.appendChild(thumbnailImg);
|
|
|
|
// --- Content Column ---
|
|
const content = document.createElement("div");
|
|
content.className = "flex flex-col flex-1 min-w-0 gap-1.5";
|
|
|
|
// Title Row
|
|
const header = document.createElement("div");
|
|
header.className = "flex justify-between items-start gap-2";
|
|
|
|
const titleLink = document.createElement("a");
|
|
titleLink.className =
|
|
"text-sm font-semibold text-text line-clamp-2 leading-snug hover:text-accent transition-colors";
|
|
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";
|
|
|
|
header.appendChild(titleLink);
|
|
content.appendChild(header);
|
|
|
|
// Author Row
|
|
const authorRow = document.createElement("div");
|
|
authorRow.className = "flex items-center gap-2";
|
|
|
|
const avatarBtn = document.createElement("button");
|
|
avatarBtn.type = "button";
|
|
avatarBtn.className =
|
|
"flex-shrink-0 w-5 h-5 rounded-full overflow-hidden bg-surface-alt hover:opacity-80 transition-opacity";
|
|
avatarBtn.dataset.historyAction = "channel";
|
|
avatarBtn.dataset.pointerKey = item.pointerKey;
|
|
if (video?.pubkey) {
|
|
avatarBtn.dataset.author = video.pubkey;
|
|
}
|
|
|
|
const avatarImg = document.createElement("img");
|
|
avatarImg.className = "w-full h-full object-cover";
|
|
const avatarSrc =
|
|
profile && typeof profile.picture === "string" && profile.picture.trim()
|
|
? profile.picture.trim()
|
|
: FALLBACK_AVATAR;
|
|
avatarImg.src = avatarSrc;
|
|
avatarImg.alt = profile?.name || "Avatar";
|
|
avatarBtn.appendChild(avatarImg);
|
|
|
|
const authorNameBtn = document.createElement("button");
|
|
authorNameBtn.type = "button";
|
|
authorNameBtn.className =
|
|
"text-xs font-medium text-muted hover:text-text transition-colors truncate";
|
|
authorNameBtn.dataset.historyAction = "channel";
|
|
authorNameBtn.dataset.pointerKey = item.pointerKey;
|
|
if (video?.pubkey) {
|
|
authorNameBtn.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 =
|
|
formatShortNpub(encoded) || encoded || video.pubkey.slice(0, 8).concat("…");
|
|
}
|
|
authorNameBtn.textContent = creatorLabel;
|
|
|
|
authorRow.appendChild(avatarBtn);
|
|
authorRow.appendChild(authorNameBtn);
|
|
content.appendChild(authorRow);
|
|
|
|
// Moderation Badge Mount
|
|
const moderationMount = document.createElement("div");
|
|
moderationMount.className = "flex flex-wrap items-center gap-2 mt-0.5";
|
|
content.appendChild(moderationMount);
|
|
|
|
// Meta Row (Timestamp)
|
|
const metaRow = document.createElement("div");
|
|
metaRow.className =
|
|
"flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-strong mt-auto pt-1";
|
|
|
|
if (item.watchedAt) {
|
|
const watchedSpan = document.createElement("span");
|
|
watchedSpan.textContent = formatRelativeTime(item.watchedAt);
|
|
watchedSpan.title = new Date(item.watchedAt * 1000).toLocaleString();
|
|
metaRow.appendChild(watchedSpan);
|
|
} else {
|
|
const unknownSpan = document.createElement("span");
|
|
unknownSpan.textContent = "Time unavailable";
|
|
metaRow.appendChild(unknownSpan);
|
|
}
|
|
|
|
if (video?.created_at) {
|
|
const sep = document.createElement("span");
|
|
sep.className = "text-muted/40";
|
|
sep.textContent = "•";
|
|
metaRow.appendChild(sep);
|
|
|
|
const pubSpan = document.createElement("span");
|
|
pubSpan.textContent = `Published ${formatRelativeTime(video.created_at)}`;
|
|
metaRow.appendChild(pubSpan);
|
|
}
|
|
|
|
content.appendChild(metaRow);
|
|
|
|
// Actions Row
|
|
const actionsRow = document.createElement("div");
|
|
actionsRow.className =
|
|
"flex flex-wrap items-center gap-4 mt-2 pt-2 border-t border-border/40";
|
|
|
|
const createActionBtn = (label, action, btnVariant = "default") => {
|
|
const btn = document.createElement("button");
|
|
btn.type = "button";
|
|
btn.className = `text-xs font-semibold ${
|
|
btnVariant === "danger"
|
|
? "text-status-danger hover:text-status-danger-strong"
|
|
: "text-muted hover:text-text"
|
|
} transition-colors`;
|
|
btn.textContent = label;
|
|
btn.dataset.historyAction = action;
|
|
btn.dataset.pointerKey = item.pointerKey;
|
|
|
|
if (action === "play") {
|
|
if (pointerVideoId) btn.dataset.videoId = pointerVideoId;
|
|
if (playbackData.url)
|
|
btn.dataset.playUrl = encodeURIComponent(playbackData.url);
|
|
if (playbackData.magnet)
|
|
btn.dataset.playMagnet = playbackData.magnet;
|
|
} else if (action === "channel" && video?.pubkey) {
|
|
btn.dataset.author = video.pubkey;
|
|
} else if (action === "remove") {
|
|
if (item.pointer?.type) btn.dataset.pointerType = item.pointer.type;
|
|
if (item.pointer?.value) btn.dataset.pointerValue = item.pointer.value;
|
|
if (item.pointer?.relay) btn.dataset.pointerRelay = item.pointer.relay;
|
|
if (Number.isFinite(item.watchedAt))
|
|
btn.dataset.pointerWatchedAt = String(item.watchedAt);
|
|
if (item.pointer?.session === true)
|
|
btn.dataset.pointerSession = "true";
|
|
btn.title = "Remove from history";
|
|
}
|
|
return btn;
|
|
};
|
|
|
|
const playBtn = createActionBtn("Play now", "play", "default");
|
|
playBtn.classList.add("text-accent", "hover:text-accent-strong");
|
|
|
|
const channelBtn = createActionBtn("Channel", "channel");
|
|
const removeBtn = createActionBtn("Remove", "remove", "danger");
|
|
|
|
actionsRow.appendChild(playBtn);
|
|
actionsRow.appendChild(channelBtn);
|
|
actionsRow.appendChild(removeBtn);
|
|
|
|
content.appendChild(actionsRow);
|
|
|
|
article.appendChild(thumbnailLink);
|
|
article.appendChild(content);
|
|
|
|
const moderationContext = normalizeVideoModerationContext(video?.moderation);
|
|
const cardRef = {
|
|
pointerKey: item.pointerKey,
|
|
article,
|
|
primary: thumbnailLink,
|
|
meta: content,
|
|
details: content,
|
|
badgeMount: moderationMount,
|
|
thumbnailInner: null,
|
|
thumbnailLink,
|
|
titleLink,
|
|
playButton: playBtn,
|
|
channelButton: channelBtn,
|
|
removeButton: removeBtn,
|
|
creatorNameButton: authorNameBtn,
|
|
avatarButton: avatarBtn,
|
|
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 {
|
|
container = typeof document !== "undefined" ? document : null,
|
|
fetchHistory = async (
|
|
actorInput,
|
|
{ cursor = 0, forceRefresh = false } = {}
|
|
) => {
|
|
const app = getAppInstance();
|
|
const engine = app?.feedEngine;
|
|
if (engine && typeof engine.run === "function") {
|
|
const runtime = buildWatchHistoryFeedRuntime({
|
|
actor: actorInput,
|
|
cursor,
|
|
forceRefresh,
|
|
});
|
|
return engine.run("watch-history", { runtime });
|
|
}
|
|
const items = await watchHistoryService.loadLatest(actorInput, {
|
|
allowStale: !forceRefresh,
|
|
forceRefresh,
|
|
});
|
|
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"]',
|
|
refreshButtonSelector = '[data-history-action="refresh"]',
|
|
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);
|
|
},
|
|
variant = "default"
|
|
} = 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,
|
|
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,
|
|
refreshButton: null,
|
|
errorBanner: null,
|
|
scrollContainer: null,
|
|
featureBanner: null,
|
|
sessionWarning: null
|
|
};
|
|
|
|
let boundGridClickHandler = null;
|
|
let boundLoadMoreHandler = null;
|
|
let boundClearHandler = null;
|
|
let boundRefreshHandler = null;
|
|
|
|
const subscriptions = new Set();
|
|
|
|
function refreshElements() {
|
|
const scope = container || document;
|
|
elements = {
|
|
view: resolveElement(viewSelector, scope),
|
|
grid: resolveElement(gridSelector, scope),
|
|
loading: resolveElement(loadingSelector, scope),
|
|
status: resolveElement(statusSelector, scope),
|
|
empty: resolveElement(emptySelector, scope),
|
|
sentinel: resolveElement(sentinelSelector, scope),
|
|
loadMore: resolveElement(loadMoreSelector, scope),
|
|
clearButton: resolveElement(clearButtonSelector, scope),
|
|
refreshButton: resolveElement(refreshButtonSelector, scope),
|
|
errorBanner: resolveElement(errorBannerSelector, scope),
|
|
scrollContainer: resolveElement(scrollContainerSelector, scope),
|
|
featureBanner: resolveElement(featureBannerSelector, scope),
|
|
sessionWarning: resolveElement(sessionWarningSelector, scope)
|
|
};
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
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,
|
|
variant
|
|
});
|
|
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();
|
|
userLogger.info("[historyView] 'Clear local history' clicked.");
|
|
const actor = await resolveActorKey();
|
|
try {
|
|
await watchHistoryService.resetProgress(actor);
|
|
state.items = [];
|
|
state.cursor = 0;
|
|
state.hasMore = false;
|
|
showEmptyState();
|
|
const app = getAppInstance();
|
|
if (typeof app?.showSuccess === "function") {
|
|
app.showSuccess("Local watch history reset.");
|
|
} else {
|
|
console.log("Local watch history reset.");
|
|
}
|
|
} catch (error) {
|
|
const message =
|
|
error && typeof error.message === "string"
|
|
? error.message
|
|
: "Failed to reset local watch history.";
|
|
const app = getAppInstance();
|
|
if (typeof app?.showError === "function") {
|
|
app.showError(message);
|
|
} else {
|
|
console.error(message);
|
|
}
|
|
}
|
|
};
|
|
elements.clearButton.addEventListener("click", boundClearHandler);
|
|
}
|
|
|
|
if (elements.refreshButton) {
|
|
if (boundRefreshHandler) {
|
|
elements.refreshButton.removeEventListener("click", boundRefreshHandler);
|
|
}
|
|
boundRefreshHandler = async (event) => {
|
|
event.preventDefault();
|
|
userLogger.info("[historyView] 'Refresh' clicked.");
|
|
await renderer.refresh({ force: true });
|
|
const app = getAppInstance();
|
|
if (typeof app?.showSuccess === "function") {
|
|
app.showSuccess("Watch history refreshed from relays.");
|
|
}
|
|
};
|
|
elements.refreshButton.addEventListener("click", boundRefreshHandler);
|
|
}
|
|
}
|
|
|
|
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,
|
|
forceRefresh: force === true,
|
|
});
|
|
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();
|
|
subscribeToFingerprintUpdates();
|
|
subscribeToModerationEvents();
|
|
state.initialized = true;
|
|
if (!state.featureEnabled) {
|
|
state.items = [];
|
|
state.fingerprint = "";
|
|
state.cursor = 0;
|
|
state.hasMore = false;
|
|
state.lastError = null;
|
|
showFeatureDisabledState();
|
|
return;
|
|
}
|
|
// Use cached data immediately if available (force: false) unless overridden
|
|
const force = options.force === true;
|
|
await this.refresh({ ...options, force });
|
|
},
|
|
async ensureInitialLoad(options = {}) {
|
|
if (!state.initialized) {
|
|
await this.init(options);
|
|
return;
|
|
}
|
|
if (options.actor !== state.actor) {
|
|
await this.refresh(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.refreshButton && boundRefreshHandler) {
|
|
elements.refreshButton.removeEventListener("click", boundRefreshHandler);
|
|
boundRefreshHandler = 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;
|
|
}
|
|
|
|
const watchHistoryRenderer = createWatchHistoryRenderer();
|
|
|
|
export async function initHistoryView() {
|
|
const infoTrigger = document.getElementById("historyInfoTrigger");
|
|
if (infoTrigger) {
|
|
attachFeedInfoPopover(
|
|
infoTrigger,
|
|
"Chronological list of videos you have watched."
|
|
);
|
|
}
|
|
await watchHistoryRenderer.init();
|
|
}
|
|
|
|
if (typeof window !== "undefined") {
|
|
window.bitvid = window.bitvid || {};
|
|
window.bitvid.initHistoryView = initHistoryView;
|
|
window.bitvid.watchHistoryRenderer = watchHistoryRenderer;
|
|
}
|