Merge pull request #129 from PR0M3TH3AN/codex/add-revert-modal-for-note-versions

Add revert history modal for video notes
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2025-09-27 22:07:36 -04:00
commit 3da14ee174
3 files changed
+1076 -22

No files matched your search

+121
View File
@@ -0,0 +1,121 @@
<!-- components/revert-video-modal.html -->
<div
id="revertVideoModal"
class="fixed inset-0 z-50 hidden"
style="background: transparent"
>
<div
id="revertVideoModalOverlay"
class="absolute inset-0 z-10"
style="
background-color: rgba(0, 0, 0, 0.9);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
"
></div>
<div
class="relative modal-container h-full w-full flex items-start md:items-center justify-center overflow-y-auto z-20"
>
<div
class="modal-content bg-gray-900 w-full max-w-[95%] lg:max-w-5xl my-0 rounded-lg overflow-hidden relative"
style="max-height: 92vh"
>
<div
class="sticky top-0 bg-gradient-to-b from-black/80 to-transparent transition-transform duration-150 p-4 flex items-center justify-between"
>
<div class="flex flex-col">
<h2 id="revertModalTitle" class="text-xl font-bold text-white">
Revert Video Note
</h2>
<p id="revertModalSubtitle" class="text-xs text-gray-400">
Review previous versions before restoring an older state.
</p>
</div>
<button
id="closeRevertVideoModal"
class="flex items-center justify-center w-10 h-10 rounded-full bg-black/50 hover:bg-black/70 transition-all duration-200 backdrop-blur focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-black focus:ring-blue-500"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-6 h-6 text-gray-300"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<div class="p-6 space-y-6 overflow-y-auto" style="max-height: calc(92vh - 4rem)">
<div class="rounded-md border border-gray-700 bg-gray-800/60 p-4 text-sm text-gray-300">
<p>
Selecting an older version will mark any newer edits as reverted.
The previous revision will become the active note on the relays.
</p>
<p class="mt-2 text-xs text-gray-400">
Versions are grouped by their <code class="px-1 py-0.5 bg-gray-900/60 rounded border border-gray-700 text-[0.7rem]">d</code>
tag or shared <code class="px-1 py-0.5 bg-gray-900/60 rounded border border-gray-700 text-[0.7rem]">videoRootId</code>. This ensures legacy
clients see consistent history when multiple edits were published.
</p>
</div>
<div class="grid gap-6 lg:grid-cols-[minmax(240px,280px)_1fr]">
<aside class="space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-gray-200">Revision history</h3>
<span
id="revertHistoryCount"
class="rounded-full bg-gray-800 px-2 py-0.5 text-xs text-gray-400"
></span>
</div>
<div
id="revertVersionsList"
class="space-y-2 overflow-y-auto pr-1"
style="max-height: 48vh"
></div>
</aside>
<section
id="revertVersionDetails"
class="rounded-lg border border-gray-800 bg-gray-900/70 p-4 text-sm text-gray-200"
aria-live="polite"
>
<div id="revertVersionPlaceholder" class="flex h-full items-center justify-center text-center text-gray-500">
Select an earlier revision to inspect its metadata.
</div>
</section>
</div>
<p
id="revertSelectionStatus"
class="text-xs text-gray-400"
></p>
<div class="flex flex-col gap-3 sm:flex-row sm:justify-end">
<button
type="button"
id="cancelRevertVideo"
class="w-full sm:w-auto px-4 py-2 rounded-md bg-gray-800 text-gray-200 hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-900 focus:ring-gray-500"
>
Cancel
</button>
<button
type="button"
id="confirmRevertVideo"
class="w-full sm:w-auto px-4 py-2 rounded-md bg-red-600 text-white hover:bg-red-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-900 focus:ring-red-500 disabled:opacity-60 disabled:cursor-not-allowed"
disabled
>
Revert to selected version
</button>
</div>
</div>
</div>
</div>
</div>
+827 -22
View File
@@ -549,6 +549,27 @@ class bitvidApp {
this.editVideoFieldButtons = [];
this.activeEditVideo = null;
// 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 = [];
// Optional small inline player stats
this.status = document.getElementById("status") || null;
this.progressBar = document.getElementById("progress") || null;
@@ -1424,6 +1445,779 @@ class bitvidApp {
this.resetEditVideoForm();
}
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");
}
}
hideRevertVideoModal() {
if (this.revertVideoModal) {
this.revertVideoModal.classList.add("hidden");
}
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 =
'<p class="text-xs text-gray-500">No revisions found.</p>';
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 = `
<div class="flex items-start justify-between gap-3">
<div class="space-y-1">
<p class="font-semibold">${this.escapeHTML(
entry.title || "Untitled"
)}</p>
<p class="text-xs text-gray-300">${this.escapeHTML(
relative
)} • ${this.escapeHTML(absolute)}</p>
${
meta
? `<p class="text-xs ${metaClass}">${this.escapeHTML(meta)}</p>`
: ""
}
</div>
<div class="text-xs uppercase tracking-wide text-gray-400">
${this.escapeHTML(versionLabel)}
</div>
</div>
`;
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 = '<span class="text-gray-500">None</span>';
if (url) {
const safeUrl = this.escapeHTML(url);
const displayUrl = this.escapeHTML(truncateMiddle(url, 72));
urlHtml = `<a href="${safeUrl}" target="_blank" rel="noopener noreferrer" class="text-blue-400 hover:text-blue-300 break-all">${displayUrl}</a>`;
}
let magnetHtml = '<span class="text-gray-500">None</span>';
if (displayMagnet) {
const label = this.escapeHTML(truncateMiddle(displayMagnet, 72));
const caption = isPrivate
? '<span class="block text-xs text-purple-200/90 mt-1">Encrypted magnet (only decrypted locally for the owner).</span>'
: "";
magnetHtml = `<div class="break-all">${label}${caption}</div>`;
}
const chips = [];
if (version.deleted) {
chips.push(
'<span class="inline-flex items-center rounded-full border border-red-700/70 bg-red-900/40 px-2 py-0.5 text-xs text-red-200/90">Marked deleted</span>'
);
}
if (isPrivate) {
chips.push(
'<span class="inline-flex items-center rounded-full border border-purple-600/60 bg-purple-900/40 px-2 py-0.5 text-xs text-purple-200/90">Private</span>'
);
}
if (version.version !== undefined) {
chips.push(
`<span class="inline-flex items-center rounded-full border border-gray-700 bg-gray-800/80 px-2 py-0.5 text-xs text-gray-200">Schema v${this.escapeHTML(
String(version.version)
)}</span>`
);
}
const descriptionHtml = description
? `<p class="whitespace-pre-wrap text-gray-200">${this.escapeHTML(
description
)}</p>`
: '<p class="text-gray-500">No description provided.</p>';
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 = `
<div class="space-y-4">
<div class="flex flex-col gap-4 lg:flex-row lg:items-start">
<div class="overflow-hidden rounded-md border border-gray-800 bg-black/40 w-full max-w-sm">
<img
src="${thumbnailSrc}"
alt="${this.escapeHTML(thumbnailAlt)}"
class="w-full h-auto object-cover"
loading="lazy"
/>
</div>
<div class="flex-1 space-y-3">
<div class="space-y-1">
<h3 class="text-lg font-semibold text-white">${this.escapeHTML(
version.title || "Untitled"
)}</h3>
<p class="text-xs text-gray-400">${this.escapeHTML(
absolute
)} (${this.escapeHTML(relative)})</p>
</div>
${
chips.length
? `<div class="flex flex-wrap gap-2">${chips.join("")}</div>`
: ""
}
<div class="space-y-2 text-sm text-gray-200">
<div>
<span class="font-medium text-gray-300">Hosted URL:</span>
<div class="mt-1">${urlHtml}</div>
</div>
<div>
<span class="font-medium text-gray-300">Magnet:</span>
<div class="mt-1">${magnetHtml}</div>
</div>
</div>
</div>
</div>
<div class="space-y-2">
<h4 class="text-sm font-semibold text-gray-200">Description</h4>
${descriptionHtml}
</div>
<dl class="grid gap-3 sm:grid-cols-2 text-xs text-gray-300">
<div>
<dt class="font-semibold text-gray-200">Mode</dt>
<dd class="mt-1">${this.escapeHTML(version.mode || "live")}</dd>
</div>
<div>
<dt class="font-semibold text-gray-200">d tag</dt>
<dd class="mt-1">
${dTagValue
? `<code class="rounded bg-gray-800/80 px-1.5 py-0.5">${this.escapeHTML(
dTagValue
)}</code>`
: '<span class="text-gray-500">Not provided</span>'}
</dd>
</div>
<div>
<dt class="font-semibold text-gray-200">videoRootId</dt>
<dd class="mt-1">
${rootDisplay
? `<code class="break-all rounded bg-gray-800/80 px-1.5 py-0.5" title="${this.escapeHTML(
rootId
)}">${rootDisplay}</code>`
: '<span class="text-gray-500">Not provided</span>'}
</dd>
</div>
<div>
<dt class="font-semibold text-gray-200">Event ID</dt>
<dd class="mt-1">
${eventDisplay
? `<code class="break-all rounded bg-gray-800/80 px-1.5 py-0.5" title="${this.escapeHTML(
version.id || ""
)}">${eventDisplay}</code>`
: '<span class="text-gray-500">Unknown</span>'}
</dd>
</div>
</dl>
</div>
`;
}
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 "";
}
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 "";
}
populateEditVideoForm(video) {
if (!video || !this.editVideoModal) {
return;
@@ -4691,33 +5485,21 @@ class bitvidApp {
return;
}
// 2) Grab all known events so older overshadowed ones are included
const allEvents = Array.from(nostrClient.allEvents.values());
// 3) Check for older versions among *all* events, not just the active ones
if (!this.hasOlderVersion(video, allEvents)) {
this.showError("No older version exists to revert to.");
return;
if (!this.revertVideoModal) {
const initialized = await this.initRevertVideoModal();
if (!initialized || !this.revertVideoModal) {
this.showError("Revert modal is not available right now.");
return;
}
}
if (!confirm(`Revert current version of "${video.title}"?`)) {
return;
}
const history = await nostrClient.hydrateVideoHistory(video);
const originalEvent = {
id: video.id,
pubkey: video.pubkey,
tags: video.tags,
};
await nostrClient.revertVideo(originalEvent, this.pubkey);
await this.loadVideos();
this.showSuccess("Current version reverted successfully!");
this.forceRefreshAllProfiles();
this.populateRevertVideoModal(video, history);
this.showRevertVideoModal();
} catch (err) {
console.error("Failed to revert video:", err);
this.showError("Failed to revert video. Please try again.");
this.showError("Failed to load revision history. Please try again.");
}
}
@@ -5748,6 +6530,29 @@ class bitvidApp {
/**
* Format "time ago" for a given timestamp (in seconds).
*/
formatAbsoluteTimestamp(timestamp) {
if (!Number.isFinite(timestamp)) {
return "Unknown date";
}
const date = new Date(timestamp * 1000);
if (Number.isNaN(date.getTime())) {
return "Unknown date";
}
try {
return date.toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
} catch (err) {
return date.toISOString();
}
}
formatTimeAgo(timestamp) {
const seconds = Math.floor(Date.now() / 1000 - timestamp);
const intervals = {
+128
View File
@@ -1208,6 +1208,134 @@ class NostrClient {
return null;
}
/**
* Ensure we have every historical revision for a given video in memory and
* return the complete set sorted newest-first. We primarily group revisions
* by their shared `videoRootId`, but fall back to the NIP-33 `d` tag when
* working with legacy notes. The explicit `d` tag fetch is important because
* relays cannot be queried by values that only exist inside the JSON
* content. Without this pass, the UI would occasionally miss mid-history
* edits that were published from other devices.
*/
async hydrateVideoHistory(video) {
if (!video || typeof video !== "object") {
return [];
}
const targetRoot = typeof video.videoRootId === "string" ? video.videoRootId : "";
const targetPubkey = typeof video.pubkey === "string" ? video.pubkey.toLowerCase() : "";
const findDTagValue = (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 "";
};
const targetDTag = findDTagValue(video.tags);
const collectLocalMatches = () => {
const seen = new Set();
const matches = [];
for (const candidate of this.allEvents.values()) {
if (!candidate || typeof candidate !== "object") {
continue;
}
if (targetPubkey) {
const candidatePubkey = typeof candidate.pubkey === "string"
? candidate.pubkey.toLowerCase()
: "";
if (candidatePubkey !== targetPubkey) {
continue;
}
}
const candidateRoot =
typeof candidate.videoRootId === "string" ? candidate.videoRootId : "";
const candidateDTag = findDTagValue(candidate.tags);
const sameRoot = targetRoot && candidateRoot === targetRoot;
const sameD = targetDTag && candidateDTag === targetDTag;
// Legacy fallbacks: some old posts reused only the "d" tag without a
// canonical videoRootId. If neither identifier exists we at least keep
// the active event so the caller can surface an informative message.
const sameLegacyRoot =
!targetRoot && candidateRoot && candidateRoot === video.id;
if (sameRoot || sameD || sameLegacyRoot || candidate.id === video.id) {
if (!seen.has(candidate.id)) {
seen.add(candidate.id);
matches.push(candidate);
}
}
}
return matches;
};
let localMatches = collectLocalMatches();
const shouldFetchFromRelays =
localMatches.filter((entry) => !entry.deleted).length <= 1 && targetDTag;
if (shouldFetchFromRelays && this.pool) {
const filter = {
kinds: [30078],
"#t": ["video"],
"#d": [targetDTag],
limit: 200,
};
if (targetPubkey) {
filter.authors = [video.pubkey];
}
try {
const perRelay = await Promise.all(
this.relays.map(async (url) => {
try {
const events = await this.pool.list([url], [filter]);
return events || [];
} catch (err) {
if (isDevMode) {
console.warn(`[nostr] History fetch failed on ${url}:`, err);
}
return [];
}
})
);
const merged = perRelay.flat();
for (const evt of merged) {
try {
const parsed = convertEventToVideo(evt);
if (!parsed.invalid) {
this.allEvents.set(evt.id, parsed);
}
} catch (err) {
if (isDevMode) {
console.warn("[nostr] Failed to convert historical event:", err);
}
}
}
} catch (err) {
if (isDevMode) {
console.warn("[nostr] hydrateVideoHistory relay fetch error:", err);
}
}
localMatches = collectLocalMatches();
}
return localMatches.sort((a, b) => b.created_at - a.created_at);
}
getActiveVideos() {
return Array.from(this.activeMap.values()).sort(
(a, b) => b.created_at - a.created_at