[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
+309 -65

No files matched your search

+5 -3
View File
@@ -18,6 +18,7 @@
*/ */
import { isDevMode } from "../config.js"; import { isDevMode } from "../config.js";
import { showConfirm } from "../ui/confirmDialog.js";
import { infoHashFromMagnet } from "../magnets.js"; import { infoHashFromMagnet } from "../magnets.js";
// 🔧 merged conflicting changes from codex/update-video-publishing-and-parsing-logic vs unstable // 🔧 merged conflicting changes from codex/update-video-publishing-and-parsing-logic vs unstable
import { import {
@@ -3465,9 +3466,10 @@ export class NostrClient {
options && typeof options.video === "object" ? options.video : null; options && typeof options.video === "object" ? options.video : null;
let confirmed = true; let confirmed = true;
if (shouldConfirm && typeof window?.confirm === "function") { if (shouldConfirm) {
confirmed = window.confirm( confirmed = await showConfirm(
"Are you sure you want to delete all versions of this video? This action cannot be undone." "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. // if the user accepts.
import { storageSyncService as defaultStorageSync } from "./storageSyncService.js"; import { storageSyncService as defaultStorageSync } from "./storageSyncService.js";
import { showConfirm } from "../ui/confirmDialog.js";
const OFFERED_KEY = "bitvid:settings-sync:offered:v1"; const OFFERED_KEY = "bitvid:settings-sync:offered:v1";
@@ -63,10 +64,7 @@ const ITEM_LABELS = {
export function createSettingsRestorePrompt({ export function createSettingsRestorePrompt({
storageSync = defaultStorageSync, storageSync = defaultStorageSync,
walletSync = null, walletSync = null,
confirm = (message) => confirm = (message) => showConfirm(message, { confirmLabel: "Restore" }),
typeof window !== "undefined" && typeof window.confirm === "function"
? window.confirm(message)
: false,
logger = null, logger = null,
} = {}) { } = {}) {
// Items not already enabled / not already offered on THIS device that have a // 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 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. ` + `Found an encrypted copy of your ${list} on your Nostr account. ` +
`Restore ${candidates.length > 1 ? "them" : "it"} to this device?` `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, requestDefaultExtensionPermissions,
} from "../../nostrClientFacade.js"; } from "../../nostrClientFacade.js";
import { UI_FEEDBACK_DELAY_MS } from "../../constants.js"; import { UI_FEEDBACK_DELAY_MS } from "../../constants.js";
import { showConfirm } from "../confirmDialog.js";
const INFO_HASH_PATTERN = /^[a-f0-9]{40}$/; const INFO_HASH_PATTERN = /^[a-f0-9]{40}$/;
@@ -518,12 +519,12 @@ export class UploadModal {
void this.captureVideoMetadata(file); void this.captureVideoMetadata(file);
if (!this.storageConfigured) { 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 e.target.value = ""; // Clear selection
return; return;
} }
if (!this.isStorageUnlocked) { if (!this.isStorageUnlocked) {
alert("Please unlock storage before selecting a file."); this.showError("Please unlock storage before selecting a file.");
e.target.value = ""; e.target.value = "";
return; return;
} }
@@ -614,7 +615,7 @@ export class UploadModal {
if (this.results.magnet) this.results.magnet.value = "Upload Failed"; if (this.results.magnet) this.results.magnet.value = "Upload Failed";
if (this.results.torrentUrl) this.results.torrentUrl.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 if (this.inputs.file) this.inputs.file.value = ""; // Reset
} }
@@ -676,7 +677,7 @@ export class UploadModal {
userLogger.error("Thumbnail upload failed:", err); userLogger.error("Thumbnail upload failed:", err);
this.thumbnailUploadState.status = 'error'; this.thumbnailUploadState.status = 'error';
this.updateThumbnailProgress(null, "Failed."); this.updateThumbnailProgress(null, "Failed.");
alert("Thumbnail upload failed."); this.showError("Thumbnail upload failed.");
} }
} }
@@ -824,7 +825,7 @@ export class UploadModal {
if (this.promptStoredNsecUnlock(pubkey)) { if (this.promptStoredNsecUnlock(pubkey)) {
return; return;
} }
alert("No signer available to unlock storage."); this.showError("No signer available to unlock storage.");
return; return;
} }
@@ -832,7 +833,7 @@ export class UploadModal {
if (signer?.type === "extension" || signer?.type === "nip07") { if (signer?.type === "extension" || signer?.type === "nip07") {
const permissionResult = await requestDefaultExtensionPermissions(); const permissionResult = await requestDefaultExtensionPermissions();
if (!permissionResult?.ok) { if (!permissionResult?.ok) {
alert("Extension permissions are required to unlock storage."); this.showError("Extension permissions are required to unlock storage.");
return; return;
} }
} }
@@ -846,7 +847,7 @@ export class UploadModal {
await this.loadFromStorage(); await this.loadFromStorage();
} catch (err) { } catch (err) {
userLogger.error("Unlock failed", err); userLogger.error("Unlock failed", err);
alert("Failed to unlock storage: " + err.message); this.showError("Failed to unlock storage: " + err.message);
} finally { } finally {
if (this.toggles.storageUnlock) { if (this.toggles.storageUnlock) {
this.toggles.storageUnlock.textContent = "Unlock"; this.toggles.storageUnlock.textContent = "Unlock";
@@ -945,17 +946,17 @@ export class UploadModal {
// Check upload state // Check upload state
if (this.activeSource === "upload") { if (this.activeSource === "upload") {
if (this.videoUploadState.status === 'uploading' || this.thumbnailUploadState.status === 'uploading') { 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; return;
} }
if (this.videoUploadState.status === 'error') { if (this.videoUploadState.status === 'error') {
alert("Video upload failed. Please try again."); this.showError("Video upload failed. Please try again.");
return; return;
} }
if (this.videoUploadState.status !== 'complete') { if (this.videoUploadState.status !== 'complete') {
// Fallback: If no file selected, maybe they want to submit without a new file? // Fallback: If no file selected, maybe they want to submit without a new file?
// (Not supported in this simplified modal, assume file required) // (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; return;
} }
} }
@@ -1104,7 +1105,7 @@ export class UploadModal {
} }
if (!hasUrl && !hasImeta && hasMagnet) { 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); 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;
+15 -12
View File
@@ -6,6 +6,7 @@ import { classifyVideoHealth, isUrlUnderBase } from "./myVideosHealth.js";
import { reconcileStorage } from "./myVideosReconcile.js"; import { reconcileStorage } from "./myVideosReconcile.js";
import { FEATURE_NIP71_MIRROR } from "../../constants.js"; import { FEATURE_NIP71_MIRROR } from "../../constants.js";
import { nip71MirrorService } from "../../services/nip71MirrorService.js"; import { nip71MirrorService } from "../../services/nip71MirrorService.js";
import { showConfirm } from "../confirmDialog.js";
import { import {
isMirrorEnabled, isMirrorEnabled,
setMirrorEnabled, setMirrorEnabled,
@@ -282,10 +283,13 @@ export class MyVideosController {
if (!r2Service || typeof r2Service.deleteStorageKeys !== "function") { if (!r2Service || typeof r2Service.deleteStorageKeys !== "function") {
return; return;
} }
if (typeof window !== "undefined" && typeof window.confirm === "function") { if (
if (!window.confirm(`Permanently delete this file from your bucket?\n\n${key}`)) { !(await showConfirm(`Permanently delete this file from your bucket?\n\n${key}`, {
return; confirmLabel: "Delete",
} danger: true,
}))
) {
return;
} }
let result; let result;
try { try {
@@ -314,14 +318,13 @@ export class MyVideosController {
if (!r2Service || typeof r2Service.deleteStorageKeys !== "function" || !keys.length) { if (!r2Service || typeof r2Service.deleteStorageKeys !== "function" || !keys.length) {
return; return;
} }
if (typeof window !== "undefined" && typeof window.confirm === "function") { if (
if ( !(await showConfirm(
!window.confirm( `Permanently delete all ${keys.length} orphaned files from your bucket?`,
`Permanently delete all ${keys.length} orphaned files from your bucket?`, { confirmLabel: "Delete all", danger: true },
) ))
) { ) {
return; return;
}
} }
if (this.orphanDeleteAllBtn instanceof HTMLElement) { if (this.orphanDeleteAllBtn instanceof HTMLElement) {
this.orphanDeleteAllBtn.disabled = true; this.orphanDeleteAllBtn.disabled = true;
+6 -4
View File
@@ -1,4 +1,5 @@
import { devLogger, userLogger } from "../../utils/logger.js"; import { devLogger, userLogger } from "../../utils/logger.js";
import { showConfirm } from "../confirmDialog.js";
import { sanitizeRelayList } from "../../nostr/nip46Client.js"; import { sanitizeRelayList } from "../../nostr/nip46Client.js";
const noop = () => {}; const noop = () => {};
@@ -556,7 +557,7 @@ export class ProfileRelayController {
reason: null, 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; context.confirmed = confirmed;
if (!confirmed) { if (!confirmed) {
context.reason = "cancelled"; context.reason = "cancelled";
@@ -604,9 +605,10 @@ export class ProfileRelayController {
return; return;
} }
const confirmed = window.confirm( const confirmed = await showConfirm(`Remove ${url} from your relay list?`, {
`Remove ${url} from your relay list?`, confirmLabel: "Remove",
); danger: true,
});
if (!confirmed) { if (!confirmed) {
return; return;
} }
@@ -10,6 +10,7 @@ import { getActiveSigner } from "../../nostr/client.js";
import { DEFAULT_NIP07_ENCRYPTION_METHODS } from "../../nostr/nip07Permissions.js"; import { DEFAULT_NIP07_ENCRYPTION_METHODS } from "../../nostr/nip07Permissions.js";
import { storageSyncService } from "../../services/storageSyncService.js"; import { storageSyncService } from "../../services/storageSyncService.js";
import { StorageCorsHelp } from "./storageCorsHelp.js"; import { StorageCorsHelp } from "./storageCorsHelp.js";
import { showConfirm } from "../confirmDialog.js";
import { import {
fillStorageForm, fillStorageForm,
clearCredentialFields, clearCredentialFields,
@@ -241,12 +242,10 @@ export class ProfileStorageController {
} }
confirmSyncOverwrite() { confirmSyncOverwrite() {
if (typeof window === "undefined" || typeof window.confirm !== "function") { return showConfirm(
return true;
}
return window.confirm(
"A newer copy of your storage settings is on your account (changed on " + "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 },
); );
} }
+10 -13
View File
@@ -1,4 +1,5 @@
import { devLogger } from "../../utils/logger.js"; import { devLogger } from "../../utils/logger.js";
import { showConfirm } from "../confirmDialog.js";
import { NWC_URI_SCHEME } from "../profileModalContract.js"; import { NWC_URI_SCHEME } from "../profileModalContract.js";
import { createWalletSyncService } from "../../services/walletSyncService.js"; import { createWalletSyncService } from "../../services/walletSyncService.js";
import { runWalletZappabilityCheck } from "./walletZappabilityCheck.js"; import { runWalletZappabilityCheck } from "./walletZappabilityCheck.js";
@@ -104,12 +105,10 @@ export class ProfileWalletController {
} }
confirmSyncOverwrite() { confirmSyncOverwrite() {
if (typeof window === "undefined" || typeof window.confirm !== "function") { return showConfirm(
return true;
}
return window.confirm(
"A newer copy of your wallet connection is on your account (changed on " + "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 { try {
if (enabled) { if (enabled) {
// Spending capability — require explicit confirmation before publishing. // Spending capability — require explicit confirmation before publishing.
const confirmed = const confirmed = await showConfirm(
typeof window !== "undefined" && typeof window.confirm === "function" "This publishes an ENCRYPTED copy of your wallet connection to public relays.\n\n" +
? window.confirm( "A wallet connect URI can SPEND from your wallet. Anyone who controls your " +
"This publishes an ENCRYPTED copy of your wallet connection to public relays.\n\n" + "Nostr key could decrypt this and spend from this wallet.\n\nContinue?",
"A wallet connect URI can SPEND from your wallet. Anyone who controls your " + { confirmLabel: "Publish", danger: true },
"Nostr key could decrypt this and spend from this wallet.\n\nContinue?" );
)
: true;
if (!confirmed) { if (!confirmed) {
if (this.walletSyncToggle instanceof HTMLInputElement) { if (this.walletSyncToggle instanceof HTMLInputElement) {
this.walletSyncToggle.checked = false; this.walletSyncToggle.checked = false;
+9 -5
View File
@@ -6,6 +6,7 @@ import {
MAX_WALLET_DEFAULT_ZAP as CONFIG_MAX_WALLET_DEFAULT_ZAP, MAX_WALLET_DEFAULT_ZAP as CONFIG_MAX_WALLET_DEFAULT_ZAP,
ENABLE_NIP17_RELAY_WARNING as CONFIG_ENABLE_NIP17_RELAY_WARNING, ENABLE_NIP17_RELAY_WARNING as CONFIG_ENABLE_NIP17_RELAY_WARNING,
} from "../config.js"; } from "../config.js";
import { showConfirm } from "./confirmDialog.js";
import { normalizeDesignSystemContext } from "../designSystem.js"; import { normalizeDesignSystemContext } from "../designSystem.js";
import { RUNTIME_FLAGS } from "../constants.js"; import { RUNTIME_FLAGS } from "../constants.js";
import { import {
@@ -1926,14 +1927,17 @@ export class ProfileModalController {
} }
if (this.profileSubscriptionsBackupBtn) { if (this.profileSubscriptionsBackupBtn) {
this.profileSubscriptionsBackupBtn.addEventListener("click", () => { this.profileSubscriptionsBackupBtn.addEventListener("click", async () => {
const pubkey = this.normalizeHexPubkey(this.getActivePubkey()); const pubkey = this.normalizeHexPubkey(this.getActivePubkey());
if (pubkey) { if (
if (confirm("Create a backup of your current subscription list?")) { pubkey &&
(await showConfirm("Create a backup of your current subscription list?", {
confirmLabel: "Create backup",
}))
) {
this.subscriptionHistoryController.handleCreateBackup(pubkey).then(() => { this.subscriptionHistoryController.handleCreateBackup(pubkey).then(() => {
this.showSuccess("Backup created."); this.showSuccess("Backup created.");
}); });
}
} else { } else {
this.showError("Please log in to backup subscriptions."); this.showError("Please log in to backup subscriptions.");
} }
@@ -2930,7 +2934,7 @@ export class ProfileModalController {
? confirmValue.trim() ? confirmValue.trim()
: "this entry"; : "this entry";
const prompt = confirmMessage.replace("{npub}", replacement); const prompt = confirmMessage.replace("{npub}", replacement);
if (!window.confirm(prompt)) { if (!(await showConfirm(prompt, { danger: true }))) {
return; return;
} }
} }
+9 -7
View File
@@ -3,6 +3,7 @@ import { profileCache } from "../state/profileCache.js";
import { formatTimeAgo } from "../utils/formatters.js"; import { formatTimeAgo } from "../utils/formatters.js";
import { userLogger } from "../utils/logger.js"; import { userLogger } from "../utils/logger.js";
import { getApplication } from "../applicationContext.js"; import { getApplication } from "../applicationContext.js";
import { notifyError, notifySuccess, showConfirm } from "./appNotify.js";
export class SubscriptionHistoryController { export class SubscriptionHistoryController {
constructor(options = {}) { constructor(options = {}) {
@@ -302,9 +303,10 @@ export class SubscriptionHistoryController {
async handleRestore() { async handleRestore() {
if (!this.selectedEvent) return; if (!this.selectedEvent) return;
if ( if (
!confirm( !(await showConfirm(
"Are you sure you want to restore this list? It will overwrite your current subscriptions." "Are you sure you want to restore this list? It will overwrite your current subscriptions.",
) { confirmLabel: "Restore", danger: true },
))
) )
return; return;
@@ -313,11 +315,11 @@ export class SubscriptionHistoryController {
try { try {
await subscriptions.restoreBackup(this.selectedEvent, this.activePubkey); 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(); this.hide();
} catch (error) { } catch (error) {
userLogger.error("Restore failed:", error); userLogger.error("Restore failed:", error);
alert("Failed to restore: " + error.message); notifyError("Failed to restore: " + error.message);
} finally { } finally {
this.restoreBtn.disabled = false; this.restoreBtn.disabled = false;
this.restoreBtn.textContent = "Restore as Active"; this.restoreBtn.textContent = "Restore as Active";
@@ -338,7 +340,7 @@ export class SubscriptionHistoryController {
this.fetchHistory(); this.fetchHistory();
} catch (error) { } catch (error) {
userLogger.error("Rebroadcast failed:", error); userLogger.error("Rebroadcast failed:", error);
alert("Failed to rebroadcast: " + error.message); notifyError("Failed to rebroadcast: " + error.message);
} finally { } finally {
this.rebroadcastBtn.disabled = false; this.rebroadcastBtn.disabled = false;
this.rebroadcastBtn.textContent = "Rebroadcast"; this.rebroadcastBtn.textContent = "Rebroadcast";
@@ -364,7 +366,7 @@ export class SubscriptionHistoryController {
} }
} catch (error) { } catch (error) {
userLogger.error("Backup failed:", 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). - [ ] Consider removing the CDN/WebTorrent source badge from the card (clutter).
- [ ] Run `npm run test:visual` after layout changes; update baselines deliberately. - [ ] 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 ### 8. Orphan storage garbage-collection tool
- [x] **Largely delivered by the My Videos tab** (`9d3a0df0`): lists bucket objects no - [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). live note references and offers per-file delete (under the user's prefix only).