[ui] route all native alert()/confirm() through the site notification system

Every browser alert()/confirm() now uses the app's UI instead of native dialogs
(no prompt() existed). Two new pieces:

- js/ui/confirmDialog.js — showConfirm(message, opts) -> Promise<boolean>, a styled
  `bv-modal modal-always-on-top` dialog (Confirm/Cancel/backdrop/Escape; danger variant
  focuses Cancel). Importable anywhere, so future confirmations use it too. It stacks
  above other modals and is typable thanks to the recent stacked-modal focus fix.
- js/ui/appNotify.js — notifyError/notifyStatus/notifySuccess routing to the app's
  NotificationController, for modules without a local showError; re-exports showConfirm.

Migrated: UploadModal (10 alert -> this.showError, 1 confirm), subscriptionHistory
(3 alert -> notify*, 1 confirm), MyVideosController (2), ProfileStorage/Wallet/Relay
controllers, profileModalController (2), settingsRestorePrompt (default confirm now
showConfirm), nostr/client deleteAllVersions. confirmSyncOverwrite helpers made async
(syncConflict already awaits confirmOverwrite).

Tests: tests/confirm-dialog.test.mjs (6 — confirm/cancel/backdrop/Escape resolution,
DOM cleanup, danger focus, no-DOM fallback). Build + lint clean; affected suites green.
(Note: build/lint don't parse JS — caught a multi-line-import split + a wrong import
path via module-import checks.)

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-30 16:49:42 -04:00
1 parent 6e85c2a32b
commit ad83c9f8db
13 files changed
+302 -58

No files matched your search

+5 -3
View File
@@ -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 },
);
}
+3 -5
View File
@@ -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?`
);
+34
View File
@@ -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 };
+12 -11
View File
@@ -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);
+108
View File
@@ -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<boolean>: 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;
+10 -7
View File
@@ -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,11 +283,14 @@ 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}`)) {
if (
!(await showConfirm(`Permanently delete this file from your bucket?\n\n${key}`, {
confirmLabel: "Delete",
danger: true,
}))
) {
return;
}
}
let result;
try {
result = await r2Service.deleteStorageKeys({ keys: [key], pubkey: this.pubkey });
@@ -314,15 +318,14 @@ export class MyVideosController {
if (!r2Service || typeof r2Service.deleteStorageKeys !== "function" || !keys.length) {
return;
}
if (typeof window !== "undefined" && typeof window.confirm === "function") {
if (
!window.confirm(
!(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;
this.orphanDeleteAllBtn.textContent = "Deleting…";
+6 -4
View File
@@ -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;
}
@@ -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 },
);
}
+8 -11
View File
@@ -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(
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?"
)
: true;
"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;
+9 -5
View File
@@ -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;
}
}
+9 -7
View File
@@ -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);
}
}
}
+75
View File
@@ -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("<!doctype html><html><body></body></html>");
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);
});
+19
View File
@@ -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<boolean>` (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).