/** * UpdateDialog - Modal dialog shown when a new version is available. * * Prompts the user to download the update or dismiss it. * Release notes may be HTML from the updater; we normalize to text and render as markdown. */ import { useEffect, useRef } from 'react'; import ReactMarkdown from 'react-markdown'; import { markdownComponents } from '@renderer/components/chat/markdownComponents'; import { useStore } from '@renderer/store'; import { X } from 'lucide-react'; import remarkGfm from 'remark-gfm'; /** * Normalize release notes: strip HTML tags and convert block elements to newlines. * Uses DOMParser for proper HTML entity decoding (handles all entities like —, ', etc.) */ function normalizeReleaseNotes(html: string): string { if (!html?.trim()) return ''; // Convert block elements to newlines for better formatting const processed = html .replace(/<\/p>\s*/gi, '\n\n') .replace(/\s*/gi, '\n') .replace(/<\/div>\s*/gi, '\n') .replace(/<\/li>\s*/gi, '\n') .replace(/<\/h[1-6]>\s*/gi, '\n\n'); // Use DOMParser to decode HTML entities and strip remaining tags // This properly handles all HTML entities ( , —, ', etc.) const parser = new DOMParser(); const doc = parser.parseFromString(processed, 'text/html'); const text = doc.body.textContent || ''; // Normalize multiple newlines return text.replace(/\n{3,}/g, '\n\n').trim(); } export const UpdateDialog = (): React.JSX.Element | null => { const showUpdateDialog = useStore((s) => s.showUpdateDialog); const availableVersion = useStore((s) => s.availableVersion); const releaseNotes = useStore((s) => s.releaseNotes); const downloadUpdate = useStore((s) => s.downloadUpdate); const dismissUpdateDialog = useStore((s) => s.dismissUpdateDialog); const dialogRef = useRef(null); // Handle ESC key to close dialog useEffect(() => { if (!showUpdateDialog) return; const handleEscape = (e: KeyboardEvent): void => { if (e.key === 'Escape') { dismissUpdateDialog(); } }; document.addEventListener('keydown', handleEscape); return () => document.removeEventListener('keydown', handleEscape); }, [showUpdateDialog, dismissUpdateDialog]); // Focus trap: keep focus within dialog useEffect(() => { if (!showUpdateDialog || !dialogRef.current) return; const dialog = dialogRef.current; const focusableElements = dialog.querySelectorAll( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' ); const firstElement = focusableElements[0]; const lastElement = focusableElements[focusableElements.length - 1]; // Focus first element when dialog opens firstElement?.focus(); const handleTab = (e: KeyboardEvent): void => { if (e.key !== 'Tab') return; if (e.shiftKey) { // Shift+Tab: if on first element, go to last if (document.activeElement === firstElement) { e.preventDefault(); lastElement?.focus(); } } else { // Tab: if on last element, go to first if (document.activeElement === lastElement) { e.preventDefault(); firstElement?.focus(); } } }; dialog.addEventListener('keydown', handleTab); return () => dialog.removeEventListener('keydown', handleTab); }, [showUpdateDialog]); if (!showUpdateDialog) return null; return ( {/* Backdrop */} {/* Close button */} Update Available {availableVersion && ( v{availableVersion} )} {/* Release notes — normalize HTML then render as markdown */} {releaseNotes && ( {normalizeReleaseNotes(releaseNotes)} )} {/* Actions */} Later Download ); };