diff options
Diffstat (limited to 'packages')
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" |