aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-21 19:53:33 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-21 19:53:33 +0200
commit9f3445d03f106ee3ebd8b4b1bd546a08d9169af7 (patch)
treefabae2b4c9e2fee75a73c5ba1479075be908a584 /packages/meshbay-hub
parent24b563d0073849d0ba74c3fce9822a70c98e9d9f (diff)
downloadmeshbay-9f3445d03f106ee3ebd8b4b1bd546a08d9169af7.tar.gz
fix: ask in the page instead of native confirm/alert
A native confirm() or alert() leaves the desktop client unable to type until the window is refocused. ask.js draws both in the page; the SPA test now bans all three browser dialogs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/admin-page.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/ask.js70
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/chat-app.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/explore-page.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/file-utils.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/files-app.js12
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-settings.js15
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/node-page.js15
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/playlist-menu.js8
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/profile-page.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css3
-rw-r--r--packages/meshbay-hub/tests/harness/playlist_ui_probe.py13
-rw-r--r--packages/meshbay-hub/tests/test_hook_ordering.py2
-rw-r--r--packages/meshbay-hub/tests/test_no_native_dialogs_in_the_spa.py (renamed from packages/meshbay-hub/tests/test_no_prompt_in_the_spa.py)51
-rw-r--r--packages/meshbay-hub/tests/test_playlist_ui.py3
-rw-r--r--packages/meshbay-hub/tests/test_zip_size_limit.py7
26 files changed, 200 insertions, 58 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/admin-page.js b/packages/meshbay-hub/src/meshbay_hub/static/admin-page.js
index d5cc7a8..b3d71dd 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/admin-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/admin-page.js
@@ -2,6 +2,7 @@ import {
html, useState, useEffect, useCallback,
} from './vendor/htm-preact.js';
import { t } from './i18n.js';
+import { ask, tell } from './ask.js';
import { hubFetch } from './hub-client.js';
import { GroupName } from './group-name.js';
@@ -129,12 +130,12 @@ export function AdminPage({ token, role }) {
const deleteUser = useCallback(async (u) => {
// Suspension is the reversible tool and stays one click away; this one is
// not, so it names the account and says what it cannot reach.
- if (!confirm(t('admin.delete_confirm', { user: u.username }))) return;
+ if (!await ask(t('admin.delete_confirm', { user: u.username }))) return;
try {
await hubFetch(`/v1/admin/users/${u.id}`, { method: 'DELETE', token });
loadUsers(userSearch);
} catch (err) {
- alert(err.message);
+ tell(err.message);
}
}, [token, userSearch, loadUsers]);
@@ -157,7 +158,7 @@ export function AdminPage({ token, role }) {
// Suspending is the reversible tool and stays one click away; revoking
// pushes a signed revocation to every node hosting the group and there is
// no undo from here, so it names the group and asks first.
- if (!confirm(t('admin.revoke_group_confirm', { group: g.name }))) return;
+ if (!await ask(t('admin.revoke_group_confirm', { group: g.name }))) return;
try {
await hubFetch('/v1/admin/revoke',
{ method: 'POST', body: { target: 'group', target_id: g.id }, token });
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/ask.js b/packages/meshbay-hub/src/meshbay_hub/static/ask.js
new file mode 100644
index 0000000..8a8b895
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/ask.js
@@ -0,0 +1,70 @@
+import { html, render, useEffect, useRef } from './vendor/htm-preact.js';
+import { t } from './i18n.js';
+
+/**
+ * `confirm` and `alert`, drawn by the page instead of the browser.
+ *
+ * In the desktop client a native `confirm()` leaves the window unable to type:
+ * once it closes, `document.hasFocus()` stays false, a click still moves
+ * `activeElement` to a field, and every keystroke after it goes nowhere — until
+ * the window loses and regains focus. Found as "the Create group fields are
+ * frozen" right after removing a member, and reproduced against Electron 44 by
+ * real X input events (`test_no_native_dialogs_in_the_spa.py`). `prompt` was
+ * already banned for throwing; the other two fail more quietly, and later.
+ *
+ * Both return a promise, so a call site reads as it did: `if (!(await
+ * ask(msg))) return;`.
+ */
+
+function Dialog({ message, cancellable, onDone }) {
+ const okRef = useRef(null);
+ useEffect(() => { if (okRef.current) okRef.current.focus(); }, []);
+
+ const onKeyDown = (e) => {
+ if (e.key === 'Escape') { e.preventDefault(); onDone(!cancellable); }
+ };
+
+ return html`
+ <div class="video-overlay" onClick=${(e) => {
+ if (e.target.classList.contains('video-overlay')) onDone(!cancellable);
+ }}>
+ <form class="music-detail playlist-modal" role="alertdialog" aria-modal="true"
+ onKeyDown=${onKeyDown}
+ onSubmit=${(e) => { e.preventDefault(); onDone(true); }}>
+ <div class="playlist-modal-body">
+ <div class="ask-message">${message}</div>
+ <div class="playlist-modal-actions">
+ ${cancellable && html`
+ <button type="button" class="tb-btn" onClick=${() => onDone(false)}>
+ ${t('dialog.cancel')}</button>`}
+ <button type="submit" class="admin-btn" ref=${okRef}>${t('dialog.ok')}</button>
+ </div>
+ </div>
+ </form>
+ </div>
+ `;
+}
+
+function open(message, cancellable) {
+ return new Promise((resolve) => {
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const previous = document.activeElement;
+ let settled = false;
+ const onDone = (value) => {
+ if (settled) return;
+ settled = true;
+ render(null, host);
+ host.remove();
+ if (previous && previous.isConnected && typeof previous.focus === 'function') {
+ previous.focus();
+ }
+ resolve(value);
+ };
+ render(html`<${Dialog} message=${String(message)} cancellable=${cancellable}
+ onDone=${onDone} />`, host);
+ });
+}
+
+export const ask = (message) => open(message, true);
+export const tell = (message) => open(message, false);
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js b/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js
index 6e9de5f..0057218 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js
@@ -2,6 +2,7 @@ import {
html, useState, useEffect, useLayoutEffect, useCallback, useRef,
} from './vendor/htm-preact.js';
import { t, getLocale } from './i18n.js';
+import { tell } from './ask.js';
import { Icon } from './icon.js';
import { formatSize, CHUNK_SIZE, pipelinedDownload } from './file-utils.js';
@@ -516,7 +517,7 @@ function ChatPanel({ transportRef, username, userId, entries, gekRef,
}]);
jumpToBottom();
} catch (err) {
- alert(err.message);
+ tell(err.message);
} finally {
setAttaching(false);
}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/explore-page.js b/packages/meshbay-hub/src/meshbay_hub/static/explore-page.js
index bb47487..36b63a1 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/explore-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/explore-page.js
@@ -2,6 +2,7 @@ import {
html, useState, useEffect, useCallback,
} from './vendor/htm-preact.js';
import { t } from './i18n.js';
+import { tell } from './ask.js';
import { hubFetch, navigate } from './hub-client.js';
import * as platform from './platform.js';
import { GroupName } from './group-name.js';
@@ -40,7 +41,7 @@ export function ExplorePage({ token, myGroupIds, allowPublicGroups = true }) {
if (err.message.includes('Already a member')) {
navigate(`/group/${gid}`);
} else {
- alert(err.message);
+ tell(err.message);
}
} finally {
setJoining(null);
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js b/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js
index decf916..bdfba6e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js
@@ -1,6 +1,7 @@
import * as downloads from './downloads.js';
import * as platform from './platform.js';
import { t } from './i18n.js';
+import { ask } from './ask.js';
import { ZipStream, entriesUnder } from './zipstream.js';
const FILE_ICONS = {
@@ -556,7 +557,7 @@ async function downloadDirectory(transfers, transport, gek, entries, dir, { setE
accept: { 'application/zip': ['.zip'] } }],
}, 0);
if (target === false) return false;
- if (!target && !confirm(t('group.zip_no_stream', {
+ if (!target && !await ask(t('group.zip_no_stream', {
size: formatSize(totalBytes), name: suggested,
}))) {
return false;
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/files-app.js b/packages/meshbay-hub/src/meshbay_hub/static/files-app.js
index 3f4212e..6aabea3 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/files-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/files-app.js
@@ -2,6 +2,7 @@ import {
html, useState, useEffect, useRef, useCallback,
} from './vendor/htm-preact.js';
import { t } from './i18n.js';
+import { ask } from './ask.js';
import { Icon } from './icon.js';
import { entriesUnder } from './zipstream.js';
import { transfers } from './transfers.js';
@@ -280,9 +281,10 @@ function FilesPanel({
* The name comes from a field in the toolbar rather than `window.prompt`,
* which **throws** in Electron — "prompt() is not supported" — and threw
* outside this function's try, so clicking the button did nothing at all:
- * no folder, no error, nothing in the interface to react to. `confirm()` and
- * `alert()` do work there and are used elsewhere; `prompt` is the one
- * Chromium leaves to the embedder and Electron declines to implement.
+ * no folder, no error, nothing in the interface to react to. `prompt` is the
+ * one Chromium leaves to the embedder and Electron declines to implement;
+ * `confirm()` and `alert()` open but leave the window unable to type after
+ * them, which is why `ask.js` exists.
*
* An inline field is better anyway — it can show the refusal next to the
* input instead of after the dialog has closed.
@@ -636,10 +638,10 @@ function FilesPanel({
}), { disabled: selectedDirs.length === 0 })}
${mayEverDelete && !readOnly && action('trash',
deletableCount ? t('group.delete_n', { n: deletableCount }) : t('group.delete'),
- () => {
+ async () => {
const names = [...deletableFiles.map(e => e.name),
...(operatorPaired ? selectedDirs : [])];
- if (!confirm(t('group.delete_n_confirm', { n: names.length,
+ if (!await ask(t('group.delete_n_confirm', { n: names.length,
names: names.join(', ') }))) return;
run(() => {
for (const e of deletableFiles) deleteFile(e);
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
index d7a1a66..5f811e8 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
@@ -2,6 +2,7 @@ import {
html, useState, useEffect, useCallback, useMemo, useRef,
} from './vendor/htm-preact.js';
import { t } from './i18n.js';
+import { ask } from './ask.js';
import { Icon } from './icon.js';
import { CollapsibleSection, ToggleSwitch } from './settings-ui.js';
import { hubFetch, navigate } from './hub-client.js';
@@ -189,7 +190,7 @@ function SharedDirectoriesTable({ roots, groupId, transport, signFn,
}
return;
}
- if (!confirm(t('node.root_remove_confirm', { name: rootName }))) return;
+ if (!await ask(t('node.root_remove_confirm', { name: rootName }))) return;
const ok = await run(async () => {
if (overMnp) await transport.removeRoot(groupId, rootName, signFn);
else if (overLoopback) {
@@ -531,7 +532,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
}, [approveCode, userId, transportRef, loadDevices]);
const revokeDevice = useCallback(async (device) => {
- if (!confirm(t('device.revoke_confirm'))) return;
+ if (!await ask(t('device.revoke_confirm'))) return;
setDeviceMsg('');
try {
await transportRef.current.revokeDevice(
@@ -1046,7 +1047,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
${isOwner
? html`
<button class="admin-btn danger" onClick=${async () => {
- if (!confirm(t('group.delete_group_confirm', {
+ if (!await ask(t('group.delete_group_confirm', {
name: group.owner_username ? `${group.name}@${group.owner_username}` : group.name,
}))) return;
try {
@@ -1056,7 +1057,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
await platform.node.call('POST', '/api/groups/detach',
{ name: nodeGroupName });
} catch (detachErr) {
- if (!confirm(t('settings_node.detach_failed_continue'))) return;
+ if (!await ask(t('settings_node.detach_failed_continue'))) return;
}
}
await hubFetch('/v1/groups/' + groupId, { method: 'DELETE', token });
@@ -1067,7 +1068,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
`
: html`
<button class="admin-btn danger" onClick=${async () => {
- if (!confirm(t('group.leave_confirm', {
+ if (!await ask(t('group.leave_confirm', {
name: group.owner_username ? `${group.name}@${group.owner_username}` : group.name,
}))) return;
try {
@@ -1146,8 +1147,8 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
<td class="admin-actions">
${isAdmin && m.user_id !== adminId && html`
<button class="admin-btn danger" disabled=${removing === m.user_id}
- onClick=${() => {
- if (!confirm(t('members.remove_confirm', { user: m.username }))) return;
+ onClick=${async () => {
+ if (!await ask(t('members.remove_confirm', { user: m.username }))) return;
removeMember(m);
}}>
${removing === m.user_id ? '...' : t('members.remove')}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
index 989b5ca..e742787 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -1140,4 +1140,8 @@ export default {
'node.root_rw': 'Lesen/Schreiben',
'node.root_ro': 'Nur lesen',
'node.removable': 'wechselbar',
+
+ // In-page confirm/alert (ask.js)
+ 'dialog.ok': 'OK',
+ 'dialog.cancel': 'Abbrechen',
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
index 5042b31..87e5a4a 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -1121,4 +1121,8 @@ export default {
'node.tab_peers': 'Peers',
'node.tab_audit': 'Audit',
'node.tab_settings': 'Settings',
+
+ // In-page confirm/alert (ask.js)
+ 'dialog.ok': 'OK',
+ 'dialog.cancel': 'Cancel',
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
index 80beb15..9e4040d 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -1134,4 +1134,8 @@ export default {
'node.root_rw': 'lectura-escritura',
'node.root_ro': 'solo lectura',
'node.removable': 'extraíble',
+
+ // In-page confirm/alert (ask.js)
+ 'dialog.ok': 'Aceptar',
+ 'dialog.cancel': 'Cancelar',
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
index bc2e221..aaa3e19 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -1149,4 +1149,8 @@ export default {
'node.tab_peers': 'Peers',
'node.tab_audit': 'Audit',
'node.tab_settings': 'Settings',
+
+ // In-page confirm/alert (ask.js)
+ 'dialog.ok': 'OK',
+ 'dialog.cancel': 'Annuler',
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
index 34296e1..a976e09 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -1148,4 +1148,8 @@ export default {
'node.root_rw': 'lettura-scrittura',
'node.root_ro': 'sola lettura',
'node.removable': 'rimovibile',
+
+ // In-page confirm/alert (ask.js)
+ 'dialog.ok': 'OK',
+ 'dialog.cancel': 'Annulla',
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
index d256d1b..2660e7f 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -1132,4 +1132,8 @@ export default {
'node.root_rw': '読み書き',
'node.root_ro': '読み取り専用',
'node.removable': 'リムーバブル',
+
+ // In-page confirm/alert (ask.js)
+ 'dialog.ok': 'OK',
+ 'dialog.cancel': 'キャンセル',
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
index 2c5c854..81ed678 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -1150,4 +1150,8 @@ export default {
'node.root_rw': 'lezen-schrijven',
'node.root_ro': 'alleen-lezen',
'node.removable': 'verwijderbaar',
+
+ // In-page confirm/alert (ask.js)
+ 'dialog.ok': 'OK',
+ 'dialog.cancel': 'Annuleren',
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
index f2cfb8d..bd91a07 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -1176,4 +1176,8 @@ export default {
'node.root_rw': 'odczyt-zapis',
'node.root_ro': 'tylko odczyt',
'node.removable': 'wymienny',
+
+ // In-page confirm/alert (ask.js)
+ 'dialog.ok': 'OK',
+ 'dialog.cancel': 'Anuluj',
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
index 9d51a9c..490f2f9 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
@@ -1135,4 +1135,8 @@ export default {
'node.root_rw': 'leitura-escrita',
'node.root_ro': 'somente leitura',
'node.removable': 'removível',
+
+ // In-page confirm/alert (ask.js)
+ 'dialog.ok': 'OK',
+ 'dialog.cancel': 'Cancelar',
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
index 5ca0571..8609297 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
@@ -1121,4 +1121,8 @@ export default {
'node.root_rw': '读写',
'node.root_ro': '只读',
'node.removable': '可移除',
+
+ // In-page confirm/alert (ask.js)
+ 'dialog.ok': '确定',
+ 'dialog.cancel': '取消',
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/node-page.js b/packages/meshbay-hub/src/meshbay_hub/static/node-page.js
index 0b05dad..a59c100 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/node-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/node-page.js
@@ -2,6 +2,7 @@ import {
html, useState, useEffect, useCallback, useRef,
} from './vendor/htm-preact.js';
import { t } from './i18n.js';
+import { ask } from './ask.js';
import * as platform from './platform.js';
import { Icon } from './icon.js';
import { HUB } from './hub-client.js';
@@ -286,7 +287,7 @@ export function NodePage({ groups, token, username }) {
}, [refresh]);
const removeRoot = useCallback(async (groupId, rootName) => {
- if (!confirm(t('node.root_remove_confirm', { name: rootName }))) return;
+ if (!await ask(t('node.root_remove_confirm', { name: rootName }))) return;
setBusy(true);
setActionMsg('');
try {
@@ -315,7 +316,7 @@ export function NodePage({ groups, token, username }) {
}, []);
const unpinMember = useCallback(async (userId) => {
- if (!confirm(t('node.unpin_confirm', { name: userId }))) return;
+ if (!await ask(t('node.unpin_confirm', { name: userId }))) return;
setBusy(true);
setActionMsg('');
try {
@@ -330,7 +331,7 @@ export function NodePage({ groups, token, username }) {
}, [rosterGroup, loadRoster]);
const rotateGek = useCallback(async (groupId) => {
- if (!confirm(t('node.gek_rotate_confirm'))) return;
+ if (!await ask(t('node.gek_rotate_confirm'))) return;
setBusy(true);
setActionMsg('');
try {
@@ -358,7 +359,7 @@ export function NodePage({ groups, token, username }) {
const clearDenylist = useCallback(async (subject) => {
const label = subject || t('node.denylist_clear_all');
- if (!confirm(t('node.denylist_clear_confirm', { subject: label }))) return;
+ if (!await ask(t('node.denylist_clear_confirm', { subject: label }))) return;
setBusy(true);
setActionMsg('');
try {
@@ -373,7 +374,7 @@ export function NodePage({ groups, token, username }) {
}, [loadDenylist]);
const detachGroup = useCallback(async (name) => {
- if (!confirm(t('node.detach_confirm', { name }))) return;
+ if (!await ask(t('node.detach_confirm', { name }))) return;
setBusy(true);
setActionMsg('');
try {
@@ -640,7 +641,7 @@ export function NodePage({ groups, token, username }) {
}, []);
const unpinNodeMember = useCallback(async (userId) => {
- if (!confirm(t('node.unpin_confirm', { name: userId }))) return;
+ if (!await ask(t('node.unpin_confirm', { name: userId }))) return;
setBusy(true);
setActionMsg('');
try {
@@ -655,7 +656,7 @@ export function NodePage({ groups, token, username }) {
}, [loadNodeRoster]);
const unlinkNode = useCallback(async () => {
- if (!confirm(t('node.unlink_confirm'))) return;
+ if (!await ask(t('node.unlink_confirm'))) return;
setUnlinkBusy(true);
setActionMsg('');
try {
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/playlist-menu.js b/packages/meshbay-hub/src/meshbay_hub/static/playlist-menu.js
index 64d1c62..a65d26d 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/playlist-menu.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/playlist-menu.js
@@ -2,6 +2,7 @@ import {
html, useState, useCallback, useEffect,
} from './vendor/htm-preact.js';
import { t } from './i18n.js';
+import { ask } from './ask.js';
import { Icon } from './icon.js';
import { Menu, useMenu } from './menu.js';
import * as P from './playlists.js';
@@ -123,10 +124,9 @@ function PlaylistMenuButton({ userId, lists, reload, onPlayQueue, onSync, cached
}, [userId, reload, say]);
const deletePlaylist = useCallback(async (p) => {
- // `confirm` and not a component: Electron implements it, a dozen places in
- // this SPA already use it, and a deletion is a tombstone rather than
- // something that can be undone from the interface.
- if (!window.confirm(t('playlists.confirm_delete', { name: p.name }))) return;
+ // Asked first: a deletion is a tombstone rather than something that can be
+ // undone from the interface.
+ if (!await ask(t('playlists.confirm_delete', { name: p.name }))) return;
await P.deletePlaylist(userId, p.id);
await reload();
say(t('playlists.deleted', { name: p.name }));
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js b/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js
index a19b543..ed7a2fa 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js
@@ -2,6 +2,7 @@ import {
html, useState, useEffect, useCallback,
} from './vendor/htm-preact.js';
import { t } from './i18n.js';
+import { ask } from './ask.js';
import { Icon } from './icon.js';
import {
hubFetch, HUB, session, setAuth,
@@ -48,7 +49,7 @@ export function ProfilePage({ user, onLogout }) {
// Every session of this account stops renewing, this one included — the
// case it exists for is a browser left signed in somewhere else.
const signOutEverywhere = useCallback(async () => {
- if (!confirm(t('settings.sign_out_everywhere_confirm'))) return;
+ if (!await ask(t('settings.sign_out_everywhere_confirm'))) return;
setRevoking(true);
setRevokeError('');
try {
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css
index e012a1a..5f16a6c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/style.css
+++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css
@@ -5229,6 +5229,9 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; }
flex-wrap: wrap;
}
+/* ask.js — the page's own confirm/alert, in the playlist modal's frame. */
+.ask-message { white-space: pre-line; line-height: 1.5; overflow-wrap: anywhere; }
+
/* The result of an action, said once and then gone. Not a dialog: adding an
album to a playlist is not a thing anyone should have to dismiss. Fixed
above the player bar, which is itself pinned to the bottom — `--music-bar-h`
diff --git a/packages/meshbay-hub/tests/harness/playlist_ui_probe.py b/packages/meshbay-hub/tests/harness/playlist_ui_probe.py
index e6f99f7..c705244 100644
--- a/packages/meshbay-hub/tests/harness/playlist_ui_probe.py
+++ b/packages/meshbay-hub/tests/harness/playlist_ui_probe.py
@@ -180,11 +180,9 @@ const clickMenu = async (i) => {
['encrypt', 'decrypt']),
v2hkdf: await crypto.subtle.importKey('raw', raw, 'HKDF', false, ['deriveKey']),
};
- // Deleting a playlist asks. A modal dialog blocks the page and would wedge
- // the probe, so the answer is stubbed rather than the question avoided —
- // `confirm` is what the application really calls.
+ // Deleting a playlist asks, in the page (ask.js) — so the probe answers the
+ // dialog the way a person would, by clicking its OK button.
let asked = null;
- window.confirm = (q) => { asked = q; return true; };
render(html`<${Harness} />`, document.getElementById('root'));
@@ -282,6 +280,13 @@ const clickMenu = async (i) => {
await openToolbar();
await clickLabel(t('playlists.delete'));
await clickLabel('Soirée');
+ for (let i = 0; i < 40 && !asked; i++) {
+ const dlg = document.querySelector('[role=alertdialog]');
+ if (dlg) {
+ asked = dlg.querySelector('.ask-message').textContent;
+ dlg.querySelector('button[type=submit]').click();
+ } else await sleep(50);
+ }
await sleep(300);
steps.push({ step: 'deleted', asked: !!asked,
lists: (await P.listPlaylists('u1')).map((p) => p.name) });
diff --git a/packages/meshbay-hub/tests/test_hook_ordering.py b/packages/meshbay-hub/tests/test_hook_ordering.py
index d03cdf5..d5ffcfe 100644
--- a/packages/meshbay-hub/tests/test_hook_ordering.py
+++ b/packages/meshbay-hub/tests/test_hook_ordering.py
@@ -52,6 +52,8 @@ STATIC_FILES = [
# The shared pop-up menu (docs/playlists.md §10.1), reached from the media
# views rather than imported by the shell.
"menu.js", "playlist-menu.js",
+ # The page's own confirm/alert, mounted outside the app tree.
+ "ask.js",
]
pytestmark = pytest.mark.skipif(not APP.exists(), reason="SPA sources unavailable")
diff --git a/packages/meshbay-hub/tests/test_no_prompt_in_the_spa.py b/packages/meshbay-hub/tests/test_no_native_dialogs_in_the_spa.py
index d126a05..c868aa0 100644
--- a/packages/meshbay-hub/tests/test_no_prompt_in_the_spa.py
+++ b/packages/meshbay-hub/tests/test_no_native_dialogs_in_the_spa.py
@@ -1,24 +1,27 @@
"""
-`window.prompt` does not exist in the desktop client.
+No `prompt`, `confirm` or `alert` in the SPA — `ask.js` draws them instead.
The same `static/` tree is the web page and the application (CLAUDE.md's "one
-UI source"), and Electron does not implement `prompt` — Chromium leaves it to
-the embedder and Electron declines. It does not return null: it **throws**,
-`Error: prompt() is not supported.`
-
-That made the Files toolbar's New folder button do nothing whatsoever. The call
-sat above its own try, so the click produced no folder, no error, and nothing
-on screen to react to — the failure looks exactly like a dead button, which is
-what it was reported as.
-
-Measured rather than assumed, against this repo's own Electron 44:
+UI source"), and in the desktop client none of the three browser dialogs can be
+used. Measured against this repo's own Electron 44:
prompt('name?') -> Error: prompt() is not supported.
- confirm('sure?') -> opens a real modal
- alert('hi') -> opens a real modal
+ confirm('sure?') -> opens a real modal, and breaks typing once it closes
+ alert('hi') -> same as confirm
+
+`prompt` throws, which made the Files toolbar's New folder button do nothing
+whatsoever: the call sat above its own try, so the click produced no folder, no
+error, and nothing on screen to react to.
-So `confirm` and `alert` stay allowed and are used in a dozen places; only
-`prompt` is banned. Anything that needs typed input needs a field.
+`confirm` and `alert` fail later and more quietly. After one closes,
+`document.hasFocus()` stays false: a click still moves `activeElement` into a
+field, but every keystroke goes nowhere until the window loses and regains
+focus. It was reported as "the Create group fields are frozen" after removing a
+member, and it was confirmed with real X input events under Xvfb (click and type
+into an input and a textarea, before and after a `confirm()`): three runs out of
+three, typing lands before the dialog and nothing lands after it. A test that
+dispatches DOM events cannot see this, since the focus is lost below the page,
+so the guard is on the source.
"""
import re
@@ -31,9 +34,9 @@ STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
pytestmark = pytest.mark.skipif(not STATIC.exists(),
reason="SPA sources unavailable")
-# `prompt(` as a call, not `window.prompt` inside a comment or a longer
-# identifier like `mkdir_prompt` / `promptForName`.
-CALL = re.compile(r"(?<![\w.$])(?:window\.)?prompt\s*\(")
+# A call, not the word inside a comment or a longer identifier like
+# `mkdir_prompt` / `promptForName` / `role="alert"`.
+CALL = re.compile(r"(?<![\w.$])(?:window\.)?(?:prompt|confirm|alert)\s*\(")
def _code_only(source: str) -> str:
@@ -41,7 +44,7 @@ def _code_only(source: str) -> str:
return re.sub(r"^\s*//.*$", "", source, flags=re.M)
-def test_nothing_calls_prompt():
+def test_nothing_calls_a_native_dialog():
offenders = []
for path in sorted(STATIC.glob("*.js")):
if path.name == "sw.js":
@@ -52,8 +55,8 @@ def test_nothing_calls_prompt():
offenders.append(f"{path.name}:{i}: {line.strip()}")
assert not offenders, (
- "prompt() throws in the desktop client, and the click that reaches it "
- "does nothing at all:\n " + "\n ".join(offenders))
+ "prompt() throws in the desktop client and confirm()/alert() leave it "
+ "unable to type; use ask()/tell() from ask.js:\n " + "\n ".join(offenders))
def test_the_pattern_would_catch_a_real_call():
@@ -63,6 +66,12 @@ def test_the_pattern_would_catch_a_real_call():
"""
assert CALL.search("const n = prompt('x');")
assert CALL.search("const n = window.prompt('x');")
+ assert CALL.search("if (!confirm(t('x'))) return;")
+ assert CALL.search("if (!window.confirm(q)) return;")
+ assert CALL.search("alert(err.message);")
+ assert not CALL.search("<div role=\"alert\">")
+ assert not CALL.search("t('members.remove_confirm')")
+ assert not CALL.search("await ask(q)")
assert not CALL.search("t('group.mkdir_prompt')")
assert not CALL.search("promptForName();")
assert not CALL.search("this.prompt(1);")
diff --git a/packages/meshbay-hub/tests/test_playlist_ui.py b/packages/meshbay-hub/tests/test_playlist_ui.py
index c449dc7..8259088 100644
--- a/packages/meshbay-hub/tests/test_playlist_ui.py
+++ b/packages/meshbay-hub/tests/test_playlist_ui.py
@@ -101,8 +101,7 @@ def test_removing_a_track_removes_that_one(steps):
def test_deleting_a_playlist_asks_first(steps):
"""A deletion is a tombstone: there is nothing in the interface that undoes
- it. `confirm` and not a component — Electron implements it and a dozen
- places in this SPA already use it."""
+ it. Asked in the page, not by `confirm` (`test_no_native_dialogs_in_the_spa.py`)."""
s = steps["deleted"]
assert s["asked"] is True, "a playlist was deleted without asking"
assert s["lists"] == []
diff --git a/packages/meshbay-hub/tests/test_zip_size_limit.py b/packages/meshbay-hub/tests/test_zip_size_limit.py
index 26c5552..9243fd1 100644
--- a/packages/meshbay-hub/tests/test_zip_size_limit.py
+++ b/packages/meshbay-hub/tests/test_zip_size_limit.py
@@ -14,7 +14,7 @@ of allowance and nobody would ever notice. And that the two limits in play do
not contradict each other: ZIP_MAX_BYTES (512 MB) bounds the archive, while
MEMORY_CEILING (100 MB, test_memory_ceiling.py) bounds what may be built in the
page — so a 400 MB zip is allowed when there is somewhere to stream it and
-refused when the only route left is memory. The `confirm()` that offers the
+refused when the only route left is memory. The `ask()` that offers the
build-in-memory path therefore only ever appears below the ceiling.
"""
@@ -47,6 +47,11 @@ def _run(total_bytes, tmp_path, picker=False):
(sandbox / src.name).write_text(src.read_text(encoding="utf-8"),
encoding="utf-8")
(tmp_path / "package.json").write_text('{"type":"module"}')
+ # ask.js draws a dialog in the DOM, which Node has none of; the question is
+ # answered here instead, exactly where `confirm` used to be stubbed.
+ (sandbox / "ask.js").write_text(
+ "export const ask = async (q) => globalThis.confirm(q);\n"
+ "export const tell = async () => {};\n", encoding="utf-8")
script = tmp_path / "case.mjs"
picker_js = "true" if picker else "false"