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:
archipelago
2026-08-16 05:28:53 -04:00
co-authored by Claude Fable 5
parent 458444d700
commit 876ecc4bdf
7 changed files with 124 additions and 7 deletions
+7 -1
View File
@@ -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
}
+4 -2
View File
@@ -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(() => {})
}
})
}
}
}
+15 -2
View File
@@ -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) {
+12 -2
View File
@@ -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 {