import { useEffect, useRef, useState } from 'react'; import { usePlatform } from '../../platform'; import styles from './HeaderUpdateIcon.module.css'; interface UpdateStatus { hasUpdate: boolean; required: boolean; latestVersion: string | null; currentVersion: string | null; releaseNotes: string | null; downloading: boolean; downloaded: boolean; progress: number; error: string | null; } const INITIAL: UpdateStatus = { hasUpdate: false, required: false, latestVersion: null, currentVersion: null, releaseNotes: null, downloading: false, downloaded: false, progress: 0, error: null, }; // Strip the `[REQUIRED]` marker from the notes so the popover doesn't // repeat information the UI itself already conveys. function cleanNotes(notes: string | null): string { if (!notes) return ''; return notes.replace(/^\s*\[REQUIRED\]\s*/i, '').trim(); } function UpdateArrow() { return ( ); } export function HeaderUpdateIcon() { const platform = usePlatform() as any; const updates = platform?.updates ?? null; const hasInApp = typeof updates?.getStatus === 'function' && typeof updates?.downloadAndInstall === 'function'; const [status, setStatus] = useState(INITIAL); const [open, setOpen] = useState(false); const wrapRef = useRef(null); useEffect(() => { if (!hasInApp) return; let cancelled = false; (async () => { try { const current = await updates.getStatus(); if (!cancelled && current) setStatus({ ...INITIAL, ...current }); } catch {} })(); const off = updates.onStatusChanged?.((next: UpdateStatus) => { setStatus({ ...INITIAL, ...next }); }); return () => { cancelled = true; if (typeof off === 'function') off(); }; }, [hasInApp, updates]); useEffect(() => { if (!open) return; const onDocClick = (e: MouseEvent) => { if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) { setOpen(false); } }; document.addEventListener('mousedown', onDocClick); return () => document.removeEventListener('mousedown', onDocClick); }, [open]); if (!hasInApp) return null; if (!status.hasUpdate) return null; const versionLabel = status.latestVersion ? `v${status.latestVersion}` : 'a new version'; const currentLabel = status.currentVersion ? ` (you have v${status.currentVersion})` : ''; const notes = cleanNotes(status.releaseNotes); const onInstall = () => { if (!updates?.downloadAndInstall) return; void updates.downloadAndInstall(); }; // Required update: render as a blocking overlay instead of a // silent icon. The user must update to continue. if (status.required) { return ( Update required {`${versionLabel} is required to keep using the app${currentLabel}.`} {notes ? `\n\n${notes}` : ''} {status.downloading && ( )} {status.downloading ? `Downloading… ${Math.round(status.progress)}%` : status.downloaded ? 'Install and restart' : 'Update now'} ); } return ( setOpen((v) => !v)} aria-label={`Update to ${versionLabel}`} title={`Update to ${versionLabel}`} > {open && ( {`Update to ${versionLabel}`} {notes || `A new version is available${currentLabel}. You can keep using the current version, or update now.`} {status.downloading && ( )} setOpen(false)} disabled={status.downloading} > Later {status.downloading ? `…${Math.round(status.progress)}%` : status.downloaded ? 'Restart' : 'Update now'} )} ); }
{`${versionLabel} is required to keep using the app${currentLabel}.`} {notes ? `\n\n${notes}` : ''}