1.1.3
This commit is contained in:
194
packages/shared/src/components/layout/HeaderUpdateIcon.tsx
Normal file
194
packages/shared/src/components/layout/HeaderUpdateIcon.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user