storebound/apps/desktop/src/screens/tabs/Releases.tsx
CRT dece83c3e8 Desktop: custom context menus everywhere, never the browser's menu
One ContextMenu in the design system (keyboard, type-ahead, submenus, check items,
disabled reasons, Shift+F10 / Menu key, viewport placement, light and dark). The
native menu is prevented globally; text fields keep Cut / Copy / Paste / Select all.
Menus on sidebar and portfolio apps, app hero, releases, store art, live art,
reviews, checks, editor fields, catalog, prices, accounts, app folders, ignored.
2026-10-23 08:59:14 +08:00

250 lines
16 KiB
TypeScript

// Releases: the release timeline (one version on both stores on one track: build -> testing -> review -> live ->
// rollout %), then every version read from the stores, then sending for review (Submit.tsx): App Store submit /
// resubmit / in-app purchases and the Google Play production release as owner actions, the Play steps without an API
// as console plans. Releasing an approved version (manual release) stays in App Store Connect.
import { plainError } from '../../../../../cli/lib/google-errors';
import { type CSSProperties, type ReactNode, useState } from 'react';
import type { AppStatus, ReleaseTrain, StoreRelease } from '../../api';
import { laneGap, storesDiffer } from '../../../../../cli/lib/train';
import type { UnifiedApp } from '../../lib/identity';
import { WriteButton } from '../../ui/WriteButton';
import { type Key, t } from '../../i18n';
import { ago, date, share } from '../../lib/format';
import { type Lane, STAGES, type TestFlightStatus, trainLanes, versionHistory } from '../../lib/model';
import { useData, useRead, useWrite } from '../../store';
import { Icon, StoreGlyph } from '../../ui/icons';
import { RolloutBar } from '../../ui/charts';
import { Card, Empty, Notice, PhaseChip, phaseLabel, Skeleton, storeName } from '../../ui/kit';
import { EmptyState, Section, StatusPill, tintStyle, useTint } from '../../ui/ds';
import { AppleSubmitCard, PlayProductionCard } from './Submit';
import { useContextMenu } from '../../ui/ContextMenu';
import { releaseMenu } from '../../ui/app-menu';
import '../../styles/releases.css';
const STAGE_KEY: Record<(typeof STAGES)[number], Key> = { build: 'stage.build', testing: 'stage.testing', review: 'stage.review', live: 'stage.live', rollout: 'stage.rollout' };
function stageCaption(l: Lane, i: number): string {
if (i !== l.at || !l.release) return '';
const r = l.release;
if (i === 1) return r.store === 'ios' ? 'TestFlight' : (r.track ?? t('stage.testing'));
if (i === 2) return r.store === 'android' ? phaseLabel(r).text : t(`phase.${r.phase}` as Key);
if (i === 3 && r.phase === 'released') return phaseLabel(r).text;
return '';
}
/** The text of a lane that does not carry this version: couldn't read, the store's own current version, not there yet,
* or (only without any record of the app on that store) "Not on this store". */
function gapText(lane: Lane, version: string, st: AppStatus): { who: string; line: string; kind: string } {
const g = laneGap(lane.store, st.release.all ?? [], st.storeErrors[lane.store]);
const store = storeName(lane.store);
if (g.kind === 'unreadable') return { kind: g.kind, who: t('tl.unknown'), line: t('tl.unreadable', { store }) };
if (g.kind === 'other') return { kind: g.kind, who: g.now.version, line: t('tl.otherVersion', { now: g.now.version, phase: t(`phase.${g.now.phase}` as Key), version }) };
if (g.kind === 'missing') return { kind: g.kind, who: t('tl.notThisVersion'), line: t('tl.noVersion', { version, store }) };
return { kind: g.kind, who: t('tl.notOnStore'), line: t('tl.notOnStore') };
}
/** One store's card of a release train: store mark, version (build), the current state as a pill, the five stages
* as a stepper, a big rollout ring when it is rolling out, and that store's rollout controls. */
function LaneRow({ lane, version, st, app }: { lane: Lane; version: string; st: AppStatus; app: UnifiedApp }) {
const r = lane.release;
const gap = lane.at < 0 ? gapText(lane, version, st) : null;
const ph = r ? phaseLabel(r) : null;
const rolling = lane.at === STAGES.length - 1 && lane.share !== null;
const ringTone = lane.tone === 'bad' ? 'bad' : (lane.share ?? 0) >= 1 ? 'ok' : 'accent';
const d = useData();
const menu = useContextMenu();
return (
<article {...menu(() => releaseMenu(d, app, lane.store, r?.version ?? version, r?.build))} className="tl-lane rel-store" data-store={lane.store} data-tone={lane.tone} aria-label={storeName(lane.store)}>
<header className="rel-store-h">
<span className="rel-mark"><StoreGlyph store={lane.store} /></span>
<div className="rel-who">
<div className="rel-store-name">{storeName(lane.store)}</div>
<div className="rel-store-ver num">{r ? `${r.version ?? version}${r.build ? ` (${r.build})` : ''}` : gap!.who}</div>
</div>
{ph && <StatusPill tone={ph.tone} title={r?.state} solid={lane.tone === 'bad'}>{ph.text}</StatusPill>}
</header>
{gap ? <div className="tl-none rel-gap" data-gap={gap.kind}><Icon name={gap.kind === 'unreadable' ? 'alert' : 'info'} />{gap.line}</div> : (
<div className={`rel-body${rolling ? ' has-ring' : ''}`}>
<ol className="rel-steps" aria-label={t('v2rel.stages', { store: storeName(lane.store) })}>
{STAGES.map((s, i) => {
const cls = i < lane.at ? 'done' : i === lane.at ? `cur ${lane.tone}` : 'todo';
const cap = stageCaption(lane, i);
return (
<li key={s} className={`rel-step ${cls}`} title={i === lane.at ? r?.state : undefined} aria-current={i === lane.at ? 'step' : undefined}>
<span className="rel-node">{i < lane.at ? <Icon name="check" /> : <span className="num">{i + 1}</span>}</span>
<span className="rel-step-l">{t(STAGE_KEY[s])}</span>
{cap && <span className="rel-step-c">{cap}</span>}
</li>
);
})}
</ol>
{rolling && lane.share !== null && (
<div className={`tl-roll rel-ring-wrap ${ringTone}`}>
<div className="rel-ring" style={{ '--p': String(Math.round(Math.min(1, lane.share) * 100)) } as CSSProperties} aria-hidden="true">
<span className="rel-ring-v num">{lane.share >= 1 ? '100' : Math.round(lane.share * 100)}<small>%</small></span>
</div>
<div className="rel-ring-side">
<span className="pct">{lane.share >= 1 ? t('tl.everyone') : `${share(lane.share)}${r?.phased?.day ? ` · ${t('tl.day', { day: r.phased.day })}` : ''}`}</span>
<span className="rel-ring-sub">{t('v2rel.ofUsers')}</span>
<RolloutBar value={lane.share} tone={ringTone} label={t('tl.rolloutAria')} />
</div>
</div>
)}
</div>
)}
{r && app.project && <RolloutControls app={app} r={r} />}
</article>
);
}
const SHARES = [1, 2, 5, 10, 20, 50];
/** Rollout controls of one store release: Play staged rollout share / halt / resume / complete, iOS phased release
* pause / resume / everyone, or turning the phased release on for an upcoming iOS version. Owner actions: each one is
* a dry run first, then a system dialog that names it. */
function RolloutControls({ app, r }: { app: UnifiedApp; r: StoreRelease }) {
const w = useWrite(app.project?.id ?? null);
const pct = r.fraction !== null ? Math.round(r.fraction * 100) : null;
const [share, setShare] = useState(() => String(SHARES.find(s => pct === null || s > pct) ?? 50));
if (!app.project) return null;
const v = r.version ?? '';
if (r.store === 'android' && (r.phase === 'rollout' || r.phase === 'halted')) {
const track = r.track && ['production', 'beta', 'alpha', 'internal'].includes(r.track) ? r.track : 'production';
const next = SHARES.filter(s => pct === null || s > pct);
return (
<div className="roll-ctl">
<span className="small muted">{t('roll.playNow', { pct: pct ?? 0 })}</span>
{r.phase === 'rollout' && next.length > 0 && <>
<select className="input input-sm" value={share} onChange={e => setShare(e.target.value)} aria-label={t('roll.share')}>{next.map(s => <option key={s} value={s}>{s} %</option>)}</select>
<WriteButton run={w} icon="arrowRight" w={{ method: 'owner.play_rollout', args: { action: 'percent', percent: Number(share), track }, title: t('roll.toShare', { pct: share }), done: t('roll.done') }}>{t('roll.toShare', { pct: share })}</WriteButton>
</>}
{r.phase === 'rollout' && <WriteButton run={w} icon="pause" w={{ method: 'owner.play_rollout', args: { action: 'halt', track }, title: t('roll.halt'), done: t('roll.halted') }} hint={t('roll.haltHint')}>{t('roll.halt')}</WriteButton>}
{r.phase === 'halted' && <WriteButton run={w} icon="play" w={{ method: 'owner.play_rollout', args: { action: 'resume', track }, title: t('roll.resume'), done: t('roll.done') }}>{t('roll.resume')}</WriteButton>}
<WriteButton run={w} icon="users" w={{ method: 'owner.play_rollout', args: { action: 'complete', track }, title: t('roll.complete'), done: t('roll.done') }} hint={t('roll.completeHint')}>{t('roll.complete')}</WriteButton>
</div>
);
}
if (r.store === 'ios' && r.phased && (r.phased.state === 'ACTIVE' || r.phased.state === 'PAUSED')) {
return (
<div className="roll-ctl">
<span className="small muted">{t('roll.iosNow', { day: r.phased.day ?? 0 })}</span>
{r.phased.state === 'ACTIVE' && <WriteButton run={w} icon="pause" w={{ method: 'owner.ios_phased', args: { action: 'pause', version: v }, title: t('roll.pause'), done: t('roll.paused') }} hint={t('roll.pauseHint')}>{t('roll.pause')}</WriteButton>}
{r.phased.state === 'PAUSED' && <WriteButton run={w} icon="play" w={{ method: 'owner.ios_phased', args: { action: 'resume', version: v }, title: t('roll.resume'), done: t('roll.done') }}>{t('roll.resume')}</WriteButton>}
<WriteButton run={w} icon="users" w={{ method: 'owner.ios_phased', args: { action: 'complete', version: v }, title: t('roll.everyone'), done: t('roll.done') }} hint={t('roll.everyoneHint')}>{t('roll.everyone')}</WriteButton>
</div>
);
}
if (r.store === 'ios' && !r.phased && ['draft', 'review', 'approved'].includes(r.phase) && /^\d+(\.\d+){0,2}$/.test(v)) {
return (
<div className="roll-ctl">
<WriteButton run={w} icon="layers" w={{ method: 'owner.ios_phased', args: { action: 'on', version: v }, title: t('roll.phasedOn'), done: t('roll.phasedOnDone') }} hint={t('roll.phasedOnHint')}>{t('roll.phasedOn')}</WriteButton>
</div>
);
}
return null;
}
function Train({ title, train, tf, tone, app, st }: { title: string; train: ReleaseTrain; tf: TestFlightStatus | null; tone: 'accent' | 'ok'; app: UnifiedApp; st: AppStatus }) {
const lanes = trainLanes(train, tf);
// the stores' current versions differ (normal: they do not always move together): a neutral "Current" header
const differ = tone === 'ok' ? storesDiffer(st.release.all ?? []) : null;
// "Live" only when a store has it live or rolling out; a Play release whose store page could not be checked is not
const label = differ ? t('tl.current') : tone === 'ok' && !lanes.some(l => l.release && ['live', 'rollout', 'halted'].includes(l.release.phase)) ? t('tl.released') : title;
const ver = differ ? t('tl.bothVersions', { play: differ.android, ios: differ.ios }) : train.version;
const created = train.ios?.date ? t('tl.created', { date: date(train.ios.date) }) : null;
return (
<section className={`rel-train ${tone}`} aria-label={`${label} ${ver}`}>
<header className="rel-train-h">
<div className="rel-train-title">
<span className="rel-train-eyebrow">{t('v2rel.version')}</span>
<span className="rel-train-ver num">{ver}</span>
</div>
<StatusPill tone={differ ? 'neutral' : tone === 'ok' && label !== title ? 'info' : tone} solid={tone === 'accent' && !differ}>{label}</StatusPill>
<span className="grow" />
{created && <span className="rel-train-meta"><Icon name="clock" />{created}</span>}
</header>
<div className="rel-lanes">
{lanes.map(l => <LaneRow key={l.store} lane={l} version={train.version} st={st} app={app} />)}
</div>
</section>
);
}
export function ReleasesTab({ app }: { app: UnifiedApp }) {
const d = useData();
const menu = useContextMenu();
const l = d.liveFor(app);
const project = app.project;
const hue = useTint(d.iconFor(app), app.name);
const tf = useRead<TestFlightStatus>(app.target, 'apple.testflight_status', { network: true, title: 'TestFlight', skip: !app.ios && !d.statusFor(app)?.app?.platforms.ios });
const sum = project ? d.statusOf(project)?.summary : null;
const submit = project && (sum?.apple || sum?.play) ? (
<section className="rel-submit stack" aria-labelledby="sub-section" data-section="submit">
<div className="rel-sec-h">
<h2 id="sub-section" className="rel-sec-title"><span className="ds-sec-ic"><Icon name="send" /></span>{t('sub.section')}</h2>
<p className="rel-sec-sub">{t('releases.ownerNote')}</p>
</div>
<div className="rel-submit-grid">
{sum?.apple && <AppleSubmitCard app={app} project={project} />}
{sum?.play && <PlayProductionCard app={app} project={project} />}
</div>
</section>
) : null;
const wrap = (children: ReactNode) => <div className="scr-releases" style={tintStyle(hue)}>{children}</div>;
if (!l && d.portfolioBusy === 'live') return wrap(<><Card><Skeleton lines={5} block /></Card>{submit}</>);
if (!l) {
return wrap(
<>
<Card pad={false}>
<Empty illo="cloud" title={t('releases.notReadTitle')} actions={<>
<button className="btn btn-primary" onClick={() => d.refreshPortfolio(true)} disabled={!!d.portfolioBusy}><Icon name="refresh" />{t('action.readStores')}</button>
{project && !project.playAccount && !project.appleAccount && <span className="small muted">{t('releases.noAccounts')}</span>}
</>}>{t('releases.notReadText')}</Empty>
</Card>
{submit}
</>,
);
}
const history = versionHistory(l.release.all ?? []);
const errs = Object.entries(l.storeErrors);
return wrap(
<>
{errs.map(([s, msg]) => {
const when = l.staleStores?.[s as 'ios'];
return <Notice key={s} tone="warn">{when ? t('releases.storeStale', { store: storeName(s as 'ios'), when: ago(when), error: plainError(msg) }) : t('releases.storeUnreadable', { store: storeName(s as 'ios'), error: plainError(msg) })}</Notice>;
})}
{l.release.next && <Train title={t('tl.next')} train={l.release.next} tf={tf.data} tone="accent" app={app} st={l} />}
{l.release.current && <Train title={t('tl.live')} train={l.release.current} tf={tf.data} tone="ok" app={app} st={l} />}
{!l.release.next && !l.release.current && <Section><EmptyState icon="layers">{t('releases.noVersions')} {t('releases.noVersionsText')}</EmptyState></Section>}
<Section title={t('releases.history')} icon="layers" sub={t('releases.historySub', { when: ago(d.live?.at ?? d.portfolio?.at) })} pad={false} className="rel-history"
actions={history.length ? <span className="rel-count">{t('v2rel.versions', { count: history.length })}</span> : undefined}>
{history.length ? (
<div className="ds-table-wrap">
<table className="table v2">
<thead><tr><th>{t('col.version')}</th><th>App Store</th><th>{t('col.build')}</th><th>Google Play</th><th>{t('col.versionCode')}</th><th>{t('col.track')}</th></tr></thead>
<tbody>
{history.map(h => (
<tr key={h.version} {...menu(() => [
...(h.ios ? [{ label: 'App Store', icon: 'phone' as const, submenu: releaseMenu(d, app, 'ios', h.ios.version, h.ios.build) }] : []),
...(h.android ? [{ label: 'Google Play', icon: 'phone' as const, submenu: releaseMenu(d, app, 'android', h.android.version, h.android.build) }] : []),
])}>
<td className="strong num rel-hv">{[...new Set([h.version, h.ios?.version, h.android?.version].filter(Boolean))].join(' / ')}</td>
<td>{h.ios ? <PhaseChip r={h.ios} /> : <span className="muted small">–</span>}</td>
<td className="num muted">{h.ios?.build ?? '–'}</td>
<td>{h.android ? <PhaseChip r={h.android} /> : <span className="muted small">–</span>}</td>
<td className="num muted">{h.android?.build ?? '–'}</td>
<td className="muted">{h.android?.track ?? '–'}</td>
</tr>
))}
</tbody>
</table>
</div>
) : <div className="ds-sec-b"><EmptyState icon="layers">{t('releases.noVersions')}</EmptyState></div>}
</Section>
{submit}
</>,
);
}