Files
DiscordClone/packages/shared/src/components/layout/HeaderUpdateIcon.tsx
Bryan1029384756 593eaba82e 1.1.3
2026-04-18 15:41:51 -05:00

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>
);
}