mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-28 08:22:24 +00:00
Four issues caused login to be extremely slow and lists to fail syncing: 1. Subscription list decryption excluded NIP-04 when NIP-44 was available, preventing decryption of NIP-04-encrypted lists entirely. Removed the allowNip04 gate so NIP-04 is always a fallback (matching userBlocks.js and hashtagPreferencesService.js which already worked correctly). 2. When the NIP-07 extension permission pre-grant failed (common on page refresh when the extension injects slowly), all list services were called with allowPermissionPrompt:false and could never recover. Now list services are allowed to prompt for permissions themselves when the pre-grant fails, avoiding 6-10 second retry delays. 3. The profile modal storage tab was never refreshed on login or logout, so it stayed locked even after authentication succeeded. Added populateStoragePane() calls to handleAuthLogin and handleAuthLogout. 4. The watch-history feed engine.run() call was missing await, so rejected promises from unregistered feeds escaped the try/catch and propagated as unhandled errors instead of falling through to the service fallback. https://claude.ai/code/session_013puYzbf1cC7a59HLcGQUdJ
2529 lines
74 KiB
JavaScript
2529 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";
|
|
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") {
|
|
// Ensure the watch-history feed is registered. It may not be if the
|
|
// feed engine was ready but registration was silently skipped during
|
|
// bootstrap (e.g. timing edge cases).
|
|
if (
|
|
typeof engine.getFeedDefinition === "function" &&
|
|
!engine.getFeedDefinition("watch-history") &&
|
|
typeof app.registerWatchHistoryFeed === "function"
|
|
) {
|
|
app.registerWatchHistoryFeed();
|
|
}
|
|
try {
|
|
const runtime = buildWatchHistoryFeedRuntime({
|
|
actor: actorInput,
|
|
cursor,
|
|
forceRefresh,
|
|
});
|
|
return await engine.run("watch-history", { runtime });
|
|
} catch (feedError) {
|
|
// Fall through to service fallback if feed is still not registered
|
|
}
|
|
}
|
|
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;
|
|
}
|