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(thumbnailAlt)} -
-
-
-

${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(thumbnailAlt)} +
+
+
+

${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 ""; + } +} +