From ad83c9f8db6ae703e3f77bf17fd772392488c59e Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Tue, 30 Jun 2026 16:49:42 -0400
Subject: [PATCH 01/48] [ui] route all native alert()/confirm() through the
site notification system
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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, 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
---
js/nostr/client.js | 8 +-
js/services/settingsRestorePrompt.js | 8 +-
js/ui/appNotify.js | 34 ++++++
js/ui/components/UploadModal.js | 23 ++--
js/ui/confirmDialog.js | 108 ++++++++++++++++++
js/ui/profileModal/MyVideosController.js | 27 +++--
js/ui/profileModal/ProfileRelayController.js | 10 +-
.../profileModal/ProfileStorageController.js | 9 +-
js/ui/profileModal/ProfileWalletController.js | 23 ++--
js/ui/profileModalController.js | 14 ++-
js/ui/subscriptionHistoryController.js | 16 +--
tests/confirm-dialog.test.mjs | 75 ++++++++++++
todo/TODO_2026-06-20_pre-launch.md | 19 +++
13 files changed, 309 insertions(+), 65 deletions(-)
create mode 100644 js/ui/appNotify.js
create mode 100644 js/ui/confirmDialog.js
create mode 100644 tests/confirm-dialog.test.mjs
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).
From c48083dfcc3390dceb481a8b64bd309518689cd3 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Tue, 30 Jun 2026 17:00:57 -0400
Subject: [PATCH 02/48] [admin] fix whitelist/blacklist edits with nsec &
NIP-46; loosen file-size limits
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Admin list edits worked with a NIP-07 extension but silently failed with nsec and
NIP-46 (Amber). persistNostrState signed with window.nostr.signEvent directly (and
required requestDefaultExtensionPermissions), so non-extension logins threw
"nostr-extension-missing".
Fix: sign with the ACTIVE signer (getActiveSigner() from the registry — extension/nsec/
NIP-46 all expose signEvent), and only run the extension-permission prompt when the
signer is an extension (type extension/nip07). Regression test in
tests/admin-list-store.test.mjs (nsec signer used while window.nostr.signEvent throws).
Also (per maintainer request): raise the file-size lint margins to stop churning on an
arbitrary cap — THRESHOLD 1000→1200 (new files), GROWTH_MARGIN 50→200 (grandfathered).
TODOs added: #42 follow-up audit of every signing path (must use the active signer, not
window.nostr); #43 logged-in profile card at the top of the profile modal (half-on/half-
off); #44 in-modal storage selector + unlock for the upload modal.
Build + lint clean; admin-list suite green.
Co-Authored-By: Claude Opus 4.8
---
js/adminListStore.js | 36 +++++++++++++++---------------
scripts/check-file-size.mjs | 4 ++--
tests/admin-list-store.test.mjs | 28 +++++++++++++++++++++++
todo/TODO_2026-06-20_pre-launch.md | 35 +++++++++++++++++++++++++++++
4 files changed, 83 insertions(+), 20 deletions(-)
diff --git a/js/adminListStore.js b/js/adminListStore.js
index 3059d9cd..6b739297 100644
--- a/js/adminListStore.js
+++ b/js/adminListStore.js
@@ -12,6 +12,7 @@ import {
import { SHORT_TIMEOUT_MS } from "./constants.js";
import {
getRegisteredNostrClient,
+ getActiveSigner,
requestDefaultExtensionPermissions,
} from "./nostrClientRegistry.js";
import { devLogger, userLogger } from "./utils/logger.js";
@@ -574,8 +575,7 @@ async function fetchLatestListEvent(filter, contextLabel = "admin-list") {
pubkey,
dTag,
relayUrls: relays,
- // Force a full fetch: admin lists are replaceable (see
- // tests/nostr/relayBatchFetcherReplaceable.test.mjs).
+ // Full fetch: admin lists are replaceable (relayBatchFetcherReplaceable.test).
since: 0,
}),
timeoutPromise,
@@ -682,8 +682,7 @@ async function loadCommunityBlacklistEntries() {
return [];
}
- // Batch all curator blacklists into ONE REQ (was the cold-start kind-30000 storm).
- // Best-effort: on failure the cached admin state carries over.
+ // Batch all curator blacklists into ONE REQ (best-effort; cache carries over on fail).
try {
const matched = await fetchBatchedReferenceEvents({
references,
@@ -874,20 +873,21 @@ async function persistNostrState(actorNpub, updates = {}) {
}
ensureNostrReady();
- const permissionResult = await requestDefaultExtensionPermissions();
- if (!permissionResult.ok) {
- throw createError(
- "extension-permission-denied",
- "The NIP-07 extension must allow signing before updating admin lists.",
- permissionResult.error,
- );
+ // Sign with the ACTIVE signer (extension / nsec / NIP-46), not window.nostr.
+ const signer = getActiveSigner();
+ if (!signer || typeof signer.signEvent !== "function") {
+ throw createError("nostr-signer-missing", "No active signer to update admin lists. Please log in.");
}
- const extension = window?.nostr;
- if (!extension || typeof extension.signEvent !== "function") {
- throw createError(
- "nostr-extension-missing",
- "A Nostr extension with signEvent support is required."
- );
+ // Only a NIP-07 extension needs an explicit permission prompt before signing.
+ if (signer.type === "extension" || signer.type === "nip07") {
+ const permissionResult = await requestDefaultExtensionPermissions();
+ if (!permissionResult.ok) {
+ throw createError(
+ "extension-permission-denied",
+ "The NIP-07 extension must allow signing before updating admin lists.",
+ permissionResult.error,
+ );
+ }
}
const actorHex = decodeNpubToHex(actorNpub);
@@ -950,7 +950,7 @@ async function persistNostrState(actorNpub, updates = {}) {
let signedEvent;
try {
- signedEvent = await extension.signEvent(event);
+ signedEvent = await signer.signEvent(event);
} catch (error) {
throw createError("signature-failed", "Failed to sign admin list event.", error);
}
diff --git a/scripts/check-file-size.mjs b/scripts/check-file-size.mjs
index 3b3fcb81..113a6a69 100644
--- a/scripts/check-file-size.mjs
+++ b/scripts/check-file-size.mjs
@@ -19,8 +19,8 @@ import { join, relative } from "node:path";
const ROOT = new URL("..", import.meta.url).pathname.replace(/\/$/, "");
const SOURCE_DIR = join(ROOT, "js");
-const THRESHOLD = 1000; // lines — new files must stay under this
-const GROWTH_MARGIN = 50; // lines — allowlisted files may grow by this much for minor fixes
+const THRESHOLD = 1200; // lines — new files must stay under this (soft ceiling)
+const GROWTH_MARGIN = 200; // lines — allowlisted files may grow by this much for fixes
// Grandfathered files: path relative to repo root → line count when recorded.
// These files are known to be oversized and are decomposition targets.
diff --git a/tests/admin-list-store.test.mjs b/tests/admin-list-store.test.mjs
index 0c062f2c..6d321c87 100644
--- a/tests/admin-list-store.test.mjs
+++ b/tests/admin-list-store.test.mjs
@@ -667,4 +667,32 @@ assert.ok(
"should warn when blacklist updates fail on a relay",
);
+// Regression: admin list edits must sign with the ACTIVE signer (nsec / NIP-46), not
+// window.nostr — persistNostrState previously hardcoded the NIP-07 extension, so nsec
+// and NIP-46 logins couldn't edit the whitelist.
+let nsecSignerCalls = 0;
+globalThis.window.nostr.signEvent = async () => {
+ throw new Error("window.nostr must NOT be used to sign admin lists");
+};
+setActiveSigner({
+ pubkey: superHex,
+ type: "nsec",
+ signEvent: async (event) => {
+ nsecSignerCalls += 1;
+ return { ...event, id: "nsec-signed" };
+ },
+});
+nostrClient.writeRelays = [relays[1]];
+setPublishBehaviors([{ url: relays[1], success: true }]);
+
+await assert.doesNotReject(
+ () =>
+ persistAdminState(ADMIN_SUPER_NPUB, { whitelist: ["npub1nsecwhitelistmember"] }),
+ "admin list edit must succeed with an nsec signer and no usable window.nostr",
+);
+assert.ok(
+ nsecSignerCalls > 0,
+ "should sign admin list updates with the active (nsec) signer, not window.nostr",
+);
+
console.log("admin-list-store tests passed");
diff --git a/todo/TODO_2026-06-20_pre-launch.md b/todo/TODO_2026-06-20_pre-launch.md
index a20b49a5..51fc43c9 100644
--- a/todo/TODO_2026-06-20_pre-launch.md
+++ b/todo/TODO_2026-06-20_pre-launch.md
@@ -523,6 +523,41 @@ toward freshness and looked identical. Gave each a structural identity:
Build + lint clean; affected suites green. NOTE: `npm run build`/`lint` do NOT
parse JS — verify migrations by importing the module / running its test.
+### 42. Admin whitelist edit fails with nsec / NIP-46 (only NIP-07 worked) — FIXED
+- [x] **DONE 2026-06-30.** Editing the admin whitelist/blacklist worked with a NIP-07
+ extension but silently failed with nsec and NIP-46 (Amber). Root cause:
+ `adminListStore.persistNostrState` signed with `window.nostr.signEvent` directly
+ (and required `requestDefaultExtensionPermissions`), so non-extension logins threw
+ "nostr-extension-missing". Fix: sign with the **active signer**
+ (`getActiveSigner()` from the registry — works for extension/nsec/NIP-46, all expose
+ `signEvent`), and only run the extension-permission prompt when the signer is an
+ extension (`type === "extension"/"nip07"`). Regression test added to
+ `tests/admin-list-store.test.mjs` (nsec signer used while `window.nostr.signEvent`
+ throws). Build + lint clean.
+- [ ] **AUDIT (follow-up):** sweep EVERY place that signs an event and confirm it uses the
+ active-signer path (`getActiveSigner().signEvent` / the client's sign helper), NOT
+ `window.nostr` directly. `window.nostr`-only signing breaks nsec + NIP-46. Grep for
+ `window.nostr`, `extension.signEvent`, and direct `signEvent` calls across publish
+ paths (lists, reactions, reposts, comments, profile metadata, NWC, watch history,
+ deletes, NIP-71 mirror, view events) and fix any that bypass the active signer.
+
+### 43. Logged-in profile card at the top of the profile modal (UX)
+- [ ] Show the **logged-in user's profile photo + name** at the **top-center** of the
+ profile modal, styled as a "profile card" that sits **half-on / half-off** the
+ modal edge (overlapping the top border) for a polished look. Goal: users can see
+ WHICH profile they're acting as (e.g. uploading as) at a glance. Pull avatar/name
+ from the profile cache for the active pubkey; update on profile switch.
+
+### 44. In-modal storage selector for uploads (UX)
+- [ ] Make storage unlock + selection easier from the **video upload modal**: let the
+ user **pick any configured storage connection** (R2 / B2 / Custom S3) and unlock it
+ **without leaving the upload modal** — today they must close it, open Profile →
+ Storage, change the default/selected connection, then reopen upload. Add a provider
+ picker (driven by `storageService.listConnections`) + inline unlock in the upload
+ modal's storage section; selecting one sets it as the active upload target for this
+ upload (and optionally the default). Reuse `loadFromStorage` / the per-provider
+ connection model.
+
### 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).
From 7a11473d0d29cee48d4cd36d5b0e9cceb0632e5d Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Tue, 30 Jun 2026 21:54:43 -0400
Subject: [PATCH 03/48] =?UTF-8?q?[docs]=20#42=20signer=20audit=20complete?=
=?UTF-8?q?=20=E2=80=94=20active-signer=20path=20clean=20(adminListStore?=
=?UTF-8?q?=20was=20the=20only=20bug)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Swept every .signEvent(/window.nostr/ensureExtensionPermissions caller. No window.nostr
is used to sign or encrypt anywhere; all publishing uses the active signer, and every
extension-permission gate is guarded on signer type/capability so nsec + NIP-46 pass
through. Recorded the findings + one dead-code note (auth/signingAdapter.js
createNip07SigningAdapter is unused by app code).
Co-Authored-By: Claude Opus 4.8
---
todo/TODO_2026-06-20_pre-launch.md | 25 +++++++++++++++++++------
1 file changed, 19 insertions(+), 6 deletions(-)
diff --git a/todo/TODO_2026-06-20_pre-launch.md b/todo/TODO_2026-06-20_pre-launch.md
index 51fc43c9..b5dbeda7 100644
--- a/todo/TODO_2026-06-20_pre-launch.md
+++ b/todo/TODO_2026-06-20_pre-launch.md
@@ -534,12 +534,25 @@ toward freshness and looked identical. Gave each a structural identity:
extension (`type === "extension"/"nip07"`). Regression test added to
`tests/admin-list-store.test.mjs` (nsec signer used while `window.nostr.signEvent`
throws). Build + lint clean.
-- [ ] **AUDIT (follow-up):** sweep EVERY place that signs an event and confirm it uses the
- active-signer path (`getActiveSigner().signEvent` / the client's sign helper), NOT
- `window.nostr` directly. `window.nostr`-only signing breaks nsec + NIP-46. Grep for
- `window.nostr`, `extension.signEvent`, and direct `signEvent` calls across publish
- paths (lists, reactions, reposts, comments, profile metadata, NWC, watch history,
- deletes, NIP-71 mirror, view events) and fix any that bypass the active signer.
+- [x] **AUDIT DONE 2026-06-30 — clean (adminListStore was the only bug).** Swept every
+ `.signEvent(`, `window.nostr`, and `ensureExtensionPermissions`/
+ `requestDefaultExtensionPermissions` caller:
+ - **Signing:** NO `window.nostr` is used to sign/encrypt anywhere. Every publish
+ path signs with the active signer (`signer.signEvent`): subscriptions, userBlocks,
+ hashtag prefs, relay prefs, moderationService reports, nip71MirrorService,
+ encryptedSyncFacade, DM seal + legacy DM (`nostr/client` signingAdapter defaults
+ to the active signer), DM relay list, admin lists.
+ - **Extension-permission gates:** every caller (reactions, comments, subscriptions,
+ userBlocks, watchHistory, relayManager, DM relays) guards the request behind a
+ signer-type (`extension`/`nip07`) or capability check, so nsec/NIP-46 skip it and
+ go straight to active-signer signing — they never hit the `extension-missing`
+ failure that broke admin lists.
+ - **Decrypt:** gates on the active signer's nip04/nip44 capability with `window.nostr`
+ only as a fallback; nsec (closures) + NIP-46 (remote) both decrypt fine.
+ - **Cleanup note (low priority):** `js/auth/signingAdapter.js`
+ (`createNip07SigningAdapter`, defaults to `window.nostr`) is dead production code —
+ referenced only by its own test, no app callers. Harmless; remove or repoint at
+ the active signer if ever reused.
### 43. Logged-in profile card at the top of the profile modal (UX)
- [ ] Show the **logged-in user's profile photo + name** at the **top-center** of the
From 577e5d27c56718ad69e29bd7079e28c66b971353 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Tue, 30 Jun 2026 22:00:15 -0400
Subject: [PATCH 04/48] [docs] add TODOs #45-#48 (tag suggestions, feed
auto-refresh, Most Zapped tab, fluent profile switching)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Per maintainer: #45 upload-modal tag suggestions from the user's previous videos;
#46 auto-refresh the feed after posting a video; #47 "Most Zapped" sidebar tab
(trending-by-zaps); #48 fluent profile switching across signing methods.
#48 verified NOT already done: switching to a saved nsec profile throws "secret-required"
(switchProfile -> requestLogin -> nsec provider.login with no passphrase) and silently
fails — it never prompts for the PIN. Same root gap as #36 but in the switch path;
NIP-46 likely needs an analogous reconnect on switch.
Co-Authored-By: Claude Opus 4.8
---
todo/TODO_2026-06-20_pre-launch.md | 38 ++++++++++++++++++++++++++++++
1 file changed, 38 insertions(+)
diff --git a/todo/TODO_2026-06-20_pre-launch.md b/todo/TODO_2026-06-20_pre-launch.md
index b5dbeda7..041cc7bb 100644
--- a/todo/TODO_2026-06-20_pre-launch.md
+++ b/todo/TODO_2026-06-20_pre-launch.md
@@ -571,6 +571,44 @@ toward freshness and looked identical. Gave each a structural identity:
upload (and optionally the default). Reuse `loadFromStorage` / the per-provider
connection model.
+### 45. Upload modal: suggest tags from the user's previous videos (UX)
+- [ ] In the upload modal, let the user **pick tags reused from their other videos** —
+ surface a set of their **commonly-used tags** (frequency-ranked across their past
+ uploads) as one-tap chips, plus their less-common individual tags. Source from the
+ user's own video events' `t` tags (My Videos / their kind-30078+NIP-71 posts).
+ Rank by usage count; clicking a chip adds it to the new video's tags.
+
+### 46. Feed auto-refreshes after posting a video (UX)
+- [ ] After a video note is published, **automatically refresh the feed** so the new
+ video appears without a manual page reload. Hook the post-publish success (upload
+ modal `upload:submit` / `publishVideoNote` completion) to
+ `refreshAllVideoGrids`/`onVideosShouldRefresh` (optimistically inject the just-
+ published event so it shows instantly). Mind the feed-identity + dedupe paths (#20/
+ #21) so the injected event isn't dropped or duplicated.
+
+### 47. "Most Zapped" sidebar tab (trending-by-zaps)
+- [ ] Add a **Most Zapped** sidebar tab: like Trending (#27, view-count) but ranked by
+ **total zaps** (sats / zap count) per video. Reuse the zap accounting (kind-9735
+ receipts) already used for the zap system; build a feed source/ranker that sorts by
+ zap total over a window, wired into the feed engine like Trending. Depends on
+ reliable zap data (couples with the zap system already shipped in #3).
+
+### 48. Fluent profile switching across signing methods (nsec / NIP-46 / NIP-07)
+- [ ] **CONFIRMED NOT DONE (verified 2026-06-30).** Switching to a saved **nsec** profile
+ fails: `switchProfile(pubkey, { providerId: "nsec" })` → `requestLogin` → the nsec
+ provider's `login()` is called with **no secret/passphrase**, so it throws
+ `"secret-required"` and the switch silently fails (caught + logged in
+ `profileModalController`). It never prompts for the PIN or routes to the stored-key
+ unlock flow. Same root gap as #36 (nsec signer needs the passphrase to be restored),
+ but in the SWITCH path. Fix: when switching to a saved nsec profile, detect the
+ stored encrypted key (`getStoredSessionActorMetadata`) and prompt for the pin/
+ passphrase (route through `unlockStoredSessionActor`, e.g. open the login modal's
+ unlock flow), then activate. Apply the same for **NIP-46** (reconnect / re-authorize
+ the remote signer on switch if needed). Goal: switch fluently between NIP-07 ⇄ nsec
+ ⇄ NIP-46 accounts regardless of the current signing method. NOTE: multiple saved
+ nsec accounts share ONE stored session entry today — confirm per-account key storage
+ is needed for switching among several nsec profiles.
+
### 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).
From fb2f12b49d1747c43e96ac2436427725454b0bda Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Tue, 30 Jun 2026 22:11:43 -0400
Subject: [PATCH 05/48] [auth] #48: fluent switch to a saved nsec account
(prompt for PIN + unlock stored key)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Switching to a saved nsec profile silently failed: switchProfile -> requestLogin -> the
nsec provider was called with no passphrase, so it threw "secret-required" and the switch
was caught+logged with no user feedback. It never prompted for the PIN.
Now handleProfileSwitchRequest detects an nsec target and, before switching, prepares the
stored-key unlock:
- evaluateStoredNsecSwitch(meta, target) (pure) gates on a persisted nsec key for THIS
account (via getStoredSessionActorMetadata); errors clearly otherwise ("log in with its
nsec", "different account's key saved").
- showPasswordPrompt (js/ui/promptDialog.js) collects the PIN/passphrase in a styled
dialog (Promise, stacks above modals).
- switchProfile(pubkey, { unlockStored: true, passphrase }) — authService.switchProfile
now forwards unlockStored/passphrase to requestLogin, which routes to the nsec
provider's unlockStoredSessionActor to restore the signer and activate the account.
- Wrong PIN -> "Incorrect PIN/passphrase" toast (decrypt-failed).
So NIP-07 -> nsec switching works fluently. NIP-46 switching is unchanged (follow-up to
verify/extend); multiple-nsec accounts still share one stored slot (follow-up).
Tests: tests/stored-nsec-switch.test.mjs (6), tests/password-prompt.test.mjs (6). Build +
lint clean; auth suites 25/25.
Co-Authored-By: Claude Opus 4.8
---
js/app/authSessionCoordinator.js | 60 +++++++++++++++-
js/app/storedNsecSwitch.js | 24 +++++++
js/services/authService.js | 11 +++
js/ui/promptDialog.js | 111 +++++++++++++++++++++++++++++
tests/password-prompt.test.mjs | 71 ++++++++++++++++++
tests/stored-nsec-switch.test.mjs | 49 +++++++++++++
todo/TODO_2026-06-20_pre-launch.md | 35 +++++----
7 files changed, 346 insertions(+), 15 deletions(-)
create mode 100644 js/app/storedNsecSwitch.js
create mode 100644 js/ui/promptDialog.js
create mode 100644 tests/password-prompt.test.mjs
create mode 100644 tests/stored-nsec-switch.test.mjs
diff --git a/js/app/authSessionCoordinator.js b/js/app/authSessionCoordinator.js
index 22fc2f9a..e156e7d3 100644
--- a/js/app/authSessionCoordinator.js
+++ b/js/app/authSessionCoordinator.js
@@ -13,6 +13,8 @@ import { clearDecryptionSchemeCache } from "../nostr/decryptionSchemeCache.js";
import { clearWatchHistoryConversationKeyCache } from "../nostr/watchHistory.js";
import { clearWatchHistoryDecryptedChunkCache } from "../nostr/watchHistoryDecryptCache.js";
import { FEED_TYPES, FEATURE_TRENDING_FEED } from "../constants.js";
+import { showPasswordPrompt } from "../ui/promptDialog.js";
+import { evaluateStoredNsecSwitch } from "./storedNsecSwitch.js";
/**
* Kick a background watch-history refresh after login, once the feed-driving
@@ -1372,12 +1374,68 @@ export function createAuthSessionCoordinator(deps) {
}
},
+ // For an nsec target, ensure its stored key can be unlocked and collect the
+ // passphrase. Returns { status: "ok", passphrase } | { status: "cancelled" } |
+ // { status: "error", reason }.
+ async prepareStoredNsecSwitch(targetPubkey) {
+ const meta =
+ typeof nostrClient?.getStoredSessionActorMetadata === "function"
+ ? nostrClient.getStoredSessionActorMetadata()
+ : null;
+ const decision = evaluateStoredNsecSwitch(meta, targetPubkey);
+ if (decision.action === "error") {
+ this.showError?.(
+ decision.reason === "pubkey-mismatch"
+ ? "A different account's key is saved on this device. Log in with this account's nsec to switch to it."
+ : "This account's key isn't saved on this device. Log in with its nsec to switch to it.",
+ );
+ return { status: "error", reason: decision.reason };
+ }
+ const passphrase = await showPasswordPrompt(
+ "Enter your PIN / passphrase to unlock this account's saved key.",
+ { title: "Switch account", confirmLabel: "Switch" },
+ );
+ if (passphrase == null || passphrase === "") {
+ return { status: "cancelled", reason: "cancelled" };
+ }
+ return { status: "ok", passphrase };
+ },
+
async handleProfileSwitchRequest({ pubkey, providerId } = {}) {
if (!pubkey) {
throw new Error("Missing pubkey for profile switch request.");
}
- const result = await this.authService.switchProfile(pubkey, { providerId });
+ // Switching to a saved nsec account needs its passphrase — the private key is
+ // stored encrypted, so switchProfile alone throws "secret-required". Prompt for
+ // the PIN and unlock the stored key so the switch actually works (matches #36).
+ let switchOptions = { providerId };
+ const isNsecTarget =
+ String(providerId || "").trim().toLowerCase() === "nsec";
+ if (isNsecTarget) {
+ const prep = await this.prepareStoredNsecSwitch(pubkey);
+ if (prep.status !== "ok") {
+ return { switched: false, reason: prep.reason || prep.status };
+ }
+ switchOptions = {
+ providerId,
+ unlockStored: true,
+ passphrase: prep.passphrase,
+ };
+ }
+
+ let result;
+ try {
+ result = await this.authService.switchProfile(pubkey, switchOptions);
+ } catch (error) {
+ if (isNsecTarget && error?.code === "decrypt-failed") {
+ this.showError?.(
+ "Incorrect PIN / passphrase. Try switching to this account again.",
+ );
+ return { switched: false, reason: "bad-passphrase" };
+ }
+ throw error;
+ }
if (result?.switched) {
const detail = result.detail || null;
diff --git a/js/app/storedNsecSwitch.js b/js/app/storedNsecSwitch.js
new file mode 100644
index 00000000..37b6a036
--- /dev/null
+++ b/js/app/storedNsecSwitch.js
@@ -0,0 +1,24 @@
+// Decide whether switching to a saved nsec account can proceed via the stored key.
+// Pure so the branching is unit-testable; the coordinator handles the prompt + I/O.
+//
+// { action: "prompt" } → stored nsec key matches; ask for the PIN
+// { action: "error", reason: "no-stored-key" } → no persisted nsec key on this device
+// { action: "error", reason: "pubkey-mismatch" } → the saved key is a different account
+
+function normalizeHex(value) {
+ return typeof value === "string" && /^[0-9a-f]{64}$/i.test(value.trim())
+ ? value.trim().toLowerCase()
+ : "";
+}
+
+export function evaluateStoredNsecSwitch(meta, targetPubkey) {
+ if (!meta || meta.hasEncryptedKey !== true || meta.source !== "nsec") {
+ return { action: "error", reason: "no-stored-key" };
+ }
+ const stored = normalizeHex(meta.pubkey);
+ const target = normalizeHex(targetPubkey);
+ if (stored && target && stored !== target) {
+ return { action: "error", reason: "pubkey-mismatch" };
+ }
+ return { action: "prompt" };
+}
diff --git a/js/services/authService.js b/js/services/authService.js
index 1715f2ef..16d131b2 100644
--- a/js/services/authService.js
+++ b/js/services/authService.js
@@ -992,6 +992,17 @@ export default class AuthService {
expectPubkey: normalizedTarget,
persistActive: true,
...(providerId ? { providerId } : {}),
+ // Switching to a saved nsec account: unlock its stored key with the passphrase
+ // the caller collected (the private key can't be restored otherwise).
+ ...(normalizedOptions.unlockStored
+ ? {
+ unlockStored: true,
+ passphrase:
+ typeof normalizedOptions.passphrase === "string"
+ ? normalizedOptions.passphrase
+ : "",
+ }
+ : {}),
};
const autoApply = requestOptions.autoApply !== false;
diff --git a/js/ui/promptDialog.js b/js/ui/promptDialog.js
new file mode 100644
index 00000000..33d6c171
--- /dev/null
+++ b/js/ui/promptDialog.js
@@ -0,0 +1,111 @@
+// Promise-based password/PIN prompt rendered with the site's modal styling (sibling of
+// confirmDialog). Used to collect the passphrase that unlocks a stored nsec key — e.g.
+// when switching to a saved nsec account. Resolves the entered string, or null if the
+// user cancels. Builds its own `bv-modal modal-always-on-top` overlay so it stacks above
+// other modals (and is typable thanks to the stacked-modal focus fix).
+
+export function showPasswordPrompt(message, options = {}) {
+ const {
+ title = "",
+ confirmLabel = "Unlock",
+ cancelLabel = "Cancel",
+ placeholder = "PIN / passphrase",
+ } = options || {};
+
+ if (typeof document === "undefined" || !document.body) {
+ return Promise.resolve(null);
+ }
+
+ 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 space-y-3";
+ if (message) {
+ const text = document.createElement("p");
+ text.className = "text-sm text-muted-strong whitespace-pre-line";
+ text.textContent = String(message);
+ body.appendChild(text);
+ }
+ const input = document.createElement("input");
+ input.type = "password";
+ input.className = "input w-full";
+ input.placeholder = placeholder;
+ input.autocomplete = "off";
+ input.setAttribute("aria-label", placeholder);
+ body.appendChild(input);
+ 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 = document.createElement("button");
+ cancelBtn.type = "button";
+ cancelBtn.className = "btn-ghost focus-ring";
+ cancelBtn.textContent = cancelLabel;
+ const confirmBtn = document.createElement("button");
+ confirmBtn.type = "button";
+ confirmBtn.className = "btn focus-ring";
+ confirmBtn.textContent = confirmLabel;
+ 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);
+ };
+ const submit = () => cleanup(input.value);
+ function onKeydown(event) {
+ if (event.key === "Escape") {
+ event.preventDefault();
+ cleanup(null);
+ }
+ }
+
+ cancelBtn.addEventListener("click", () => cleanup(null));
+ backdrop.addEventListener("click", () => cleanup(null));
+ confirmBtn.addEventListener("click", submit);
+ input.addEventListener("keydown", (event) => {
+ if (event.key === "Enter") {
+ event.preventDefault();
+ submit();
+ }
+ });
+ document.addEventListener("keydown", onKeydown, true);
+
+ document.body.appendChild(overlay);
+ input.focus();
+ });
+}
+
+export default showPasswordPrompt;
diff --git a/tests/password-prompt.test.mjs b/tests/password-prompt.test.mjs
new file mode 100644
index 00000000..86ee8fa2
--- /dev/null
+++ b/tests/password-prompt.test.mjs
@@ -0,0 +1,71 @@
+// showPasswordPrompt() collects a passphrase/PIN via a styled dialog (used to unlock a
+// stored nsec key when switching accounts). Resolves the entered string, or null when
+// the user cancels/escapes.
+
+import test, { beforeEach, afterEach } from "node:test";
+import { strict as assert } from "node:assert";
+import { JSDOM } from "jsdom";
+import { showPasswordPrompt } from "../js/ui/promptDialog.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;
+});
+
+const overlay = () => dom.window.document.querySelector(".bv-modal");
+const input = () => overlay().querySelector('input[type="password"]');
+
+test("resolves the entered value on Confirm and removes the dialog", async () => {
+ const p = showPasswordPrompt("Enter your PIN", { confirmLabel: "Switch" });
+ input().value = "hunter2";
+ overlay().querySelector(".btn").click(); // confirm (cancel is .btn-ghost)
+ assert.equal(await p, "hunter2");
+ assert.equal(overlay(), null, "dialog removed after resolving");
+});
+
+test("resolves the value when Enter is pressed in the input", async () => {
+ const p = showPasswordPrompt("PIN");
+ const el = input();
+ el.value = "s3cret";
+ el.dispatchEvent(
+ new dom.window.KeyboardEvent("keydown", { key: "Enter", bubbles: true }),
+ );
+ assert.equal(await p, "s3cret");
+});
+
+test("resolves null when cancelled", async () => {
+ const p = showPasswordPrompt("PIN");
+ overlay().querySelector(".btn-ghost").click();
+ assert.equal(await p, null);
+});
+
+test("resolves null on Escape", async () => {
+ const p = showPasswordPrompt("PIN");
+ dom.window.document.dispatchEvent(
+ new dom.window.KeyboardEvent("keydown", { key: "Escape", bubbles: true }),
+ );
+ assert.equal(await p, null);
+});
+
+test("focuses the input so the user can type immediately", async () => {
+ const p = showPasswordPrompt("PIN");
+ assert.equal(dom.window.document.activeElement, input());
+ overlay().querySelector(".btn-ghost").click();
+ await p;
+});
+
+test("without a DOM it resolves null rather than throwing", async () => {
+ delete globalThis.document;
+ delete globalThis.window;
+ assert.equal(await showPasswordPrompt("PIN"), null);
+});
diff --git a/tests/stored-nsec-switch.test.mjs b/tests/stored-nsec-switch.test.mjs
new file mode 100644
index 00000000..e3aa89d6
--- /dev/null
+++ b/tests/stored-nsec-switch.test.mjs
@@ -0,0 +1,49 @@
+// #48: switching to a saved nsec account must prompt for the PIN and unlock its stored
+// key (switchProfile alone threw "secret-required"). evaluateStoredNsecSwitch is the
+// pure gate: proceed to prompt when a matching stored nsec key exists; error otherwise.
+
+import test from "node:test";
+import { strict as assert } from "node:assert";
+import { evaluateStoredNsecSwitch } from "../js/app/storedNsecSwitch.js";
+
+const A = "a".repeat(64);
+const B = "b".repeat(64);
+const nsecMeta = (pubkey = A) => ({ hasEncryptedKey: true, pubkey, source: "nsec" });
+
+test("prompts when a stored nsec key matches the target account", () => {
+ assert.deepEqual(evaluateStoredNsecSwitch(nsecMeta(A), A), { action: "prompt" });
+});
+
+test("prompts when the stored key has no pubkey to compare (can't rule it out)", () => {
+ assert.deepEqual(
+ evaluateStoredNsecSwitch({ hasEncryptedKey: true, source: "nsec" }, A),
+ { action: "prompt" },
+ );
+});
+
+test("errors when there is no stored session at all", () => {
+ assert.deepEqual(evaluateStoredNsecSwitch(null, A), {
+ action: "error",
+ reason: "no-stored-key",
+ });
+});
+
+test("errors when the stored key isn't an nsec (e.g. left-over non-nsec metadata)", () => {
+ assert.deepEqual(
+ evaluateStoredNsecSwitch({ hasEncryptedKey: true, pubkey: A, source: "extension" }, A),
+ { action: "error", reason: "no-stored-key" },
+ );
+});
+
+test("errors when the stored key belongs to a DIFFERENT account", () => {
+ assert.deepEqual(evaluateStoredNsecSwitch(nsecMeta(A), B), {
+ action: "error",
+ reason: "pubkey-mismatch",
+ });
+});
+
+test("case-insensitive pubkey match", () => {
+ assert.deepEqual(evaluateStoredNsecSwitch(nsecMeta(A.toUpperCase()), A), {
+ action: "prompt",
+ });
+});
diff --git a/todo/TODO_2026-06-20_pre-launch.md b/todo/TODO_2026-06-20_pre-launch.md
index 041cc7bb..8b70a0cd 100644
--- a/todo/TODO_2026-06-20_pre-launch.md
+++ b/todo/TODO_2026-06-20_pre-launch.md
@@ -594,20 +594,27 @@ toward freshness and looked identical. Gave each a structural identity:
reliable zap data (couples with the zap system already shipped in #3).
### 48. Fluent profile switching across signing methods (nsec / NIP-46 / NIP-07)
-- [ ] **CONFIRMED NOT DONE (verified 2026-06-30).** Switching to a saved **nsec** profile
- fails: `switchProfile(pubkey, { providerId: "nsec" })` → `requestLogin` → the nsec
- provider's `login()` is called with **no secret/passphrase**, so it throws
- `"secret-required"` and the switch silently fails (caught + logged in
- `profileModalController`). It never prompts for the PIN or routes to the stored-key
- unlock flow. Same root gap as #36 (nsec signer needs the passphrase to be restored),
- but in the SWITCH path. Fix: when switching to a saved nsec profile, detect the
- stored encrypted key (`getStoredSessionActorMetadata`) and prompt for the pin/
- passphrase (route through `unlockStoredSessionActor`, e.g. open the login modal's
- unlock flow), then activate. Apply the same for **NIP-46** (reconnect / re-authorize
- the remote signer on switch if needed). Goal: switch fluently between NIP-07 ⇄ nsec
- ⇄ NIP-46 accounts regardless of the current signing method. NOTE: multiple saved
- nsec accounts share ONE stored session entry today — confirm per-account key storage
- is needed for switching among several nsec profiles.
+- [x] **nsec switching FIXED 2026-06-30.** Switching to a saved nsec profile used to fail
+ silently ("secret-required") — `switchProfile` called the nsec provider with no
+ passphrase and never prompted. Now `handleProfileSwitchRequest` detects an nsec
+ target, checks the stored key (`getStoredSessionActorMetadata` +
+ `evaluateStoredNsecSwitch` — must be a persisted nsec for THIS account), prompts for
+ the PIN/passphrase via a new `showPasswordPrompt` dialog
+ (`js/ui/promptDialog.js`), and calls `switchProfile(pubkey, { unlockStored: true,
+ passphrase })`. `authService.switchProfile` now forwards `unlockStored`/`passphrase`
+ → `requestLogin` → the nsec provider's `unlockStoredSessionActor` restores the signer
+ and activates the account. Wrong PIN → "Incorrect PIN/passphrase" toast; no stored
+ key / different account → a clear "log in with its nsec" toast. So you can switch
+ NIP-07 → nsec fluently. Tests: `tests/stored-nsec-switch.test.mjs` (6, pure gate) +
+ `tests/password-prompt.test.mjs` (6, dialog). Build + lint clean; auth suites 25/25.
+- [ ] **VERIFY + extend to NIP-46 (follow-up).** My change only intercepts nsec targets;
+ NIP-46 switching is unchanged (`switchProfile({ providerId: "nip46" })`). Verify a
+ NIP-07 → NIP-46 (and nsec → NIP-46) switch actually reconnects/re-authorizes the
+ remote signer; if it fails, apply the same "prep before switch" pattern (reconnect
+ the bunker / re-open the NIP-46 connect flow).
+- [ ] **Multiple nsec accounts:** the stored session is a SINGLE slot
+ (`bitvid:sessionActor:v1`), so only the last-persisted nsec key is on the device —
+ switching among several saved nsec accounts needs per-account encrypted-key storage.
### 8. Orphan storage garbage-collection tool
- [x] **Largely delivered by the My Videos tab** (`9d3a0df0`): lists bucket objects no
From abdd11b18c505f710f7b5525d865d395031ad2e8 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Tue, 30 Jun 2026 22:19:13 -0400
Subject: [PATCH 06/48] [auth] #48: NIP-46 switch reconnects stored session +
reject wrong-account switches
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Completes fluent switching across signing methods (NIP-07 / nsec / NIP-46):
- NIP-46 switching: it had the same bug — switchProfile called the provider with no
reuseStored, so it started a fresh handshake (needs a new connect URI/QR) instead of
reconnecting. Now handleProfileSwitchRequest sends { providerId: "nip46",
reuseStored: true } for NIP-46 targets; authService.switchProfile forwards reuseStored;
the nip46 provider's useStoredRemoteSigner reconnects the stored session and activates
the account. On failure -> a clear "reconnect your remote signer" toast.
- Wrong-account guard (all methods): requestLogin now enforces expectPubkey (only set by
switchProfile). A single-slot stored nsec/NIP-46 session resolving to a DIFFERENT
account than requested is rejected with `pubkey-mismatch` instead of silently switching
to the wrong profile. Also protects NIP-07 (switching to an account the extension isn't
currently on fails clearly instead of grabbing the extension's active account).
Tests: tests/authService.test.mjs (+3 — unlockStored/passphrase + reuseStored forwarding,
expectPubkey rejection). Build + lint clean; auth suites green.
Co-Authored-By: Claude Opus 4.8
---
js/app/authSessionCoordinator.js | 20 ++++++++++++-----
js/services/authService.js | 15 +++++++++++++
tests/authService.test.mjs | 35 ++++++++++++++++++++++++++++++
todo/TODO_2026-06-20_pre-launch.md | 19 +++++++++++-----
4 files changed, 79 insertions(+), 10 deletions(-)
diff --git a/js/app/authSessionCoordinator.js b/js/app/authSessionCoordinator.js
index e156e7d3..0ba298eb 100644
--- a/js/app/authSessionCoordinator.js
+++ b/js/app/authSessionCoordinator.js
@@ -1406,12 +1406,14 @@ export function createAuthSessionCoordinator(deps) {
throw new Error("Missing pubkey for profile switch request.");
}
- // Switching to a saved nsec account needs its passphrase — the private key is
- // stored encrypted, so switchProfile alone throws "secret-required". Prompt for
- // the PIN and unlock the stored key so the switch actually works (matches #36).
+ // Different signing methods need help to activate on switch:
+ // - nsec: its key is passphrase-encrypted → prompt for the PIN + unlock it.
+ // - NIP-46: reuse the stored remote-signer session (don't restart a handshake).
+ // - NIP-07: the extension connects directly, no prep needed.
+ const normalizedProviderId = String(providerId || "").trim().toLowerCase();
+ const isNsecTarget = normalizedProviderId === "nsec";
+ const isNip46Target = normalizedProviderId === "nip46";
let switchOptions = { providerId };
- const isNsecTarget =
- String(providerId || "").trim().toLowerCase() === "nsec";
if (isNsecTarget) {
const prep = await this.prepareStoredNsecSwitch(pubkey);
if (prep.status !== "ok") {
@@ -1422,6 +1424,8 @@ export function createAuthSessionCoordinator(deps) {
unlockStored: true,
passphrase: prep.passphrase,
};
+ } else if (isNip46Target) {
+ switchOptions = { providerId, reuseStored: true };
}
let result;
@@ -1434,6 +1438,12 @@ export function createAuthSessionCoordinator(deps) {
);
return { switched: false, reason: "bad-passphrase" };
}
+ if (isNip46Target) {
+ this.showError?.(
+ "Couldn't reconnect to this account's remote signer. Reconnect it (Login → remote signer) to switch to it.",
+ );
+ return { switched: false, reason: "nip46-reconnect-failed" };
+ }
throw error;
}
diff --git a/js/services/authService.js b/js/services/authService.js
index 16d131b2..67542a09 100644
--- a/js/services/authService.js
+++ b/js/services/authService.js
@@ -248,6 +248,18 @@ export default class AuthService {
return { pubkey: null, providerId, authType: detailAuthType, signer };
}
+ // Only switchProfile sets expectPubkey: don't activate a DIFFERENT account than the
+ // one requested (e.g. a single-slot stored nsec/NIP-46 session resolving to another
+ // pubkey). Reject instead of silently switching to the wrong profile.
+ const expectPubkey = this.normalizeHexPubkey(providerOptions?.expectPubkey);
+ if (expectPubkey && this.normalizeHexPubkey(trimmed) !== expectPubkey) {
+ const mismatchError = new Error(
+ "Signed in as a different account than the one selected.",
+ );
+ mismatchError.code = "pubkey-mismatch";
+ throw mismatchError;
+ }
+
if (autoApply === false) {
return { pubkey: trimmed, providerId, authType: detailAuthType, signer };
}
@@ -1003,6 +1015,9 @@ export default class AuthService {
: "",
}
: {}),
+ // Switching to a saved NIP-46 account: reuse the stored remote-signer session
+ // instead of starting a fresh handshake (which would need a new connect URI/QR).
+ ...(normalizedOptions.reuseStored ? { reuseStored: true } : {}),
};
const autoApply = requestOptions.autoApply !== false;
diff --git a/tests/authService.test.mjs b/tests/authService.test.mjs
index 3481aef0..16b92027 100644
--- a/tests/authService.test.mjs
+++ b/tests/authService.test.mjs
@@ -204,5 +204,40 @@ describe("AuthService Coverage", () => {
// So yes, moved to front.
assert.equal(saved[0].pubkey, pubkey1);
});
+
+ // #48: fluent switching across signing methods — switchProfile must forward the
+ // per-method restore options to the provider.
+ test("forwards unlockStored + passphrase to the provider (nsec switch)", async () => {
+ const target = "00000000000000000000000000000000000000000000000000000000000000a1";
+ mockAuthProvider.login.mock.mockImplementation(async () => ({ pubkey: target, authType: "nsec" }));
+ await authService.switchProfile(target, {
+ providerId: "nsec",
+ unlockStored: true,
+ passphrase: "pin1234",
+ });
+ const opts = mockAuthProvider.login.mock.calls.at(-1).arguments[0].options;
+ assert.equal(opts.unlockStored, true);
+ assert.equal(opts.passphrase, "pin1234");
+ });
+
+ test("forwards reuseStored to the provider (NIP-46 switch)", async () => {
+ const target = "00000000000000000000000000000000000000000000000000000000000000b2";
+ mockAuthProvider.login.mock.mockImplementation(async () => ({ pubkey: target, authType: "nip46" }));
+ await authService.switchProfile(target, { providerId: "nip46", reuseStored: true });
+ const opts = mockAuthProvider.login.mock.calls.at(-1).arguments[0].options;
+ assert.equal(opts.reuseStored, true);
+ });
+
+ test("rejects and does NOT activate a DIFFERENT account than requested (expectPubkey)", async () => {
+ const target = "00000000000000000000000000000000000000000000000000000000000000c3";
+ const other = "00000000000000000000000000000000000000000000000000000000000000d4";
+ // A single-slot stored session that resolves to another account:
+ mockAuthProvider.login.mock.mockImplementation(async () => ({ pubkey: other, authType: "nip46" }));
+ await assert.rejects(
+ () => authService.switchProfile(target, { providerId: "nip46", reuseStored: true }),
+ { code: "pubkey-mismatch" },
+ );
+ assert.notEqual(getPubkey(), other, "must not silently switch to the wrong account");
+ });
});
});
diff --git a/todo/TODO_2026-06-20_pre-launch.md b/todo/TODO_2026-06-20_pre-launch.md
index 8b70a0cd..b201aaa6 100644
--- a/todo/TODO_2026-06-20_pre-launch.md
+++ b/todo/TODO_2026-06-20_pre-launch.md
@@ -607,11 +607,20 @@ toward freshness and looked identical. Gave each a structural identity:
key / different account → a clear "log in with its nsec" toast. So you can switch
NIP-07 → nsec fluently. Tests: `tests/stored-nsec-switch.test.mjs` (6, pure gate) +
`tests/password-prompt.test.mjs` (6, dialog). Build + lint clean; auth suites 25/25.
-- [ ] **VERIFY + extend to NIP-46 (follow-up).** My change only intercepts nsec targets;
- NIP-46 switching is unchanged (`switchProfile({ providerId: "nip46" })`). Verify a
- NIP-07 → NIP-46 (and nsec → NIP-46) switch actually reconnects/re-authorizes the
- remote signer; if it fails, apply the same "prep before switch" pattern (reconnect
- the bunker / re-open the NIP-46 connect flow).
+- [x] **NIP-46 switching FIXED 2026-06-30.** Confirmed the same bug: switching to a saved
+ NIP-46 account called the provider with no `reuseStored`, so it started a fresh
+ handshake (needing a new connect URI/QR) instead of reconnecting. Now
+ `handleProfileSwitchRequest` sends `{ providerId: "nip46", reuseStored: true }` for
+ NIP-46 targets → `switchProfile` forwards `reuseStored` → the nip46 provider's
+ `useStoredRemoteSigner` reconnects the stored session and activates the account. On
+ failure → "Couldn't reconnect to this account's remote signer" toast.
+- [x] **Wrong-account guard (all methods) 2026-06-30.** `requestLogin` now enforces
+ `expectPubkey` (only set by `switchProfile`): if a single-slot stored session
+ resolves to a DIFFERENT account than requested, it rejects with `pubkey-mismatch`
+ instead of silently switching to the wrong profile. Also protects NIP-07 (switching
+ to an account the extension isn't currently on now fails clearly instead of grabbing
+ the extension's active account). Tests in `tests/authService.test.mjs` (option
+ forwarding + expectPubkey rejection).
- [ ] **Multiple nsec accounts:** the stored session is a SINGLE slot
(`bitvid:sessionActor:v1`), so only the last-persisted nsec key is on the device —
switching among several saved nsec accounts needs per-account encrypted-key storage.
From 8692dac6715f39065d193487857b9d6a091e6b3f Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Tue, 30 Jun 2026 22:33:03 -0400
Subject: [PATCH 07/48] [nostr-core] Per-account nsec key storage for
multi-account switching (#48)
Move the encrypted-key store from a single slot to a per-pubkey map so
several saved nsec accounts each keep their own key on the device and the
profile switcher can move between them. Previously bitvid:sessionActor:v1
held one key (last-saved wins), so switching among saved nsec accounts
silently failed.
- sessionActor.js: new bitvid:sessionActors:v2 map keyed by pubkey (follows
the existing bitvid:: convention). readStoredSessionActorEntry,
persistSessionActor, clearStoredSessionActor take an optional target pubkey;
legacy v1 slot is kept in sync as the last-saved default and migrated into
the map on first read. IndexedDB mirror keyed per-pubkey. Adds
listStoredSessionActorPubkeys().
- client.js: getStoredSessionActorMetadata / unlockStoredSessionActor accept a
target pubkey; access-denial cleanup forgets only the blocked account.
- nsec provider threads expectPubkey into the unlock so a switch unlocks the
requested account, with the expectPubkey guard as a backstop.
- authSessionCoordinator: look up the target account's stored-key metadata.
NIP-46 multi-account (single slot bitvid:nip46:session:v1) remains as a
follow-up. Tests: tests/session-actor-multi-account.test.mjs (7).
Co-Authored-By: Claude Opus 4.8
---
js/app/authSessionCoordinator.js | 2 +-
js/nostr/client.js | 13 +-
js/nostr/sessionActor.js | 221 ++++++++++++++++++++-
js/services/authProviders/nsec.js | 6 +-
tests/session-actor-multi-account.test.mjs | 123 ++++++++++++
todo/TODO_2026-06-20_pre-launch.md | 22 +-
6 files changed, 372 insertions(+), 15 deletions(-)
create mode 100644 tests/session-actor-multi-account.test.mjs
diff --git a/js/app/authSessionCoordinator.js b/js/app/authSessionCoordinator.js
index 0ba298eb..f6511407 100644
--- a/js/app/authSessionCoordinator.js
+++ b/js/app/authSessionCoordinator.js
@@ -1380,7 +1380,7 @@ export function createAuthSessionCoordinator(deps) {
async prepareStoredNsecSwitch(targetPubkey) {
const meta =
typeof nostrClient?.getStoredSessionActorMetadata === "function"
- ? nostrClient.getStoredSessionActorMetadata()
+ ? nostrClient.getStoredSessionActorMetadata(targetPubkey)
: null;
const decision = evaluateStoredNsecSwitch(meta, targetPubkey);
if (decision.action === "error") {
diff --git a/js/nostr/client.js b/js/nostr/client.js
index 053639da..64da3edc 100644
--- a/js/nostr/client.js
+++ b/js/nostr/client.js
@@ -2597,10 +2597,10 @@ export class NostrClient {
// Metadata about a stored, passphrase-encrypted nsec session (no secrets).
// The login modal reads `hasEncryptedKey` to offer the "unlock saved key"
// flow; without this the unlock UI never appeared.
- getStoredSessionActorMetadata() {
+ getStoredSessionActorMetadata(pubkey) {
let entry = null;
try {
- entry = readStoredSessionActorEntry();
+ entry = readStoredSessionActorEntry(pubkey);
} catch (error) {
devLogger.warn(
"[nostr] Failed to read stored session actor metadata:",
@@ -2623,14 +2623,16 @@ export class NostrClient {
// passphrase and re-activating the signer via the same path as fresh login.
// The access-control validator is enforced before activation, and a
// now-blocked key is forgotten.
- async unlockStoredSessionActor(passphrase, { validator } = {}) {
+ async unlockStoredSessionActor(passphrase, { validator, pubkey: targetPubkey } = {}) {
if (typeof passphrase !== "string" || !passphrase.trim()) {
const error = new Error("A passphrase is required to unlock the saved key.");
error.code = "passphrase-required";
throw error;
}
- const entry = readStoredSessionActorEntry();
+ // When switching accounts, `targetPubkey` selects that account's stored key
+ // so we unlock the one the user asked for — not just the last-saved default.
+ const entry = readStoredSessionActorEntry(targetPubkey);
if (!entry || !entry.privateKeyEncrypted || !entry.encryption) {
const error = new Error("There is no saved key to unlock.");
error.code = "no-stored-session";
@@ -2646,7 +2648,8 @@ export class NostrClient {
validator(storedPubkey);
} catch (error) {
try {
- clearStoredSessionActorEntry();
+ // Forget only the now-blocked account, not every saved key.
+ clearStoredSessionActorEntry(storedPubkey);
} catch (cleanupError) {
devLogger.warn(
"[nostr] Failed to clear stored session after access denial:",
diff --git a/js/nostr/sessionActor.js b/js/nostr/sessionActor.js
index 937a087f..18b98b99 100644
--- a/js/nostr/sessionActor.js
+++ b/js/nostr/sessionActor.js
@@ -1,6 +1,13 @@
import { devLogger } from "../utils/logger.js";
export const SESSION_ACTOR_STORAGE_KEY = "bitvid:sessionActor:v1";
+// Per-pubkey encrypted-key store. Follows the existing `bitvid::`
+// convention (profileCache, nwcSettings, …) so several saved nsec accounts can
+// each keep their own encrypted key on the device and be switched between. The
+// v1 single slot above is kept in sync as the "active/last-saved" default and is
+// migrated into this map on first read.
+export const SESSION_ACTORS_MAP_STORAGE_KEY = "bitvid:sessionActors:v2";
+export const SESSION_ACTORS_MAP_VERSION = 2;
export const SESSION_ACTOR_ENCRYPTION_VERSION = 1;
export const SESSION_ACTOR_KDF_ITERATIONS = 250_000;
export const SESSION_ACTOR_KDF_HASH = "SHA-256";
@@ -114,7 +121,9 @@ function persistSessionActorToIndexedDb(payload) {
return new Promise((resolve, reject) => {
const tx = db.transaction(SESSION_ACTOR_STORE, "readwrite");
const store = tx.objectStore(SESSION_ACTOR_STORE);
- store.put(payload, SESSION_ACTOR_STORAGE_KEY);
+ // Key each account's payload by pubkey so multiple saved accounts don't
+ // clobber one another (legacy single-slot records used the storage key).
+ store.put(payload, normalizePubkeyKey(payload?.pubkey) || SESSION_ACTOR_STORAGE_KEY);
tx.oncomplete = () => resolve(true);
tx.onerror = () =>
reject(tx.error || new Error("Session actor IndexedDB write failed"));
@@ -128,7 +137,7 @@ function persistSessionActorToIndexedDb(payload) {
});
}
-function clearStoredSessionActorIndexedDb() {
+function clearStoredSessionActorIndexedDb(pubkey) {
return openSessionActorDb()
.then((db) => {
if (!db) {
@@ -137,7 +146,14 @@ function clearStoredSessionActorIndexedDb() {
return new Promise((resolve, reject) => {
const tx = db.transaction(SESSION_ACTOR_STORE, "readwrite");
const store = tx.objectStore(SESSION_ACTOR_STORE);
- store.delete(SESSION_ACTOR_STORAGE_KEY);
+ const key = normalizePubkeyKey(pubkey);
+ if (key) {
+ // Forget just this account's key…
+ store.delete(key);
+ } else {
+ // …or wipe everything (logout / legacy single-slot record too).
+ store.clear();
+ }
tx.oncomplete = () => resolve(true);
tx.onerror = () =>
reject(tx.error || new Error("Session actor IndexedDB delete failed"));
@@ -351,7 +367,113 @@ export async function decryptSessionPrivateKey(payload, passphrase) {
return decoder.decode(decrypted);
}
-export function readStoredSessionActorEntry() {
+function normalizePubkeyKey(pubkey) {
+ return typeof pubkey === "string" && pubkey.trim()
+ ? pubkey.trim().toLowerCase()
+ : "";
+}
+
+// Turn a parsed stored payload into a canonical entry, or null if it lacks a
+// usable encrypted key. Never exposes plaintext (privateKey is always "").
+function normalizeStoredActorEntry(parsed) {
+ const pubkey = typeof parsed?.pubkey === "string" ? parsed.pubkey.trim() : "";
+ const privateKeyEncrypted =
+ typeof parsed?.privateKeyEncrypted === "string"
+ ? parsed.privateKeyEncrypted.trim()
+ : "";
+ const encryption = normalizeStoredEncryptionMetadata(parsed?.encryption);
+ const createdAt = Number.isFinite(parsed?.createdAt)
+ ? parsed.createdAt
+ : Date.now();
+ if (!privateKeyEncrypted || !encryption) {
+ return null;
+ }
+ return { pubkey, privateKey: "", privateKeyEncrypted, encryption, createdAt };
+}
+
+// Read the per-pubkey map { [pubkeyLower]: entry } from localStorage.
+function readSessionActorsMap() {
+ if (typeof localStorage === "undefined") {
+ return {};
+ }
+ let raw = null;
+ try {
+ raw = localStorage.getItem(SESSION_ACTORS_MAP_STORAGE_KEY);
+ } catch (error) {
+ devLogger.warn("[nostr] Failed to read session actor map:", error);
+ return {};
+ }
+ if (!raw || typeof raw !== "string") {
+ return {};
+ }
+ try {
+ const parsed = JSON.parse(raw);
+ const entries =
+ parsed && typeof parsed === "object" ? parsed.entries : null;
+ if (!entries || typeof entries !== "object") {
+ return {};
+ }
+ const map = {};
+ for (const key of Object.keys(entries)) {
+ const entry = normalizeStoredActorEntry(entries[key]);
+ if (!entry) {
+ continue;
+ }
+ const normalizedKey = normalizePubkeyKey(entry.pubkey || key);
+ if (normalizedKey) {
+ map[normalizedKey] = entry;
+ }
+ }
+ return map;
+ } catch (error) {
+ devLogger.warn("[nostr] Failed to parse session actor map:", error);
+ try {
+ localStorage.removeItem(SESSION_ACTORS_MAP_STORAGE_KEY);
+ } catch (cleanupError) {
+ devLogger.warn(
+ "[nostr] Failed to clear corrupt session actor map:",
+ cleanupError,
+ );
+ }
+ return {};
+ }
+}
+
+function writeSessionActorsMap(map) {
+ if (typeof localStorage === "undefined") {
+ return;
+ }
+ const entries = {};
+ for (const key of Object.keys(map || {})) {
+ const entry = map[key];
+ if (!entry || !entry.privateKeyEncrypted || !entry.encryption) {
+ continue;
+ }
+ entries[key] = {
+ pubkey: entry.pubkey || key,
+ privateKeyEncrypted: entry.privateKeyEncrypted,
+ encryption: entry.encryption,
+ createdAt: Number.isFinite(entry.createdAt) ? entry.createdAt : Date.now(),
+ };
+ }
+ try {
+ if (!Object.keys(entries).length) {
+ localStorage.removeItem(SESSION_ACTORS_MAP_STORAGE_KEY);
+ return;
+ }
+ localStorage.setItem(
+ SESSION_ACTORS_MAP_STORAGE_KEY,
+ JSON.stringify({ version: SESSION_ACTORS_MAP_VERSION, entries }),
+ );
+ } catch (error) {
+ devLogger.warn("[nostr] Failed to persist session actor map:", error);
+ }
+}
+
+// Read the legacy single-slot (v1) entry. Also handles the legacy plaintext
+// migration: a payload that still carries `privateKey` is re-persisted in the
+// encrypted-only shape, or cleared if it can't be salvaged.
+function readV1Entry() {
if (typeof localStorage === "undefined") {
return null;
}
@@ -427,6 +549,50 @@ export function readStoredSessionActorEntry() {
return null;
}
+// Read a stored, passphrase-encrypted nsec entry.
+// readStoredSessionActorEntry(pubkey) → that specific account's entry (or null)
+// readStoredSessionActorEntry() → the active/last-saved account (v1 slot),
+// for boot restore / backward-compat.
+// The v1 single slot is migrated into the per-pubkey map on first read so
+// existing single-account installs keep working and gain multi-account storage.
+export function readStoredSessionActorEntry(pubkey) {
+ const v1 = readV1Entry();
+ const map = readSessionActorsMap();
+
+ // Migrate the legacy single slot into the per-pubkey map.
+ if (v1 && v1.pubkey) {
+ const key = normalizePubkeyKey(v1.pubkey);
+ if (key && !map[key]) {
+ map[key] = v1;
+ writeSessionActorsMap(map);
+ }
+ }
+
+ const target = normalizePubkeyKey(pubkey);
+ if (target) {
+ if (map[target]) {
+ return map[target];
+ }
+ if (v1 && normalizePubkeyKey(v1.pubkey) === target) {
+ return v1;
+ }
+ return null;
+ }
+
+ // No target: the active/last-saved account (v1 slot). Fall back to the sole
+ // stored account if the v1 slot was cleared but the map still has exactly one.
+ if (v1) {
+ return v1;
+ }
+ const keys = Object.keys(map);
+ return keys.length === 1 ? map[keys[0]] : null;
+}
+
+// Pubkeys (hex, lowercase) of every account with an encrypted key on this device.
+export function listStoredSessionActorPubkeys() {
+ return Object.keys(readSessionActorsMap());
+}
+
export function persistSessionActor(actor) {
if (
!actor ||
@@ -458,6 +624,21 @@ export function persistSessionActor(actor) {
persistSessionActorToIndexedDb(payload);
+ // Store under the account's pubkey so several saved accounts coexist…
+ const key = normalizePubkeyKey(payload.pubkey);
+ if (key) {
+ const map = readSessionActorsMap();
+ map[key] = {
+ pubkey: payload.pubkey,
+ privateKeyEncrypted: payload.privateKeyEncrypted,
+ encryption: payload.encryption,
+ createdAt,
+ };
+ writeSessionActorsMap(map);
+ }
+
+ // …and keep the v1 single slot pointed at the last-saved account so boot
+ // restore and no-arg reads resolve a sensible default.
if (typeof localStorage !== "undefined") {
try {
localStorage.setItem(
@@ -470,7 +651,32 @@ export function persistSessionActor(actor) {
}
}
-export function clearStoredSessionActor() {
+// Forget a stored key. With a pubkey, only that account is removed (the others
+// stay switchable); without one, every stored account is wiped (logout).
+export function clearStoredSessionActor(pubkey) {
+ const target = normalizePubkeyKey(pubkey);
+
+ if (target) {
+ clearStoredSessionActorIndexedDb(target);
+ const map = readSessionActorsMap();
+ if (map[target]) {
+ delete map[target];
+ writeSessionActorsMap(map);
+ }
+ // Drop the v1 default only if it pointed at the account we just removed.
+ if (typeof localStorage !== "undefined") {
+ const v1 = readV1Entry();
+ if (v1 && normalizePubkeyKey(v1.pubkey) === target) {
+ try {
+ localStorage.removeItem(SESSION_ACTOR_STORAGE_KEY);
+ } catch (error) {
+ devLogger.warn("[nostr] Failed to clear stored session actor:", error);
+ }
+ }
+ }
+ return;
+ }
+
clearStoredSessionActorIndexedDb();
if (typeof localStorage !== "undefined") {
@@ -479,6 +685,11 @@ export function clearStoredSessionActor() {
} catch (error) {
devLogger.warn("[nostr] Failed to clear stored session actor:", error);
}
+ try {
+ localStorage.removeItem(SESSION_ACTORS_MAP_STORAGE_KEY);
+ } catch (error) {
+ devLogger.warn("[nostr] Failed to clear session actor map:", error);
+ }
}
}
diff --git a/js/services/authProviders/nsec.js b/js/services/authProviders/nsec.js
index 705005ca..5b1aacbc 100644
--- a/js/services/authProviders/nsec.js
+++ b/js/services/authProviders/nsec.js
@@ -60,6 +60,10 @@ function normalizeOptions(options) {
persist: options.persist === true,
passphrase: typeof options.passphrase === "string" ? options.passphrase : "",
unlockStored: options.unlockStored === true,
+ // Set when switching to a specific saved account, so the stored-key unlock
+ // targets that account rather than the last-saved default.
+ expectPubkey:
+ typeof options.expectPubkey === "string" ? options.expectPubkey.trim() : "",
};
}
@@ -120,7 +124,7 @@ export default {
const unlockResult = await nostrClient.unlockStoredSessionActor(
suppliedPassphrase,
- { validator },
+ { validator, pubkey: normalized.expectPubkey || undefined },
);
const unlockedPubkey = normalizePubkey(unlockResult);
diff --git a/tests/session-actor-multi-account.test.mjs b/tests/session-actor-multi-account.test.mjs
new file mode 100644
index 00000000..a54cf89c
--- /dev/null
+++ b/tests/session-actor-multi-account.test.mjs
@@ -0,0 +1,123 @@
+// Per-account nsec key storage: several saved accounts each keep their own
+// encrypted key on the device so the profile switcher can move between them.
+// Before this, a single slot (bitvid:sessionActor:v1) meant only the last-saved
+// key survived, so switching among saved nsec accounts silently failed.
+//
+// test_integrity_note:
+// change_type: ["new_tests"]
+// scenarios:
+// - id: SCN-nsec-multi-account-storage
+// given: "two nsec accounts each persist their encrypted key"
+// when: "the store is read by a specific pubkey / cleared for one account"
+// then: "each account resolves its own key; clearing one keeps the other"
+// observable_outcomes:
+// - "readStoredSessionActorEntry(pubkey) returns that account's ciphertext"
+// - "clearStoredSessionActor(pubkey) removes only the named account"
+// - "the legacy v1 single slot migrates into the per-pubkey map on read"
+// determinism_controls:
+// - "in-memory localStorage polyfill; no network/clock dependence"
+// anti_cheat_rationale:
+// prevents: ["hard-coded return value", "over-mocking internal logic"]
+// relaxation:
+// did_relax_any_assertion: false
+
+import "./test-helpers/setup-localstorage.mjs";
+import test, { beforeEach } from "node:test";
+import { strict as assert } from "node:assert";
+import {
+ persistSessionActor,
+ readStoredSessionActorEntry,
+ clearStoredSessionActor,
+ listStoredSessionActorPubkeys,
+ SESSION_ACTOR_STORAGE_KEY,
+ SESSION_ACTORS_MAP_STORAGE_KEY,
+} from "../js/nostr/sessionActor.js";
+
+const PUB_A =
+ "00000000000000000000000000000000000000000000000000000000000000a1";
+const PUB_B =
+ "00000000000000000000000000000000000000000000000000000000000000b2";
+
+function actor(pubkey, cipher) {
+ return {
+ pubkey,
+ privateKeyEncrypted: cipher,
+ encryption: { salt: "c2FsdA==", iv: "aXY=", iterations: 1000, hash: "SHA-256" },
+ createdAt: 1700000000000,
+ };
+}
+
+beforeEach(() => {
+ localStorage.clear();
+});
+
+test("each saved account resolves its own encrypted key", () => {
+ persistSessionActor(actor(PUB_A, "cipher-A"));
+ persistSessionActor(actor(PUB_B, "cipher-B"));
+
+ assert.equal(readStoredSessionActorEntry(PUB_A).privateKeyEncrypted, "cipher-A");
+ assert.equal(readStoredSessionActorEntry(PUB_B).privateKeyEncrypted, "cipher-B");
+ // Never leak plaintext through the read path.
+ assert.equal(readStoredSessionActorEntry(PUB_A).privateKey, "");
+});
+
+test("lists every account that has a stored key", () => {
+ persistSessionActor(actor(PUB_A, "cipher-A"));
+ persistSessionActor(actor(PUB_B, "cipher-B"));
+ assert.deepEqual(listStoredSessionActorPubkeys().sort(), [PUB_A, PUB_B].sort());
+});
+
+test("clearing one account keeps the others switchable", () => {
+ persistSessionActor(actor(PUB_A, "cipher-A"));
+ persistSessionActor(actor(PUB_B, "cipher-B"));
+
+ clearStoredSessionActor(PUB_A);
+
+ assert.equal(readStoredSessionActorEntry(PUB_A), null);
+ assert.equal(readStoredSessionActorEntry(PUB_B).privateKeyEncrypted, "cipher-B");
+});
+
+test("no-arg read returns the last-saved account as the default", () => {
+ persistSessionActor(actor(PUB_A, "cipher-A"));
+ persistSessionActor(actor(PUB_B, "cipher-B"));
+ assert.equal(readStoredSessionActorEntry().privateKeyEncrypted, "cipher-B");
+});
+
+test("no-arg clear wipes every stored account (logout)", () => {
+ persistSessionActor(actor(PUB_A, "cipher-A"));
+ persistSessionActor(actor(PUB_B, "cipher-B"));
+
+ clearStoredSessionActor();
+
+ assert.deepEqual(listStoredSessionActorPubkeys(), []);
+ assert.equal(readStoredSessionActorEntry(PUB_A), null);
+ assert.equal(readStoredSessionActorEntry(), null);
+});
+
+test("a legacy v1 single-slot entry migrates into the per-pubkey map", () => {
+ // Simulate an install that only ever had the old single slot.
+ localStorage.setItem(
+ SESSION_ACTOR_STORAGE_KEY,
+ JSON.stringify({
+ pubkey: PUB_A,
+ privateKeyEncrypted: "legacy-cipher",
+ encryption: { salt: "c2FsdA==", iv: "aXY=", iterations: 1000, hash: "SHA-256" },
+ createdAt: 1699999999000,
+ }),
+ );
+ assert.equal(localStorage.getItem(SESSION_ACTORS_MAP_STORAGE_KEY), null);
+
+ // Reading by the account's pubkey works and seeds the v2 map.
+ assert.equal(readStoredSessionActorEntry(PUB_A).privateKeyEncrypted, "legacy-cipher");
+ assert.deepEqual(listStoredSessionActorPubkeys(), [PUB_A]);
+
+ // A second account can now be added alongside the migrated one.
+ persistSessionActor(actor(PUB_B, "cipher-B"));
+ assert.equal(readStoredSessionActorEntry(PUB_A).privateKeyEncrypted, "legacy-cipher");
+ assert.equal(readStoredSessionActorEntry(PUB_B).privateKeyEncrypted, "cipher-B");
+});
+
+test("requesting an account with no stored key returns null (not a wrong one)", () => {
+ persistSessionActor(actor(PUB_A, "cipher-A"));
+ assert.equal(readStoredSessionActorEntry(PUB_B), null);
+});
diff --git a/todo/TODO_2026-06-20_pre-launch.md b/todo/TODO_2026-06-20_pre-launch.md
index b201aaa6..2c360cf0 100644
--- a/todo/TODO_2026-06-20_pre-launch.md
+++ b/todo/TODO_2026-06-20_pre-launch.md
@@ -621,9 +621,25 @@ toward freshness and looked identical. Gave each a structural identity:
to an account the extension isn't currently on now fails clearly instead of grabbing
the extension's active account). Tests in `tests/authService.test.mjs` (option
forwarding + expectPubkey rejection).
-- [ ] **Multiple nsec accounts:** the stored session is a SINGLE slot
- (`bitvid:sessionActor:v1`), so only the last-persisted nsec key is on the device —
- switching among several saved nsec accounts needs per-account encrypted-key storage.
+- [x] **Multiple nsec accounts — DONE 2026-06-30.** The encrypted-key store is now
+ per-pubkey (`bitvid:sessionActors:v2` = `{ [pubkeyLower]: { privateKeyEncrypted,
+ encryption, createdAt } }`), following the existing `bitvid::`
+ convention (profileCache, nwcSettings, …). Several saved nsec accounts each keep
+ their own key on the device, so the switcher can move between them. The legacy v1
+ single slot is kept in sync as the "last-saved default" (boot restore / no-arg
+ reads) and is migrated into the map on first read. `readStoredSessionActorEntry`,
+ `getStoredSessionActorMetadata`, and `unlockStoredSessionActor` all take an optional
+ target pubkey; the nsec provider threads `expectPubkey` through so a switch unlocks
+ the requested account (not the last-saved one), with the `expectPubkey` guard as a
+ backstop. IndexedDB mirror is now keyed per-pubkey too. Added
+ `listStoredSessionActorPubkeys()`. Tests: `tests/session-actor-multi-account.test.mjs`
+ (7 — per-account resolve, list, clear-one-keeps-others, no-arg default, no-arg wipe,
+ v1→v2 migration, missing-account returns null).
+- [ ] **Multiple NIP-46 accounts (remaining):** the remote-signer session is still a
+ SINGLE slot (`bitvid:nip46:session:v1`), so only the last-connected remote signer is
+ restorable — switching among several saved NIP-46 accounts needs the same per-pubkey
+ treatment applied to `js/nostr/nip46Client.js` (session store keyed by user pubkey +
+ `useStoredRemoteSigner({ pubkey })` selecting it). Separate, larger change than nsec.
### 8. Orphan storage garbage-collection tool
- [x] **Largely delivered by the My Videos tab** (`9d3a0df0`): lists bucket objects no
From 95fe88784a4f5e78e4783cd0d853611d2120d429 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Tue, 30 Jun 2026 22:40:22 -0400
Subject: [PATCH 08/48] [nostr-core] Per-account NIP-46 session storage for
multi-account switching (#48)
Mirror the per-account nsec key store for remote-signer sessions: move from a
single slot (bitvid:nip46:session:v1) to a per-user-pubkey map
(bitvid:nip46:sessions:v2) so several saved NIP-46 accounts each keep their own
reconnectable session and the profile switcher can move between them.
Previously only the last-connected session survived, so switching among saved
NIP-46 accounts reconnected the wrong one (or failed).
- nip46Client.js: new per-pubkey session map keyed by userPubkey.
readStoredNip46Session takes an optional target pubkey; writeStoredNip46-
SessionSync writes both the map and the v1 last-connected default;
clearStoredNip46Session(pubkey) forgets one account (no-arg wipes all). Legacy
v1 slot migrated into the map on first read. Adds listStoredNip46SessionPubkeys().
- SignerManager: useStoredRemoteSigner({ pubkey }) selects the target account's
session and, on access-denial, forgets only that account. getStoredNip46-
Metadata(pubkey) is per-account.
- nip46 provider threads expectPubkey into the reuse call so a switch reconnects
the requested account, with the expectPubkey guard as a backstop.
nip46Connector.js is dead code; the live path is entirely in SignerManager.
Tests: tests/nip46-multi-account.test.mjs (7).
Co-Authored-By: Claude Opus 4.8
---
js/nostr/client.js | 4 +-
js/nostr/managers/SignerManager.js | 17 ++-
js/nostr/nip46Client.js | 173 ++++++++++++++++++++++++++++-
js/services/authProviders/nip46.js | 9 +-
tests/nip46-multi-account.test.mjs | 127 +++++++++++++++++++++
todo/TODO_2026-06-20_pre-launch.md | 19 +++-
6 files changed, 334 insertions(+), 15 deletions(-)
create mode 100644 tests/nip46-multi-account.test.mjs
diff --git a/js/nostr/client.js b/js/nostr/client.js
index 64da3edc..38d8993c 100644
--- a/js/nostr/client.js
+++ b/js/nostr/client.js
@@ -2919,8 +2919,8 @@ export class NostrClient {
return this.signerManager.onRemoteSignerChange(listener);
}
- getStoredNip46Metadata() {
- return this.signerManager.getStoredNip46Metadata();
+ getStoredNip46Metadata(pubkey) {
+ return this.signerManager.getStoredNip46Metadata(pubkey);
}
async ensureExtensionPermissions(
diff --git a/js/nostr/managers/SignerManager.js b/js/nostr/managers/SignerManager.js
index 84da75f4..6338d4fc 100644
--- a/js/nostr/managers/SignerManager.js
+++ b/js/nostr/managers/SignerManager.js
@@ -778,8 +778,14 @@ export class SignerManager {
typeof normalizedOptions.validator === "function"
? normalizedOptions.validator
: null;
+ // When switching accounts, select that account's stored session so we
+ // reconnect the one the user asked for — not just the last-connected default.
+ const targetPubkey =
+ typeof normalizedOptions.pubkey === "string"
+ ? normalizedOptions.pubkey.trim().toLowerCase()
+ : "";
- let stored = readStoredNip46Session();
+ let stored = readStoredNip46Session(targetPubkey || undefined);
if (!stored) {
const error = new Error(
"No remote signer session is stored on this device.",
@@ -850,9 +856,10 @@ export class SignerManager {
this.nip46Client = null;
this.emitRemoteSignerChange({ state: "error", error: err });
// Always forget a stored session that was rejected by access control —
- // no point keeping a blocked pubkey's session to retry.
+ // no point keeping a blocked pubkey's session to retry. Forget only the
+ // account we tried, leaving other saved sessions switchable.
if (forgetOnError || err?.code === "remote-signer-access-denied") {
- clearStoredNip46Session();
+ clearStoredNip46Session(stored?.userPubkey || targetPubkey || undefined);
}
throw err;
}
@@ -947,8 +954,8 @@ export class SignerManager {
this.emitRemoteSignerChange();
}
- getStoredNip46Metadata() {
- const stored = readStoredNip46Session() || {};
+ getStoredNip46Metadata(pubkey) {
+ const stored = readStoredNip46Session(pubkey) || {};
const userPubkey =
typeof stored.userPubkey === "string" ? stored.userPubkey : "";
return {
diff --git a/js/nostr/nip46Client.js b/js/nostr/nip46Client.js
index 893d25b5..310e4906 100644
--- a/js/nostr/nip46Client.js
+++ b/js/nostr/nip46Client.js
@@ -46,6 +46,13 @@ import { SHORT_TIMEOUT_MS } from "../constants.js";
export const NIP46_RPC_KIND = 24_133;
const NIP46_SESSION_STORAGE_KEY = "bitvid:nip46:session:v1";
+// Per-user-pubkey remote-signer session store, mirroring the per-account nsec
+// key store (bitvid:sessionActors:v2). Lets several saved NIP-46 accounts each
+// keep their own reconnectable session so the profile switcher can move between
+// them. The v1 single slot above is kept in sync as the last-connected default
+// (boot restore) and migrated into this map on first read.
+const NIP46_SESSIONS_MAP_STORAGE_KEY = "bitvid:nip46:sessions:v2";
+const NIP46_SESSIONS_MAP_VERSION = 2;
const NIP46_PUBLISH_TIMEOUT_MS = 8_000;
const NIP46_PING_TIMEOUT_MS = SHORT_TIMEOUT_MS;
const NIP46_RESPONSE_TIMEOUT_MS = 15_000;
@@ -248,7 +255,88 @@ export function sanitizeStoredNip46Session(candidate) {
};
}
-export function readStoredNip46Session() {
+function normalizeNip46PubkeyKey(pubkey) {
+ return typeof pubkey === "string" && pubkey.trim()
+ ? pubkey.trim().toLowerCase()
+ : "";
+}
+
+// Read the per-user-pubkey session map { [userPubkeyLower]: session }.
+function readNip46SessionsMap() {
+ const storage = getNip46Storage();
+ if (!storage) {
+ return {};
+ }
+ let raw = null;
+ try {
+ raw = storage.getItem(NIP46_SESSIONS_MAP_STORAGE_KEY);
+ } catch (error) {
+ return {};
+ }
+ if (!raw) {
+ return {};
+ }
+ try {
+ const parsed = JSON.parse(raw);
+ const entries =
+ parsed && typeof parsed === "object" ? parsed.entries : null;
+ if (!entries || typeof entries !== "object") {
+ return {};
+ }
+ const map = {};
+ for (const key of Object.keys(entries)) {
+ const session = sanitizeStoredNip46Session(entries[key]);
+ if (!session) {
+ continue;
+ }
+ const normalizedKey = normalizeNip46PubkeyKey(session.userPubkey || key);
+ if (normalizedKey) {
+ map[normalizedKey] = session;
+ }
+ }
+ return map;
+ } catch (error) {
+ try {
+ storage.removeItem(NIP46_SESSIONS_MAP_STORAGE_KEY);
+ } catch (cleanupError) {
+ devLogger.warn(
+ "[nostr] Failed to clear corrupt NIP-46 session map:",
+ cleanupError,
+ );
+ }
+ return {};
+ }
+}
+
+function writeNip46SessionsMap(map) {
+ const storage = getNip46Storage();
+ if (!storage) {
+ return;
+ }
+ const entries = {};
+ for (const key of Object.keys(map || {})) {
+ const session = sanitizeStoredNip46Session(map[key]);
+ if (session) {
+ entries[key] = session;
+ }
+ }
+ try {
+ if (!Object.keys(entries).length) {
+ storage.removeItem(NIP46_SESSIONS_MAP_STORAGE_KEY);
+ return;
+ }
+ storage.setItem(
+ NIP46_SESSIONS_MAP_STORAGE_KEY,
+ JSON.stringify({ version: NIP46_SESSIONS_MAP_VERSION, entries }),
+ );
+ } catch (error) {
+ // ignore persistence failures
+ }
+}
+
+// Read the legacy v1 single slot, re-persisting it in the sanitized (no
+// plaintext) shape if it still carries raw credentials.
+function readV1Nip46Session() {
const storage = getNip46Storage();
if (!storage) {
return null;
@@ -289,6 +377,49 @@ export function readStoredNip46Session() {
}
}
+// Read a stored remote-signer session.
+// readStoredNip46Session(pubkey) → that account's session (or null)
+// readStoredNip46Session() → the last-connected account (v1 slot),
+// for boot restore / backward-compat.
+// The v1 single slot is migrated into the per-pubkey map on first read.
+export function readStoredNip46Session(pubkey) {
+ const v1 = readV1Nip46Session();
+ const map = readNip46SessionsMap();
+
+ // Migrate the legacy single slot into the per-pubkey map.
+ if (v1 && v1.userPubkey) {
+ const key = normalizeNip46PubkeyKey(v1.userPubkey);
+ if (key && !map[key]) {
+ map[key] = v1;
+ writeNip46SessionsMap(map);
+ }
+ }
+
+ const target = normalizeNip46PubkeyKey(pubkey);
+ if (target) {
+ if (map[target]) {
+ return map[target];
+ }
+ if (v1 && normalizeNip46PubkeyKey(v1.userPubkey) === target) {
+ return v1;
+ }
+ return null;
+ }
+
+ // No target: the last-connected account. Fall back to the sole stored session
+ // if the v1 slot was cleared but the map still holds exactly one.
+ if (v1) {
+ return v1;
+ }
+ const keys = Object.keys(map);
+ return keys.length === 1 ? map[keys[0]] : null;
+}
+
+// User pubkeys (hex, lowercase) of every account with a stored session.
+export function listStoredNip46SessionPubkeys() {
+ return Object.keys(readNip46SessionsMap());
+}
+
export function writeStoredNip46SessionSync(payload) {
const storage = getNip46Storage();
if (!storage) {
@@ -321,6 +452,16 @@ export function writeStoredNip46SessionSync(payload) {
return;
}
+ // Store under the account's user pubkey so several saved accounts coexist…
+ const key = normalizeNip46PubkeyKey(normalized.userPubkey);
+ if (key) {
+ const map = readNip46SessionsMap();
+ map[key] = normalized;
+ writeNip46SessionsMap(map);
+ }
+
+ // …and keep the v1 single slot pointed at the last-connected account so boot
+ // restore and no-arg reads resolve a sensible default.
try {
storage.setItem(NIP46_SESSION_STORAGE_KEY, JSON.stringify(normalized));
} catch (error) {
@@ -412,17 +553,45 @@ export async function decryptNip46Session(session, passphrase) {
};
}
-export function clearStoredNip46Session() {
+// Forget a stored session. With a pubkey, only that account is removed (the
+// others stay reconnectable); without one, every stored session is wiped
+// (logout).
+export function clearStoredNip46Session(pubkey) {
const storage = getNip46Storage();
if (!storage) {
return;
}
+ const target = normalizeNip46PubkeyKey(pubkey);
+
+ if (target) {
+ const map = readNip46SessionsMap();
+ if (map[target]) {
+ delete map[target];
+ writeNip46SessionsMap(map);
+ }
+ // Drop the v1 default only if it pointed at the account we just removed.
+ const v1 = readV1Nip46Session();
+ if (v1 && normalizeNip46PubkeyKey(v1.userPubkey) === target) {
+ try {
+ storage.removeItem(NIP46_SESSION_STORAGE_KEY);
+ } catch (error) {
+ // ignore cleanup issues
+ }
+ }
+ return;
+ }
+
try {
storage.removeItem(NIP46_SESSION_STORAGE_KEY);
} catch (error) {
// ignore cleanup issues
}
+ try {
+ storage.removeItem(NIP46_SESSIONS_MAP_STORAGE_KEY);
+ } catch (error) {
+ // ignore cleanup issues
+ }
}
export function decodeNpubToHex(npub) {
diff --git a/js/services/authProviders/nip46.js b/js/services/authProviders/nip46.js
index 3e421c87..ac8a8841 100644
--- a/js/services/authProviders/nip46.js
+++ b/js/services/authProviders/nip46.js
@@ -97,6 +97,10 @@ function normalizeOptions(options) {
handshakeTimeoutMs: Number.isFinite(options.handshakeTimeoutMs)
? Number(options.handshakeTimeoutMs)
: undefined,
+ // Set when switching to a specific saved account, so reusing a stored
+ // session targets that account rather than the last-connected default.
+ expectPubkey:
+ typeof options.expectPubkey === "string" ? options.expectPubkey.trim() : "",
};
}
@@ -163,7 +167,10 @@ export default {
// For now, I will assume I will fix client.js later or accept that stored sessions are trusted?
// No, I must fix useStoredRemoteSigner too.
- result = await nostrClient.useStoredRemoteSigner({ validator });
+ result = await nostrClient.useStoredRemoteSigner({
+ validator,
+ pubkey: normalized.expectPubkey || undefined,
+ });
devLogger.debug("[nip46] Stored remote signer returned", {
pubkey: summarizeHexForLog(normalizePubkey(result)),
});
diff --git a/tests/nip46-multi-account.test.mjs b/tests/nip46-multi-account.test.mjs
new file mode 100644
index 00000000..9d18d896
--- /dev/null
+++ b/tests/nip46-multi-account.test.mjs
@@ -0,0 +1,127 @@
+// Per-account NIP-46 remote-signer session storage: several saved NIP-46
+// accounts each keep their own reconnectable session so the profile switcher
+// can move between them. Before this, a single slot (bitvid:nip46:session:v1)
+// meant only the last-connected session survived, so switching among saved
+// NIP-46 accounts silently reconnected the wrong one (or failed).
+//
+// test_integrity_note:
+// change_type: ["new_tests"]
+// scenarios:
+// - id: SCN-nip46-multi-account-storage
+// given: "two NIP-46 accounts each persist a stored session"
+// when: "the store is read by user pubkey / cleared for one account"
+// then: "each account resolves its own session; clearing one keeps the other"
+// observable_outcomes:
+// - "readStoredNip46Session(pubkey) returns that account's remotePubkey/relays"
+// - "clearStoredNip46Session(pubkey) removes only the named account"
+// - "the legacy v1 single slot migrates into the per-pubkey map on read"
+// determinism_controls:
+// - "in-memory localStorage polyfill; no network/clock dependence"
+// anti_cheat_rationale:
+// prevents: ["hard-coded return value", "over-mocking internal logic"]
+// relaxation:
+// did_relax_any_assertion: false
+
+import "./test-helpers/setup-localstorage.mjs";
+import test, { beforeEach } from "node:test";
+import { strict as assert } from "node:assert";
+import {
+ writeStoredNip46SessionSync,
+ readStoredNip46Session,
+ clearStoredNip46Session,
+ listStoredNip46SessionPubkeys,
+} from "../js/nostr/nip46Client.js";
+
+const USER_A =
+ "00000000000000000000000000000000000000000000000000000000000000a1";
+const USER_B =
+ "00000000000000000000000000000000000000000000000000000000000000b2";
+const REMOTE_A =
+ "1111111111111111111111111111111111111111111111111111111111111111";
+const REMOTE_B =
+ "2222222222222222222222222222222222222222222222222222222222222222";
+const NIP46_SESSION_STORAGE_KEY = "bitvid:nip46:session:v1";
+const NIP46_SESSIONS_MAP_STORAGE_KEY = "bitvid:nip46:sessions:v2";
+
+function session(userPubkey, remotePubkey, relay) {
+ return {
+ version: 1,
+ clientPublicKey:
+ "3333333333333333333333333333333333333333333333333333333333333333",
+ remotePubkey,
+ relays: [relay],
+ encryption: "nip44.v2",
+ userPubkey,
+ lastConnectedAt: 1700000000000,
+ encryptedSecrets: "cipher",
+ keyEncryption: { salt: "c2FsdA==", iv: "aXY=", iterations: 1000, hash: "SHA-256" },
+ };
+}
+
+beforeEach(() => {
+ localStorage.clear();
+});
+
+test("each saved account resolves its own session", () => {
+ writeStoredNip46SessionSync(session(USER_A, REMOTE_A, "wss://a.example"));
+ writeStoredNip46SessionSync(session(USER_B, REMOTE_B, "wss://b.example"));
+
+ assert.equal(readStoredNip46Session(USER_A).remotePubkey, REMOTE_A);
+ assert.equal(readStoredNip46Session(USER_B).remotePubkey, REMOTE_B);
+ assert.deepEqual(readStoredNip46Session(USER_A).relays, ["wss://a.example"]);
+});
+
+test("lists every account that has a stored session", () => {
+ writeStoredNip46SessionSync(session(USER_A, REMOTE_A, "wss://a.example"));
+ writeStoredNip46SessionSync(session(USER_B, REMOTE_B, "wss://b.example"));
+ assert.deepEqual(listStoredNip46SessionPubkeys().sort(), [USER_A, USER_B].sort());
+});
+
+test("clearing one account keeps the others reconnectable", () => {
+ writeStoredNip46SessionSync(session(USER_A, REMOTE_A, "wss://a.example"));
+ writeStoredNip46SessionSync(session(USER_B, REMOTE_B, "wss://b.example"));
+
+ clearStoredNip46Session(USER_A);
+
+ assert.equal(readStoredNip46Session(USER_A), null);
+ assert.equal(readStoredNip46Session(USER_B).remotePubkey, REMOTE_B);
+});
+
+test("no-arg read returns the last-connected account as the default", () => {
+ writeStoredNip46SessionSync(session(USER_A, REMOTE_A, "wss://a.example"));
+ writeStoredNip46SessionSync(session(USER_B, REMOTE_B, "wss://b.example"));
+ assert.equal(readStoredNip46Session().remotePubkey, REMOTE_B);
+});
+
+test("no-arg clear wipes every stored session (logout)", () => {
+ writeStoredNip46SessionSync(session(USER_A, REMOTE_A, "wss://a.example"));
+ writeStoredNip46SessionSync(session(USER_B, REMOTE_B, "wss://b.example"));
+
+ clearStoredNip46Session();
+
+ assert.deepEqual(listStoredNip46SessionPubkeys(), []);
+ assert.equal(readStoredNip46Session(USER_A), null);
+ assert.equal(readStoredNip46Session(), null);
+});
+
+test("a legacy v1 single-slot session migrates into the per-pubkey map", () => {
+ // Simulate an install that only ever had the old single slot.
+ localStorage.setItem(
+ NIP46_SESSION_STORAGE_KEY,
+ JSON.stringify(session(USER_A, REMOTE_A, "wss://a.example")),
+ );
+ assert.equal(localStorage.getItem(NIP46_SESSIONS_MAP_STORAGE_KEY), null);
+
+ assert.equal(readStoredNip46Session(USER_A).remotePubkey, REMOTE_A);
+ assert.deepEqual(listStoredNip46SessionPubkeys(), [USER_A]);
+
+ // A second account can now be added alongside the migrated one.
+ writeStoredNip46SessionSync(session(USER_B, REMOTE_B, "wss://b.example"));
+ assert.equal(readStoredNip46Session(USER_A).remotePubkey, REMOTE_A);
+ assert.equal(readStoredNip46Session(USER_B).remotePubkey, REMOTE_B);
+});
+
+test("requesting an account with no stored session returns null (not a wrong one)", () => {
+ writeStoredNip46SessionSync(session(USER_A, REMOTE_A, "wss://a.example"));
+ assert.equal(readStoredNip46Session(USER_B), null);
+});
diff --git a/todo/TODO_2026-06-20_pre-launch.md b/todo/TODO_2026-06-20_pre-launch.md
index 2c360cf0..0dc12b4f 100644
--- a/todo/TODO_2026-06-20_pre-launch.md
+++ b/todo/TODO_2026-06-20_pre-launch.md
@@ -635,11 +635,20 @@ toward freshness and looked identical. Gave each a structural identity:
`listStoredSessionActorPubkeys()`. Tests: `tests/session-actor-multi-account.test.mjs`
(7 — per-account resolve, list, clear-one-keeps-others, no-arg default, no-arg wipe,
v1→v2 migration, missing-account returns null).
-- [ ] **Multiple NIP-46 accounts (remaining):** the remote-signer session is still a
- SINGLE slot (`bitvid:nip46:session:v1`), so only the last-connected remote signer is
- restorable — switching among several saved NIP-46 accounts needs the same per-pubkey
- treatment applied to `js/nostr/nip46Client.js` (session store keyed by user pubkey +
- `useStoredRemoteSigner({ pubkey })` selecting it). Separate, larger change than nsec.
+- [x] **Multiple NIP-46 accounts — DONE 2026-06-30.** The remote-signer session store is
+ now per-user-pubkey (`bitvid:nip46:sessions:v2` = `{ [userPubkeyLower]: session }`),
+ mirroring the nsec store. Several saved NIP-46 accounts each keep their own
+ reconnectable session, so the switcher can move between them. The legacy v1 single
+ slot is kept in sync as the last-connected default (boot restore) and migrated into
+ the map on first read. `readStoredNip46Session` and `getStoredNip46Metadata` take an
+ optional target pubkey; `writeStoredNip46SessionSync` writes both the map (keyed by
+ `userPubkey`) and the v1 default; `clearStoredNip46Session(pubkey)` forgets one
+ account (no-arg wipes all, for logout). `SignerManager.useStoredRemoteSigner({ pubkey
+ })` selects the target account's session and, on access-denial, forgets only that
+ account; the nsec/nip46 providers thread `expectPubkey` through so a switch
+ reconnects the requested account (with the `expectPubkey` guard as a backstop). Added
+ `listStoredNip46SessionPubkeys()`. (`nip46Connector.js` is dead code — the live path
+ is entirely in `SignerManager`.) Tests: `tests/nip46-multi-account.test.mjs` (7).
### 8. Orphan storage garbage-collection tool
- [x] **Largely delivered by the My Videos tab** (`9d3a0df0`): lists bucket objects no
From 61e4b25de707e32c4dca5b1f491da7efd5a3173f Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Tue, 30 Jun 2026 22:49:02 -0400
Subject: [PATCH 09/48] [feed] Auto-refresh + optimistic insert after
publishing a video (#46)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
After a successful publish, the new video now appears in the feed instantly
without a manual page reload.
- Add nostrClient.ingestLocalVideoEvent(rawEvent) + a thin nostrService wrapper:
optimistically insert the just-published event into the shared active-video
cache. Mirrors live ingestion (convert -> root created_at -> tombstone guard
-> allEvents/activeMap), so it obeys the same dedupe/active-key rules — a
later relay echo of the same event won't duplicate it and an older revision
can't overwrite a newer active entry (#20/#21).
- app.publishVideoNote: inject publishResult.legacy, then refresh via the
canonical refreshAllVideoGrids({ reason: "video-published", forceMainReload:
true }) instead of a bare loadVideos() — refreshes whichever feed is active
(For You / Explore / Recent) plus subscription/channel grids and forces a
render (bypassing the load cooldown) so the injected video shows.
Tests: tests/optimistic-feed-insert.test.mjs (6).
Co-Authored-By: Claude Opus 4.8
---
js/app.js | 26 +++++-
js/nostr/client.js | 56 +++++++++++++
js/services/nostrService.js | 18 +++++
tests/optimistic-feed-insert.test.mjs | 112 ++++++++++++++++++++++++++
todo/TODO_2026-06-20_pre-launch.md | 21 +++--
5 files changed, 225 insertions(+), 8 deletions(-)
create mode 100644 tests/optimistic-feed-insert.test.mjs
diff --git a/js/app.js b/js/app.js
index 3954d30c..8c3fea39 100644
--- a/js/app.js
+++ b/js/app.js
@@ -3197,8 +3197,9 @@ class Application {
}
let relaySummary = null;
+ let publishResult = null;
try {
- const publishResult = await this.nostrService.publishVideoNote(
+ publishResult = await this.nostrService.publishVideoNote(
publishPayload,
this.pubkey,
);
@@ -3219,10 +3220,31 @@ class Application {
this.uploadModal.close();
}
+ // Optimistically insert the just-published note into the shared cache so it
+ // appears in the feed instantly, before the relays echo it back. Obeys the
+ // same dedupe/active-key rules as live ingestion, so a later relay copy
+ // won't duplicate it (see #20/#21).
+ try {
+ if (publishResult?.legacy) {
+ this.nostrService.ingestLocalVideoEvent(publishResult.legacy);
+ }
+ } catch (error) {
+ devLogger.warn(
+ "[Application] Optimistic feed insert after publishing failed:",
+ error,
+ );
+ }
+
let loadVideosError = null;
try {
- await this.loadVideos();
+ // Refresh whichever feed is active (For You / Explore / Recent) plus the
+ // subscription and channel grids — not just the main recent feed — and
+ // force a render so the newly injected video is shown.
+ await this.refreshAllVideoGrids({
+ reason: "video-published",
+ forceMainReload: true,
+ });
} catch (error) {
loadVideosError = error;
devLogger.error(
diff --git a/js/nostr/client.js b/js/nostr/client.js
index 38d8993c..e7fa1fd2 100644
--- a/js/nostr/client.js
+++ b/js/nostr/client.js
@@ -4306,6 +4306,62 @@ export class NostrClient {
);
}
+ // Optimistically add a just-published video event to the local caches so it
+ // shows in the feed immediately, without waiting for it to propagate back
+ // from the relays. Mirrors the per-event ingestion the live subscription and
+ // fetchVideosByAuthors use (convert → root created_at → tombstone guard →
+ // allEvents/activeMap), so it obeys the same dedupe/active-key rules — a later
+ // relay copy of the same event (same created_at) won't displace it, and an
+ // older revision can't overwrite a newer active entry. Returns the ingested
+ // video, or null if the event isn't a usable video note.
+ ingestLocalVideoEvent(rawEvent) {
+ if (!rawEvent || typeof rawEvent !== "object" || !rawEvent.id) {
+ return null;
+ }
+
+ let video;
+ try {
+ video = convertEventToVideo(rawEvent);
+ } catch (error) {
+ devLogger.warn(
+ "[nostr] Failed to convert published event for optimistic feed insert:",
+ error,
+ );
+ return null;
+ }
+ if (!video || video.invalid) {
+ return null;
+ }
+
+ this.applyRootCreatedAt(video);
+ if (this.rawEvents && typeof this.rawEvents.set === "function") {
+ this.rawEvents.set(rawEvent.id, rawEvent);
+ }
+
+ const activeKey = getActiveKey(video);
+ if (video.deleted) {
+ this.recordTombstone(activeKey, video.created_at);
+ return video;
+ }
+
+ this.applyTombstoneGuard(video);
+ if (video.deleted) {
+ // A tombstone guard flipped it to deleted — don't surface it.
+ return video;
+ }
+
+ this.allEvents.set(rawEvent.id, video);
+ this.dirtyEventIds.add(rawEvent.id);
+
+ const existing = this.activeMap.get(activeKey);
+ if (!existing || video.created_at > existing.created_at) {
+ this.activeMap.set(activeKey, video);
+ this.applyRootCreatedAt(video);
+ }
+
+ return video;
+ }
+
handleEvent(event) {
if (!event || typeof event !== "object") return;
diff --git a/js/services/nostrService.js b/js/services/nostrService.js
index 639f2713..203d915f 100644
--- a/js/services/nostrService.js
+++ b/js/services/nostrService.js
@@ -1575,6 +1575,24 @@ export class NostrService {
return this.filterVideos(all, options);
}
+ // Optimistically insert a just-published video into the shared client caches
+ // so the feed can render it before it round-trips through the relays. Keeps
+ // the author index in sync so channel/subscription grids reflect it too.
+ ingestLocalVideoEvent(rawEvent) {
+ if (
+ !this.nostrClient ||
+ typeof this.nostrClient.ingestLocalVideoEvent !== "function"
+ ) {
+ return null;
+ }
+ const video = this.nostrClient.ingestLocalVideoEvent(rawEvent);
+ if (video && !video.deleted && !video.invalid) {
+ this.markAuthorIndexDirty();
+ this.emit("videos:updated", { videos: null, reason: "local-publish" });
+ }
+ return video;
+ }
+
getActiveVideosByAuthors(authors = [], options = {}) {
const candidates = ensureSet(authors);
const normalizedAuthors = new Set();
diff --git a/tests/optimistic-feed-insert.test.mjs b/tests/optimistic-feed-insert.test.mjs
new file mode 100644
index 00000000..fe2896bc
--- /dev/null
+++ b/tests/optimistic-feed-insert.test.mjs
@@ -0,0 +1,112 @@
+// Optimistic feed insert after publishing (#46): a just-published video is
+// added to the client's active-video cache immediately so it shows in the feed
+// without waiting for the relays to echo it back — while still obeying the same
+// dedupe / active-key rules as live ingestion, so a relay copy of the same
+// event can't duplicate it and an older revision can't overwrite a newer one.
+//
+// test_integrity_note:
+// change_type: ["new_tests"]
+// scenarios:
+// - id: SCN-optimistic-feed-insert
+// given: "a freshly signed video event"
+// when: "ingestLocalVideoEvent is called with it"
+// then: "getActiveVideos includes it exactly once, newest-revision-wins"
+// observable_outcomes:
+// - "getActiveVideos() surfaces the ingested video"
+// - "re-ingesting the same event does not create a duplicate active entry"
+// - "an older revision of the same root does not replace a newer active one"
+// - "an invalid event returns null and inserts nothing"
+// determinism_controls:
+// - "in-memory NostrClient + localStorage polyfill; explicit created_at values"
+// anti_cheat_rationale:
+// prevents: ["hard-coded return value", "over-mocking internal logic"]
+// relaxation:
+// did_relax_any_assertion: false
+
+import "./test-helpers/setup-localstorage.mjs";
+import test from "node:test";
+import { strict as assert } from "node:assert";
+import { NostrClient } from "../js/nostr/client.js";
+
+const PUB = "f".repeat(64);
+
+function videoEvent({ id, root, title, createdAt }) {
+ return {
+ id,
+ pubkey: PUB,
+ kind: 30078,
+ created_at: createdAt,
+ content: JSON.stringify({
+ version: 3,
+ title,
+ url: "https://cdn.example/v.mp4",
+ videoRootId: root,
+ }),
+ tags: [["d", root]],
+ };
+}
+
+test("a published event is surfaced in the active feed immediately", () => {
+ const client = new NostrClient();
+ const video = client.ingestLocalVideoEvent(
+ videoEvent({ id: "evt-1", root: "root-1", title: "Hello", createdAt: 1000 }),
+ );
+
+ assert.ok(video, "returns the ingested video");
+ const active = client.getActiveVideos();
+ assert.equal(active.length, 1);
+ assert.equal(active[0].id, "evt-1");
+ assert.equal(active[0].title, "Hello");
+});
+
+test("re-ingesting the same event does not duplicate it in the feed", () => {
+ const client = new NostrClient();
+ const evt = videoEvent({ id: "evt-1", root: "root-1", title: "Hello", createdAt: 1000 });
+ client.ingestLocalVideoEvent(evt);
+ client.ingestLocalVideoEvent(evt);
+
+ assert.equal(client.getActiveVideos().length, 1, "same event stays a single active entry");
+});
+
+test("a newer revision of the same root replaces the active entry", () => {
+ const client = new NostrClient();
+ client.ingestLocalVideoEvent(
+ videoEvent({ id: "evt-old", root: "root-1", title: "Old", createdAt: 1000 }),
+ );
+ client.ingestLocalVideoEvent(
+ videoEvent({ id: "evt-new", root: "root-1", title: "New", createdAt: 2000 }),
+ );
+
+ const active = client.getActiveVideos();
+ assert.equal(active.length, 1, "one active entry per root");
+ assert.equal(active[0].title, "New", "newest revision wins");
+});
+
+test("an older revision does NOT overwrite a newer active entry", () => {
+ const client = new NostrClient();
+ client.ingestLocalVideoEvent(
+ videoEvent({ id: "evt-new", root: "root-1", title: "New", createdAt: 2000 }),
+ );
+ // A late-arriving older copy (e.g. a stale relay echo) must not clobber it.
+ client.ingestLocalVideoEvent(
+ videoEvent({ id: "evt-old", root: "root-1", title: "Old", createdAt: 1000 }),
+ );
+
+ const active = client.getActiveVideos();
+ assert.equal(active.length, 1);
+ assert.equal(active[0].title, "New", "older revision must not displace the newer active entry");
+});
+
+test("an invalid event returns null and inserts nothing", () => {
+ const client = new NostrClient();
+ const result = client.ingestLocalVideoEvent({ id: "bad", content: "not-json", tags: [] });
+ assert.equal(result, null);
+ assert.equal(client.getActiveVideos().length, 0);
+});
+
+test("a null/garbage argument is handled without throwing", () => {
+ const client = new NostrClient();
+ assert.equal(client.ingestLocalVideoEvent(null), null);
+ assert.equal(client.ingestLocalVideoEvent({}), null);
+ assert.equal(client.getActiveVideos().length, 0);
+});
diff --git a/todo/TODO_2026-06-20_pre-launch.md b/todo/TODO_2026-06-20_pre-launch.md
index 0dc12b4f..e2cb65dc 100644
--- a/todo/TODO_2026-06-20_pre-launch.md
+++ b/todo/TODO_2026-06-20_pre-launch.md
@@ -579,12 +579,21 @@ toward freshness and looked identical. Gave each a structural identity:
Rank by usage count; clicking a chip adds it to the new video's tags.
### 46. Feed auto-refreshes after posting a video (UX)
-- [ ] After a video note is published, **automatically refresh the feed** so the new
- video appears without a manual page reload. Hook the post-publish success (upload
- modal `upload:submit` / `publishVideoNote` completion) to
- `refreshAllVideoGrids`/`onVideosShouldRefresh` (optimistically inject the just-
- published event so it shows instantly). Mind the feed-identity + dedupe paths (#20/
- #21) so the injected event isn't dropped or duplicated.
+- [x] **DONE 2026-06-30.** `publishVideoNote` now (1) **optimistically injects** the
+ just-published legacy event into the shared client cache via a new
+ `nostrClient.ingestLocalVideoEvent(rawEvent)` (+ thin `nostrService` wrapper), so the
+ new video shows in the feed **instantly**, before the relays echo it back; and (2)
+ refreshes via the canonical `refreshAllVideoGrids({ reason: "video-published",
+ forceMainReload: true })` instead of a bare `loadVideos()` — so it refreshes
+ **whichever feed is active** (For You / Explore / Recent) plus the subscription and
+ channel grids, and forces a render (bypassing the load cooldown) so the injected
+ video is actually shown. `ingestLocalVideoEvent` mirrors live ingestion (convert →
+ root created_at → tombstone guard → allEvents/activeMap), so it obeys the same
+ dedupe/active-key rules (#20/#21): a later relay copy of the same event (same
+ `created_at`) won't duplicate it, and an older revision can't overwrite a newer
+ active entry. Tests: `tests/optimistic-feed-insert.test.mjs` (6 — surfaced instantly,
+ no-duplicate on re-ingest, newer-wins, older-doesn't-clobber, invalid→null, garbage
+ handled).
### 47. "Most Zapped" sidebar tab (trending-by-zaps)
- [ ] Add a **Most Zapped** sidebar tab: like Trending (#27, view-count) but ranked by
From a4549f967f82604f535bdaa1ce5268ed4e5fc0f3 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Wed, 1 Jul 2026 08:47:54 -0400
Subject: [PATCH 10/48] [channel] Fix slow/incomplete channel video grid:
coalesce loads, bound relays, parity source
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Three fixes for "the channel grid loads slowly and not all videos come in even
when they show on other grids":
1. Race condition (primary "won't load" cause). loadUserVideos() bumps a shared
currentVideoLoadToken and only renders if its token is still current. A burst
of refresh triggers — e.g. the NIP-71 ingest flushing repeatedly right after
a channel opens (RELAY STORM in the logs) — supersedes every in-flight load
before it paints, so the grid stays on "Loading videos…". Add
requestChannelVideoLoad(): while a load for the same channel is in flight, a
refresh flags a single trailing rerun instead of starting a competing load;
navigation still starts immediately. All three call sites routed through it.
2. Slowness from hung relays. The per-relay pool.list() fan-out had no timeout,
so one relay that never sends EOSE stalled the merged render. Add
raceListWithTimeout() (exported, pure) bounding each list() to 8s, resolving
to [] on timeout/rejection.
3. Completeness/parity. The channel grid sourced only nostrClient.allEvents + a
scoped relay fetch, which can diverge from the shared feed cache the other
grids render from. getCachedChannelVideoEvents() now also unions the author's
videos from nostrService.getActiveVideosByAuthors() (same videosMap-backed
author index), so a video shown on another grid is present here by construction.
Tests: tests/channel-relay-timeout.test.mjs (5).
Co-Authored-By: Claude Opus 4.8
---
js/channelProfile.js | 115 +++++++++++++++++++++++++--
tests/channel-relay-timeout.test.mjs | 65 +++++++++++++++
2 files changed, 174 insertions(+), 6 deletions(-)
create mode 100644 tests/channel-relay-timeout.test.mjs
diff --git a/js/channelProfile.js b/js/channelProfile.js
index b6e59fe4..26f47a4c 100644
--- a/js/channelProfile.js
+++ b/js/channelProfile.js
@@ -76,6 +76,36 @@ import { RELAY_BACKGROUND_CONCURRENCY } from "./nostr/relayConstants.js";
const getApp = () => getApplication();
+// A single hung relay (one that never sends EOSE) used to stall the whole
+// channel-grid fetch because `pool.list` has no built-in timeout. Bound each
+// relay's list() so a slow relay resolves to "no events" instead of holding up
+// the merged render — the other relays + the shared cache still fill the grid.
+export const CHANNEL_RELAY_LIST_TIMEOUT_MS = 8000;
+
+// Pure, testable core: race a list() call (via a factory) against a timeout.
+// Resolves to the list's array on success, or [] on timeout OR rejection — a
+// failing/hung relay contributes nothing rather than stalling or throwing (and
+// a late rejection after the timeout wins can't surface as unhandled).
+export function raceListWithTimeout(listFn, timeoutMs = CHANNEL_RELAY_LIST_TIMEOUT_MS) {
+ let timer = null;
+ const timeout = new Promise((resolve) => {
+ timer = setTimeout(() => resolve([]), timeoutMs);
+ });
+ const listPromise = Promise.resolve()
+ .then(() => listFn())
+ .then((result) => (Array.isArray(result) ? result : []))
+ .catch(() => []);
+ return Promise.race([listPromise, timeout]).finally(() => {
+ if (timer) {
+ clearTimeout(timer);
+ }
+ });
+}
+
+function listRelayWithTimeout(relays, filters, timeoutMs = CHANNEL_RELAY_LIST_TIMEOUT_MS) {
+ return raceListWithTimeout(() => nostrClient.pool.list(relays, filters), timeoutMs);
+}
+
const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
const CHANNEL_MODERATION_BADGE_ID = "channel-moderation-badge";
@@ -1310,6 +1340,17 @@ let zapPopoverOpenPromise = null;
let channelMenuPopover = null;
let channelMenuOpen = false;
let currentVideoLoadToken = 0;
+// Coalescing state for channel-grid loads. loadUserVideos() bumps
+// currentVideoLoadToken and only renders if its token is still current, so a
+// burst of refresh triggers (e.g. the NIP-71 ingest flushing repeatedly right
+// after open) can supersede every in-flight load before it paints — the grid
+// gets stuck on "Loading videos…". requestChannelVideoLoad() collapses those:
+// while a load for the same channel is in flight, a refresh flags a single
+// trailing rerun instead of starting a competing load. Navigation to a channel
+// (isRefresh:false) still starts immediately (and supersedes a stale channel).
+let channelVideoLoadInFlight = null;
+let channelVideoLoadPubkey = null;
+let channelVideoRerunPending = false;
let currentProfileLoadToken = 0;
const FALLBACK_CHANNEL_BANNER = "assets/jpg/bitvid.jpg";
@@ -3821,7 +3862,7 @@ export async function initChannelProfileView() {
syncChannelShareButtonState();
});
- const videosPromise = loadUserVideos(hexPub).catch((error) => {
+ const videosPromise = requestChannelVideoLoad(hexPub).catch((error) => {
userLogger.error("Failed to load channel videos:", error);
});
@@ -4448,7 +4489,26 @@ function getCachedChannelVideoEvents(pubkey) {
}
);
- return [...rawMatches, ...processedMatches];
+ // Parity with the other grids: also pull the author's videos from the shared
+ // feed cache (nostrService videosMap → author index) — the SAME source the
+ // main/subscriptions grids render from. The channel grid otherwise sources
+ // only nostrClient.allEvents + a scoped relay fetch, which can DIVERGE from
+ // that cache, so a video shown on another grid could be missing here. Appended
+ // last so it only fills gaps (richer raw/processed copies win the id-dedupe).
+ let parityMatches = [];
+ try {
+ const service = getApp()?.nostrService;
+ if (service && typeof service.getActiveVideosByAuthors === "function") {
+ const parity = service.getActiveVideosByAuthors([normalized]);
+ if (Array.isArray(parity)) {
+ parityMatches = parity;
+ }
+ }
+ } catch (error) {
+ // Best-effort; the allEvents scan + scoped relay fetch still populate the grid.
+ }
+
+ return [...rawMatches, ...processedMatches, ...parityMatches];
}
function normalizeRenderableChannelVideo(entry) {
@@ -5315,7 +5375,7 @@ export function refreshActiveChannelVideoGrid({ reason } = {}) {
}
try {
- const result = loadUserVideos(currentChannelHex);
+ const result = requestChannelVideoLoad(currentChannelHex, { isRefresh: true });
return result instanceof Promise ? result : Promise.resolve(result);
} catch (error) {
userLogger.warn(
@@ -5328,6 +5388,49 @@ export function refreshActiveChannelVideoGrid({ reason } = {}) {
}
}
+// Entry point for channel-grid loads. Coalesces refresh-driven loads so a
+// storm of refreshes can't perpetually cancel each other before rendering.
+// isRefresh:true → if a load for the same channel is in flight, flag one
+// trailing rerun and reuse the in-flight promise.
+// isRefresh:false → navigation; always start (supersedes a stale channel).
+function requestChannelVideoLoad(pubkey, { isRefresh = false } = {}) {
+ const normalized =
+ typeof pubkey === "string" ? pubkey.trim().toLowerCase() : "";
+ if (
+ isRefresh &&
+ channelVideoLoadInFlight &&
+ channelVideoLoadPubkey === normalized
+ ) {
+ channelVideoRerunPending = true;
+ return channelVideoLoadInFlight;
+ }
+ return startChannelVideoLoad(pubkey);
+}
+
+function startChannelVideoLoad(pubkey) {
+ const normalized =
+ typeof pubkey === "string" ? pubkey.trim().toLowerCase() : "";
+ channelVideoLoadPubkey = normalized;
+ const promise = Promise.resolve(loadUserVideos(pubkey)).finally(() => {
+ if (channelVideoLoadInFlight === promise) {
+ channelVideoLoadInFlight = null;
+ }
+ // Fire at most one trailing reload, and only if we're still on this
+ // channel (navigation elsewhere will have started its own load).
+ if (channelVideoRerunPending) {
+ channelVideoRerunPending = false;
+ const stillHere =
+ typeof currentChannelHex === "string" &&
+ currentChannelHex.trim().toLowerCase() === normalized;
+ if (stillHere) {
+ startChannelVideoLoad(currentChannelHex);
+ }
+ }
+ });
+ channelVideoLoadInFlight = promise;
+ return promise;
+}
+
async function loadUserVideos(pubkey) {
const app = getApp();
const container = document.getElementById("channelVideoList");
@@ -5398,7 +5501,7 @@ async function loadUserVideos(pubkey) {
if (relayList.length === 0) {
try {
- const fallbackEvents = await nostrClient.pool.list(
+ const fallbackEvents = await listRelayWithTimeout(
Array.from(DEFAULT_RELAY_URLS),
filters
);
@@ -5411,7 +5514,7 @@ async function loadUserVideos(pubkey) {
} else {
const settled = await pMapSettled(
relayList,
- (url) => nostrClient.pool.list([url], filters),
+ (url) => listRelayWithTimeout([url], filters),
{ concurrency: RELAY_BACKGROUND_CONCURRENCY }
);
settled.forEach((result, index) => {
@@ -5493,7 +5596,7 @@ if (typeof window !== "undefined" && typeof window.addEventListener === "functio
return;
}
- loadUserVideos(currentChannelHex).catch((error) => {
+ requestChannelVideoLoad(currentChannelHex, { isRefresh: true }).catch((error) => {
userLogger.error(
"Failed to refresh channel videos after admin update:",
error
diff --git a/tests/channel-relay-timeout.test.mjs b/tests/channel-relay-timeout.test.mjs
new file mode 100644
index 00000000..2892cd3e
--- /dev/null
+++ b/tests/channel-relay-timeout.test.mjs
@@ -0,0 +1,65 @@
+// Channel-grid relay fetch is bounded (raceListWithTimeout): a hung relay (one
+// that never sends EOSE) must resolve to "no events" instead of stalling the
+// whole grid render. A failing relay likewise contributes [] rather than
+// throwing, and a late rejection after the timeout can't surface as unhandled.
+//
+// test_integrity_note:
+// change_type: ["new_tests"]
+// scenarios:
+// - id: SCN-channel-relay-timeout
+// given: "a relay list() that resolves / hangs / rejects / returns non-array"
+// when: "raceListWithTimeout wraps it with a short timeout"
+// then: "success returns the array; hang/reject/garbage resolve to []"
+// observable_outcomes:
+// - "a fast success passes the array through unchanged"
+// - "a hang resolves to [] within the timeout bound (does not stall)"
+// - "a rejection resolves to [] (does not throw)"
+// determinism_controls:
+// - "explicit short timeoutMs; hang modeled as a never-resolving promise"
+// anti_cheat_rationale:
+// prevents: ["retry/sleep-based flake masking", "hard-coded return value"]
+// relaxation:
+// did_relax_any_assertion: false
+
+import "./test-helpers/setup-localstorage.mjs";
+import test from "node:test";
+import { strict as assert } from "node:assert";
+import { raceListWithTimeout } from "../js/channelProfile.js";
+
+test("passes a fast successful result through unchanged", async () => {
+ const events = [{ id: "a" }, { id: "b" }];
+ const result = await raceListWithTimeout(async () => events, 1000);
+ assert.deepEqual(result, events);
+});
+
+test("a hung relay resolves to [] within the timeout instead of stalling", async () => {
+ const started = Date.now();
+ // Never resolves — models a relay that never sends EOSE.
+ const result = await raceListWithTimeout(() => new Promise(() => {}), 30);
+ assert.deepEqual(result, []);
+ assert.ok(Date.now() - started < 500, "must resolve promptly via the timeout");
+});
+
+test("a rejecting relay resolves to [] rather than throwing", async () => {
+ const result = await raceListWithTimeout(async () => {
+ throw new Error("relay exploded");
+ }, 1000);
+ assert.deepEqual(result, []);
+});
+
+test("a non-array result is normalized to []", async () => {
+ const result = await raceListWithTimeout(async () => null, 1000);
+ assert.deepEqual(result, []);
+});
+
+test("a late rejection after the timeout wins does not throw (no unhandled)", async () => {
+ // The list rejects AFTER the timeout has already resolved to []. The helper
+ // must swallow it so it can't surface as an unhandled rejection.
+ const result = await raceListWithTimeout(
+ () => new Promise((_, reject) => setTimeout(() => reject(new Error("late")), 40)),
+ 10,
+ );
+ assert.deepEqual(result, []);
+ // Give the late rejection a tick to (not) blow up.
+ await new Promise((resolve) => setTimeout(resolve, 60));
+});
From a1ed04b22c7f5079ea62c38afbf6634c1dc39fd5 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Wed, 1 Jul 2026 09:29:43 -0400
Subject: [PATCH 11/48] [dm] Fix NIP-17 send failing with nip17-keygen-failed
(missing bytesToHex import)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Sending a NIP-17 DM builds a kind-1059 gift wrap, which needs a fresh ephemeral
keypair. generateEphemeralKeypair() hex-encodes the generated secret with
bytesToHex — but bytesToHex was never imported in client.js, so the call threw
ReferenceError. The try/catch swallowed it into privateKey="", keygen returned
null, and every NIP-17 send failed with "We couldn't create secure wrapper keys
for NIP-17 delivery." (nip17-keygen-failed). The seal (kind 13) signed fine
because it uses the active signer; only the ephemeral keygen was broken. Build
and lint don't parse JS, so the missing import wasn't caught.
- Add bytesToHex to the canonical js/utils/hex.js and import it in client.js.
- Add tests/nip17-send-gift-wrap.test.mjs: forces the NIP-17 path (useNip17 +
relay hints, nip44-capable signer) and asserts a kind-1059 gift wrap is
published. Reverting the import reproduces the exact nip17-keygen-failed error;
the existing DM tests only covered the legacy kind-4/NIP-04 path (they delete
nip44Encrypt), so this path had no coverage.
Co-Authored-By: Claude Opus 4.8
---
js/nostr/client.js | 2 +-
js/utils/hex.js | 10 ++
tests/nip17-send-gift-wrap.test.mjs | 151 ++++++++++++++++++++++++++++
3 files changed, 162 insertions(+), 1 deletion(-)
create mode 100644 tests/nip17-send-gift-wrap.test.mjs
diff --git a/js/nostr/client.js b/js/nostr/client.js
index e7fa1fd2..c1452801 100644
--- a/js/nostr/client.js
+++ b/js/nostr/client.js
@@ -173,7 +173,7 @@ import {
readStoredSessionActorEntry,
isSessionActor,
} from "./sessionActor.js";
-import { HEX64_REGEX, normalizeHexHash } from "../utils/hex.js";
+import { HEX64_REGEX, normalizeHexHash, bytesToHex } from "../utils/hex.js";
import {
NIP46_RPC_KIND,
NIP46_HANDSHAKE_TIMEOUT_MS,
diff --git a/js/utils/hex.js b/js/utils/hex.js
index 49307eb0..247ead81 100644
--- a/js/utils/hex.js
+++ b/js/utils/hex.js
@@ -12,6 +12,16 @@ export function normalizeHexString(value) {
export const normalizeHexId = normalizeHexString;
export const normalizeHexPubkey = normalizeHexString;
+// Lowercase hex encoding of a byte array (e.g. a generated secret key).
+export function bytesToHex(bytes) {
+ if (!bytes) {
+ return "";
+ }
+ return Array.from(bytes)
+ .map((byte) => byte.toString(16).padStart(2, "0"))
+ .join("");
+}
+
export function normalizeHexHash(value) {
if (value === undefined || value === null) {
return "";
diff --git a/tests/nip17-send-gift-wrap.test.mjs b/tests/nip17-send-gift-wrap.test.mjs
new file mode 100644
index 00000000..56ef3beb
--- /dev/null
+++ b/tests/nip17-send-gift-wrap.test.mjs
@@ -0,0 +1,151 @@
+// Sending a NIP-17 DM (kind 1059 gift wrap) must generate an ephemeral wrapper
+// keypair. That keygen hex-encodes the generated secret via bytesToHex; a
+// missing import made bytesToHex throw (swallowed into "") so keygen returned
+// null and every NIP-17 send failed with `nip17-keygen-failed`. The legacy
+// (kind 4 / NIP-04) send tests delete nip44Encrypt, so they never exercised
+// this path. This test forces the NIP-17 path and asserts a gift wrap is built
+// and published.
+//
+// test_integrity_note:
+// change_type: ["new_tests"]
+// scenarios:
+// - id: SCN-nip17-send-gift-wrap
+// given: "a nip44-capable signer and known DM relays"
+// when: "sendDirectMessage is called with useNip17:true"
+// then: "it resolves ok and publishes a kind-1059 gift wrap (no keygen failure)"
+// observable_outcomes:
+// - "result.ok === true (not { error: 'nip17-keygen-failed' })"
+// - "a kind 1059 gift-wrap event is published to the recipient relay"
+// determinism_controls:
+// - "real nostr-tools injected; pool.publish/list stubbed; relay hints supplied"
+// anti_cheat_rationale:
+// prevents: ["hard-coded return value", "over-mocking internal logic"]
+// relaxation:
+// did_relax_any_assertion: false
+
+import assert from "node:assert/strict";
+import test, { after } from "node:test";
+
+const RELAY_URL = "wss://relay.nip17.test";
+
+after(() => {
+ setTimeout(() => process.exit(0), 100);
+});
+
+test("sendDirectMessage (NIP-17) builds and publishes a kind-1059 gift wrap", async () => {
+ const previousCanonical = globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__;
+ const previousNostrTools = globalThis.NostrTools;
+ const previousReady = globalThis.nostrToolsReady;
+
+ const nostrTools = await import("nostr-tools");
+ const canonicalTools = { ...nostrTools };
+
+ try {
+ globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__ = canonicalTools;
+ globalThis.NostrTools = canonicalTools;
+ globalThis.nostrToolsReady = Promise.resolve({ ok: true, value: canonicalTools });
+
+ const [{ nostrClient }, { getActiveSigner, clearActiveSigner }] =
+ await Promise.all([
+ import("../js/nostrClientFacade.js"),
+ import("../js/nostr/client.js"),
+ ]);
+
+ const previousPool = nostrClient.pool;
+ const previousRelays = nostrClient.relays;
+ const previousPubkey = nostrClient.pubkey;
+
+ const publishInvocations = [];
+ nostrClient.pool = {
+ publish(urls, event) {
+ publishInvocations.push({ urls, event });
+ return {
+ on(eventName, handler) {
+ if (eventName === "ok") setTimeout(() => handler(), 0);
+ return true;
+ },
+ };
+ },
+ list() {
+ return Promise.resolve([]);
+ },
+ };
+ nostrClient.relays = [RELAY_URL];
+ nostrClient.readRelays = [RELAY_URL];
+ nostrClient.writeRelays = [RELAY_URL];
+
+ const senderSecret = canonicalTools.generateSecretKey();
+ const senderPubkey = canonicalTools.getPublicKey(senderSecret);
+ const recipientSecret = canonicalTools.generateSecretKey();
+ const recipientPubkey = canonicalTools.getPublicKey(recipientSecret);
+
+ await nostrClient.registerPrivateKeySigner({
+ privateKey: canonicalTools.utils.bytesToHex(senderSecret),
+ pubkey: senderPubkey,
+ });
+ nostrClient.pubkey = senderPubkey;
+
+ const signer = getActiveSigner();
+ // Keep nip44 (so the NIP-17 path is taken) and give it a working impl.
+ signer.signEvent = (event) => {
+ const finalized = canonicalTools.finalizeEvent(event, senderSecret);
+ return { ...event, id: finalized.id, sig: finalized.sig };
+ };
+ signer.nip44Encrypt = async (peerPubkey, plaintext) => {
+ const key = canonicalTools.nip44.v2.utils.getConversationKey(
+ senderSecret,
+ peerPubkey,
+ );
+ return canonicalTools.nip44.v2.encrypt(plaintext, key);
+ };
+
+ const result = await nostrClient.sendDirectMessage(
+ recipientPubkey,
+ "nip17 gift wrap smoke test",
+ {
+ useNip17: true,
+ recipientRelayHints: [RELAY_URL],
+ senderRelayHints: [RELAY_URL],
+ },
+ );
+
+ assert.equal(
+ result.ok,
+ true,
+ `NIP-17 send should succeed, got: ${JSON.stringify(result)}`,
+ );
+ assert.notEqual(
+ result.error,
+ "nip17-keygen-failed",
+ "ephemeral wrapper keygen must not fail",
+ );
+
+ const giftWraps = publishInvocations.filter(
+ ({ event }) => event?.kind === 1059,
+ );
+ assert.ok(
+ giftWraps.length >= 1,
+ "at least one kind-1059 gift wrap should be published",
+ );
+ // A gift wrap is signed by a fresh ephemeral key, never the sender's.
+ assert.notEqual(
+ giftWraps[0].event.pubkey,
+ senderPubkey,
+ "gift wrap must be authored by the ephemeral wrapper key, not the sender",
+ );
+
+ signer.signEvent = undefined;
+ signer.nip44Encrypt = undefined;
+ clearActiveSigner();
+ nostrClient.pool = previousPool;
+ nostrClient.relays = previousRelays;
+ nostrClient.pubkey = previousPubkey;
+ } finally {
+ if (previousCanonical === undefined) delete globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__;
+ else globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__ = previousCanonical;
+ if (previousNostrTools === undefined) delete globalThis.NostrTools;
+ else globalThis.NostrTools = previousNostrTools;
+ if (previousReady === undefined) delete globalThis.nostrToolsReady;
+ else globalThis.nostrToolsReady = previousReady;
+ }
+});
From e3139e8712a043950f5469b09d6201b69d651fa3 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Wed, 1 Jul 2026 10:14:03 -0400
Subject: [PATCH 12/48] [feed] Break up per-creator clustering in For You /
Trending grids
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The For You (personalized) and Trending tabs ranked strictly by tier/score and
view count, which stacked a creator's whole back-catalogue into one block.
Add spreadAuthors() to the feed sorters: a gentle diversity re-rank that keeps
the caller's ranked order as the priority but avoids runs of the same author —
at each step it takes the highest-ranked remaining item whose author wasn't
among the last `window` picks (default 1), falling back to the plain top item
when every remaining candidate is a recent author. The #1 ranked item is always
chosen first, so "best-fit"/"most-viewed" stays topmost, and each author's items
keep their relative ranked order.
Applied to createForYouScoreSorter (personalized branch) and createTrendingSorter
(live items). Unlike interleaveByAuthor (full round-robin, used only for For
You's no-signal fallback + Explore), this preserves ranking rather than
flattening it, so each tab keeps its unique ordering process — just de-clustered.
Recent (chronological) and Explore (already diversified) are unchanged.
Tests: tests/feed-engine/spread-authors.test.mjs (7 — top preserved, no
avoidable adjacent dupes, per-author order kept, single-author/short/anon edge
cases, window:2 spread).
Co-Authored-By: Claude Opus 4.8
---
js/feedEngine/sorters.js | 48 +++++++-
tests/feed-engine/spread-authors.test.mjs | 132 ++++++++++++++++++++++
2 files changed, 179 insertions(+), 1 deletion(-)
create mode 100644 tests/feed-engine/spread-authors.test.mjs
diff --git a/js/feedEngine/sorters.js b/js/feedEngine/sorters.js
index 66b52878..f23bd52c 100644
--- a/js/feedEngine/sorters.js
+++ b/js/feedEngine/sorters.js
@@ -55,7 +55,9 @@ export function createTrendingSorter() {
const live = items.filter((entry) => !isMuted(entry));
const muted = items.filter((entry) => isMuted(entry));
- return [...live.sort(compare), ...muted.sort(compare)];
+ // Rank by views, then spread authors so the tab isn't walls of one creator.
+ // The most-viewed item is still chosen first, so it stays topmost.
+ return [...spreadAuthors(live.sort(compare)), ...muted.sort(compare)];
};
}
@@ -98,6 +100,47 @@ export function interleaveByAuthor(items, { authorOf } = {}) {
return result;
}
+// Gentle author-diversity re-rank. Unlike interleaveByAuthor (full round-robin,
+// which flattens the ranking), this keeps the caller's ranked order as the
+// priority and only breaks up runs of the same author: at each step it takes the
+// highest-ranked remaining item whose author wasn't among the last `window`
+// picks, falling back to the plain top item when every remaining candidate is a
+// recent author. The #1 ranked item is always chosen first, so "best/most-X on
+// top" is preserved while consecutive cards stop clustering by creator.
+export function spreadAuthors(items, { authorOf, window: windowSize = 1 } = {}) {
+ if (!Array.isArray(items) || items.length < 3) {
+ return Array.isArray(items) ? [...items] : [];
+ }
+ const size = Number.isFinite(windowSize) && windowSize > 0 ? Math.floor(windowSize) : 1;
+ const keyOf =
+ typeof authorOf === "function"
+ ? authorOf
+ : (entry) =>
+ typeof entry?.video?.pubkey === "string" ? entry.video.pubkey : "";
+
+ const remaining = items.map((item, index) => ({ item, key: keyOf(item), index }));
+ const result = [];
+ const recent = []; // authors of the last `size` picks
+ while (remaining.length) {
+ let pickIdx = 0; // default: the highest-ranked remaining item
+ for (let i = 0; i < remaining.length; i += 1) {
+ const { key } = remaining[i];
+ // Anonymous/unknown authors ("") never count as a repeat.
+ if (!key || !recent.includes(key)) {
+ pickIdx = i;
+ break;
+ }
+ }
+ const [picked] = remaining.splice(pickIdx, 1);
+ result.push(picked.item);
+ if (picked.key) {
+ recent.push(picked.key);
+ if (recent.length > size) recent.shift();
+ }
+ }
+ return result;
+}
+
// Ranks the For You feed "your people first": followed authors and interest/
// watch-topic matches (forYouTier, set by the scorer) lead regardless of
// recency, then everyone else by score. When NOTHING is tiered (logged-out / no
@@ -154,6 +197,9 @@ export function createForYouScoreSorter() {
if (tierDiff !== 0) return tierDiff;
return byScore(a, b);
});
+ // Break up per-creator clusters (a followed author's whole back-catalogue
+ // would otherwise sit in one block) while keeping the best-fit item on top.
+ ordered = spreadAuthors(ordered);
} else {
// No signals → discovery fallback: score order, then interleave authors so
// it doesn't read as a chronological clone of Recently-added.
diff --git a/tests/feed-engine/spread-authors.test.mjs b/tests/feed-engine/spread-authors.test.mjs
new file mode 100644
index 00000000..5a73db9f
--- /dev/null
+++ b/tests/feed-engine/spread-authors.test.mjs
@@ -0,0 +1,132 @@
+// spreadAuthors() is a gentle diversity re-rank: it keeps the caller's ranked
+// order as the priority but breaks up runs of the same creator so a feed tab
+// doesn't read as walls of one uploader. The #1 ranked item is always chosen
+// first (so "best-fit"/"most-viewed" stays on top), and each author's items keep
+// their relative ranked order.
+//
+// test_integrity_note:
+// change_type: ["new_tests"]
+// scenarios:
+// - id: SCN-spread-authors
+// given: "a ranked list with clustered authors"
+// when: "spreadAuthors re-ranks it (window 1)"
+// then: "top item is unchanged, no avoidable adjacent same-author, order-within-author preserved"
+// observable_outcomes:
+// - "result[0] === input[0] (top rank preserved)"
+// - "no two consecutive items share an author when another author remains"
+// - "same-author items keep their input relative order"
+// determinism_controls:
+// - "pure function; fixed input arrays; no clock/network"
+// anti_cheat_rationale:
+// prevents: ["hard-coded return value", "snapshot rubber-stamping"]
+// relaxation:
+// did_relax_any_assertion: false
+
+import test from "node:test";
+import { strict as assert } from "node:assert";
+import { spreadAuthors } from "../../js/feedEngine/sorters.js";
+
+const item = (id, pubkey) => ({ video: { id, pubkey } });
+const ids = (list) => list.map((i) => i.video.id);
+const authorsOf = (list) => list.map((i) => i.video.pubkey);
+
+function hasAvoidableAdjacentDupe(list) {
+ const authors = authorsOf(list);
+ const distinct = new Set(authors).size;
+ if (distinct <= 1) return false; // unavoidable
+ for (let i = 1; i < authors.length; i += 1) {
+ if (authors[i] && authors[i] === authors[i - 1]) {
+ return true;
+ }
+ }
+ return false;
+}
+
+test("keeps the #1 ranked item on top", () => {
+ const input = [
+ item("a1", "A"),
+ item("a2", "A"),
+ item("b1", "B"),
+ item("c1", "C"),
+ ];
+ const out = spreadAuthors(input);
+ assert.equal(out[0].video.id, "a1", "highest-ranked item stays first");
+});
+
+test("breaks up a run of one author into non-adjacent slots", () => {
+ // Ranked order clusters author A at the top.
+ const input = [
+ item("a1", "A"),
+ item("a2", "A"),
+ item("a3", "A"),
+ item("b1", "B"),
+ item("c1", "C"),
+ ];
+ const out = spreadAuthors(input);
+ assert.equal(hasAvoidableAdjacentDupe(out), false, "no avoidable adjacent same-author");
+ assert.equal(out.length, input.length, "no items dropped or duplicated");
+ assert.deepEqual(ids(out).sort(), ids(input).sort(), "same set of items");
+});
+
+test("preserves each author's relative ranked order", () => {
+ const input = [
+ item("a1", "A"),
+ item("a2", "A"),
+ item("b1", "B"),
+ item("a3", "A"),
+ item("b2", "B"),
+ ];
+ const out = ids(spreadAuthors(input));
+ // A's items stay in a1 < a2 < a3 order; B's in b1 < b2 order.
+ assert.ok(out.indexOf("a1") < out.indexOf("a2"), "a1 before a2");
+ assert.ok(out.indexOf("a2") < out.indexOf("a3"), "a2 before a3");
+ assert.ok(out.indexOf("b1") < out.indexOf("b2"), "b1 before b2");
+});
+
+test("all-one-author input is returned in original order", () => {
+ const input = [item("a1", "A"), item("a2", "A"), item("a3", "A")];
+ assert.deepEqual(ids(spreadAuthors(input)), ["a1", "a2", "a3"]);
+});
+
+test("lists shorter than 3 are returned unchanged (copy)", () => {
+ const input = [item("a1", "A"), item("a2", "A")];
+ const out = spreadAuthors(input);
+ assert.deepEqual(ids(out), ["a1", "a2"]);
+ assert.notEqual(out, input, "returns a copy, not the same reference");
+});
+
+test("anonymous authors are allowed to sit adjacent (not treated as repeats)", () => {
+ const input = [
+ item("x1", ""),
+ item("x2", ""),
+ item("x3", ""),
+ ];
+ // No real author to diversify against → order preserved, no infinite defer.
+ assert.deepEqual(ids(spreadAuthors(input)), ["x1", "x2", "x3"]);
+});
+
+test("window:2 spreads a clustered author across the feed (tail dupes only when forced)", () => {
+ // A is 50% of the list; a larger window pushes A's items further apart.
+ const input = [
+ item("a1", "A"),
+ item("a2", "A"),
+ item("a3", "A"),
+ item("b1", "B"),
+ item("c1", "C"),
+ item("d1", "D"),
+ ];
+ const out = authorsOf(spreadAuthors(input, { window: 2 }));
+ assert.equal(out[0], "A", "top item preserved");
+ // No adjacent dupes (always achievable here since no author exceeds 50%).
+ for (let i = 1; i < out.length; i += 1) {
+ assert.notEqual(out[i], out[i - 1], `no adjacent dupe at ${i}`);
+ }
+ // A's three items are genuinely spread out, not stacked at the front.
+ const aPositions = out
+ .map((author, idx) => (author === "A" ? idx : -1))
+ .filter((idx) => idx >= 0);
+ assert.ok(
+ aPositions[aPositions.length - 1] - aPositions[0] >= 4,
+ "A's items span most of the feed rather than clustering",
+ );
+});
From cf4ce5c3b8efff98b31a8b5f842498155fc1bc28 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Wed, 1 Jul 2026 10:56:38 -0400
Subject: [PATCH 13/48] [admin] Add sub-tab toolbar + Blocked-videos tool to
the admin pane (#25 follow-up)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The admin pane stacked every list (Moderators / Whitelist / Blacklist) in one
scroll, and the per-event block list had no admin UI (block-only via the ⋯ menu,
no view/unblock). This reorganizes the pane into a sub-tab toolbar and adds the
missing Blocked-videos tool.
- Sub-tabs: a role="tablist" toolbar in #profilePaneAdmin switches between
Whitelist (default), Blacklist, Blocked videos, and Moderators. Moderators is
super-admin-only — its tab button is hidden otherwise. selectAdminSubtab()
shows one section at a time (ARIA + data-state), falling back to the default
when a hidden tab is requested.
- Blocked videos: view the per-event admin block list, unblock per row (confirm
→ removeFromEventBlacklist), and add by nevent/hex id. Rows show the video
title+author when it's in the local cache, else the shortened id. Reuses the
existing accessControl event-blacklist API + onEventBlacklistChange (live
re-render) and refreshes the grids on change. No backend changes.
- Row-building/render logic in js/ui/profileModal/blockedVideosSection.js
(keeps ProfileAdminController under its size cap; unit-testable without a DOM).
Spec correction: profile-modal-controller.test's admin assertion updated for the
sub-tab layout (Moderators section now hidden until its tab is active; its tab
button is shown to a super admin; selecting it reveals the section).
Tests: tests/profile-blocked-videos.test.mjs (7 — rows dedupe/enrich/fallback,
render, unblock wiring); profile-modal-controller admin test updated.
Co-Authored-By: Claude Opus 4.8
---
components/profile-modal.html | 102 ++++++-
js/ui/profileModal/ProfileAdminController.js | 278 ++++++++++++++++++-
js/ui/profileModal/blockedVideosSection.js | 136 +++++++++
tests/profile-blocked-videos.test.mjs | 124 +++++++++
tests/profile-modal-controller.test.mjs | 21 +-
5 files changed, 652 insertions(+), 9 deletions(-)
create mode 100644 js/ui/profileModal/blockedVideosSection.js
create mode 100644 tests/profile-blocked-videos.test.mjs
diff --git a/components/profile-modal.html b/components/profile-modal.html
index f6a268a9..198f88d8 100644
--- a/components/profile-modal.html
+++ b/components/profile-modal.html
@@ -1693,6 +1693,59 @@
+
+
+
+
+
+
+