diff --git a/neode-ui/src/App.vue b/neode-ui/src/App.vue index 4ce503d0..a4a28a9b 100644 --- a/neode-ui/src/App.vue +++ b/neode-ui/src/App.vue @@ -41,6 +41,10 @@ + + + @@ -102,6 +106,7 @@ import HelpGuideModal from './components/HelpGuideModal.vue' import GlobalAudioPlayer from './components/GlobalAudioPlayer.vue' import MeshDeviceSetupModal from './components/mesh/MeshDeviceSetupModal.vue' import ExternalExplorerModal from './components/ExternalExplorerModal.vue' +import AppConfirmModal from './components/AppConfirmModal.vue' import LndSeedBackupPrompt from './components/LndSeedBackupPrompt.vue' import LightningRequiredModal from './components/LightningRequiredModal.vue' import { useMeshStore } from './stores/mesh' diff --git a/neode-ui/src/components/AppConfirmModal.vue b/neode-ui/src/components/AppConfirmModal.vue new file mode 100644 index 00000000..ead8ebeb --- /dev/null +++ b/neode-ui/src/components/AppConfirmModal.vue @@ -0,0 +1,37 @@ + + + diff --git a/neode-ui/src/composables/useAppConfirm.ts b/neode-ui/src/composables/useAppConfirm.ts new file mode 100644 index 00000000..106f01e4 --- /dev/null +++ b/neode-ui/src/composables/useAppConfirm.ts @@ -0,0 +1,44 @@ +/** + * Promise-based in-app replacement for window.confirm(). Native browser + * modals block the JS event loop while open, which freezes the companion + * remote-input relay (and every other async path) — a remote user could + * raise a confirm they can never dismiss, since the synthetic events that + * would dismiss it are queued behind the very dialog they need to close. + * + * State is module-level so the single mounted in + * App.vue serves every caller: + * + * const ok = await appConfirm('Clear everything? This cannot be undone.') + * const ok = await appConfirm({ message: '…', confirmLabel: 'Delete', danger: true }) + */ +import { ref } from 'vue' + +export interface AppConfirmOptions { + title?: string + message: string + confirmLabel?: string + cancelLabel?: string + /** Styles the confirm button as destructive (orange/warning). */ + danger?: boolean +} + +interface ActiveConfirm extends AppConfirmOptions { + _resolve: (value: boolean) => void +} + +export const confirmState = ref(null) + +export function appConfirm(options: AppConfirmOptions | string): Promise { + const opts = typeof options === 'string' ? { message: options } : options + // A second confirm while one is open cancels the first — the browser + // primitive this replaces could only ever show one at a time. + confirmState.value?._resolve(false) + return new Promise((resolve) => { + confirmState.value = { ...opts, _resolve: resolve } + }) +} + +export function resolveConfirm(result: boolean) { + confirmState.value?._resolve(result) + confirmState.value = null +} diff --git a/neode-ui/src/views/ContainerAppDetails.vue b/neode-ui/src/views/ContainerAppDetails.vue index ef99b5c5..2202c51e 100644 --- a/neode-ui/src/views/ContainerAppDetails.vue +++ b/neode-ui/src/views/ContainerAppDetails.vue @@ -125,6 +125,7 @@ import { ref, computed, onMounted, onUnmounted } from 'vue' import { useRoute, useRouter } from 'vue-router' import { useI18n } from 'vue-i18n' import { useContainerStore } from '@/stores/container' +import { appConfirm } from '@/composables/useAppConfirm' import { type ContainerStatus as ContainerStatusData } from '@/api/container-client' import ContainerStatus from '@/components/ContainerStatus.vue' import BackButton from '@/components/BackButton.vue' @@ -325,7 +326,12 @@ onUnmounted(() => { }) async function handleRemove() { - if (!confirm(t('apps.uninstallConfirm', { name: appName.value }))) { + const ok = await appConfirm({ + message: t('apps.uninstallConfirm', { name: appName.value }), + confirmLabel: t('apps.uninstallTitle'), + danger: true, + }) + if (!ok) { return } diff --git a/neode-ui/src/views/Dashboard.vue b/neode-ui/src/views/Dashboard.vue index bfc02334..437078a1 100644 --- a/neode-ui/src/views/Dashboard.vue +++ b/neode-ui/src/views/Dashboard.vue @@ -119,6 +119,7 @@ import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue' import { useRouter, useRoute } from 'vue-router' import { useAppStore } from '../stores/app' import { useAppLauncherStore } from '../stores/appLauncher' +import { appConfirm } from '@/composables/useAppConfirm' import AppSession from '@/views/AppSession.vue' import { useLoginTransitionStore } from '../stores/loginTransition' import { playDashboardLoadOomph } from '@/composables/useLoginSounds' @@ -376,9 +377,10 @@ function handleKioskShortcuts(e: KeyboardEvent) { router.push('/dashboard') } else if (e.key === 'Q' || e.key === 'q') { e.preventDefault() - if (confirm('Reboot the server?')) { + appConfirm({ title: 'Reboot', message: 'Reboot the server?', confirmLabel: 'Reboot', danger: true }).then((ok) => { + if (!ok) return fetch('/rpc/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ method: 'system.reboot' }) }).catch(() => {}) - } + }) } } } diff --git a/neode-ui/src/views/Mesh.vue b/neode-ui/src/views/Mesh.vue index 2749a269..4dceed67 100644 --- a/neode-ui/src/views/Mesh.vue +++ b/neode-ui/src/views/Mesh.vue @@ -13,6 +13,7 @@ import MeshDevicePanel from '@/views/mesh/MeshDevicePanel.vue' import MeshAssistantPanel from '@/views/mesh/MeshAssistantPanel.vue' import HopVizModal from '@/views/mesh/HopVizModal.vue' import { rpcClient } from '@/api/rpc-client' +import { appConfirm } from '@/composables/useAppConfirm' import { wsClient } from '@/api/websocket' import { IMAGE_COMPRESSION_PRESETS, compressImage, makeThumbnail, type ImageCompressionPreset } from '@/utils/imageCompression' import MediaLightbox from '@/components/cloud/MediaLightbox.vue' @@ -281,7 +282,13 @@ async function refreshOutboxCount() { } async function clearAllMesh() { - if (!window.confirm('Clear all mesh peers, messages, and chat history? This cannot be undone.')) return + const ok = await appConfirm({ + title: 'Clear mesh data', + message: 'Clear all mesh peers, messages, and chat history? This cannot be undone.', + confirmLabel: 'Clear everything', + danger: true, + }) + if (!ok) return try { await rpcClient.call({ method: 'mesh.clear-all' }) await mesh.refreshAll() @@ -1419,7 +1426,13 @@ function clearPendingEdit() { } async function deleteOwnMessage(msg: MeshMessage) { if (msg.direction !== 'sent' || msg.sender_seq == null || !activeChatPeer.value) return - if (!window.confirm('Delete this message? Peers already received it — this only marks it as deleted.')) return + const ok = await appConfirm({ + title: 'Delete message', + message: 'Delete this message? Peers already received it — this only marks it as deleted.', + confirmLabel: 'Delete', + danger: true, + }) + if (!ok) return try { await mesh.deleteMessage(activeChatPeer.value.contact_id, msg.sender_seq) } catch (e) { diff --git a/neode-ui/src/views/settings/BackupSection.vue b/neode-ui/src/views/settings/BackupSection.vue index 5fdebcce..906f6acc 100644 --- a/neode-ui/src/views/settings/BackupSection.vue +++ b/neode-ui/src/views/settings/BackupSection.vue @@ -2,6 +2,7 @@ import { ref } from 'vue' import { useI18n } from 'vue-i18n' import { rpcClient } from '@/api/rpc-client' +import { appConfirm } from '@/composables/useAppConfirm' import SeedRevealPanel from '@/components/SeedRevealPanel.vue' const { t } = useI18n() @@ -114,7 +115,12 @@ async function restoreBackup() { } async function deleteBackup(id: string) { - if (!confirm(t('settings.deleteBackupConfirm'))) return + const ok = await appConfirm({ + message: t('settings.deleteBackupConfirm'), + confirmLabel: t('common.delete'), + danger: true, + }) + if (!ok) return deletingBackupId.value = id try { await rpcClient.call({ method: 'backup.delete', params: { id } }) @@ -229,7 +235,11 @@ async function backupToUsb(backupId: string) { return } const label = target.label || target.device - if (!confirm(`Copy backup to USB drive "${label}" at ${target.mount_point}?`)) return + const ok = await appConfirm({ + message: `Copy backup to USB drive "${label}" at ${target.mount_point}?`, + confirmLabel: 'Copy', + }) + if (!ok) return await rpcClient.call({ method: 'backup.to-usb', params: { id: backupId, mount_point: target.mount_point } }) showBackupStatus(t('settings.backupCopiedToUsb', { path: target.mount_point }), 'success') } catch {