195 lines
5.1 KiB
TypeScript
195 lines
5.1 KiB
TypeScript
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 (
|
|
<svg
|
|
aria-hidden="true"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="20"
|
|
height="20"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
fill="currentColor"
|
|
d="M12 2a1 1 0 0 1 1 1v10.59l3.3-3.3a1 1 0 1 1 1.4 1.42l-5 5a1 1 0 0 1-1.4 0l-5-5a1 1 0 1 1 1.4-1.42l3.3 3.3V3a1 1 0 0 1 1-1M3 20a1 1 0 1 0 0 2h18a1 1 0 1 0 0-2z"
|
|
/>
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
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<UpdateStatus>(INITIAL);
|
|
const [open, setOpen] = useState(false);
|
|
const wrapRef = useRef<HTMLDivElement | null>(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 (
|
|
<div className={styles.blocker} role="alertdialog" aria-modal="true">
|
|
<div className={styles.blockerCard}>
|
|
<h2>Update required</h2>
|
|
<p>
|
|
{`${versionLabel} is required to keep using the app${currentLabel}.`}
|
|
{notes ? `\n\n${notes}` : ''}
|
|
</p>
|
|
{status.downloading && (
|
|
<div className={styles.progressTrack}>
|
|
<div
|
|
className={styles.progressFill}
|
|
style={{ width: `${Math.max(2, status.progress)}%` }}
|
|
/>
|
|
</div>
|
|
)}
|
|
<button
|
|
type="button"
|
|
className={styles.primaryBtn}
|
|
onClick={onInstall}
|
|
disabled={status.downloading}
|
|
>
|
|
{status.downloading
|
|
? `Downloading… ${Math.round(status.progress)}%`
|
|
: status.downloaded
|
|
? 'Install and restart'
|
|
: 'Update now'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className={styles.wrap} ref={wrapRef}>
|
|
<button
|
|
type="button"
|
|
className={styles.button}
|
|
onClick={() => setOpen((v) => !v)}
|
|
aria-label={`Update to ${versionLabel}`}
|
|
title={`Update to ${versionLabel}`}
|
|
>
|
|
<UpdateArrow />
|
|
</button>
|
|
{open && (
|
|
<div className={styles.popover} role="dialog">
|
|
<div className={styles.popoverTitle}>{`Update to ${versionLabel}`}</div>
|
|
<div className={styles.popoverSubtitle}>
|
|
{notes ||
|
|
`A new version is available${currentLabel}. You can keep using the current version, or update now.`}
|
|
</div>
|
|
{status.downloading && (
|
|
<div className={styles.progressTrack}>
|
|
<div
|
|
className={styles.progressFill}
|
|
style={{ width: `${Math.max(2, status.progress)}%` }}
|
|
/>
|
|
</div>
|
|
)}
|
|
<div className={styles.actions}>
|
|
<button
|
|
type="button"
|
|
className={styles.secondaryBtn}
|
|
onClick={() => setOpen(false)}
|
|
disabled={status.downloading}
|
|
>
|
|
Later
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={styles.primaryBtn}
|
|
onClick={onInstall}
|
|
disabled={status.downloading}
|
|
>
|
|
{status.downloading
|
|
? `…${Math.round(status.progress)}%`
|
|
: status.downloaded
|
|
? 'Restart'
|
|
: 'Update now'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|