Add delete modal for full video removal

This commit is contained in:
thePR0M3TH3AN committed 2025-10-19 22:26:12 -04:00
1 parent f4ebb255a2
commit 0a8f3a7b05
5 files changed
+740 -45

No files matched your search

+71
View File
@@ -0,0 +1,71 @@
<!-- components/delete-video-modal.html -->
<div
id="deleteVideoModal"
class="bv-modal hidden items-start justify-center md:items-center"
role="dialog"
aria-modal="true"
aria-labelledby="deleteModalTitle"
aria-describedby="deleteModalDescription"
data-ds="new"
>
<div
id="deleteVideoModalOverlay"
class="bv-modal-backdrop"
data-dismiss
></div>
<div class="modal-sheet max-h-modal-shell w-full max-w-lg overflow-y-auto" tabindex="-1">
<div class="modal-header">
<div class="flex flex-col">
<h2 id="deleteModalTitle" class="text-xl font-bold text-text">Delete video</h2>
<p id="deleteModalSubtitle" class="text-xs text-muted">
This action removes the note from every feed.
</p>
</div>
<button
id="closeDeleteVideoModal"
class="modal-close"
type="button"
aria-label="Close delete modal"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6"
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="modal-body space-y-6">
<div class="rounded-md border border-critical/40 bg-critical/10 p-4 text-sm text-critical">
<p class="font-semibold">Deleting publishes tombstones for every revision.</p>
<p class="mt-2 text-xs">
Relays and clients will treat the note as deleted. This cannot be undone.
</p>
</div>
<div class="space-y-4 text-sm text-text">
<p id="deleteModalDescription">
Are you sure you want to delete this video? Viewers will no longer see it in any grid.
</p>
<dl id="deleteModalMetadata" class="grid gap-3 text-xs text-muted sm:grid-cols-2"></dl>
</div>
</div>
<div class="modal-footer flex flex-col gap-3 sm:flex-row sm:justify-end">
<button type="button" id="cancelDeleteVideo" class="btn-ghost w-full sm:w-auto">Cancel</button>
<button
type="button"
id="confirmDeleteVideo"
class="btn w-full sm:w-auto"
data-variant="critical"
>
Delete all versions
</button>
</div>
</div>
</div>
+110 -18
View File
@@ -72,6 +72,7 @@ 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 { DeleteModal } from "./ui/components/DeleteModal.js";
import {
prepareStaticModal,
openStaticModal,
@@ -204,6 +205,8 @@ class Application {
this.boundEditModalSubmitHandler = null;
this.boundEditModalCancelHandler = null;
this.boundRevertConfirmHandler = null;
this.boundDeleteConfirmHandler = null;
this.boundDeleteCancelHandler = null;
this.boundVideoModalCloseHandler = null;
this.boundVideoModalCopyHandler = null;
this.boundVideoModalShareHandler = null;
@@ -215,6 +218,7 @@ class Application {
this.videoListViewEditHandler = null;
this.videoListViewRevertHandler = null;
this.videoListViewDeleteHandler = null;
this.deleteModal = null;
this.moreMenuController = null;
this.latestFeedMetadata = null;
this.lastModalTrigger = null;
@@ -485,6 +489,35 @@ class Application {
this.boundRevertConfirmHandler
);
const deleteModalEvents = new EventTarget();
this.deleteModal =
(typeof ui.deleteModal === "function"
? ui.deleteModal({ app: this, eventTarget: deleteModalEvents })
: ui.deleteModal) ||
new DeleteModal({
removeTrackingScripts,
setGlobalModalState,
truncateMiddle,
container: document.getElementById("modalContainer") || null,
eventTarget: deleteModalEvents,
});
this.boundDeleteConfirmHandler = (event) => {
this.handleDeleteModalConfirm(event);
};
this.deleteModal.addEventListener(
"video:delete-confirm",
this.boundDeleteConfirmHandler
);
this.boundDeleteCancelHandler = () => {
this.showError("");
};
this.deleteModal.addEventListener(
"video:delete-cancel",
this.boundDeleteCancelHandler
);
try {
const profileModalContainer = document.getElementById("modalContainer") || null;
if (profileModalContainer) {
@@ -5967,12 +6000,58 @@ class Application {
}
}
async handleDeleteModalConfirm(event) {
const detail = event?.detail || {};
const targetVideo = detail.video || this.deleteModal?.activeVideo || null;
if (!targetVideo) {
return;
}
if (!this.pubkey) {
this.showError("Please login to delete videos.");
return;
}
if (!this.deleteModal) {
this.showError("Delete modal is not available right now.");
return;
}
const rootId = targetVideo.videoRootId || targetVideo.id || "";
if (!rootId) {
this.showError("Unable to determine the video root for deletion.");
return;
}
try {
this.deleteModal.setBusy(true, "Deleting…");
await this.nostrService.handleFullDeleteVideo({
videoRootId: rootId,
video: targetVideo,
pubkey: this.pubkey,
confirm: false,
});
await this.loadVideos();
this.showSuccess("All versions deleted successfully!");
this.deleteModal.setBusy(false);
this.deleteModal.close();
this.forceRefreshAllProfiles();
} catch (err) {
devLogger.error("Failed to delete all versions:", err);
this.showError("Failed to delete all versions. Please try again.");
this.deleteModal.setBusy(false);
}
}
/**
* Handle "Delete Video" from gear menu.
*/
async handleFullDeleteVideo(target) {
try {
const normalizedTarget = this.normalizeActionTarget(target);
const { triggerElement } = normalizedTarget;
const all = this.nostrService.getFilteredActiveVideos({
blacklistedEventIds: this.blacklistedEventIds,
isAuthorBlocked: (pubkey) => this.isAuthorBlocked(pubkey),
@@ -5992,28 +6071,19 @@ class Application {
this.showError("You do not own this video.");
return;
}
// Make sure the user is absolutely sure:
if (
!confirm(
`Delete ALL versions of "${video.title}"? This action is permanent.`
)
) {
if (!this.deleteModal) {
this.showError("Delete modal is not available right now.");
return;
}
// We assume video.videoRootId is not empty, or fallback to video.id if needed
const rootId = video.videoRootId || video.id;
const loaded = await this.deleteModal.load();
if (!loaded) {
this.showError("Delete modal is not available right now.");
return;
}
await this.nostrService.handleFullDeleteVideo({
videoRootId: rootId,
pubkey: this.pubkey,
confirm: false,
});
// Reload
await this.loadVideos();
this.showSuccess("All versions deleted successfully!");
this.forceRefreshAllProfiles();
this.deleteModal.setVideo(video);
this.deleteModal.open({ video }, { triggerElement });
} catch (err) {
devLogger.error("Failed to delete all versions:", err);
this.showError("Failed to delete all versions. Please try again.");
@@ -7638,6 +7708,28 @@ class Application {
}
}
if (this.deleteModal && this.boundDeleteConfirmHandler) {
this.deleteModal.removeEventListener(
"video:delete-confirm",
this.boundDeleteConfirmHandler
);
this.boundDeleteConfirmHandler = null;
}
if (this.deleteModal && this.boundDeleteCancelHandler) {
this.deleteModal.removeEventListener(
"video:delete-cancel",
this.boundDeleteCancelHandler
);
this.boundDeleteCancelHandler = null;
}
if (typeof this.deleteModal?.destroy === "function") {
try {
this.deleteModal.destroy();
} catch (error) {
devLogger.warn("[Application] Failed to destroy delete modal:", error);
}
}
if (this.videoModal) {
if (this.boundVideoModalCloseHandler) {
this.videoModal.removeEventListener(
+103 -25
View File
@@ -6906,6 +6906,8 @@ export class NostrClient {
}
const shouldConfirm = options?.confirm !== false;
const targetVideo =
options && typeof options.video === "object" ? options.video : null;
let confirmed = true;
if (shouldConfirm && typeof window?.confirm === "function") {
@@ -6919,43 +6921,119 @@ export class NostrClient {
return null; // Cancel deletion if user clicks "Cancel"
}
// 1) Find all events in our local allEvents that share the same root.
const matchingEvents = [];
for (const [id, vid] of this.allEvents.entries()) {
if (
vid.videoRootId === videoRootId &&
vid.pubkey === pubkey &&
!vid.deleted
) {
matchingEvents.push(vid);
const normalizedPubkey = typeof pubkey === "string" ? pubkey.toLowerCase() : "";
const normalizedRootInput =
typeof videoRootId === "string" && videoRootId.trim().length
? videoRootId.trim()
: "";
const inferredRoot =
normalizedRootInput ||
(targetVideo && typeof targetVideo.videoRootId === "string"
? targetVideo.videoRootId.trim()
: "");
const targetDTag = targetVideo ? getDTagValueFromTags(targetVideo.tags) : "";
if (targetVideo) {
try {
await this.hydrateVideoHistory(targetVideo);
} catch (error) {
devLogger.warn(
"[nostr] Failed to hydrate video history before delete:",
error
);
}
}
if (!matchingEvents.length) {
const matchingEvents = new Map();
for (const candidate of this.allEvents.values()) {
if (!candidate || typeof candidate !== "object") {
continue;
}
const candidatePubkey =
typeof candidate.pubkey === "string" ? candidate.pubkey.toLowerCase() : "";
if (!candidatePubkey || candidatePubkey !== normalizedPubkey) {
continue;
}
if (candidate.deleted === true) {
continue;
}
const candidateRoot =
typeof candidate.videoRootId === "string" ? candidate.videoRootId : "";
const candidateDTag = getDTagValueFromTags(candidate.tags);
const sameRoot = inferredRoot && candidateRoot === inferredRoot;
const sameD = targetDTag && candidateDTag === targetDTag;
const legacyMatch =
!inferredRoot &&
targetVideo &&
candidateRoot &&
candidateRoot === targetVideo.id;
const directMatch = targetVideo && candidate.id === targetVideo.id;
if (!sameRoot && !sameD && !legacyMatch && !directMatch) {
continue;
}
matchingEvents.set(candidate.id, candidate);
}
if (targetVideo && !matchingEvents.has(targetVideo.id)) {
matchingEvents.set(targetVideo.id, targetVideo);
}
if (!matchingEvents.size) {
throw new Error("No existing events found for that root.");
}
// 2) For each event, create a "revert" event to mark it as deleted.
// This will prompt the user (via the extension) to sign the deletion.
for (const vid of matchingEvents) {
for (const vid of matchingEvents.values()) {
const baseRoot =
(typeof vid.videoRootId === "string" && vid.videoRootId) ||
inferredRoot ||
(targetVideo && typeof targetVideo.videoRootId === "string"
? targetVideo.videoRootId
: "") ||
(targetVideo ? targetVideo.id : "") ||
vid.id;
const contentPayload = {
version: Number.isFinite(vid.version) ? vid.version : 3,
deleted: vid.deleted === true,
isPrivate: vid.isPrivate === true,
isNsfw: vid.isNsfw === true,
isForKids: vid.isForKids === true && vid.isNsfw !== true,
title: typeof vid.title === "string" ? vid.title : "",
url: typeof vid.url === "string" ? vid.url : "",
magnet: typeof vid.magnet === "string" ? vid.magnet : "",
thumbnail: typeof vid.thumbnail === "string" ? vid.thumbnail : "",
description: typeof vid.description === "string" ? vid.description : "",
mode: typeof vid.mode === "string" ? vid.mode : "live",
videoRootId: baseRoot,
};
await this.revertVideo(
{
id: vid.id,
pubkey: vid.pubkey,
content: JSON.stringify({
version: vid.version,
deleted: vid.deleted,
isPrivate: vid.isPrivate,
title: vid.title,
url: vid.url,
magnet: vid.magnet,
thumbnail: vid.thumbnail,
description: vid.description,
mode: vid.mode,
}),
tags: vid.tags,
content: JSON.stringify(contentPayload),
tags: Array.isArray(vid.tags) ? vid.tags : [],
},
pubkey
);
const cached = this.allEvents.get(vid.id) || vid;
cached.deleted = true;
cached.url = "";
cached.magnet = "";
cached.thumbnail = "";
cached.description = "This version was deleted by the creator.";
cached.videoRootId = baseRoot;
this.allEvents.set(vid.id, cached);
const activeKey = getActiveKey(cached);
if (activeKey) {
this.activeMap.delete(activeKey);
}
}
return true;
+3 -2
View File
@@ -639,12 +639,13 @@ export class NostrService {
return result;
}
async handleFullDeleteVideo({ videoRootId, pubkey, confirm = true } = {}) {
async handleFullDeleteVideo({ videoRootId, video, pubkey, confirm = true } = {}) {
const result = await this.nostrClient.deleteAllVersions(videoRootId, pubkey, {
confirm,
video,
});
if (result) {
this.emit("videos:deleted", { videoRootId, pubkey });
this.emit("videos:deleted", { videoRootId, video, pubkey });
}
return result;
}
+453
View File
@@ -0,0 +1,453 @@
import { createModalAccessibility } from "./modalAccessibility.js";
function extractDTagValue(tags) {
if (!Array.isArray(tags)) {
return "";
}
for (const entry of tags) {
if (!Array.isArray(entry) || entry.length < 2) {
continue;
}
if (entry[0] === "d" && typeof entry[1] === "string") {
return entry[1];
}
}
return "";
}
export class DeleteModal {
constructor({
removeTrackingScripts,
setGlobalModalState,
truncateMiddle,
eventTarget,
container,
} = {}) {
this.removeTrackingScripts =
typeof removeTrackingScripts === "function" ? removeTrackingScripts : () => {};
this.setGlobalModalState =
typeof setGlobalModalState === "function" ? setGlobalModalState : () => {};
this.truncateMiddle =
typeof truncateMiddle === "function" ? truncateMiddle : (value, maxLength = 48) => {
if (typeof value !== "string") {
return "";
}
if (value.length <= maxLength) {
return value;
}
if (maxLength <= 1) {
return value.slice(0, maxLength);
}
const ellipsis = "…";
const charsToShow = maxLength - ellipsis.length;
const front = Math.ceil(charsToShow / 2);
const back = Math.floor(charsToShow / 2);
return `${value.slice(0, front)}${ellipsis}${value.slice(value.length - back)}`;
};
this.eventTarget = eventTarget instanceof EventTarget ? eventTarget : new EventTarget();
this.container = container || document.getElementById("modalContainer") || null;
this.modal = null;
this.overlay = null;
this.modalPanel = null;
this.title = null;
this.subtitle = null;
this.description = null;
this.metadataList = null;
this.confirmButton = null;
this.cancelButton = null;
this.closeButton = null;
this.modalAccessibility = null;
this.activeVideo = null;
this.context = null;
this.busy = false;
this.bound = false;
this.confirmDefaultLabel = "Delete all versions";
this.boundHandlers = {
confirm: (event) => this.handleConfirmClick(event),
cancel: (event) => this.handleCancelInteraction(event),
overlay: (event) => this.handleCancelInteraction(event),
};
}
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 this.modal;
}
const targetContainer = this.container || document.getElementById("modalContainer");
if (!targetContainer) {
throw new Error("Modal container element not found!");
}
let modal = targetContainer.querySelector("#deleteVideoModal");
if (!modal) {
const response = await fetch("components/delete-video-modal.html");
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const html = await response.text();
const wrapper = document.createElement("div");
wrapper.innerHTML = html;
this.removeTrackingScripts(wrapper);
targetContainer.appendChild(wrapper);
modal = wrapper.querySelector("#deleteVideoModal");
}
if (!modal) {
throw new Error("Delete video modal markup missing after load.");
}
this.cacheElements(modal);
this.setupModalAccessibility();
this.bindEvents();
this.reset();
return this.modal;
}
cacheElements(modal) {
this.modal = modal;
this.overlay = modal.querySelector("#deleteVideoModalOverlay") || null;
this.modalPanel = modal.querySelector(".modal-sheet") || modal;
this.title = modal.querySelector("#deleteModalTitle") || null;
this.subtitle = modal.querySelector("#deleteModalSubtitle") || null;
this.description = modal.querySelector("#deleteModalDescription") || null;
this.metadataList = modal.querySelector("#deleteModalMetadata") || null;
this.confirmButton = modal.querySelector("#confirmDeleteVideo") || null;
this.cancelButton = modal.querySelector("#cancelDeleteVideo") || null;
this.closeButton = modal.querySelector("#closeDeleteVideoModal") || null;
if (this.confirmButton) {
const text = this.confirmButton.textContent?.trim();
if (text) {
this.confirmDefaultLabel = text;
}
}
}
setupModalAccessibility() {
if (!this.modal) {
return;
}
this.modalAccessibility = createModalAccessibility({
root: this.modal,
panel: this.modalPanel || this.modal,
backdrop: this.overlay,
onRequestClose: () => this.handleCancelInteraction(),
});
}
bindEvents() {
if (this.bound || !this.modal) {
return;
}
if (this.confirmButton) {
this.confirmButton.addEventListener("click", this.boundHandlers.confirm);
}
if (this.cancelButton) {
this.cancelButton.addEventListener("click", this.boundHandlers.cancel);
}
if (this.closeButton) {
this.closeButton.addEventListener("click", this.boundHandlers.cancel);
}
if (this.overlay) {
this.overlay.addEventListener("click", this.boundHandlers.overlay);
}
this.bound = true;
}
open(context = {}, { triggerElement } = {}) {
if (!this.modal) {
throw new Error("Delete modal has not been loaded.");
}
this.context = context || {};
this.modal.classList.remove("hidden");
this.modalAccessibility?.activate({ triggerElement });
this.setGlobalModalState("deleteVideo", true);
this.dispatch("video:delete-open", {
context: this.context,
video: this.activeVideo,
});
}
close() {
if (!this.modal) {
return;
}
const detail = {
context: this.context,
video: this.activeVideo,
};
this.modal.classList.add("hidden");
this.modalAccessibility?.deactivate();
this.setGlobalModalState("deleteVideo", false);
this.dispatch("video:delete-close", detail);
this.reset();
}
setVideo(video) {
this.activeVideo = video || null;
if (!this.modal) {
return;
}
const titleText =
video && typeof video.title === "string" && video.title.trim().length
? `Delete “${video.title.trim()}”`
: "Delete video";
if (this.title) {
this.title.textContent = titleText;
}
const rootId =
video && typeof video.videoRootId === "string" && video.videoRootId.trim().length
? video.videoRootId.trim()
: "";
const dTagValue = extractDTagValue(video?.tags);
if (this.subtitle) {
const subtitleParts = [];
if (rootId) {
subtitleParts.push(`Root ${this.truncateMiddle(rootId, 48)}`);
}
if (dTagValue) {
subtitleParts.push(`d=${this.truncateMiddle(dTagValue, 48)}`);
}
this.subtitle.textContent = subtitleParts.length
? `Marks ${subtitleParts.join(" • ")} as deleted across relays.`
: "This action removes the note from every feed.";
}
if (this.description) {
const hasTitle =
video && typeof video.title === "string" && video.title.trim().length > 0;
this.description.textContent = hasTitle
? `Deleting will mark every revision of “${video.title.trim()}” as deleted and hide it from viewers.`
: "Deleting will mark every revision as deleted and hide it from viewers.";
}
this.renderMetadata(video, { rootId, dTagValue });
}
renderMetadata(video, { rootId, dTagValue } = {}) {
if (!this.metadataList) {
return;
}
const doc = this.metadataList.ownerDocument;
this.metadataList.innerHTML = "";
if (!doc) {
return;
}
const entries = [];
const scopeMessage = rootId
? "All revisions sharing this root will be tombstoned."
: "All revisions linked to this note will be tombstoned.";
entries.push({
label: "Scope",
value: scopeMessage,
span: true,
});
if (rootId) {
entries.push({
label: "Video root",
value: this.truncateMiddle(rootId, 60),
});
}
if (dTagValue) {
entries.push({
label: "d tag",
value: this.truncateMiddle(dTagValue, 60),
});
}
const author =
video && typeof video.pubkey === "string" && video.pubkey.trim().length
? video.pubkey.trim()
: "";
if (author) {
entries.push({
label: "Author",
value: this.truncateMiddle(author, 60),
});
}
const version = Number.isFinite(video?.version) ? video.version : null;
if (version !== null) {
entries.push({
label: "Active version",
value: `${version}`,
});
}
const visibility = video?.isPrivate === true ? "Private" : "Public";
entries.push({
label: "Visibility",
value: visibility,
});
for (const entry of entries) {
const wrapper = doc.createElement("div");
wrapper.className = entry.span === true ? "space-y-1 sm:col-span-2" : "space-y-1";
const dt = doc.createElement("dt");
dt.className = "text-2xs font-semibold uppercase tracking-wide text-muted-strong";
dt.textContent = entry.label;
const dd = doc.createElement("dd");
dd.className = "text-xs text-muted";
dd.textContent = entry.value;
wrapper.appendChild(dt);
wrapper.appendChild(dd);
this.metadataList.appendChild(wrapper);
}
}
setBusy(isBusy, label = "Deleting…") {
this.busy = isBusy === true;
if (this.confirmButton) {
this.confirmButton.disabled = this.busy;
if (this.busy) {
this.confirmButton.textContent = label;
this.confirmButton.dataset.state = "pending";
} else {
this.confirmButton.textContent = this.confirmDefaultLabel;
this.confirmButton.dataset.state = "";
delete this.confirmButton.dataset.state;
}
}
if (this.cancelButton) {
this.cancelButton.disabled = this.busy;
}
if (this.closeButton) {
this.closeButton.disabled = this.busy;
}
if (this.modalPanel) {
this.modalPanel.setAttribute("aria-busy", this.busy ? "true" : "false");
}
}
handleConfirmClick(event) {
if (event) {
event.preventDefault();
}
if (this.busy) {
return;
}
this.dispatch("video:delete-confirm", {
video: this.activeVideo,
context: this.context,
});
}
handleCancelInteraction(event) {
if (event) {
event.preventDefault();
}
if (this.busy) {
return;
}
this.dispatch("video:delete-cancel", {
video: this.activeVideo,
context: this.context,
});
this.close();
}
reset() {
this.activeVideo = null;
this.context = null;
this.busy = false;
if (this.confirmButton) {
this.confirmButton.disabled = false;
this.confirmButton.textContent = this.confirmDefaultLabel;
delete this.confirmButton.dataset.state;
}
if (this.cancelButton) {
this.cancelButton.disabled = false;
}
if (this.closeButton) {
this.closeButton.disabled = false;
}
if (this.modalPanel) {
this.modalPanel.setAttribute("aria-busy", "false");
}
if (this.subtitle) {
this.subtitle.textContent = "This action removes the note from every feed.";
}
if (this.description) {
this.description.textContent =
"Are you sure you want to delete this video? Viewers will no longer see it in any grid.";
}
if (this.metadataList) {
this.metadataList.innerHTML = "";
}
}
destroy() {
if (!this.modal) {
return;
}
if (this.confirmButton) {
this.confirmButton.removeEventListener("click", this.boundHandlers.confirm);
}
if (this.cancelButton) {
this.cancelButton.removeEventListener("click", this.boundHandlers.cancel);
}
if (this.closeButton) {
this.closeButton.removeEventListener("click", this.boundHandlers.cancel);
}
if (this.overlay) {
this.overlay.removeEventListener("click", this.boundHandlers.overlay);
}
this.modalAccessibility?.destroy();
this.modalAccessibility = null;
this.bound = false;
this.modal = null;
}
}
export default DeleteModal;