From 0a8f3a7b05fa9d6a09ddc108cb7be3b74c50541c Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 19 Oct 2025 22:26:12 -0400 Subject: [PATCH] Add delete modal for full video removal --- components/delete-video-modal.html | 71 +++++ js/app.js | 128 ++++++-- js/nostr.js | 128 ++++++-- js/services/nostrService.js | 5 +- js/ui/components/DeleteModal.js | 453 +++++++++++++++++++++++++++++ 5 files changed, 740 insertions(+), 45 deletions(-) create mode 100644 components/delete-video-modal.html create mode 100644 js/ui/components/DeleteModal.js diff --git a/components/delete-video-modal.html b/components/delete-video-modal.html new file mode 100644 index 00000000..5f9e83b8 --- /dev/null +++ b/components/delete-video-modal.html @@ -0,0 +1,71 @@ + +
diff --git a/js/app.js b/js/app.js index 16195666..14514290 100644 --- a/js/app.js +++ b/js/app.js @@ -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( diff --git a/js/nostr.js b/js/nostr.js index e70a27d1..1c004e80 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -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; diff --git a/js/services/nostrService.js b/js/services/nostrService.js index ce053751..e8d80107 100644 --- a/js/services/nostrService.js +++ b/js/services/nostrService.js @@ -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; } diff --git a/js/ui/components/DeleteModal.js b/js/ui/components/DeleteModal.js new file mode 100644 index 00000000..d3ef17ac --- /dev/null +++ b/js/ui/components/DeleteModal.js @@ -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;