fix(ui): replace native confirm() dialogs with the global in-app modal
window.confirm blocks the JS event loop, which froze companion remote input while open — the remote user could raise the mesh "Clear" prompt (or reboot / backup-delete / uninstall confirms) and then never dismiss it, because the synthetic events that would dismiss it queue behind the dialog itself. New promise-based appConfirm() (useAppConfirm.ts) + one AppConfirmModal mounted globally in App.vue, built on BaseModal (Teleport-to-body, full-viewport backdrop, glass card — the canonical modal contract). All six native confirm() call sites migrated: mesh clear-all, mesh message delete, dashboard reboot, backup delete, backup USB copy, app uninstall. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
458444d700
commit
876ecc4bdf
@@ -41,6 +41,10 @@
|
|||||||
<MeshDeviceSetupModal />
|
<MeshDeviceSetupModal />
|
||||||
<ExternalExplorerModal />
|
<ExternalExplorerModal />
|
||||||
|
|
||||||
|
<!-- In-app confirm() replacement — native browser dialogs block the JS
|
||||||
|
event loop and freeze companion remote input (see useAppConfirm) -->
|
||||||
|
<AppConfirmModal />
|
||||||
|
|
||||||
<!-- Nudge to back up the Lightning seed once a wallet exists (any page) -->
|
<!-- Nudge to back up the Lightning seed once a wallet exists (any page) -->
|
||||||
<LndSeedBackupPrompt />
|
<LndSeedBackupPrompt />
|
||||||
|
|
||||||
@@ -102,6 +106,7 @@ import HelpGuideModal from './components/HelpGuideModal.vue'
|
|||||||
import GlobalAudioPlayer from './components/GlobalAudioPlayer.vue'
|
import GlobalAudioPlayer from './components/GlobalAudioPlayer.vue'
|
||||||
import MeshDeviceSetupModal from './components/mesh/MeshDeviceSetupModal.vue'
|
import MeshDeviceSetupModal from './components/mesh/MeshDeviceSetupModal.vue'
|
||||||
import ExternalExplorerModal from './components/ExternalExplorerModal.vue'
|
import ExternalExplorerModal from './components/ExternalExplorerModal.vue'
|
||||||
|
import AppConfirmModal from './components/AppConfirmModal.vue'
|
||||||
import LndSeedBackupPrompt from './components/LndSeedBackupPrompt.vue'
|
import LndSeedBackupPrompt from './components/LndSeedBackupPrompt.vue'
|
||||||
import LightningRequiredModal from './components/LightningRequiredModal.vue'
|
import LightningRequiredModal from './components/LightningRequiredModal.vue'
|
||||||
import { useMeshStore } from './stores/mesh'
|
import { useMeshStore } from './stores/mesh'
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<template>
|
||||||
|
<BaseModal
|
||||||
|
:show="!!state"
|
||||||
|
:title="state?.title || 'Are you sure?'"
|
||||||
|
z-index="z-[4000]"
|
||||||
|
@close="resolveConfirm(false)"
|
||||||
|
>
|
||||||
|
<p class="text-white/80 text-sm whitespace-pre-line">{{ state?.message }}</p>
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex-1 rounded-lg bg-white/10 hover:bg-white/20 text-white text-sm font-medium py-2.5 transition-colors"
|
||||||
|
@click="resolveConfirm(false)"
|
||||||
|
>
|
||||||
|
{{ state?.cancelLabel || t('common.cancel') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex-1 glass-button rounded-lg text-sm font-semibold py-2.5"
|
||||||
|
:class="state?.danger ? 'text-orange-400 border-orange-400/30' : ''"
|
||||||
|
@click="resolveConfirm(true)"
|
||||||
|
>
|
||||||
|
{{ state?.confirmLabel || 'Confirm' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</BaseModal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import BaseModal from '@/components/BaseModal.vue'
|
||||||
|
import { confirmState as state, resolveConfirm } from '@/composables/useAppConfirm'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
</script>
|
||||||
@@ -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 <AppConfirmModal /> 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<ActiveConfirm | null>(null)
|
||||||
|
|
||||||
|
export function appConfirm(options: AppConfirmOptions | string): Promise<boolean> {
|
||||||
|
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<boolean>((resolve) => {
|
||||||
|
confirmState.value = { ...opts, _resolve: resolve }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveConfirm(result: boolean) {
|
||||||
|
confirmState.value?._resolve(result)
|
||||||
|
confirmState.value = null
|
||||||
|
}
|
||||||
@@ -125,6 +125,7 @@ import { ref, computed, onMounted, onUnmounted } from 'vue'
|
|||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useContainerStore } from '@/stores/container'
|
import { useContainerStore } from '@/stores/container'
|
||||||
|
import { appConfirm } from '@/composables/useAppConfirm'
|
||||||
import { type ContainerStatus as ContainerStatusData } from '@/api/container-client'
|
import { type ContainerStatus as ContainerStatusData } from '@/api/container-client'
|
||||||
import ContainerStatus from '@/components/ContainerStatus.vue'
|
import ContainerStatus from '@/components/ContainerStatus.vue'
|
||||||
import BackButton from '@/components/BackButton.vue'
|
import BackButton from '@/components/BackButton.vue'
|
||||||
@@ -325,7 +326,12 @@ onUnmounted(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
async function handleRemove() {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue'
|
|||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { useAppStore } from '../stores/app'
|
import { useAppStore } from '../stores/app'
|
||||||
import { useAppLauncherStore } from '../stores/appLauncher'
|
import { useAppLauncherStore } from '../stores/appLauncher'
|
||||||
|
import { appConfirm } from '@/composables/useAppConfirm'
|
||||||
import AppSession from '@/views/AppSession.vue'
|
import AppSession from '@/views/AppSession.vue'
|
||||||
import { useLoginTransitionStore } from '../stores/loginTransition'
|
import { useLoginTransitionStore } from '../stores/loginTransition'
|
||||||
import { playDashboardLoadOomph } from '@/composables/useLoginSounds'
|
import { playDashboardLoadOomph } from '@/composables/useLoginSounds'
|
||||||
@@ -376,9 +377,10 @@ function handleKioskShortcuts(e: KeyboardEvent) {
|
|||||||
router.push('/dashboard')
|
router.push('/dashboard')
|
||||||
} else if (e.key === 'Q' || e.key === 'q') {
|
} else if (e.key === 'Q' || e.key === 'q') {
|
||||||
e.preventDefault()
|
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(() => {})
|
fetch('/rpc/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ method: 'system.reboot' }) }).catch(() => {})
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import MeshDevicePanel from '@/views/mesh/MeshDevicePanel.vue'
|
|||||||
import MeshAssistantPanel from '@/views/mesh/MeshAssistantPanel.vue'
|
import MeshAssistantPanel from '@/views/mesh/MeshAssistantPanel.vue'
|
||||||
import HopVizModal from '@/views/mesh/HopVizModal.vue'
|
import HopVizModal from '@/views/mesh/HopVizModal.vue'
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
|
import { appConfirm } from '@/composables/useAppConfirm'
|
||||||
import { wsClient } from '@/api/websocket'
|
import { wsClient } from '@/api/websocket'
|
||||||
import { IMAGE_COMPRESSION_PRESETS, compressImage, makeThumbnail, type ImageCompressionPreset } from '@/utils/imageCompression'
|
import { IMAGE_COMPRESSION_PRESETS, compressImage, makeThumbnail, type ImageCompressionPreset } from '@/utils/imageCompression'
|
||||||
import MediaLightbox from '@/components/cloud/MediaLightbox.vue'
|
import MediaLightbox from '@/components/cloud/MediaLightbox.vue'
|
||||||
@@ -281,7 +282,13 @@ async function refreshOutboxCount() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function clearAllMesh() {
|
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 {
|
try {
|
||||||
await rpcClient.call({ method: 'mesh.clear-all' })
|
await rpcClient.call({ method: 'mesh.clear-all' })
|
||||||
await mesh.refreshAll()
|
await mesh.refreshAll()
|
||||||
@@ -1419,7 +1426,13 @@ function clearPendingEdit() {
|
|||||||
}
|
}
|
||||||
async function deleteOwnMessage(msg: MeshMessage) {
|
async function deleteOwnMessage(msg: MeshMessage) {
|
||||||
if (msg.direction !== 'sent' || msg.sender_seq == null || !activeChatPeer.value) return
|
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 {
|
try {
|
||||||
await mesh.deleteMessage(activeChatPeer.value.contact_id, msg.sender_seq)
|
await mesh.deleteMessage(activeChatPeer.value.contact_id, msg.sender_seq)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
|
import { appConfirm } from '@/composables/useAppConfirm'
|
||||||
import SeedRevealPanel from '@/components/SeedRevealPanel.vue'
|
import SeedRevealPanel from '@/components/SeedRevealPanel.vue'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@@ -114,7 +115,12 @@ async function restoreBackup() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function deleteBackup(id: string) {
|
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
|
deletingBackupId.value = id
|
||||||
try {
|
try {
|
||||||
await rpcClient.call({ method: 'backup.delete', params: { id } })
|
await rpcClient.call({ method: 'backup.delete', params: { id } })
|
||||||
@@ -229,7 +235,11 @@ async function backupToUsb(backupId: string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const label = target.label || target.device
|
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 } })
|
await rpcClient.call({ method: 'backup.to-usb', params: { id: backupId, mount_point: target.mount_point } })
|
||||||
showBackupStatus(t('settings.backupCopiedToUsb', { path: target.mount_point }), 'success')
|
showBackupStatus(t('settings.backupCopiedToUsb', { path: target.mount_point }), 'success')
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
Reference in New Issue
Block a user