import { Component, ReactNode, useCallback, useEffect, useMemo, useState } from 'react'; import { ActivityIndicator, Modal, Pressable, ScrollView, StyleSheet, Text, TextInput, View, } from 'react-native'; import { BranchIcon } from './BranchIcon'; import { readConfig, readLoadedState, SurfConfig } from './config'; import { BranchPage, listBranches, surfTo } from './surf'; import { cardShadow, palette, radius, space, type } from './theme'; import { XpremMark } from './XpremMark'; /** "5 min ago" beats a timestamp for someone deciding what is fresh enough to test. */ function sinceLabel(iso: string): string { const published = Date.parse(iso); if (Number.isNaN(published)) { return ''; } const minutes = Math.max(0, Math.round((Date.now() - published) / 60000)); if (minutes < 1) return 'Updated just now'; if (minutes < 60) return `Updated ${minutes} min ago`; const hours = Math.round(minutes / 60); if (hours < 24) return `Updated ${hours} hr ago`; const days = Math.round(hours / 24); return days === 1 ? 'Updated yesterday' : `Updated ${days} days ago`; } /** * One probe per JS session. Memoised at module scope rather than in the component: * a useEffect would run again if the tree remounted, and this must be exactly once * per app open. reloadAsync starts a new session, so a surf re-probes naturally. * * Whether a channel allows surfing is a setting that changes whenever an admin * says so, while a manifest is a snapshot frozen when its update was served — so * the answer cannot ride in the manifest, or enabling the feature would only reach * devices that happen to download something afterwards. */ let sessionProbe: Promise | null = null; function probeOnce(config: SurfConfig): Promise { // Only an ANSWER is remembered — a list, or the 404 that means surfing is off. // A timeout is not an answer: caching one would disable the picker until the // app is killed, and the tester has no way to know a retry would work. sessionProbe ??= listBranches(config).catch(error => { sessionProbe = null; throw error; }); return sessionProbe; } let openPanel: (() => void) | null = null; /** Opens the panel from anywhere — the edge handle is the built-in trigger. */ export function openControlCenter() { openPanel?.(); } // A QA tool has no business taking the host app down: whatever throws inside the // panel unmounts the panel, never the app around it. class ControlCenterBoundary extends Component<{ children: ReactNode }, { failed: boolean }> { state = { failed: false }; static getDerivedStateFromError() { return { failed: true }; } componentDidCatch(error: Error) { console.warn(`[xprem] The control center crashed and was unmounted: ${error.message}`); } render() { return this.state.failed ? null : this.props.children; } } export function ControlCenter() { return ( ); } function ControlCenterPanel() { const config = useMemo(readConfig, []); const loaded = useMemo(readLoadedState, []); const [visible, setVisible] = useState(false); const [page, setPage] = useState(null); const [error, setError] = useState(null); const [pending, setPending] = useState(null); const [note, setNote] = useState(null); const [query, setQuery] = useState(''); const [expanding, setExpanding] = useState(false); const [expanded, setExpanded] = useState(false); const [allowed, setAllowed] = useState(false); const active = config !== null && allowed; const open = useCallback(() => setVisible(true), []); const close = useCallback(() => setVisible(false), []); // After first paint: a QA tool must not sit on the critical path of a launch. useEffect(() => { if (!config) return; let cancelled = false; const handle = requestIdleCallback(() => { probeOnce(config) .then(result => { if (cancelled || result === null) return; setAllowed(true); setPage(result); }) .catch((cause: Error) => { // Not silent: the panel is unreachable for the rest of this session // unless the host app calls openControlCenter, and nothing else would // ever say why. console.warn(`[xprem] Could not reach the branch list: ${cause.message}`); }); }); return () => { cancelled = true; cancelIdleCallback(handle); }; }, [config]); useEffect(() => { // Registered whether or not the probe has answered. Gating it on `active` // made the host app's own trigger a silent no-op for the whole of every // launch, and permanently so whenever the probe failed — which is exactly // when someone reaches for it. Opening early shows the panel's own loading // and error states, which is the point. openPanel = open; return () => { // Only if it is still ours: a second instance may have taken over, and // clearing unconditionally would deregister a panel that is still mounted. if (openPanel === open) { openPanel = null; } }; }, [open]); useEffect(() => { if (!visible || !config) return; const controller = new AbortController(); setError(null); setNote(null); setQuery(''); setExpanding(false); setExpanded(false); listBranches(config, controller.signal) .then(result => { if (result === null) { // Surfing was turned off while this session was running. Stand down // rather than spin: null reads as "loading" everywhere below. setAllowed(false); setVisible(false); return; } setPage(result); }) .catch((cause: Error) => { if (cause.name !== 'AbortError') setError(cause.message); }); return () => controller.abort(); }, [visible, config]); const showEverything = useCallback(async () => { if (!config || expanded || expanding) return; setExpanding(true); try { const result = await listBranches(config, undefined, true); if (result === null) { // Surfing was switched off between opening the panel and asking for the // rest. Same stand-down as every other read, or the tester is left // holding switches the server will no longer honour. setAllowed(false); setVisible(false); return; } setPage(result); // Set even when the wide answer is itself capped: this only means "already // asked", so a keystroke cannot start the same fetch again. Whether the // list is COMPLETE is a separate question, read off total below. setExpanded(true); } catch (cause) { setError((cause as Error).message); } finally { setExpanding(false); } }, [config, expanded, expanding]); const search = useCallback( (text: string) => { setQuery(text); // Searching a partial list would answer "no match" for a branch that is // merely further down — a wrong answer, not a short one. So the first // keystroke pulls the rest; showEverything is a no-op once it has. if (text.length > 0) void showEverything(); }, [showEverything] ); const switchTo = useCallback( async (branch: string | null) => { if (!config) return; setPending(branch ?? ''); setNote(null); try { const outcome = await surfTo(config, branch); if (outcome === 'nothing-to-load') { setNote( branch ? `Switched to ${branch}. It has nothing newer than what is already ` + `running, so the screen did not change — you will get its next publish.` : 'Back on this build\u2019s own branch. Nothing newer to load.' ); } } catch (cause) { setError((cause as Error).message); } finally { setPending(null); } }, [config] ); if (!active || !config) { return null; } // What is RUNNING, which after a surf is the surfed branch — not the branch the // channel maps to. Excluded from the list below: there is nothing to switch to. const loadedBranch = loaded.branch; const rows = (page?.branches ?? []).filter(candidate => candidate.name !== loadedBranch); // The server sends a short page of the newest branches. Say how many are left // rather than letting the list pass for the whole truth. const withheld = page ? page.total - page.branches.length : 0; // Keyed on the total, not the page: the page is short by design, so counting // loaded rows would hide the search field exactly when it is most needed. const searchable = (page?.total ?? 0) > 10; const needle = query.trim().toLowerCase(); const shown = needle ? rows.filter(r => r.name.toLowerCase().includes(needle)) : rows; if (!visible) { // Always present while surfing is allowed: the way in a tester nobody briefed // will actually find. An edge sliver rather than an invisible corner target, // so it never swallows the host app's own controls. return ( ); } return ( // pageSheet is the native card the dev menu uses: full-height sheet, the app // pushed back behind it, swipe down to dismiss. No custom animation to own. {loadedBranch ?? 'Embedded build'} [styles.close, pressed && styles.closePressed]} onPress={close} accessibilityRole="button" accessibilityLabel="Close"> ✕ Running on: Channel: {config.channel} Runtime {config.runtimeVersion} {loaded.refusedBranch && ( {loaded.refusedBranch} was rolled back It crashed on launch, so this build came back here. Publishing a fix to that branch makes it available again. )} Switch to {searchable && ( )} {page === null && ( )} {page !== null && shown.length === 0 && ( {needle ? `No branch matches “${query.trim()}”.` : `Nothing else is published for runtime ${config.runtimeVersion}.`} )} {shown.map(candidate => ( [styles.card, styles.row, pressed && styles.rowPressed]} disabled={pending !== null} onPress={() => void switchTo(candidate.name)}> {candidate.name} {sinceLabel(candidate.lastUpdateAt)} {pending === candidate.name ? ( ) : ( › )} ))} {withheld > 0 && !expanded && ( [styles.card, styles.seeAll, pressed && styles.rowPressed]} disabled={expanding} onPress={() => void showEverything()}> {expanding ? ( ) : ( Showing the {page?.branches.length} newest · See all {page?.total} )} )} {withheld > 0 && expanded && ( // The server will not send more than this. Saying so is the whole // point: a search over a list this size must not read as exhaustive. Showing the {page?.branches.length} newest of {page?.total}. Older branches are not listed. )} {note && {note}} {error && {error}} [styles.pill, pressed && styles.pillPressed]} disabled={pending !== null} onPress={() => void switchTo(null)}> {pending === '' ? ( ) : ( Return to this build’s branch )} xprem ); } const styles = StyleSheet.create({ handle: { position: 'absolute', right: 0, top: '45%', paddingVertical: space.md, paddingLeft: space.sm, paddingRight: 2, }, handleGrip: { width: 4, height: 42, borderTopLeftRadius: 3, borderBottomLeftRadius: 3, backgroundColor: palette.ink, opacity: 0.35, }, screen: { flex: 1, backgroundColor: palette.page }, header: { backgroundColor: palette.surface, paddingHorizontal: space.lg, paddingTop: space.lg, paddingBottom: space.md, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: palette.border, gap: space.md, }, heroLine: { flexDirection: 'row', alignItems: 'center', gap: 10 }, heroName: { flex: 1 }, close: { width: 32, height: 32, borderRadius: 16, backgroundColor: palette.page, alignItems: 'center', justifyContent: 'center', }, closePressed: { backgroundColor: palette.surfacePressed }, closeGlyph: { fontSize: 15, color: palette.muted }, chipLine: { flexDirection: 'row', alignItems: 'center', flexWrap: 'wrap', gap: space.sm }, chip: { flexDirection: 'row', alignItems: 'center', gap: 6, backgroundColor: palette.chipBg, borderRadius: radius.chip, paddingHorizontal: 10, paddingVertical: 5, }, chipNeutral: { backgroundColor: palette.page }, chipNeutralInk: { color: palette.muted }, liveDot: { width: 7, height: 7, borderRadius: 4, backgroundColor: palette.live }, body: { flex: 1 }, bodyContent: { padding: space.md, paddingBottom: space.xl, gap: space.sm }, sectionTitle: { marginTop: space.sm, marginLeft: space.xs }, search: { backgroundColor: palette.surface, borderRadius: radius.chip + 2, borderWidth: StyleSheet.hairlineWidth, borderColor: palette.border, minHeight: 42, paddingHorizontal: space.md, fontSize: 16, color: palette.ink, }, card: { backgroundColor: palette.surface, borderRadius: radius.card, paddingHorizontal: space.md, paddingVertical: 14, ...cardShadow, }, cardCentered: { alignItems: 'center', justifyContent: 'center', minHeight: 64 }, row: { flexDirection: 'row', alignItems: 'center', gap: space.sm + 4 }, rowPressed: { backgroundColor: palette.surfacePressed }, seeAll: { alignItems: 'center', justifyContent: 'center', minHeight: 48 }, seeAllText: { fontSize: 15, fontWeight: '600', color: palette.ink }, rowText: { flex: 1, gap: 2 }, chevron: { fontSize: 22, color: palette.muted, marginTop: -2 }, warnCard: { backgroundColor: palette.warnBg, borderRadius: radius.card, padding: space.md, gap: 4, }, warnTitle: { fontSize: 16, fontWeight: '600', color: palette.warnInk }, warnBody: { fontSize: 14, lineHeight: 20, color: palette.warnInk }, footnote: { marginLeft: space.xs }, errorText: { marginLeft: space.xs, color: palette.danger }, pill: { marginTop: space.md, minHeight: 52, borderRadius: radius.pill, backgroundColor: palette.pill, alignItems: 'center', justifyContent: 'center', }, pillPressed: { opacity: 0.85 }, brand: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 6, marginTop: space.lg, opacity: 0.5, }, }); export default ControlCenter;