diff --git a/js/app.js b/js/app.js
index 34f8d73b..51d24c61 100644
--- a/js/app.js
+++ b/js/app.js
@@ -46,6 +46,7 @@ import {
import { VideoModal } from "./ui/components/VideoModal.js";
import { UploadModal } from "./ui/components/UploadModal.js";
import { EditModal } from "./ui/components/EditModal.js";
+import { RevertModal } from "./ui/components/RevertModal.js";
import {
getPubkey as getStoredPubkey,
setPubkey as setStoredPubkey,
@@ -530,26 +531,22 @@ class bitvidApp {
this.showError("");
});
- // Revert video modal elements
- this.revertVideoModal = null;
- this.revertVideoOverlay = null;
- this.revertVersionsList = null;
- this.revertVersionDetails = null;
- this.revertVersionPlaceholder = null;
- this.revertVersionDetailsDefaultHTML = "";
- this.revertHistoryCount = null;
- this.revertSelectionStatus = null;
- this.revertModalTitle = null;
- this.revertModalSubtitle = null;
- this.closeRevertVideoModalBtn = null;
- this.cancelRevertVideoBtn = null;
- this.confirmRevertVideoBtn = null;
- this.activeRevertVideo = null;
- this.revertHistory = [];
- this.selectedRevertTarget = null;
- this.revertModalBusy = false;
- this.revertConfirmDefaultLabel = "Revert to selected version";
- this.pendingRevertEntries = [];
+ this.revertModal = new RevertModal({
+ removeTrackingScripts,
+ setGlobalModalState,
+ formatAbsoluteTimestamp: (timestamp) =>
+ this.formatAbsoluteTimestamp(timestamp),
+ formatTimeAgo: (timestamp) => this.formatTimeAgo(timestamp),
+ escapeHTML: (value) => this.escapeHTML(value),
+ truncateMiddle,
+ fallbackThumbnailSrc: FALLBACK_THUMBNAIL_SRC,
+ container: document.getElementById("modalContainer") || null,
+ });
+
+ this.revertModal.addEventListener("video:revert-confirm", (event) => {
+ this.handleRevertModalConfirm(event);
+ });
+
// Optional small inline player stats
this.status = document.getElementById("status") || null;
@@ -966,766 +963,6 @@ class bitvidApp {
return this.videoModal.forceRemovePoster(reason);
}
- async initRevertVideoModal() {
- try {
- 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 = document.getElementById("modalContainer");
- if (!modalContainer) {
- throw new Error("Modal container element not found!");
- }
-
- const wrapper = document.createElement("div");
- wrapper.innerHTML = html;
- removeTrackingScripts(wrapper);
- modalContainer.appendChild(wrapper);
-
- modal = wrapper.querySelector("#revertVideoModal");
- }
-
- if (!modal) {
- throw new Error("Revert video modal markup missing after load.");
- }
-
- this.revertVideoModal = modal;
- this.revertVideoOverlay =
- modal.querySelector("#revertVideoModalOverlay") || null;
- this.revertVersionsList =
- modal.querySelector("#revertVersionsList") || null;
- this.revertVersionDetails =
- modal.querySelector("#revertVersionDetails") || null;
- this.revertVersionPlaceholder =
- modal.querySelector("#revertVersionPlaceholder") || null;
- if (this.revertVersionDetails && !this.revertVersionDetailsDefaultHTML) {
- this.revertVersionDetailsDefaultHTML = this.revertVersionDetails.innerHTML;
- }
- this.revertHistoryCount =
- modal.querySelector("#revertHistoryCount") || null;
- this.revertSelectionStatus =
- modal.querySelector("#revertSelectionStatus") || null;
- this.revertModalTitle =
- modal.querySelector("#revertModalTitle") || null;
- this.revertModalSubtitle =
- modal.querySelector("#revertModalSubtitle") || null;
- this.closeRevertVideoModalBtn =
- modal.querySelector("#closeRevertVideoModal") || null;
- this.cancelRevertVideoBtn =
- modal.querySelector("#cancelRevertVideo") || null;
- this.confirmRevertVideoBtn =
- modal.querySelector("#confirmRevertVideo") || null;
-
- if (
- this.revertVersionsList &&
- !this.revertVersionsList.dataset.listenerAttached
- ) {
- this.revertVersionsList.addEventListener("click", (event) => {
- this.handleRevertVersionListClick(event);
- });
- this.revertVersionsList.dataset.listenerAttached = "true";
- }
-
- if (this.closeRevertVideoModalBtn && !this.closeRevertVideoModalBtn.dataset.listenerAttached) {
- this.closeRevertVideoModalBtn.addEventListener("click", () => {
- if (!this.revertModalBusy) {
- this.hideRevertVideoModal();
- }
- });
- this.closeRevertVideoModalBtn.dataset.listenerAttached = "true";
- }
-
- if (this.cancelRevertVideoBtn && !this.cancelRevertVideoBtn.dataset.listenerAttached) {
- this.cancelRevertVideoBtn.addEventListener("click", () => {
- if (!this.revertModalBusy) {
- this.hideRevertVideoModal();
- }
- });
- this.cancelRevertVideoBtn.dataset.listenerAttached = "true";
- }
-
- if (this.revertVideoOverlay && !this.revertVideoOverlay.dataset.listenerAttached) {
- this.revertVideoOverlay.addEventListener("click", () => {
- if (!this.revertModalBusy) {
- this.hideRevertVideoModal();
- }
- });
- this.revertVideoOverlay.dataset.listenerAttached = "true";
- }
-
- if (
- this.confirmRevertVideoBtn &&
- !this.confirmRevertVideoBtn.dataset.listenerAttached
- ) {
- this.confirmRevertVideoBtn.addEventListener("click", () => {
- this.handleConfirmRevertSelection();
- });
- this.confirmRevertVideoBtn.dataset.listenerAttached = "true";
- }
-
- this.resetRevertVideoModal();
-
- return true;
- } catch (error) {
- console.error("initRevertVideoModal failed:", error);
- this.showError(`Failed to initialize revert modal: ${error.message}`);
- return false;
- }
- }
-
- resetRevertVideoModal() {
- this.activeRevertVideo = null;
- this.revertHistory = [];
- this.selectedRevertTarget = null;
- this.revertModalBusy = false;
- this.pendingRevertEntries = [];
-
- if (this.revertHistoryCount) {
- this.revertHistoryCount.textContent = "";
- }
-
- if (this.revertSelectionStatus) {
- this.revertSelectionStatus.textContent =
- "Select an older revision to inspect its metadata before reverting.";
- }
-
- if (this.revertModalTitle) {
- this.revertModalTitle.textContent = "Revert Video Note";
- }
-
- if (this.revertModalSubtitle) {
- this.revertModalSubtitle.textContent =
- "Review previous versions before restoring an older state.";
- }
-
- if (this.revertVersionsList) {
- this.revertVersionsList.innerHTML = "";
- }
-
- if (this.revertVersionDetails && this.revertVersionDetailsDefaultHTML) {
- this.revertVersionDetails.innerHTML = this.revertVersionDetailsDefaultHTML;
- this.revertVersionPlaceholder =
- this.revertVersionDetails.querySelector("#revertVersionPlaceholder");
- }
-
- if (this.confirmRevertVideoBtn) {
- this.confirmRevertVideoBtn.disabled = true;
- this.confirmRevertVideoBtn.textContent = this.revertConfirmDefaultLabel;
- this.confirmRevertVideoBtn.classList.remove("cursor-wait");
- }
-
- if (this.cancelRevertVideoBtn) {
- this.cancelRevertVideoBtn.disabled = false;
- this.cancelRevertVideoBtn.classList.remove("opacity-60", "cursor-not-allowed");
- }
-
- if (this.closeRevertVideoModalBtn) {
- this.closeRevertVideoModalBtn.disabled = false;
- this.closeRevertVideoModalBtn.classList.remove("opacity-60", "cursor-not-allowed");
- }
- }
-
- showRevertVideoModal() {
- if (this.revertVideoModal) {
- this.revertVideoModal.classList.remove("hidden");
- setGlobalModalState("revertVideo", true);
- }
- }
-
- hideRevertVideoModal() {
- if (this.revertVideoModal) {
- this.revertVideoModal.classList.add("hidden");
- }
- setGlobalModalState("revertVideo", false);
- this.resetRevertVideoModal();
- }
-
- populateRevertVideoModal(video, history = []) {
- if (!this.revertVideoModal) {
- return;
- }
-
- this.resetRevertVideoModal();
-
- if (!video || typeof video !== "object") {
- if (this.revertSelectionStatus) {
- this.revertSelectionStatus.textContent =
- "Unable to load revision history for this note.";
- }
- return;
- }
-
- this.activeRevertVideo = 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.revertHistory = Array.from(deduped.values()).sort(
- (a, b) => b.created_at - a.created_at
- );
-
- this.selectedRevertTarget = null;
- if (this.revertHistory.length > 1 && video.created_at) {
- // Auto-select the newest non-deleted revision that predates the active
- // event so the confirmation button immediately conveys its effect.
- const firstOlder = this.revertHistory.find(
- (entry) =>
- entry &&
- entry.id !== video.id &&
- entry.deleted !== true &&
- typeof entry.created_at === "number" &&
- entry.created_at < video.created_at
- );
- if (firstOlder) {
- this.selectedRevertTarget = firstOlder;
- }
- }
-
- if (this.revertHistoryCount) {
- this.revertHistoryCount.textContent = `${this.revertHistory.length}`;
- }
-
- if (this.revertModalTitle) {
- this.revertModalTitle.textContent = video.title
- ? `Revert “${video.title}”`
- : "Revert Video Note";
- }
-
- if (this.revertModalSubtitle) {
- const subtitleParts = [];
- const dTagValue = this.extractDTagValue(video.tags);
- if (dTagValue) {
- subtitleParts.push(`d=${dTagValue}`);
- }
- if (video.videoRootId) {
- subtitleParts.push(`root=${truncateMiddle(video.videoRootId, 40)}`);
- }
- this.revertModalSubtitle.textContent = subtitleParts.length
- ? `History grouped by ${subtitleParts.join(" • ")}`
- : "Review previous versions before restoring an older state.";
- }
-
- this.renderRevertVersionsList();
-
- if (this.selectedRevertTarget) {
- this.renderRevertVersionDetails(this.selectedRevertTarget);
- }
-
- if (this.revertHistory.length <= 1) {
- if (this.revertSelectionStatus) {
- this.revertSelectionStatus.textContent =
- "No earlier revisions are available for this note.";
- }
- this.updateRevertConfirmationState();
- return;
- }
-
- this.updateRevertConfirmationState();
- }
-
- renderRevertVersionsList() {
- if (!this.revertVersionsList) {
- return;
- }
-
- const history = Array.isArray(this.revertHistory)
- ? this.revertHistory
- : [];
- const selectedId = this.selectedRevertTarget?.id || "";
- const currentId = this.activeRevertVideo?.id || "";
-
- this.revertVersionsList.innerHTML = "";
-
- if (!history.length) {
- this.revertVersionsList.innerHTML =
- '
No revisions found.
';
- return;
- }
-
- const fragment = document.createDocumentFragment();
-
- history.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 relative = this.formatTimeAgo(entry.created_at);
- const absolute = this.formatAbsoluteTimestamp(entry.created_at);
- 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";
-
- button.innerHTML = `
-
-
-
${this.escapeHTML(
- entry.title || "Untitled"
- )}
-
${this.escapeHTML(
- relative
- )} • ${this.escapeHTML(absolute)}
- ${
- meta
- ? `
${this.escapeHTML(meta)}
`
- : ""
- }
-
-
- ${this.escapeHTML(versionLabel)}
-
-
- `;
-
- fragment.appendChild(button);
- });
-
- this.revertVersionsList.appendChild(fragment);
- }
-
- handleRevertVersionListClick(event) {
- 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.revertHistory || []).find(
- (entry) => entry && entry.id === versionId
- );
- if (!match) {
- return;
- }
-
- this.selectedRevertTarget = match;
- this.renderRevertVersionsList();
- this.renderRevertVersionDetails(match);
- this.updateRevertConfirmationState();
- }
-
- renderRevertVersionDetails(version) {
- if (!this.revertVersionDetails) {
- return;
- }
-
- if (!version) {
- if (this.revertVersionDetailsDefaultHTML) {
- this.revertVersionDetails.innerHTML =
- this.revertVersionDetailsDefaultHTML;
- this.revertVersionPlaceholder =
- this.revertVersionDetails.querySelector("#revertVersionPlaceholder");
- }
- return;
- }
-
- const absolute = this.formatAbsoluteTimestamp(version.created_at);
- const relative = this.formatTimeAgo(version.created_at);
- 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 fallbackThumbnail = this.escapeHTML(FALLBACK_THUMBNAIL_SRC);
- const thumbnailSrc = thumbnail
- ? this.escapeHTML(thumbnail)
- : fallbackThumbnail;
- const thumbnailAlt = thumbnail
- ? "Revision thumbnail"
- : "Fallback thumbnail";
-
- let urlHtml = 'None';
- if (url) {
- const safeUrl = this.escapeHTML(url);
- const displayUrl = this.escapeHTML(truncateMiddle(url, 72));
- urlHtml = `${displayUrl}`;
- }
-
- let magnetHtml = 'None';
- if (displayMagnet) {
- const label = this.escapeHTML(truncateMiddle(displayMagnet, 72));
- const caption = isPrivate
- ? 'Encrypted magnet (only decrypted locally for the owner).'
- : "";
- magnetHtml = `${label}${caption}
`;
- }
-
- const chips = [];
- if (version.deleted) {
- chips.push(
- 'Marked deleted'
- );
- }
- if (isPrivate) {
- chips.push(
- 'Private'
- );
- }
- if (version.version !== undefined) {
- chips.push(
- `Schema v${this.escapeHTML(
- String(version.version)
- )}`
- );
- }
-
- const descriptionHtml = description
- ? `${this.escapeHTML(
- description
- )}
`
- : 'No description provided.
';
-
- const rootId =
- typeof version.videoRootId === "string" ? version.videoRootId : "";
- const rootDisplay = rootId
- ? this.escapeHTML(truncateMiddle(rootId, 64))
- : "";
- const eventDisplay = version.id
- ? this.escapeHTML(truncateMiddle(version.id, 64))
- : "";
-
- this.revertVersionDetails.innerHTML = `
-
-
-
-

-
-
-
-
${this.escapeHTML(
- version.title || "Untitled"
- )}
-
${this.escapeHTML(
- absolute
- )} (${this.escapeHTML(relative)})
-
- ${
- chips.length
- ? `
${chips.join("")}
`
- : ""
- }
-
-
-
Hosted URL:
-
${urlHtml}
-
-
-
Magnet:
-
${magnetHtml}
-
-
-
-
-
-
-
Description
- ${descriptionHtml}
-
-
-
-
-
- Mode
- - ${this.escapeHTML(version.mode || "live")}
-
-
-
- d tag
- -
- ${dTagValue
- ? `
${this.escapeHTML(
- dTagValue
- )}`
- : 'Not provided'}
-
-
-
-
- videoRootId
- -
- ${rootDisplay
- ? `
${rootDisplay}`
- : 'Not provided'}
-
-
-
-
- Event ID
- -
- ${eventDisplay
- ? `
${eventDisplay}`
- : 'Unknown'}
-
-
-
-
- `;
- }
-
- updateRevertConfirmationState() {
- if (!this.confirmRevertVideoBtn) {
- return;
- }
-
- if (!this.selectedRevertTarget || !this.activeRevertVideo) {
- this.pendingRevertEntries = [];
- this.confirmRevertVideoBtn.disabled = true;
- if (!this.revertModalBusy) {
- this.confirmRevertVideoBtn.textContent = this.revertConfirmDefaultLabel;
- }
- if (this.revertSelectionStatus) {
- if ((this.revertHistory || []).length > 1) {
- this.revertSelectionStatus.textContent =
- "Select an older revision to enable reverting.";
- }
- }
- return;
- }
-
- const target = this.selectedRevertTarget;
- const activePubkey =
- typeof this.activeRevertVideo.pubkey === "string"
- ? this.activeRevertVideo.pubkey.toLowerCase()
- : "";
-
- const revertCandidates = (this.revertHistory || []).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.pendingRevertEntries = revertCandidates;
-
- const disable =
- this.revertModalBusy ||
- target.deleted === true ||
- revertCandidates.length === 0;
-
- this.confirmRevertVideoBtn.disabled = disable;
- if (!this.revertModalBusy) {
- this.confirmRevertVideoBtn.textContent = this.revertConfirmDefaultLabel;
- }
-
- if (this.revertSelectionStatus) {
- if (target.deleted) {
- this.revertSelectionStatus.textContent =
- "This revision was previously marked as deleted and cannot become active.";
- } else if (revertCandidates.length === 0) {
- this.revertSelectionStatus.textContent =
- "The selected revision is already the latest active version.";
- } else {
- const suffix = revertCandidates.length === 1 ? "revision" : "revisions";
- this.revertSelectionStatus.textContent = `Reverting will mark ${revertCandidates.length} newer ${suffix} as reverted.`;
- }
- }
- }
-
- setRevertModalBusy(isBusy, label) {
- this.revertModalBusy = Boolean(isBusy);
-
- if (this.confirmRevertVideoBtn) {
- const disableConfirm =
- this.revertModalBusy ||
- !this.selectedRevertTarget ||
- !this.pendingRevertEntries ||
- this.pendingRevertEntries.length === 0 ||
- (this.selectedRevertTarget && this.selectedRevertTarget.deleted === true);
- this.confirmRevertVideoBtn.disabled = disableConfirm;
- this.confirmRevertVideoBtn.textContent = this.revertModalBusy
- ? label || "Reverting…"
- : this.revertConfirmDefaultLabel;
- this.confirmRevertVideoBtn.classList.toggle(
- "cursor-wait",
- this.revertModalBusy
- );
- }
-
- const toggleDisabledStyles = (button) => {
- if (!button) {
- return;
- }
- button.disabled = this.revertModalBusy;
- button.classList.toggle("opacity-60", this.revertModalBusy);
- button.classList.toggle("cursor-not-allowed", this.revertModalBusy);
- };
-
- toggleDisabledStyles(this.cancelRevertVideoBtn);
- toggleDisabledStyles(this.closeRevertVideoModalBtn);
-
- if (!this.revertModalBusy) {
- this.updateRevertConfirmationState();
- }
- }
-
- async handleConfirmRevertSelection() {
- if (!this.selectedRevertTarget) {
- return;
- }
- if (!this.pubkey) {
- this.showError("Please login to revert.");
- return;
- }
-
- const entries = Array.isArray(this.pendingRevertEntries)
- ? this.pendingRevertEntries.slice()
- : [];
- if (!entries.length) {
- this.updateRevertConfirmationState();
- return;
- }
-
- this.setRevertModalBusy(true, "Reverting…");
-
- try {
- for (const entry of entries) {
- await nostrClient.revertVideo(
- {
- id: entry.id,
- pubkey: entry.pubkey,
- tags: entry.tags,
- },
- this.pubkey
- );
- }
-
- await this.loadVideos();
-
- const timestampLabel = this.formatAbsoluteTimestamp(
- this.selectedRevertTarget.created_at
- );
- this.showSuccess(`Reverted to revision from ${timestampLabel}.`);
- this.hideRevertVideoModal();
- this.forceRefreshAllProfiles();
- } catch (err) {
- console.error("Failed to revert video:", err);
- this.showError("Failed to revert video. Please try again.");
- } finally {
- this.setRevertModalBusy(false);
- this.pendingRevertEntries = [];
- }
- }
-
extractDTagValue(tags) {
if (!Array.isArray(tags)) {
return "";
@@ -7734,24 +6971,76 @@ class bitvidApp {
return;
}
- if (!this.revertVideoModal) {
- const initialized = await this.initRevertVideoModal();
- if (!initialized || !this.revertVideoModal) {
- this.showError("Revert modal is not available right now.");
- return;
- }
+ if (!this.revertModal) {
+ this.showError("Revert modal is not available right now.");
+ return;
+ }
+
+ const loaded = await this.revertModal.load();
+ if (!loaded) {
+ this.showError("Revert modal is not available right now.");
+ return;
}
const history = await nostrClient.hydrateVideoHistory(video);
- this.populateRevertVideoModal(video, history);
- this.showRevertVideoModal();
+ this.revertModal.setHistory(video, history);
+ this.revertModal.open({ video });
} catch (err) {
console.error("Failed to revert video:", err);
this.showError("Failed to load revision history. Please try again.");
}
}
+ async handleRevertModalConfirm(event) {
+ const detail = event?.detail || {};
+ const target = detail.target;
+ const entries = Array.isArray(detail.entries)
+ ? detail.entries.slice()
+ : [];
+
+ if (!target || !entries.length) {
+ return;
+ }
+
+ if (!this.pubkey) {
+ this.showError("Please login to revert.");
+ return;
+ }
+
+ if (!this.revertModal) {
+ this.showError("Revert modal is not available right now.");
+ return;
+ }
+
+ this.revertModal.setBusy(true, "Reverting…");
+
+ try {
+ for (const entry of entries) {
+ await nostrClient.revertVideo(
+ {
+ id: entry.id,
+ pubkey: entry.pubkey,
+ tags: entry.tags,
+ },
+ this.pubkey
+ );
+ }
+
+ await this.loadVideos();
+
+ const timestampLabel = this.formatAbsoluteTimestamp(target.created_at);
+ this.showSuccess(`Reverted to revision from ${timestampLabel}.`);
+ this.revertModal.close();
+ this.forceRefreshAllProfiles();
+ } catch (err) {
+ console.error("Failed to revert video:", err);
+ this.showError("Failed to revert video. Please try again.");
+ } finally {
+ this.revertModal.setBusy(false);
+ }
+ }
+
/**
* Handle "Delete Video" from gear menu.
*/
diff --git a/js/ui/components/RevertModal.js b/js/ui/components/RevertModal.js
new file mode 100644
index 00000000..4de7e350
--- /dev/null
+++ b/js/ui/components/RevertModal.js
@@ -0,0 +1,868 @@
+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.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.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.bindEvents();
+ this.reset();
+
+ return true;
+ }
+
+ cacheElements(modal) {
+ this.modal = modal;
+ this.overlay = modal.querySelector("#revertVideoModalOverlay") || null;
+ 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 toggleDisabledStyles = (button) => {
+ if (!button) {
+ return;
+ }
+ button.disabled = false;
+ button.classList.remove("opacity-60", "cursor-not-allowed");
+ };
+
+ toggleDisabledStyles(this.cancelButton);
+ toggleDisabledStyles(this.closeButton);
+ }
+
+ open(context = {}) {
+ 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.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");
+ 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 ? `
${escape(meta)}
` : ""}
+
+
+ ${escape(versionLabel)}
+
+
+ `;
+
+ 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;
+ }
+
+ if (!version) {
+ if (this.detailsDefaultHTML) {
+ this.details.innerHTML = this.detailsDefaultHTML;
+ this.placeholder = this.details.querySelector("#revertVersionPlaceholder");
+ } else {
+ this.details.innerHTML = "";
+ }
+ 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 escape = (value) =>
+ this.escapeHTML ? this.escapeHTML(String(value ?? "")) : String(value ?? "");
+
+ const fallbackThumbnail = escape(this.fallbackThumbnailSrc);
+ const thumbnailSrc = thumbnail ? escape(thumbnail) : fallbackThumbnail;
+ const thumbnailAlt = thumbnail ? "Revision thumbnail" : "Fallback thumbnail";
+
+ let urlHtml = 'None';
+ if (url) {
+ const safeUrl = escape(url);
+ const displayUrl = this.truncateMiddle
+ ? escape(this.truncateMiddle(url, 72))
+ : safeUrl;
+ urlHtml = `${displayUrl}`;
+ }
+
+ let magnetHtml = 'None';
+ if (displayMagnet) {
+ const label = this.truncateMiddle
+ ? escape(this.truncateMiddle(displayMagnet, 72))
+ : escape(displayMagnet);
+ const caption = isPrivate
+ ? 'Encrypted magnet (only decrypted locally for the owner).'
+ : "";
+ magnetHtml = `${label}${caption}
`;
+ }
+
+ const chips = [];
+ if (version.deleted) {
+ chips.push(
+ 'Marked deleted'
+ );
+ }
+ if (isPrivate) {
+ chips.push(
+ 'Private'
+ );
+ }
+ if (version.version !== undefined) {
+ chips.push(
+ `Schema v${escape(
+ String(version.version)
+ )}`
+ );
+ }
+
+ 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)
+ : "";
+
+ this.details.innerHTML = `
+
+
+
+

+
+
+
+
${escape(
+ version.title || "Untitled"
+ )}
+
${escape(absolute)} (${escape(
+ relative
+ )})
+
+ ${
+ chips.length
+ ? `
${chips.join("")}
`
+ : ""
+ }
+
+
+
Hosted URL:
+
${urlHtml}
+
+
+
Magnet:
+
${magnetHtml}
+
+
+
+
+
+
+
Description
+ ${descriptionHtml}
+
+
+
+
+
- Mode
+ - ${escape(version.mode || "live")}
+
+
+
- d tag
+ -
+ ${
+ dTagValue
+ ? `
${escape(
+ dTagValue
+ )}`
+ : 'Not provided'
+ }
+
+
+
+
- videoRootId
+ -
+ ${
+ rootDisplay
+ ? `
${rootDisplay}`
+ : 'Not provided'
+ }
+
+
+
+
- Event ID
+ -
+ ${
+ eventDisplay
+ ? `
${eventDisplay}`
+ : 'Unknown'
+ }
+
+
+
+
+ `;
+ }
+
+ 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 toggleDisabledStyles = (button) => {
+ if (!button) {
+ return;
+ }
+ button.disabled = this.busy;
+ button.classList.toggle("opacity-60", this.busy);
+ button.classList.toggle("cursor-not-allowed", this.busy);
+ };
+
+ toggleDisabledStyles(this.cancelButton);
+ toggleDisabledStyles(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();
+ }
+
+ 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 "";
+ }
+}
+