storebound/apps/desktop/src/card/Card.tsx

166 lines
9.7 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>
{s?.build && <footer className="tc-foot" data-testid="card-build">{T('build', { version: s.build.version, channel: T(s.build.channel === 'dev' ? 'chDev' : s.build.channel === 'beta' ? 'chBeta' : 'chStable') })}</footer>}
</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('') || '?';