import { Nip71FormManager } from "./nip71FormManager.js"; import { createModalAccessibility } from "./modalAccessibility.js"; // NOTE: Any metadata field added to the Upload or Edit modals must also be // rendered inside the Revert modal to keep the experiences aligned. export class RevertModal { constructor({ removeTrackingScripts, setGlobalModalState, formatAbsoluteTimestamp, formatTimeAgo, escapeHTML, truncateMiddle, fallbackThumbnailSrc, container, }) { this.removeTrackingScripts = removeTrackingScripts; this.setGlobalModalState = setGlobalModalState; this.formatAbsoluteTimestamp = formatAbsoluteTimestamp; this.formatTimeAgo = formatTimeAgo; this.escapeHTML = escapeHTML; this.truncateMiddle = truncateMiddle; this.fallbackThumbnailSrc = fallbackThumbnailSrc || ""; this.container = container || document.getElementById("modalContainer"); this.nip71Formatter = new Nip71FormManager(); this.eventTarget = new EventTarget(); this.modal = null; this.overlay = null; this.list = null; this.details = null; this.placeholder = null; this.detailsDefaultHTML = ""; this.historyCount = null; this.statusLabel = null; this.title = null; this.subtitle = null; this.closeButton = null; this.cancelButton = null; this.confirmButton = null; this.modalPanel = null; this.modalAccessibility = null; this.activeVideo = null; this.revisions = []; this.selectedRevision = null; this.pendingEntries = []; this.busy = false; this.context = null; this.confirmDefaultLabel = "Revert to selected version"; this.bound = false; this.boundHandlers = { listClick: (event) => this.handleListClick(event), confirm: () => this.handleConfirmClick(), cancel: () => this.handleCancelInteraction(), overlay: () => this.handleCancelInteraction(), close: () => this.handleCloseInteraction(), }; this.reset(); } addEventListener(type, listener, options) { this.eventTarget.addEventListener(type, listener, options); } removeEventListener(type, listener, options) { this.eventTarget.removeEventListener(type, listener, options); } dispatch(type, detail) { this.eventTarget.dispatchEvent( new CustomEvent(type, { detail, }) ); } async load() { if (this.modal) { return true; } let modal = document.getElementById("revertVideoModal"); if (!modal) { const response = await fetch("components/revert-video-modal.html"); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const html = await response.text(); const modalContainer = this.container; if (!modalContainer) { throw new Error("Modal container element not found!"); } const wrapper = document.createElement("div"); wrapper.innerHTML = html; if (typeof this.removeTrackingScripts === "function") { this.removeTrackingScripts(wrapper); } modalContainer.appendChild(wrapper); modal = wrapper.querySelector("#revertVideoModal"); } if (!modal) { throw new Error("Revert video modal markup missing after load."); } this.cacheElements(modal); this.setupModalAccessibility(); this.bindEvents(); this.reset(); return true; } cacheElements(modal) { this.modal = modal; this.overlay = modal.querySelector("#revertVideoModalOverlay") || null; this.modalPanel = modal.querySelector(".bv-modal__panel") || modal; this.list = modal.querySelector("#revertVersionsList") || null; this.details = modal.querySelector("#revertVersionDetails") || null; this.placeholder = modal.querySelector("#revertVersionPlaceholder") || null; this.detailsDefaultHTML = this.details ? this.details.innerHTML : ""; this.historyCount = modal.querySelector("#revertHistoryCount") || null; this.statusLabel = modal.querySelector("#revertSelectionStatus") || null; this.title = modal.querySelector("#revertModalTitle") || null; this.subtitle = modal.querySelector("#revertModalSubtitle") || null; this.closeButton = modal.querySelector("#closeRevertVideoModal") || null; this.cancelButton = modal.querySelector("#cancelRevertVideo") || null; this.confirmButton = modal.querySelector("#confirmRevertVideo") || null; if (this.confirmButton) { const text = this.confirmButton.textContent?.trim(); if (text) { this.confirmDefaultLabel = text; } } } bindEvents() { if (this.bound || !this.modal) { return; } if (this.list) { this.list.addEventListener("click", this.boundHandlers.listClick); } if (this.confirmButton) { this.confirmButton.addEventListener("click", this.boundHandlers.confirm); } if (this.cancelButton) { this.cancelButton.addEventListener("click", this.boundHandlers.cancel); } if (this.overlay) { this.overlay.addEventListener("click", this.boundHandlers.overlay); } if (this.closeButton) { this.closeButton.addEventListener("click", this.boundHandlers.close); } this.bound = true; } reset() { this.activeVideo = null; this.revisions = []; this.selectedRevision = null; this.pendingEntries = []; this.busy = false; this.context = null; if (this.historyCount) { this.historyCount.textContent = ""; } if (this.statusLabel) { this.statusLabel.textContent = "Select an older revision to inspect its metadata before reverting."; } if (this.title) { this.title.textContent = "Revert Video Note"; } if (this.subtitle) { this.subtitle.textContent = "Review previous versions before restoring an older state."; } if (this.list) { this.list.innerHTML = ""; } if (this.details) { if (this.detailsDefaultHTML) { this.details.innerHTML = this.detailsDefaultHTML; this.placeholder = this.details.querySelector("#revertVersionPlaceholder"); } else { this.details.innerHTML = ""; } } if (this.confirmButton) { this.confirmButton.disabled = true; this.confirmButton.textContent = this.confirmDefaultLabel; this.confirmButton.classList.remove("cursor-wait"); } const enableButton = (button) => { if (!button) { return; } button.disabled = false; }; enableButton(this.cancelButton); enableButton(this.closeButton); } setupModalAccessibility() { if (!this.modal) { return; } if (this.modalAccessibility?.destroy) { this.modalAccessibility.destroy(); } this.modalAccessibility = createModalAccessibility({ root: this.modal, panel: this.modalPanel || this.modal, backdrop: this.overlay || this.modal, onRequestClose: () => this.handleCancelInteraction(), }); } open(context = {}, { triggerElement } = {}) { if (!this.modal) { throw new Error("Revert modal has not been loaded."); } this.context = context || {}; this.modal.classList.remove("hidden"); if (typeof this.setGlobalModalState === "function") { this.setGlobalModalState("revertVideo", true); } this.modalAccessibility?.activate({ triggerElement }); this.dispatch("video:revert-open", { context: this.context, video: this.activeVideo, }); } close() { if (!this.modal) { return; } const detail = { context: this.context, video: this.activeVideo, }; this.modal.classList.add("hidden"); this.modalAccessibility?.deactivate(); if (typeof this.setGlobalModalState === "function") { this.setGlobalModalState("revertVideo", false); } this.dispatch("video:revert-close", detail); this.reset(); } setHistory(video, history = []) { if (!this.modal) { return; } this.reset(); if (!video || typeof video !== "object") { if (this.statusLabel) { this.statusLabel.textContent = "Unable to load revision history for this note."; } return; } this.activeVideo = video; const merged = Array.isArray(history) ? history.slice() : []; if (video.id && !merged.some((entry) => entry && entry.id === video.id)) { merged.push(video); } const deduped = new Map(); for (const entry of merged) { if (!entry || typeof entry !== "object" || !entry.id) { continue; } deduped.set(entry.id, entry); } this.revisions = Array.from(deduped.values()).sort( (a, b) => (b?.created_at || 0) - (a?.created_at || 0) ); this.selectedRevision = null; if (this.revisions.length > 1 && video.created_at) { const firstOlder = this.revisions.find( (entry) => entry && entry.id !== video.id && entry.deleted !== true && typeof entry.created_at === "number" && entry.created_at < video.created_at ); if (firstOlder) { this.selectedRevision = firstOlder; } } if (this.historyCount) { this.historyCount.textContent = `${this.revisions.length}`; } if (this.title) { this.title.textContent = video.title ? `Revert “${video.title}”` : "Revert Video Note"; } if (this.subtitle) { const subtitleParts = []; const dTagValue = this.extractDTagValue(video.tags); if (dTagValue) { subtitleParts.push(`d=${dTagValue}`); } if (video.videoRootId) { const truncated = this.truncateMiddle ? this.truncateMiddle(video.videoRootId, 40) : video.videoRootId; subtitleParts.push(`root=${truncated}`); } this.subtitle.textContent = subtitleParts.length ? `History grouped by ${subtitleParts.join(" • ")}` : "Review previous versions before restoring an older state."; } this.renderRevertVersionsList(); if (this.selectedRevision) { this.renderRevertVersionDetails(this.selectedRevision); } else { this.renderRevertVersionDetails(null); } if (this.revisions.length <= 1) { if (this.statusLabel) { this.statusLabel.textContent = "No earlier revisions are available for this note."; } this.updateConfirmationState(); return; } this.updateConfirmationState(); } renderRevertVersionsList() { if (!this.list) { return; } const selectedId = this.selectedRevision?.id || ""; const currentId = this.activeVideo?.id || ""; this.list.innerHTML = ""; if (!this.revisions.length) { this.list.innerHTML = '
No revisions found.
'; return; } const fragment = document.createDocumentFragment(); this.revisions.forEach((entry) => { if (!entry || !entry.id) { return; } const isCurrent = entry.id === currentId; const isSelected = entry.id === selectedId; const isDeleted = entry.deleted === true; const button = document.createElement("button"); button.type = "button"; button.dataset.revertVersionId = entry.id; const classes = [ "w-full", "text-left", "rounded-md", "border", "px-3", "py-3", "text-sm", "transition", "duration-150", "focus:outline-none", "focus:ring-2", "focus:ring-offset-2", "focus:ring-offset-gray-900", ]; if (isSelected) { classes.push( "border-blue-500", "bg-blue-500/20", "text-blue-100", "focus:ring-blue-500" ); } else if (isCurrent) { classes.push( "border-green-500/60", "bg-green-500/10", "text-green-100", "focus:ring-green-500/80" ); } else if (isDeleted) { classes.push( "border-red-800/70", "bg-red-900/30", "text-red-200/90", "hover:bg-red-900/40" ); } else { classes.push( "border-gray-800", "bg-gray-800/60", "hover:bg-gray-700/70", "text-gray-200" ); } button.className = classes.join(" "); button.setAttribute("aria-pressed", isSelected ? "true" : "false"); if (isCurrent) { button.setAttribute("aria-current", "true"); } const createdAt = typeof entry.created_at === "number" ? entry.created_at : 0; const relative = this.formatTimeAgo ? this.formatTimeAgo(createdAt) : ""; const absolute = this.formatAbsoluteTimestamp ? this.formatAbsoluteTimestamp(createdAt) : ""; const versionLabel = entry.version !== undefined ? `v${entry.version}` : "v?"; const metaParts = []; if (isCurrent) { metaParts.push("Current version"); } if (entry.deleted) { metaParts.push("Marked deleted"); } if (entry.isPrivate) { metaParts.push("Private"); } const meta = metaParts.join(" • "); const metaClass = entry.deleted ? "text-red-200/80" : "text-gray-400"; const escape = (value) => this.escapeHTML ? this.escapeHTML(String(value ?? "")) : String(value ?? ""); button.innerHTML = `${escape(entry.title || "Untitled")}
${escape(relative)} • ${escape(absolute)}
${meta ? `` : ""}Conflicting flags detected — this revision is marked both NSFW and for kids. Review before reverting.
' : ""; const descriptionHtml = description ? `${escape(description)}
` : 'No description provided.
'; const rootId = typeof version.videoRootId === "string" ? version.videoRootId : ""; const rootDisplay = rootId ? this.truncateMiddle ? escape(this.truncateMiddle(rootId, 64)) : escape(rootId) : ""; const eventDisplay = version.id ? this.truncateMiddle ? escape(this.truncateMiddle(version.id, 64)) : escape(version.id) : ""; const timestampParts = []; if (absolute) { timestampParts.push(escape(absolute)); } if (relative) { timestampParts.push(`(${escape(relative)})`); } const timestampHtml = timestampParts.length ? timestampParts.join(" ") : escape(absolute || relative || ""); const formatTimestamp = (seconds, raw) => { if (Number.isFinite(seconds)) { const absoluteLabel = this.formatAbsoluteTimestamp ? this.formatAbsoluteTimestamp(seconds) : `${seconds}`; const relativeLabel = this.formatTimeAgo ? this.formatTimeAgo(seconds) : ""; const parts = []; if (absoluteLabel) { parts.push(`${escape(absoluteLabel)}`); } if (relativeLabel) { parts.push(`(${escape(relativeLabel)})`); } if (parts.length) { return parts.join(" "); } } if (raw) { return `${escape(raw)}`; } return this.renderPlaceholder(); }; const formatDuration = (seconds, raw) => { if (Number.isFinite(seconds)) { const display = this.formatDurationSeconds(seconds); const suffix = seconds === 1 ? "second" : "seconds"; return `${escape(display)} (${escape( `${seconds} ${suffix}` )})`; } if (raw) { return `${escape(raw)}`; } return this.renderPlaceholder(); }; const kindValue = nip71Metadata.kind; let kindHtml = this.renderPlaceholder(); if (kindValue !== "" && kindValue !== null && kindValue !== undefined) { const label = typeof kindValue === "number" ? kindValue : `${kindValue}`; const numeric = Number(label); let suffix = ""; if (Number.isFinite(numeric)) { if (numeric === 22) { suffix = "short"; } else if (numeric === 21) { suffix = "video"; } } const badge = `kind ${escape(
label
)}`;
kindHtml = suffix
? `${badge} (${escape(suffix)})`
: badge;
}
const summaryHtml = nip71Metadata.summary
? `${escape( nip71Metadata.summary )}
` : this.renderPlaceholder("Not provided"); const contentWarningHtml = nip71Metadata.contentWarning ? `${escape( nip71Metadata.contentWarning )}` : this.renderPlaceholder("Not provided"); const publishedAtHtml = formatTimestamp( nip71Metadata.publishedAtSeconds, nip71Metadata.publishedAtRaw ); const durationHtml = formatDuration( nip71Metadata.durationSeconds, nip71Metadata.durationRaw ); const altHtml = nip71Metadata.alt ? `${escape(nip71Metadata.alt)}
` : this.renderPlaceholder("Not provided"); const definition = (label, valueHtml, { span = 1 } = {}) => { const colSpan = span > 1 ? "sm:col-span-2" : ""; return `${timestampHtml}
${escape(
dTagValue
)}`
: 'Not provided'
}
${rootDisplay}`
: 'Not provided'
}
${eventDisplay}`
: 'Unknown'
}
${escape(text)}
`; } buildLinkMarkup(value, { breakAll = true } = {}) { if (value == null) { return ""; } const escape = this.getEscapeFn(); const raw = typeof value === "string" ? value.trim() : String(value ?? "").trim(); if (!raw) { return ""; } const displayRaw = this.truncateMiddle ? this.truncateMiddle(raw, 96) : raw; const safeDisplay = escape(displayRaw); const safeValue = escape(raw); const breakClass = breakAll ? " break-all" : " break-words"; if (/^https?:/i.test(raw)) { return `${safeDisplay}`; } return `${safeDisplay}`;
}
formatDurationSeconds(seconds) {
if (!Number.isFinite(seconds)) {
return "";
}
const total = Math.max(0, Math.floor(seconds));
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
const secs = total % 60;
if (hours > 0) {
return `${hours}:${String(minutes).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
}
return `${minutes}:${String(secs).padStart(2, "0")}`;
}
buildNip71DisplayMetadata(version) {
const raw =
version && typeof version === "object" && version.nip71 && typeof version.nip71 === "object"
? version.nip71
: {};
const { normalizeNumber, toInputValue } = this.nip71Formatter || {};
const toString = (value) => {
if (value == null) {
return "";
}
if (typeof value === "string") {
return value.trim();
}
if (typeof value === "number" || typeof value === "boolean") {
const converted = toInputValue ? toInputValue(value) : `${value}`;
return typeof converted === "string" ? converted.trim() : "";
}
return "";
};
const parseNumeric = (value) => {
if (value == null) {
return null;
}
if (typeof value === "string" && value.includes(":")) {
return null;
}
if (normalizeNumber) {
const parsed = normalizeNumber(value);
if (typeof parsed === "number" && Number.isFinite(parsed)) {
return parsed;
}
}
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string") {
const trimmed = value.trim();
if (!trimmed) {
return null;
}
const numeric = Number(trimmed);
return Number.isFinite(numeric) ? numeric : null;
}
return null;
};
const parseTimestamp = (value) => {
const rawValue = toString(value);
if (!rawValue) {
return { raw: "", seconds: null };
}
if (/^\d+(?:\.\d+)?$/.test(rawValue)) {
const numeric = Number(rawValue);
if (Number.isFinite(numeric)) {
const normalized = numeric > 1e12 ? Math.floor(numeric / 1000) : Math.floor(numeric);
return { raw: rawValue, seconds: normalized };
}
}
const parsed = Date.parse(rawValue);
if (!Number.isNaN(parsed)) {
return { raw: rawValue, seconds: Math.floor(parsed / 1000) };
}
return { raw: rawValue, seconds: null };
};
const collectStrings = (values) => {
if (!Array.isArray(values)) {
return [];
}
return values
.map((entry) => toString(entry))
.map((entry) => entry.trim())
.filter((entry) => Boolean(entry));
};
const collectObjects = (values, mapFn) => {
if (!Array.isArray(values)) {
return [];
}
return values
.map((entry) => (entry && typeof entry === "object" ? mapFn(entry) : null))
.filter((entry) => entry);
};
const kindValue = parseNumeric(raw.kind);
const publishedAt = parseTimestamp(
raw.publishedAt ?? raw.published_at ?? raw["published-at"]
);
const durationNumeric = parseNumeric(raw.duration);
const durationRaw = durationNumeric != null ? `${durationNumeric}` : toString(raw.duration);
const imeta = collectObjects(raw.imeta, (variant) => {
const mapped = {
m: toString(variant.m),
dim: toString(variant.dim),
url: toString(variant.url),
x: toString(variant.x),
image: collectStrings(variant.image),
fallback: collectStrings(variant.fallback),
service: collectStrings(variant.service),
autoGenerated: variant.autoGenerated === true,
};
const hasContent =
mapped.m ||
mapped.dim ||
mapped.url ||
mapped.x ||
mapped.image.length ||
mapped.fallback.length ||
mapped.service.length;
return hasContent ? mapped : null;
});
const textTracks = collectObjects(raw.textTracks, (track) => {
const mapped = {
url: toString(track.url),
type: toString(track.type),
language: toString(track.language),
};
return mapped.url || mapped.type || mapped.language ? mapped : null;
});
const segments = collectObjects(raw.segments, (segment) => {
const startNumeric = parseNumeric(segment.start);
const endNumeric = parseNumeric(segment.end);
const mapped = {
startSeconds: startNumeric != null ? Math.max(0, startNumeric) : null,
endSeconds: endNumeric != null ? Math.max(0, endNumeric) : null,
startRaw: toString(segment.start),
endRaw: toString(segment.end),
title: toString(segment.title),
thumbnail: toString(segment.thumbnail),
};
return mapped.startRaw || mapped.endRaw || mapped.title || mapped.thumbnail
? mapped
: null;
});
const hashtags = collectStrings(raw.hashtags);
const participants = collectObjects(raw.participants, (participant) => {
const mapped = {
pubkey: toString(participant.pubkey),
relay: toString(participant.relay),
};
return mapped.pubkey || mapped.relay ? mapped : null;
});
const references = collectStrings(raw.references);
return {
kind: kindValue != null ? kindValue : toString(raw.kind),
summary: toString(raw.summary),
contentWarning:
toString(raw.contentWarning ?? raw["content-warning"] ?? raw.content_warning),
publishedAtSeconds: publishedAt.seconds,
publishedAtRaw: publishedAt.raw,
durationSeconds: durationNumeric != null ? Math.max(0, durationNumeric) : null,
durationRaw,
alt: toString(raw.alt),
imeta,
textTracks,
segments,
hashtags,
participants,
references,
};
}
renderImetaVariants(variants) {
if (!Array.isArray(variants) || variants.length === 0) {
return this.renderListEmpty("No media variants provided.");
}
const escape = this.getEscapeFn();
return `${escape(
variant.m
)}`
: this.renderPlaceholder();
const dimHtml = variant.dim
? `${escape(
variant.dim
)}`
: this.renderPlaceholder();
const urlHtml = variant.url
? this.buildLinkMarkup(variant.url)
: this.renderPlaceholder("No URL");
const hashHtml = variant.x
? `${escape(
variant.x
)}`
: this.renderPlaceholder("Not provided");
const renderNestedList = (label, values, emptyLabel) => {
if (!Array.isArray(values) || values.length === 0) {
return `${escape( label )}
${this.renderListEmpty(emptyLabel)}${escape( label )}
${this.renderListEmpty(emptyLabel)}${escape( label )}
${escape(
track.type
)}`
: this.renderPlaceholder();
const languageHtml = track.language
? `${escape(
track.language
)}`
: this.renderPlaceholder();
return `
${escape(segment.title)}
` : this.renderPlaceholder("No title"); const thumbnailHtml = segment.thumbnail ? this.buildLinkMarkup(segment.thumbnail) : this.renderPlaceholder("No thumbnail"); return `${escape(displayPubkey)}`
: this.renderPlaceholder("No pubkey");
const relayHtml = participant.relay
? this.buildLinkMarkup(participant.relay, { breakAll: false })
: this.renderPlaceholder("No relay specified");
return `