Files
bitvid/js/ui/components/RevertModal.js
T
google-labs-jules[bot]andPR0M3TH3AN f676fb2ff5 [security] replace innerHTML in RevertModal.js
Migrated `js/ui/components/RevertModal.js` to safe DOM APIs (`createElement`, `textContent`, `DOMParser`).
Extracted rendering logic to `js/ui/components/revertModalRenderers.js`.
Updated baseline in `scripts/check-innerhtml.mjs` (0 remaining usages).
Updated tests to mock DOMParser.

Co-authored-by: PR0M3TH3AN <[email protected]>
2026-02-14 18:58:02 +00:00

1509 lines
45 KiB
JavaScript

import { Nip71FormManager } from "./nip71FormManager.js";
import { createModalAccessibility } from "./modalAccessibility.js";
import {
createImetaVariants,
createTextTracks,
createSegments,
createHashtags,
createParticipants,
createReferences,
} from "./revertModalRenderers.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");
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
while (doc.body.firstChild) {
wrapper.appendChild(doc.body.firstChild);
}
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.replaceChildren();
}
if (this.details) {
this.details.replaceChildren();
if (this.detailsDefaultHTML) {
const parser = new DOMParser();
const doc = parser.parseFromString(this.detailsDefaultHTML, "text/html");
while (doc.body.firstChild) {
this.details.appendChild(doc.body.firstChild);
}
this.placeholder = this.details.querySelector("#revertVersionPlaceholder");
}
}
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.replaceChildren();
if (!this.revisions.length) {
const p = document.createElement("p");
p.className = "text-xs text-subtle";
p.textContent = "No revisions found.";
this.list.appendChild(p);
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-surface",
];
if (isSelected) {
classes.push(
"border-status-info-border",
"bg-status-info-surface",
"text-status-info-on",
"focus:ring-status-info"
);
} else if (isCurrent) {
classes.push(
"border-status-success-border",
"bg-status-success-surface",
"text-status-success-on",
"focus:ring-status-success"
);
} else if (isDeleted) {
classes.push(
"border-status-danger-border",
"bg-status-danger-surface",
"text-status-danger-on",
"hover:border-status-danger-border"
);
} else {
classes.push(
"border-overlay",
"bg-overlay-panel-soft",
"hover:bg-overlay-panel",
"text-primary"
);
}
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-status-danger-on"
: "text-muted";
const container = document.createElement("div");
container.className = "flex items-start justify-between gap-3";
const leftCol = document.createElement("div");
leftCol.className = "space-y-1";
const titleP = document.createElement("p");
titleP.className = "font-semibold";
titleP.textContent = entry.title || "Untitled";
leftCol.appendChild(titleP);
const timeP = document.createElement("p");
timeP.className = "text-xs text-subtle";
timeP.textContent = `${relative} • ${absolute}`;
leftCol.appendChild(timeP);
if (meta) {
const metaP = document.createElement("p");
metaP.className = `text-xs ${metaClass}`;
metaP.textContent = meta;
leftCol.appendChild(metaP);
}
container.appendChild(leftCol);
const rightCol = document.createElement("div");
rightCol.className = "text-xs uppercase tracking-wide text-muted";
rightCol.textContent = versionLabel;
container.appendChild(rightCol);
button.appendChild(container);
fragment.appendChild(button);
});
this.list.appendChild(fragment);
}
handleListClick(event) {
if (this.busy) {
return;
}
const button = event?.target?.closest?.("[data-revert-version-id]");
if (!button || !button.dataset) {
return;
}
const versionId = button.dataset.revertVersionId;
if (!versionId) {
return;
}
const match = (this.revisions || []).find(
(entry) => entry && entry.id === versionId
);
if (!match) {
return;
}
this.selectedRevision = match;
this.renderRevertVersionsList();
this.renderRevertVersionDetails(match);
this.updateConfirmationState();
this.dispatch("video:revert-select", {
revision: match,
video: this.activeVideo,
});
}
renderRevertVersionDetails(version) {
if (!this.details) {
return;
}
this.details.replaceChildren();
if (!version) {
if (this.detailsDefaultHTML) {
const parser = new DOMParser();
const doc = parser.parseFromString(this.detailsDefaultHTML, "text/html");
while (doc.body.firstChild) {
this.details.appendChild(doc.body.firstChild);
}
this.placeholder = this.details.querySelector("#revertVersionPlaceholder");
}
return;
}
const createdAt = typeof version.created_at === "number" ? version.created_at : 0;
const absolute = this.formatAbsoluteTimestamp
? this.formatAbsoluteTimestamp(createdAt)
: "";
const relative = this.formatTimeAgo ? this.formatTimeAgo(createdAt) : "";
const description =
typeof version.description === "string" ? version.description : "";
const thumbnail =
typeof version.thumbnail === "string" ? version.thumbnail.trim() : "";
const url = typeof version.url === "string" ? version.url.trim() : "";
const magnet =
typeof version.magnet === "string" ? version.magnet.trim() : "";
const rawMagnet =
typeof version.rawMagnet === "string" ? version.rawMagnet.trim() : "";
const displayMagnet = magnet || rawMagnet;
const isPrivate = version.isPrivate === true;
const dTagValue = this.extractDTagValue(version.tags);
const nip71Metadata = this.buildNip71DisplayMetadata(version);
const fallbackThumbnail = this.fallbackThumbnailSrc;
const thumbnailSrc = thumbnail || fallbackThumbnail;
const thumbnailAlt = thumbnail ? "Revision thumbnail" : "Fallback thumbnail";
const urlNode = url ? this.createLinkMarkup(url) : this.createPlaceholder("None");
const magnetNode = displayMagnet
? (() => {
const div = document.createElement("div");
div.className = "break-all";
const label = this.truncateMiddle
? this.truncateMiddle(displayMagnet, 72)
: displayMagnet;
div.textContent = label;
if (isPrivate) {
const span = document.createElement("span");
span.className = "block mt-1 text-xs text-status-private-on";
span.textContent = "Magnet stays visible only to you — private notes keep the raw string local.";
div.appendChild(span);
}
return div;
})()
: this.createPlaceholder("None");
const chipsDiv = document.createElement("div");
chipsDiv.className = "flex flex-wrap gap-2";
const nsfwFlag = version.isNsfw === true;
const forKidsFlag = version.isForKids === true;
const conflictingAudienceFlags = nsfwFlag && forKidsFlag;
if (version.deleted) {
const s = document.createElement("span");
s.className = "pill";
s.dataset.size = "compact";
s.dataset.variant = "critical";
s.textContent = "Marked deleted";
chipsDiv.appendChild(s);
}
if (isPrivate) {
const s = document.createElement("span");
s.className = "pill";
s.dataset.size = "compact";
s.dataset.variant = "private";
s.textContent = "Private";
chipsDiv.appendChild(s);
}
if (conflictingAudienceFlags) {
const s = document.createElement("span");
s.className = "pill";
s.dataset.size = "compact";
s.dataset.variant = "warning";
s.textContent = "NSFW + For kids conflict";
chipsDiv.appendChild(s);
} else {
if (nsfwFlag) {
const s = document.createElement("span");
s.className = "pill";
s.dataset.size = "compact";
s.dataset.variant = "critical";
s.textContent = "Marked NSFW";
chipsDiv.appendChild(s);
}
if (forKidsFlag) {
const s = document.createElement("span");
s.className = "pill";
s.dataset.size = "compact";
s.dataset.variant = "success";
s.textContent = "For kids";
chipsDiv.appendChild(s);
}
}
if (version.version !== undefined) {
const s = document.createElement("span");
s.className = "pill";
s.dataset.size = "compact";
s.dataset.variant = "neutral";
s.textContent = `Schema v${version.version}`;
chipsDiv.appendChild(s);
}
const createFlagStatus = (value, {yes, no, unspecified}) => {
if (value === true) {
const s = document.createElement("span");
s.className = "text-primary";
s.textContent = yes;
return s;
}
if (value === false) {
const s = document.createElement("span");
s.className = "text-primary";
s.textContent = no;
return s;
}
return this.createPlaceholder(unspecified);
};
const nsfwStatusNode = createFlagStatus(version.isNsfw, {
yes: "Yes — marked NSFW",
no: "No — not flagged as NSFW",
unspecified: "Not specified",
});
const kidsStatusNode = createFlagStatus(version.isForKids, {
yes: "Yes — marked for kids",
no: "No — not marked for kids",
unspecified: "Not specified",
});
const audienceConflictNotice = conflictingAudienceFlags
? (() => {
const p = document.createElement("p");
p.className = "text-xs text-status-warning-on";
p.textContent = "Conflicting flags detected — this revision is marked both NSFW and for kids. Review before reverting.";
return p;
})()
: null;
const descriptionNode = description
? (() => {
const p = document.createElement("p");
p.className = "whitespace-pre-wrap text-primary";
p.textContent = description;
return p;
})()
: (() => {
const p = document.createElement("p");
p.className = "text-subtle";
p.textContent = "No description provided.";
return p;
})();
const rootId = typeof version.videoRootId === "string" ? version.videoRootId : "";
const rootDisplay = rootId
? this.truncateMiddle
? this.truncateMiddle(rootId, 64)
: rootId
: "";
const eventDisplay = version.id
? this.truncateMiddle
? this.truncateMiddle(version.id, 64)
: version.id
: "";
const timestampText = [absolute, relative ? `(${relative})` : ""]
.filter(Boolean)
.join(" ");
const createTimestampNode = (seconds, raw) => {
if (Number.isFinite(seconds)) {
const abs = this.formatAbsoluteTimestamp ? this.formatAbsoluteTimestamp(seconds) : `${seconds}`;
const rel = this.formatTimeAgo ? this.formatTimeAgo(seconds) : "";
const frag = document.createDocumentFragment();
if (abs) {
const s = document.createElement("span");
s.className = "text-primary";
s.textContent = abs;
frag.appendChild(s);
frag.appendChild(document.createTextNode(" "));
}
if (rel) {
const s = document.createElement("span");
s.className = "text-muted";
s.textContent = `(${rel})`;
frag.appendChild(s);
}
return frag;
}
if (raw) {
const s = document.createElement("span");
s.className = "text-primary";
s.textContent = raw;
return s;
}
return this.createPlaceholder();
};
const createDurationNode = (seconds, raw) => {
if (Number.isFinite(seconds)) {
const display = this.formatDurationSeconds(seconds);
const suffix = seconds === 1 ? "second" : "seconds";
const frag = document.createDocumentFragment();
const s1 = document.createElement("span");
s1.className = "text-primary";
s1.textContent = display;
frag.appendChild(s1);
frag.appendChild(document.createTextNode(" "));
const s2 = document.createElement("span");
s2.className = "text-subtle";
s2.textContent = `(${seconds} ${suffix})`;
frag.appendChild(s2);
return frag;
}
if (raw) {
const s = document.createElement("span");
s.className = "text-primary";
s.textContent = raw;
return s;
}
return this.createPlaceholder();
};
const kindValue = nip71Metadata.kind;
const kindNode = (() => {
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 frag = document.createDocumentFragment();
const code = document.createElement("code");
code.className = "rounded bg-overlay-panel-soft px-1.5 py-0.5 text-primary";
code.textContent = `kind ${label}`;
frag.appendChild(code);
if (suffix) {
frag.appendChild(document.createTextNode(" "));
const s = document.createElement("span");
s.className = "text-muted";
s.textContent = `(${suffix})`;
frag.appendChild(s);
}
return frag;
}
return this.createPlaceholder();
})();
const summaryNode = nip71Metadata.summary
? (() => {
const p = document.createElement("p");
p.className = "whitespace-pre-wrap text-primary";
p.textContent = nip71Metadata.summary;
return p;
})()
: this.createPlaceholder("Not provided");
const contentWarningNode = nip71Metadata.contentWarning
? (() => {
const s = document.createElement("span");
s.className = "pill";
s.dataset.size = "compact";
s.dataset.variant = "warning";
s.textContent = nip71Metadata.contentWarning;
return s;
})()
: this.createPlaceholder("Not provided");
const publishedAtNode = createTimestampNode(
nip71Metadata.publishedAtSeconds,
nip71Metadata.publishedAtRaw
);
const durationNode = createDurationNode(
nip71Metadata.durationSeconds,
nip71Metadata.durationRaw
);
const altNode = nip71Metadata.alt
? (() => {
const p = document.createElement("p");
p.className = "whitespace-pre-wrap text-primary";
p.textContent = nip71Metadata.alt;
return p;
})()
: this.createPlaceholder("Not provided");
const createSection = (title) => {
const s = document.createElement("section");
s.className = "space-y-2";
const h = document.createElement("h4");
h.className = "text-sm font-semibold text-primary";
h.textContent = title;
s.appendChild(h);
return s;
};
const createDl = () => {
const dl = document.createElement("dl");
dl.className = "grid gap-3 sm:grid-cols-2 text-xs text-subtle";
return dl;
};
const createDtDd = (label, contentNode, colSpan = 1) => {
const div = document.createElement("div");
if (colSpan > 1) div.className = "sm:col-span-2";
const dt = document.createElement("dt");
dt.className = "font-semibold text-primary";
dt.textContent = label;
div.appendChild(dt);
const dd = document.createElement("dd");
dd.className = "mt-1";
dd.appendChild(contentNode);
div.appendChild(dd);
return div;
};
const wrapper = document.createElement("div");
wrapper.className = "space-y-6";
// Header Area
const headerRow = document.createElement("div");
headerRow.className = "flex flex-col gap-4 lg:flex-row lg:items-start";
const imgDiv = document.createElement("div");
imgDiv.className = "overflow-hidden rounded-md border border-overlay bg-overlay-strong w-full max-w-sm";
const img = document.createElement("img");
img.src = thumbnailSrc;
img.alt = thumbnailAlt;
img.className = "w-full h-auto object-cover";
img.loading = "lazy";
imgDiv.appendChild(img);
headerRow.appendChild(imgDiv);
const headerContent = document.createElement("div");
headerContent.className = "flex-1 space-y-3";
const titleTimeDiv = document.createElement("div");
titleTimeDiv.className = "space-y-1";
const titleH3 = document.createElement("h3");
titleH3.className = "text-lg font-semibold text-primary";
titleH3.textContent = version.title || "Untitled";
titleTimeDiv.appendChild(titleH3);
const timeP = document.createElement("p");
timeP.className = "text-xs text-muted";
timeP.textContent = timestampText;
titleTimeDiv.appendChild(timeP);
headerContent.appendChild(titleTimeDiv);
if (chipsDiv.hasChildNodes()) {
headerContent.appendChild(chipsDiv);
}
const headerDl = createDl();
headerDl.appendChild(createDtDd("Hosted URL", urlNode));
headerDl.appendChild(createDtDd("Magnet", magnetNode));
headerContent.appendChild(headerDl);
headerRow.appendChild(headerContent);
wrapper.appendChild(headerRow);
// Description
const descSection = createSection("Description");
descSection.appendChild(descriptionNode);
wrapper.appendChild(descSection);
// Audience Flags
const audSection = createSection("Audience flags");
const audDl = createDl();
audDl.appendChild(createDtDd("NSFW flag", nsfwStatusNode));
audDl.appendChild(createDtDd("For kids flag", kidsStatusNode));
audSection.appendChild(audDl);
if (audienceConflictNotice) audSection.appendChild(audienceConflictNotice);
wrapper.appendChild(audSection);
// NIP-71 event metadata
const eventSection = createSection("NIP-71 event metadata");
const eventDl = createDl();
eventDl.appendChild(createDtDd("NIP-71 kind", kindNode));
eventDl.appendChild(createDtDd("Summary", summaryNode, 2));
eventDl.appendChild(createDtDd("Content warning", contentWarningNode, 2));
eventDl.appendChild(createDtDd("Published timestamp", publishedAtNode));
eventDl.appendChild(createDtDd("Duration", durationNode));
eventDl.appendChild(createDtDd("Alt text", altNode, 2));
eventSection.appendChild(eventDl);
wrapper.appendChild(eventSection);
// Note pointers
const ptrSection = createSection("Note pointers");
const ptrDl = createDl();
ptrDl.appendChild(createDtDd("Mode", document.createTextNode(version.mode || "live")));
const dTagNode = dTagValue
? (() => {
const c = document.createElement("code");
c.className = "rounded bg-overlay-panel-soft px-1.5 py-0.5 text-primary";
c.textContent = dTagValue;
return c;
})()
: this.createPlaceholder("Not provided");
ptrDl.appendChild(createDtDd("d tag", dTagNode));
const rootIdNode = rootDisplay
? (() => {
const c = document.createElement("code");
c.className = "break-all rounded bg-overlay-panel-soft px-1.5 py-0.5 text-primary";
c.title = rootId;
c.textContent = rootDisplay;
return c;
})()
: this.createPlaceholder("Not provided");
ptrDl.appendChild(createDtDd("videoRootId", rootIdNode));
const eventIdNode = eventDisplay
? (() => {
const c = document.createElement("code");
c.className = "break-all rounded bg-overlay-panel-soft px-1.5 py-0.5 text-primary";
c.title = version.id || "";
c.textContent = eventDisplay;
return c;
})()
: (() => {
const s = document.createElement("span");
s.className = "text-subtle";
s.textContent = "Unknown";
return s;
})();
ptrDl.appendChild(createDtDd("Event ID", eventIdNode));
ptrSection.appendChild(ptrDl);
wrapper.appendChild(ptrSection);
// NIP-71 media metadata
const mediaSection = createSection("NIP-71 media metadata");
const mediaDiv = document.createElement("div");
mediaDiv.className = "space-y-4";
const createMediaSub = (label, contentNode) => {
const d = document.createElement("div");
d.className = "space-y-2";
const h = document.createElement("h5");
h.className = "text-xs font-semibold uppercase tracking-wide text-muted";
h.textContent = label;
d.appendChild(h);
d.appendChild(contentNode);
return d;
};
const renderUtils = {
createListEmpty: (t) => this.createListEmpty(t),
createPlaceholder: (t) => this.createPlaceholder(t),
createLinkMarkup: (v, o) => this.createLinkMarkup(v, o),
formatDurationSeconds: (s) => this.formatDurationSeconds(s),
truncateMiddle: this.truncateMiddle,
};
mediaDiv.appendChild(createMediaSub("Media variants (imeta)", createImetaVariants(nip71Metadata.imeta, renderUtils)));
mediaDiv.appendChild(createMediaSub("Caption tracks", createTextTracks(nip71Metadata.textTracks, renderUtils)));
mediaDiv.appendChild(createMediaSub("Chapters", createSegments(nip71Metadata.segments, renderUtils)));
mediaDiv.appendChild(createMediaSub("Hashtags", createHashtags(nip71Metadata.hashtags, renderUtils)));
mediaDiv.appendChild(createMediaSub("Participants", createParticipants(nip71Metadata.participants, renderUtils)));
mediaDiv.appendChild(createMediaSub("References", createReferences(nip71Metadata.references, renderUtils)));
mediaSection.appendChild(mediaDiv);
wrapper.appendChild(mediaSection);
this.details.appendChild(wrapper);
}
updateConfirmationState() {
if (!this.confirmButton) {
return;
}
if (!this.selectedRevision || !this.activeVideo) {
this.pendingEntries = [];
this.confirmButton.disabled = true;
if (!this.busy) {
this.confirmButton.textContent = this.confirmDefaultLabel;
}
if (this.statusLabel && (this.revisions || []).length > 1) {
this.statusLabel.textContent =
"Select an older revision to enable reverting.";
}
return;
}
const target = this.selectedRevision;
const activePubkey =
typeof this.activeVideo.pubkey === "string"
? this.activeVideo.pubkey.toLowerCase()
: "";
const revertCandidates = (this.revisions || []).filter((entry) => {
if (!entry || entry.id === target.id) {
return false;
}
if (entry.deleted) {
return false;
}
if (typeof entry.created_at !== "number") {
return false;
}
if (entry.created_at <= target.created_at) {
return false;
}
if (!entry.pubkey) {
return false;
}
const entryPubkey =
typeof entry.pubkey === "string" ? entry.pubkey.toLowerCase() : "";
if (activePubkey && entryPubkey !== activePubkey) {
return false;
}
return true;
});
this.pendingEntries = revertCandidates;
const disable =
this.busy || target.deleted === true || revertCandidates.length === 0;
this.confirmButton.disabled = disable;
if (!this.busy) {
this.confirmButton.textContent = this.confirmDefaultLabel;
}
if (!this.statusLabel) {
return;
}
if (target.deleted) {
this.statusLabel.textContent =
"This revision was previously marked as deleted and cannot become active.";
return;
}
if (revertCandidates.length === 0) {
this.statusLabel.textContent =
"The selected revision is already the latest active version.";
return;
}
const suffix = revertCandidates.length === 1 ? "revision" : "revisions";
this.statusLabel.textContent = `Reverting will mark ${revertCandidates.length} newer ${suffix} as reverted.`;
}
setBusy(isBusy, label) {
this.busy = Boolean(isBusy);
if (this.confirmButton) {
const disableConfirm =
this.busy ||
!this.selectedRevision ||
!this.pendingEntries ||
this.pendingEntries.length === 0 ||
(this.selectedRevision && this.selectedRevision.deleted === true);
this.confirmButton.disabled = disableConfirm;
this.confirmButton.textContent = this.busy
? label || "Reverting…"
: this.confirmDefaultLabel;
this.confirmButton.classList.toggle("cursor-wait", this.busy);
}
const toggleInteractionDisabled = (button) => {
if (!button) {
return;
}
button.disabled = this.busy;
};
toggleInteractionDisabled(this.cancelButton);
toggleInteractionDisabled(this.closeButton);
if (!this.busy) {
this.updateConfirmationState();
}
}
handleConfirmClick() {
if (this.busy) {
return;
}
if (!this.selectedRevision) {
return;
}
const entries = Array.isArray(this.pendingEntries)
? this.pendingEntries.slice()
: [];
if (!entries.length) {
this.updateConfirmationState();
return;
}
this.dispatch("video:revert-confirm", {
target: this.selectedRevision,
entries,
video: this.activeVideo,
context: this.context,
});
}
handleCancelInteraction() {
if (this.busy) {
return;
}
this.dispatch("video:revert-cancel", {
context: this.context,
video: this.activeVideo,
});
this.close();
}
handleCloseInteraction() {
if (this.busy) {
return;
}
this.close();
}
destroy() {
if (this.bound) {
if (this.list) {
this.list.removeEventListener("click", this.boundHandlers.listClick);
}
if (this.confirmButton) {
this.confirmButton.removeEventListener(
"click",
this.boundHandlers.confirm
);
}
if (this.cancelButton) {
this.cancelButton.removeEventListener(
"click",
this.boundHandlers.cancel
);
}
if (this.overlay) {
this.overlay.removeEventListener("click", this.boundHandlers.overlay);
}
if (this.closeButton) {
this.closeButton.removeEventListener("click", this.boundHandlers.close);
}
this.bound = false;
}
if (this.modalAccessibility?.destroy) {
this.modalAccessibility.destroy();
}
this.modalAccessibility = null;
this.modalPanel = null;
}
getEscapeFn() {
return (value) =>
this.escapeHTML ? this.escapeHTML(String(value ?? "")) : String(value ?? "");
}
createPlaceholder(text = "Not provided") {
const span = document.createElement("span");
span.className = "text-subtle";
span.textContent = text;
return span;
}
createListEmpty(text) {
const p = document.createElement("p");
p.className = "text-xs text-subtle";
p.textContent = text;
return p;
}
createLinkMarkup(value, { breakAll = true } = {}) {
if (value == null) {
return document.createTextNode("");
}
const raw = typeof value === "string" ? value.trim() : String(value ?? "").trim();
if (!raw) {
return document.createTextNode("");
}
const displayRaw = this.truncateMiddle
? this.truncateMiddle(raw, 96)
: raw;
const breakClass = breakAll ? "break-all" : "break-words";
if (/^https?:/i.test(raw)) {
const a = document.createElement("a");
a.href = raw;
a.target = "_blank";
a.rel = "noopener noreferrer";
a.className = `text-info hover:text-info-strong ${breakClass}`;
a.textContent = displayRaw;
return a;
}
const code = document.createElement("code");
code.className = `rounded bg-overlay-panel-soft px-1.5 py-0.5 text-[0.75rem] text-primary ${breakClass}`;
code.textContent = displayRaw;
return code;
}
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,
};
}
extractDTagValue(tags) {
if (!Array.isArray(tags)) {
return "";
}
for (const tag of tags) {
if (!Array.isArray(tag) || tag.length < 2) {
continue;
}
if (tag[0] === "d" && typeof tag[1] === "string") {
return tag[1];
}
}
return "";
}
}