165 lines
9.5 KiB
TypeScript
165 lines
9.5 KiB
TypeScript
// The tray card: store status at a glance next to the tray icon. It only draws the main window's snapshot (card_get /
|
|
// `card-snapshot`) and sends its buttons back (card_action); Refresh is the main window's own Refresh all.
|
|
import { ArrowsClockwiseIcon } from '@phosphor-icons/react/dist/csr/ArrowsClockwise';
|
|
import { ArrowSquareOutIcon } from '@phosphor-icons/react/dist/csr/ArrowSquareOut';
|
|
import { BellSlashIcon } from '@phosphor-icons/react/dist/csr/BellSlash';
|
|
import { BellIcon } from '@phosphor-icons/react/dist/csr/Bell';
|
|
import { DotsThreeIcon } from '@phosphor-icons/react/dist/csr/DotsThree';
|
|
import { PowerIcon } from '@phosphor-icons/react/dist/csr/Power';
|
|
import { StarIcon } from '@phosphor-icons/react/dist/csr/Star';
|
|
import { WarningOctagonIcon } from '@phosphor-icons/react/dist/csr/WarningOctagon';
|
|
import { PulseIcon } from '@phosphor-icons/react/dist/csr/Pulse';
|
|
import { AppWindowIcon } from '@phosphor-icons/react/dist/csr/AppWindow';
|
|
import { useCallback, useEffect, useRef, useState, type KeyboardEvent, type ReactNode } from 'react';
|
|
import type { CardApp, CardRelease, CardSnapshot, CardStore } from './model';
|
|
import { ago, cardT, type CardKey, type CardLang } from './text';
|
|
|
|
export interface CardHost {
|
|
get: () => Promise<CardSnapshot | null>;
|
|
onSnapshot: (fn: (s: CardSnapshot) => void) => () => void;
|
|
onShown: (fn: () => void) => () => void;
|
|
action: (action: 'hide' | 'open' | 'route' | 'refresh' | 'mute' | 'quit' | 'external' | 'ready', arg?: string) => void;
|
|
}
|
|
|
|
const route = (app: string, tab: 'releases' | 'reviews' | 'health') => JSON.stringify({ screen: 'app', appId: app, tab });
|
|
|
|
export function Card({ host, lang }: { host: CardHost; lang: CardLang }) {
|
|
const T = useCallback((k: CardKey, v?: Record<string, string | number>) => cardT(lang, k, v), [lang]);
|
|
const [s, setS] = useState<CardSnapshot | null>(null);
|
|
const [menu, setMenu] = useState(false);
|
|
const [now, setNow] = useState(() => Date.now());
|
|
const [shown, setShown] = useState(0);
|
|
const root = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
host.get().then(x => { if (x) setS(x); }).catch(() => {});
|
|
const a = host.onSnapshot(setS);
|
|
const b = host.onShown(() => { setNow(Date.now()); setMenu(false); setShown(n => n + 1); });
|
|
host.action('ready');
|
|
const tm = setInterval(() => setNow(Date.now()), 30_000);
|
|
return () => { a(); b(); clearInterval(tm); };
|
|
}, [host]);
|
|
// every show: fade in, focus the first control (keyboard users start at Refresh)
|
|
useEffect(() => { (root.current?.querySelector<HTMLElement>('button:not([disabled])') ?? root.current)?.focus(); }, [shown, !!s]);
|
|
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') { e.preventDefault(); if (menu) setMenu(false); else host.action('hide'); return; }
|
|
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
|
const all = [...(root.current?.querySelectorAll<HTMLElement>('button:not([disabled])') ?? [])];
|
|
const i = all.indexOf(document.activeElement as HTMLElement);
|
|
const n = all[(i + (e.key === 'ArrowDown' ? 1 : -1) + all.length) % all.length];
|
|
if (n) { e.preventDefault(); n.focus(); }
|
|
}
|
|
};
|
|
|
|
const store = (st: CardStore) => T(st === 'ios' ? 'appStore' : 'playStore');
|
|
const phase = (r: CardRelease) => (r.phase === 'rollout' ? (r.fraction != null && r.fraction < 1 ? T('rolloutPct', { pct: Math.round(r.fraction * 100) }) : T('rollout')) : T(r.phase));
|
|
const link = (key: string): CardApp | undefined => s?.links.find(l => l.key === key);
|
|
const busy = !!s?.busy;
|
|
const calm = s && !s.rejections.length && !s.releases.length && !s.health.length && !s.reviews.low.length;
|
|
|
|
return (
|
|
<div className="tc" ref={root} onKeyDown={onKey} data-shown={shown} tabIndex={-1} role="dialog" aria-label={T('title')}>
|
|
<header className="tc-head">
|
|
<div className="tc-title">
|
|
<strong>{T('title')}</strong>
|
|
<span className="tc-meta">{s?.muted && <span className="tc-muted"><BellSlashIcon size={12} weight="bold" />{T('muted')}</span>}{T('lastRead', { ago: ago(lang, s?.at ?? null, now) })}</span>
|
|
</div>
|
|
<button className="tc-btn tc-primary" data-first onClick={() => host.action('refresh')} disabled={busy} aria-busy={busy} data-testid="card-refresh">
|
|
<ArrowsClockwiseIcon size={14} weight="bold" className={busy ? 'spin' : undefined} />{busy ? T('refreshing') : T('refresh')}
|
|
</button>
|
|
<button className="tc-icon" onClick={() => setMenu(m => !m)} aria-haspopup="menu" aria-expanded={menu} aria-label={T('more')} title={T('more')}><DotsThreeIcon size={18} weight="bold" /></button>
|
|
{menu && (
|
|
<div className="tc-menu" role="menu">
|
|
<button role="menuitem" onClick={() => host.action('open')}><AppWindowIcon size={15} />{T('openMain')}</button>
|
|
<button role="menuitem" onClick={() => { host.action('mute'); setMenu(false); }}>{s?.muted ? <BellIcon size={15} /> : <BellSlashIcon size={15} />}{s?.muted ? T('unmute') : T('mute')}</button>
|
|
<hr />
|
|
<button role="menuitem" onClick={() => host.action('quit')}><PowerIcon size={15} />{T('quit')}</button>
|
|
</div>
|
|
)}
|
|
</header>
|
|
|
|
<div className="tc-body">
|
|
{!s && <p className="tc-empty">{T('noData')}</p>}
|
|
{s && calm && <p className="tc-empty">{s.at ? T('calm') : T('noData')}</p>}
|
|
|
|
{!!s?.rejections.length && (
|
|
<Section title={T('rejections')} tone="bad">
|
|
{s.rejections.map(r => (
|
|
<Row key={`${r.app}:${r.store}:${r.version}`} hue={r.hue} name={r.name} tone="bad" onOpen={() => host.action('route', route(r.app, 'releases'))} openLabel={T('openApp', { name: r.name })}
|
|
line={<><WarningOctagonIcon size={13} weight="fill" />{T('rejected')} · {store(r.store)}{r.version ? ` ${r.version}` : ''}</>} sub={r.reason}
|
|
consoles={consoles(link(r.app), r.store, host, T)} />
|
|
))}
|
|
</Section>
|
|
)}
|
|
|
|
{!!s?.releases.length && (
|
|
<Section title={T('moving')}>
|
|
{s.releases.map(r => (
|
|
<Row key={`${r.app}:${r.store}:${r.version}`} hue={r.hue} name={r.name} tone={r.phase === 'halted' ? 'bad' : r.phase === 'approved' ? 'ok' : r.phase === 'review' ? 'info' : 'accent'}
|
|
onOpen={() => host.action('route', route(r.app, 'releases'))} openLabel={T('openApp', { name: r.name })}
|
|
line={<>{phase(r)} · {store(r.store)}{r.version ? ` ${r.version}` : ''}</>}
|
|
bar={r.phase === 'rollout' && r.fraction != null && r.fraction < 1 ? r.fraction : null}
|
|
consoles={consoles(link(r.app), r.store, host, T)} />
|
|
))}
|
|
</Section>
|
|
)}
|
|
|
|
{!!s?.health.length && (
|
|
<Section title={T('health')} tone="warn">
|
|
{s.health.map(h => (
|
|
<Row key={h.app} hue={h.hue} name={h.name} tone="warn" onOpen={() => host.action('route', route(h.app, 'health'))} openLabel={T('openApp', { name: h.name })}
|
|
line={<><PulseIcon size={13} weight="bold" />{h.kinds.length > 1 ? T('crashAnr') : T(h.kinds[0]!)}</>} consoles={consoles(link(h.app), 'android', host, T)} />
|
|
))}
|
|
</Section>
|
|
)}
|
|
|
|
{s && s.at && (
|
|
<Section title={T('reviews')} aside={s.reviews.recent ? T('recent', { n: s.reviews.recent }) : T('recentNone')}>
|
|
{s.reviews.low.map(r => (
|
|
<button key={`${r.app}:${r.date}:${r.text}`} className="tc-review" onClick={() => host.action('route', route(r.app, 'reviews'))}>
|
|
<span className="tc-stars" aria-label={T('stars', { n: r.rating })}>{[1, 2, 3, 4, 5].map(i => <StarIcon key={i} size={11} weight={i <= r.rating ? 'fill' : 'regular'} />)}</span>
|
|
<span className="tc-rname">{r.name}</span>
|
|
<span className="tc-rtext">{r.text}</span>
|
|
</button>
|
|
))}
|
|
</Section>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function consoles(l: CardApp | undefined, st: CardStore, host: CardHost, T: (k: CardKey) => string): ReactNode {
|
|
const url = st === 'ios' ? l?.asc : l?.play;
|
|
if (!url) return null;
|
|
return <button className="tc-ext" onClick={e => { e.stopPropagation(); host.action('external', url); }} title={T(st === 'ios' ? 'asc' : 'play')} aria-label={T(st === 'ios' ? 'asc' : 'play')}><ArrowSquareOutIcon size={14} /></button>;
|
|
}
|
|
|
|
function Section({ title, tone, aside, children }: { title: string; tone?: 'bad' | 'warn'; aside?: string; children: ReactNode }) {
|
|
return (
|
|
<section className={`tc-sec${tone ? ` tc-${tone}` : ''}`}>
|
|
<h2><span>{title}</span>{aside && <em>{aside}</em>}</h2>
|
|
{children}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function Row({ hue, name, line, sub, tone, bar, onOpen, openLabel, consoles }: { hue: number; name: string; line: ReactNode; sub?: string | null; tone: string; bar?: number | null; onOpen: () => void; openLabel: string; consoles: ReactNode }) {
|
|
return (
|
|
<div className={`tc-row tone-${tone}`}>
|
|
<button className="tc-open" onClick={onOpen} aria-label={openLabel}>
|
|
<span className="tc-ic" style={{ '--tint-h': hue } as React.CSSProperties} aria-hidden>{initials(name)}</span>
|
|
<span className="tc-txt">
|
|
<span className="tc-name">{name}</span>
|
|
<span className="tc-line">{line}</span>
|
|
{sub && <span className="tc-sub">{sub}</span>}
|
|
{bar != null && <span className="tc-bar" aria-hidden><i style={{ width: `${Math.max(2, Math.round(bar * 100))}%` }} /></span>}
|
|
</span>
|
|
</button>
|
|
{consoles}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const initials = (n: string) => n.split(/\s+/).filter(Boolean).slice(0, 2).map(w => w[0]!.toUpperCase()).join('') || '?';
|