diff --git a/js/nostr/client.js b/js/nostr/client.js index 4faf16e0..053639da 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -18,6 +18,7 @@ */ import { isDevMode } from "../config.js"; +import { showConfirm } from "../ui/confirmDialog.js"; import { infoHashFromMagnet } from "../magnets.js"; // 🔧 merged conflicting changes from codex/update-video-publishing-and-parsing-logic vs unstable import { @@ -3465,9 +3466,10 @@ export class NostrClient { options && typeof options.video === "object" ? options.video : null; let confirmed = true; - if (shouldConfirm && typeof window?.confirm === "function") { - confirmed = window.confirm( - "Are you sure you want to delete all versions of this video? This action cannot be undone." + if (shouldConfirm) { + confirmed = await showConfirm( + "Are you sure you want to delete all versions of this video? This action cannot be undone.", + { confirmLabel: "Delete all", danger: true }, ); } diff --git a/js/services/settingsRestorePrompt.js b/js/services/settingsRestorePrompt.js index f1072211..306715d4 100644 --- a/js/services/settingsRestorePrompt.js +++ b/js/services/settingsRestorePrompt.js @@ -6,6 +6,7 @@ // if the user accepts. import { storageSyncService as defaultStorageSync } from "./storageSyncService.js"; +import { showConfirm } from "../ui/confirmDialog.js"; const OFFERED_KEY = "bitvid:settings-sync:offered:v1"; @@ -63,10 +64,7 @@ const ITEM_LABELS = { export function createSettingsRestorePrompt({ storageSync = defaultStorageSync, walletSync = null, - confirm = (message) => - typeof window !== "undefined" && typeof window.confirm === "function" - ? window.confirm(message) - : false, + confirm = (message) => showConfirm(message, { confirmLabel: "Restore" }), logger = null, } = {}) { // Items not already enabled / not already offered on THIS device that have a @@ -117,7 +115,7 @@ export function createSettingsRestorePrompt({ } const list = candidates.map((kind) => ITEM_LABELS[kind]).join(" and "); - const accepted = confirm( + const accepted = await confirm( `Found an encrypted copy of your ${list} on your Nostr account. ` + `Restore ${candidates.length > 1 ? "them" : "it"} to this device?` ); diff --git a/js/ui/appNotify.js b/js/ui/appNotify.js new file mode 100644 index 00000000..a36f03c2 --- /dev/null +++ b/js/ui/appNotify.js @@ -0,0 +1,34 @@ +// Single import surface for app notifications, so every alert/confirm goes through the +// site's notification system instead of native dialogs. +// +// - notifyError / notifyStatus / notifySuccess → the app's toast banners +// (NotificationController) for components/services without a local showError. +// - showConfirm → the styled promise-based confirm dialog (replaces window.confirm). +// +// Components that already receive a showError/showSuccess callback should keep using it; +// this is for modules that don't (e.g. standalone controllers/services). + +import { getApplication } from "../applicationContext.js"; +import { showConfirm } from "./confirmDialog.js"; + +function app() { + try { + return getApplication(); + } catch (error) { + return null; + } +} + +export function notifyError(message) { + app()?.showError?.(message); +} + +export function notifyStatus(message, options) { + app()?.showStatus?.(message, options); +} + +export function notifySuccess(message) { + app()?.showSuccess?.(message); +} + +export { showConfirm }; diff --git a/js/ui/components/UploadModal.js b/js/ui/components/UploadModal.js index 3e0910e3..e144adea 100644 --- a/js/ui/components/UploadModal.js +++ b/js/ui/components/UploadModal.js @@ -26,6 +26,7 @@ import { requestDefaultExtensionPermissions, } from "../../nostrClientFacade.js"; import { UI_FEEDBACK_DELAY_MS } from "../../constants.js"; +import { showConfirm } from "../confirmDialog.js"; const INFO_HASH_PATTERN = /^[a-f0-9]{40}$/; @@ -518,12 +519,12 @@ export class UploadModal { void this.captureVideoMetadata(file); if (!this.storageConfigured) { - alert("Please configure storage before selecting a file."); + this.showError("Please configure storage before selecting a file."); e.target.value = ""; // Clear selection return; } if (!this.isStorageUnlocked) { - alert("Please unlock storage before selecting a file."); + this.showError("Please unlock storage before selecting a file."); e.target.value = ""; return; } @@ -614,7 +615,7 @@ export class UploadModal { if (this.results.magnet) this.results.magnet.value = "Upload Failed"; if (this.results.torrentUrl) this.results.torrentUrl.value = "Upload Failed"; - alert(`Upload failed: ${err.message}`); + this.showError(`Upload failed: ${err.message}`); if (this.inputs.file) this.inputs.file.value = ""; // Reset } @@ -676,7 +677,7 @@ export class UploadModal { userLogger.error("Thumbnail upload failed:", err); this.thumbnailUploadState.status = 'error'; this.updateThumbnailProgress(null, "Failed."); - alert("Thumbnail upload failed."); + this.showError("Thumbnail upload failed."); } } @@ -824,7 +825,7 @@ export class UploadModal { if (this.promptStoredNsecUnlock(pubkey)) { return; } - alert("No signer available to unlock storage."); + this.showError("No signer available to unlock storage."); return; } @@ -832,7 +833,7 @@ export class UploadModal { if (signer?.type === "extension" || signer?.type === "nip07") { const permissionResult = await requestDefaultExtensionPermissions(); if (!permissionResult?.ok) { - alert("Extension permissions are required to unlock storage."); + this.showError("Extension permissions are required to unlock storage."); return; } } @@ -846,7 +847,7 @@ export class UploadModal { await this.loadFromStorage(); } catch (err) { userLogger.error("Unlock failed", err); - alert("Failed to unlock storage: " + err.message); + this.showError("Failed to unlock storage: " + err.message); } finally { if (this.toggles.storageUnlock) { this.toggles.storageUnlock.textContent = "Unlock"; @@ -945,17 +946,17 @@ export class UploadModal { // Check upload state if (this.activeSource === "upload") { if (this.videoUploadState.status === 'uploading' || this.thumbnailUploadState.status === 'uploading') { - alert("Please wait for uploads to complete."); + this.showError("Please wait for uploads to complete."); return; } if (this.videoUploadState.status === 'error') { - alert("Video upload failed. Please try again."); + this.showError("Video upload failed. Please try again."); return; } if (this.videoUploadState.status !== 'complete') { // Fallback: If no file selected, maybe they want to submit without a new file? // (Not supported in this simplified modal, assume file required) - alert("Please select a video file and wait for it to upload."); + this.showError("Please select a video file and wait for it to upload."); return; } } @@ -1104,7 +1105,7 @@ export class UploadModal { } if (!hasUrl && !hasImeta && hasMagnet) { - if (!confirm("Magnet-only uploads require active seeding. Proceed?")) return; + if (!(await showConfirm("Magnet-only uploads require active seeding. Proceed?"))) return; } await this.publish(metadata); diff --git a/js/ui/confirmDialog.js b/js/ui/confirmDialog.js new file mode 100644 index 00000000..6f821485 --- /dev/null +++ b/js/ui/confirmDialog.js @@ -0,0 +1,108 @@ +// Promise-based confirm dialog rendered with the site's modal styling, so yes/no +// confirmations live in the app's UI system instead of a native window.confirm. +// +// Importable anywhere (components AND services) — it builds its own +// `bv-modal modal-always-on-top` overlay, so it stacks above any open modal and is +// keyboard/click dismissible. Returns a Promise: true = confirmed. + +function makeButton(label, { ghost = false, danger = false } = {}) { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = ghost ? "btn-ghost focus-ring" : "btn focus-ring"; + if (danger) { + btn.setAttribute("data-variant", "danger"); + } + btn.textContent = label; + return btn; +} + +export function showConfirm(message, options = {}) { + const { + title = "", + confirmLabel = "Confirm", + cancelLabel = "Cancel", + danger = false, + } = options || {}; + + // No DOM (tests without jsdom / SSR): fall back to window.confirm, else decline. + if (typeof document === "undefined" || !document.body) { + if (typeof window !== "undefined" && typeof window.confirm === "function") { + return Promise.resolve(window.confirm(String(message || ""))); + } + return Promise.resolve(false); + } + + return new Promise((resolve) => { + const overlay = document.createElement("div"); + overlay.className = + "bv-modal modal-always-on-top items-start justify-center md:items-center"; + overlay.setAttribute("role", "dialog"); + overlay.setAttribute("aria-modal", "true"); + + const backdrop = document.createElement("div"); + backdrop.className = "bv-modal-backdrop"; + overlay.appendChild(backdrop); + + const sheet = document.createElement("div"); + sheet.className = "modal-sheet w-full max-w-md flex flex-col"; + sheet.tabIndex = -1; + overlay.appendChild(sheet); + + if (title) { + const header = document.createElement("div"); + header.className = "modal-header"; + const heading = document.createElement("h2"); + heading.className = "text-lg font-bold text-text"; + heading.textContent = title; + header.appendChild(heading); + sheet.appendChild(header); + } + + const body = document.createElement("div"); + body.className = "p-5"; + const text = document.createElement("p"); + text.className = "text-sm text-muted-strong whitespace-pre-line"; + text.textContent = String(message || ""); + body.appendChild(text); + sheet.appendChild(body); + + const footer = document.createElement("div"); + footer.className = + "flex items-center justify-end gap-3 border-t border-border/60 p-4"; + const cancelBtn = makeButton(cancelLabel, { ghost: true }); + const confirmBtn = makeButton(confirmLabel, { danger }); + footer.appendChild(cancelBtn); + footer.appendChild(confirmBtn); + sheet.appendChild(footer); + + let settled = false; + const cleanup = (result) => { + if (settled) { + return; + } + settled = true; + document.removeEventListener("keydown", onKeydown, true); + overlay.remove(); + resolve(result); + }; + function onKeydown(event) { + if (event.key === "Escape") { + event.preventDefault(); + cleanup(false); + } else if (event.key === "Enter" && document.activeElement === confirmBtn) { + cleanup(true); + } + } + + cancelBtn.addEventListener("click", () => cleanup(false)); + backdrop.addEventListener("click", () => cleanup(false)); + confirmBtn.addEventListener("click", () => cleanup(true)); + document.addEventListener("keydown", onKeydown, true); + + document.body.appendChild(overlay); + // Focus the safe action by default (Cancel for destructive prompts). + (danger ? cancelBtn : confirmBtn).focus(); + }); +} + +export default showConfirm; diff --git a/js/ui/profileModal/MyVideosController.js b/js/ui/profileModal/MyVideosController.js index 2d086e8d..0496f070 100644 --- a/js/ui/profileModal/MyVideosController.js +++ b/js/ui/profileModal/MyVideosController.js @@ -6,6 +6,7 @@ import { classifyVideoHealth, isUrlUnderBase } from "./myVideosHealth.js"; import { reconcileStorage } from "./myVideosReconcile.js"; import { FEATURE_NIP71_MIRROR } from "../../constants.js"; import { nip71MirrorService } from "../../services/nip71MirrorService.js"; +import { showConfirm } from "../confirmDialog.js"; import { isMirrorEnabled, setMirrorEnabled, @@ -282,10 +283,13 @@ export class MyVideosController { if (!r2Service || typeof r2Service.deleteStorageKeys !== "function") { return; } - if (typeof window !== "undefined" && typeof window.confirm === "function") { - if (!window.confirm(`Permanently delete this file from your bucket?\n\n${key}`)) { - return; - } + if ( + !(await showConfirm(`Permanently delete this file from your bucket?\n\n${key}`, { + confirmLabel: "Delete", + danger: true, + })) + ) { + return; } let result; try { @@ -314,14 +318,13 @@ export class MyVideosController { if (!r2Service || typeof r2Service.deleteStorageKeys !== "function" || !keys.length) { return; } - if (typeof window !== "undefined" && typeof window.confirm === "function") { - if ( - !window.confirm( - `Permanently delete all ${keys.length} orphaned files from your bucket?`, - ) - ) { - return; - } + if ( + !(await showConfirm( + `Permanently delete all ${keys.length} orphaned files from your bucket?`, + { confirmLabel: "Delete all", danger: true }, + )) + ) { + return; } if (this.orphanDeleteAllBtn instanceof HTMLElement) { this.orphanDeleteAllBtn.disabled = true; diff --git a/js/ui/profileModal/ProfileRelayController.js b/js/ui/profileModal/ProfileRelayController.js index 0eeb32ba..5cb44287 100644 --- a/js/ui/profileModal/ProfileRelayController.js +++ b/js/ui/profileModal/ProfileRelayController.js @@ -1,4 +1,5 @@ import { devLogger, userLogger } from "../../utils/logger.js"; +import { showConfirm } from "../confirmDialog.js"; import { sanitizeRelayList } from "../../nostr/nip46Client.js"; const noop = () => {}; @@ -556,7 +557,7 @@ export class ProfileRelayController { reason: null, }; - const confirmed = window.confirm("Restore the recommended relay defaults?"); + const confirmed = await showConfirm("Restore the recommended relay defaults?", { confirmLabel: "Restore" }); context.confirmed = confirmed; if (!confirmed) { context.reason = "cancelled"; @@ -604,9 +605,10 @@ export class ProfileRelayController { return; } - const confirmed = window.confirm( - `Remove ${url} from your relay list?`, - ); + const confirmed = await showConfirm(`Remove ${url} from your relay list?`, { + confirmLabel: "Remove", + danger: true, + }); if (!confirmed) { return; } diff --git a/js/ui/profileModal/ProfileStorageController.js b/js/ui/profileModal/ProfileStorageController.js index 4a770de2..7f95fabf 100644 --- a/js/ui/profileModal/ProfileStorageController.js +++ b/js/ui/profileModal/ProfileStorageController.js @@ -10,6 +10,7 @@ import { getActiveSigner } from "../../nostr/client.js"; import { DEFAULT_NIP07_ENCRYPTION_METHODS } from "../../nostr/nip07Permissions.js"; import { storageSyncService } from "../../services/storageSyncService.js"; import { StorageCorsHelp } from "./storageCorsHelp.js"; +import { showConfirm } from "../confirmDialog.js"; import { fillStorageForm, clearCredentialFields, @@ -241,12 +242,10 @@ export class ProfileStorageController { } confirmSyncOverwrite() { - if (typeof window === "undefined" || typeof window.confirm !== "function") { - return true; - } - return window.confirm( + return showConfirm( "A newer copy of your storage settings is on your account (changed on " + - "another device). Overwrite it with this one?" + "another device). Overwrite it with this one?", + { confirmLabel: "Overwrite", danger: true }, ); } diff --git a/js/ui/profileModal/ProfileWalletController.js b/js/ui/profileModal/ProfileWalletController.js index 317fdd31..17a90f47 100644 --- a/js/ui/profileModal/ProfileWalletController.js +++ b/js/ui/profileModal/ProfileWalletController.js @@ -1,4 +1,5 @@ import { devLogger } from "../../utils/logger.js"; +import { showConfirm } from "../confirmDialog.js"; import { NWC_URI_SCHEME } from "../profileModalContract.js"; import { createWalletSyncService } from "../../services/walletSyncService.js"; import { runWalletZappabilityCheck } from "./walletZappabilityCheck.js"; @@ -104,12 +105,10 @@ export class ProfileWalletController { } confirmSyncOverwrite() { - if (typeof window === "undefined" || typeof window.confirm !== "function") { - return true; - } - return window.confirm( + return showConfirm( "A newer copy of your wallet connection is on your account (changed on " + - "another device). Overwrite it with this one?" + "another device). Overwrite it with this one?", + { confirmLabel: "Overwrite", danger: true }, ); } @@ -158,14 +157,12 @@ export class ProfileWalletController { try { if (enabled) { // Spending capability — require explicit confirmation before publishing. - const confirmed = - typeof window !== "undefined" && typeof window.confirm === "function" - ? window.confirm( - "This publishes an ENCRYPTED copy of your wallet connection to public relays.\n\n" + - "A wallet connect URI can SPEND from your wallet. Anyone who controls your " + - "Nostr key could decrypt this and spend from this wallet.\n\nContinue?" - ) - : true; + const confirmed = await showConfirm( + "This publishes an ENCRYPTED copy of your wallet connection to public relays.\n\n" + + "A wallet connect URI can SPEND from your wallet. Anyone who controls your " + + "Nostr key could decrypt this and spend from this wallet.\n\nContinue?", + { confirmLabel: "Publish", danger: true }, + ); if (!confirmed) { if (this.walletSyncToggle instanceof HTMLInputElement) { this.walletSyncToggle.checked = false; diff --git a/js/ui/profileModalController.js b/js/ui/profileModalController.js index e5c293f2..4cfe80bb 100644 --- a/js/ui/profileModalController.js +++ b/js/ui/profileModalController.js @@ -6,6 +6,7 @@ import { MAX_WALLET_DEFAULT_ZAP as CONFIG_MAX_WALLET_DEFAULT_ZAP, ENABLE_NIP17_RELAY_WARNING as CONFIG_ENABLE_NIP17_RELAY_WARNING, } from "../config.js"; +import { showConfirm } from "./confirmDialog.js"; import { normalizeDesignSystemContext } from "../designSystem.js"; import { RUNTIME_FLAGS } from "../constants.js"; import { @@ -1926,14 +1927,17 @@ export class ProfileModalController { } if (this.profileSubscriptionsBackupBtn) { - this.profileSubscriptionsBackupBtn.addEventListener("click", () => { + this.profileSubscriptionsBackupBtn.addEventListener("click", async () => { const pubkey = this.normalizeHexPubkey(this.getActivePubkey()); - if (pubkey) { - if (confirm("Create a backup of your current subscription list?")) { + if ( + pubkey && + (await showConfirm("Create a backup of your current subscription list?", { + confirmLabel: "Create backup", + })) + ) { this.subscriptionHistoryController.handleCreateBackup(pubkey).then(() => { this.showSuccess("Backup created."); }); - } } else { this.showError("Please log in to backup subscriptions."); } @@ -2930,7 +2934,7 @@ export class ProfileModalController { ? confirmValue.trim() : "this entry"; const prompt = confirmMessage.replace("{npub}", replacement); - if (!window.confirm(prompt)) { + if (!(await showConfirm(prompt, { danger: true }))) { return; } } diff --git a/js/ui/subscriptionHistoryController.js b/js/ui/subscriptionHistoryController.js index 78c1312c..0d3fbf44 100644 --- a/js/ui/subscriptionHistoryController.js +++ b/js/ui/subscriptionHistoryController.js @@ -3,6 +3,7 @@ import { profileCache } from "../state/profileCache.js"; import { formatTimeAgo } from "../utils/formatters.js"; import { userLogger } from "../utils/logger.js"; import { getApplication } from "../applicationContext.js"; +import { notifyError, notifySuccess, showConfirm } from "./appNotify.js"; export class SubscriptionHistoryController { constructor(options = {}) { @@ -302,9 +303,10 @@ export class SubscriptionHistoryController { async handleRestore() { if (!this.selectedEvent) return; if ( - !confirm( - "Are you sure you want to restore this list? It will overwrite your current subscriptions." - ) + !(await showConfirm( + "Are you sure you want to restore this list? It will overwrite your current subscriptions.", + { confirmLabel: "Restore", danger: true }, + )) ) return; @@ -313,11 +315,11 @@ export class SubscriptionHistoryController { try { await subscriptions.restoreBackup(this.selectedEvent, this.activePubkey); - // alert("Subscriptions restored successfully!"); // alert blocks, toast is better if available, but alert is fine for now + notifySuccess("Subscriptions restored."); this.hide(); } catch (error) { userLogger.error("Restore failed:", error); - alert("Failed to restore: " + error.message); + notifyError("Failed to restore: " + error.message); } finally { this.restoreBtn.disabled = false; this.restoreBtn.textContent = "Restore as Active"; @@ -338,7 +340,7 @@ export class SubscriptionHistoryController { this.fetchHistory(); } catch (error) { userLogger.error("Rebroadcast failed:", error); - alert("Failed to rebroadcast: " + error.message); + notifyError("Failed to rebroadcast: " + error.message); } finally { this.rebroadcastBtn.disabled = false; this.rebroadcastBtn.textContent = "Rebroadcast"; @@ -364,7 +366,7 @@ export class SubscriptionHistoryController { } } catch (error) { userLogger.error("Backup failed:", error); - alert("Backup failed: " + error.message); + notifyError("Backup failed: " + error.message); } } } diff --git a/tests/confirm-dialog.test.mjs b/tests/confirm-dialog.test.mjs new file mode 100644 index 00000000..e6963c49 --- /dev/null +++ b/tests/confirm-dialog.test.mjs @@ -0,0 +1,75 @@ +// showConfirm() replaces native window.confirm with a styled, promise-based dialog so +// all confirmations live in the app's UI system. It must resolve true on Confirm, false +// on Cancel/backdrop/Escape, clean up its DOM, and degrade gracefully without a DOM. + +import test, { beforeEach, afterEach } from "node:test"; +import { strict as assert } from "node:assert"; +import { JSDOM } from "jsdom"; +import { showConfirm } from "../js/ui/confirmDialog.js"; + +let dom; +beforeEach(() => { + dom = new JSDOM(""); + globalThis.document = dom.window.document; + globalThis.window = dom.window; + globalThis.HTMLElement = dom.window.HTMLElement; + globalThis.KeyboardEvent = dom.window.KeyboardEvent; +}); +afterEach(() => { + delete globalThis.document; + delete globalThis.window; + delete globalThis.HTMLElement; + delete globalThis.KeyboardEvent; +}); + +function overlay() { + return dom.window.document.querySelector(".bv-modal"); +} + +test("renders a dialog with the message and resolves TRUE when Confirm is clicked", async () => { + const p = showConfirm("Delete this thing?", { confirmLabel: "Delete", danger: true }); + const modal = overlay(); + assert.ok(modal, "an overlay was added to the DOM"); + assert.match(modal.textContent, /Delete this thing\?/); + modal.querySelector(".btn").click(); // confirm button (cancel is .btn-ghost) + assert.equal(await p, true); + assert.equal(overlay(), null, "overlay removed after resolving"); +}); + +test("resolves FALSE when Cancel is clicked", async () => { + const p = showConfirm("Sure?"); + overlay().querySelector(".btn-ghost").click(); + assert.equal(await p, false); + assert.equal(overlay(), null); +}); + +test("resolves FALSE when the backdrop is clicked", async () => { + const p = showConfirm("Sure?"); + overlay().querySelector(".bv-modal-backdrop").click(); + assert.equal(await p, false); +}); + +test("resolves FALSE on Escape", async () => { + const p = showConfirm("Sure?"); + dom.window.document.dispatchEvent( + new dom.window.KeyboardEvent("keydown", { key: "Escape", bubbles: true }), + ); + assert.equal(await p, false); + assert.equal(overlay(), null); +}); + +test("a danger prompt focuses Cancel (the safe action) by default", async () => { + const p = showConfirm("Delete?", { danger: true }); + assert.equal( + dom.window.document.activeElement, + overlay().querySelector(".btn-ghost"), + ); + overlay().querySelector(".btn-ghost").click(); + await p; +}); + +test("without a DOM it declines rather than throwing", async () => { + delete globalThis.document; + delete globalThis.window; + assert.equal(await showConfirm("anything"), false); +}); diff --git a/todo/TODO_2026-06-20_pre-launch.md b/todo/TODO_2026-06-20_pre-launch.md index d46934ba..a20b49a5 100644 --- a/todo/TODO_2026-06-20_pre-launch.md +++ b/todo/TODO_2026-06-20_pre-launch.md @@ -504,6 +504,25 @@ toward freshness and looked identical. Gave each a structural identity: - [ ] Consider removing the CDN/WebTorrent source badge from the card (clutter). - [ ] Run `npm run test:visual` after layout changes; update baselines deliberately. +### 41. Native browser dialogs → site notification system +- [x] **DONE 2026-06-30.** Replaced every `alert()`/`confirm()` (no `prompt()` existed) + with the app's notification system so dialogs are styled + consistent. + - `alert()` (13) → toasts: `this.showError` where the component already had it + (UploadModal), else `notifyError`/`notifySuccess` via a new `js/ui/appNotify.js` + (routes to the app's NotificationController). + - `confirm()` (~13) → a new promise-based **`showConfirm()`** dialog + (`js/ui/confirmDialog.js`) — a `bv-modal modal-always-on-top` overlay returning + `Promise` (Confirm/Cancel/backdrop/Escape, danger variant focuses + Cancel). Importable anywhere, so future confirmations use it too. Stacks above + other modals and is typable thanks to the stacked-modal focus fix. + - Migrated: UploadModal, MyVideosController, ProfileStorage/Wallet/Relay + controllers, profileModalController, subscriptionHistoryController, + settingsRestorePrompt (default `confirm` now `showConfirm`), nostr/client + (deleteAllVersions). `confirmSyncOverwrite` helpers made async (the sync layer + already `await`s `confirmOverwrite`). Tests: `tests/confirm-dialog.test.mjs` (6). + Build + lint clean; affected suites green. NOTE: `npm run build`/`lint` do NOT + parse JS — verify migrations by importing the module / running its test. + ### 8. Orphan storage garbage-collection tool - [x] **Largely delivered by the My Videos tab** (`9d3a0df0`): lists bucket objects no live note references and offers per-file delete (under the user's prefix only).