Skip to content
Merged
2 changes: 1 addition & 1 deletion app/src/components/launchpad/LaunchForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -873,7 +873,7 @@ export default function LaunchForm({ ethUsd, gitlawbUsd = null, initialChain = D
const active = initialBuy.trim() === v;
return (
<button type="button" key={v} onClick={() => (active ? declineFirstBuy() : chooseFirstBuy(v))} aria-pressed={active} className={`${choice} ${active ? choiceOn : choiceOff}`}>
<span className="block truncate font-mono text-[15px] font-bold tnum">{Number(v) >= 100_000 ? fmtCompact(Number(v), 0) : fmtQuoteUnits(Number(v), quote.decimals)} {quote.symbol}</span>
<span className="block truncate font-mono text-[15px] font-bold tnum">{fmtQuoteUnits(Number(v), quote.decimals)} {quote.symbol}</span>
{quoteUsd ? <span className={`mt-0.5 block truncate font-mono text-[11px] tnum ${active ? "text-brand" : "text-muted"}`}>≈ {fmtUsd(Number(v) * quoteUsd)}</span> : null}
</button>
);
Expand Down
41 changes: 28 additions & 13 deletions app/src/components/launchpad/LaunchList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ export default function LaunchList({ initial, initialHasMore = false, initialSor
const interaction = useRef({ pointer: false, focus: false, at: 0 });
const listRef = useRef<HTMLUListElement>(null);
const section = usePauseOffscreen<HTMLElement>(); // a beacon pulses on every live row: not while the list is far off screen
const failedLoad = useRef<"view" | "more">("view");

useEffect(() => {
// the watchlist reads its own endpoint; the shared poll only carries the market list while it is on screen
Expand Down Expand Up @@ -138,15 +139,21 @@ export default function LaunchList({ initial, initialHasMore = false, initialSor
}
}), [subscribe]);

/** The URL carries the view as well as the selection, so going back to the page reopens the same tab. */
/**
* The URL carries the view as well as the selection, so going back to the page reopens the same tab. On the home page
* the list already holds the data, so only the address changes (history.replaceState syncs with the Next router):
* router.replace would re-render the whole page on the server, and a failing render there unmounts the page.
*/
function syncUrl(next: Selection, v: ListView): URLSearchParams {
const p = new URLSearchParams();
if (next.sort !== "live") p.set("sort", next.sort);
if (next.window !== "all") p.set("window", next.window);
if (next.chain) p.set("chain", next.chain);
if (next.filter) p.set("filter", next.filter);
if (v === "watchlist") p.set("view", "watchlist");
router.replace(p.size ? `/?${p}` : "/", { scroll: false });
const href = p.size ? `/?${p}` : "/";
if (window.location.pathname === "/") window.history.replaceState(null, "", href);
else router.replace(href, { scroll: false }); // a chain page hands over to the home page
return p;
}

Expand All @@ -158,22 +165,27 @@ export default function LaunchList({ initial, initialHasMore = false, initialSor
}
setView(v);
selectionRef.current = next;
const version = ++generation.current;
selectionRequest.current?.abort();
const controller = new AbortController();
selectionRequest.current = controller;
pendingOrder.current = null;
setHolding(false);
setSelection(next);
limitRef.current = PAGE_SIZE;
setLimit(PAGE_SIZE);
setLoadingMore(false);
syncUrl(next, v);
loadSelection(next);
}

/** Fetches the first page of a selection. A failure leaves the rows on screen and offers a retry; it never throws into render. */
function loadSelection(next: Selection) {
const version = ++generation.current;
selectionRequest.current?.abort();
const controller = new AbortController();
selectionRequest.current = controller;
setUpdating(true);
setLoadError(null);
const request = syncUrl(next, v);
request.delete("view");
request.set("sort", s); // the URL omits the default sort, but the API defaults to "new": the request must always carry it
request.set("limit", String(PAGE_SIZE));
const request = new URLSearchParams({ sort: next.sort, window: next.window, limit: String(PAGE_SIZE) }); // the API defaults the sort to "new": the request must always carry it
if (next.chain) request.set("chain", next.chain);
if (next.filter) request.set("filter", next.filter);
void fetch(`/api/launch/list?${request}`, { cache: "no-store", signal: controller.signal })
.then(async (res) => {
if (!res.ok) throw new Error("List unavailable");
Expand All @@ -185,7 +197,7 @@ export default function LaunchList({ initial, initialHasMore = false, initialSor
setHasMore(data.has_more);
setHl(new Map());
})
.catch(() => { if (!controller.signal.aborted && generation.current === version) setLoadError("Could not refresh this view. Live updates will retry."); })
.catch(() => { if (!controller.signal.aborted && generation.current === version) { failedLoad.current = "view"; setLoadError("Could not refresh this view."); } })
.finally(() => { if (generation.current === version) setUpdating(false); });
}

Expand Down Expand Up @@ -233,7 +245,7 @@ export default function LaunchList({ initial, initialHasMore = false, initialSor
limitRef.current = Math.min(200, merged.length);
setLimit(limitRef.current);
} catch {
if (generation.current === version) setLoadError("Could not load more launches. Please try again.");
if (generation.current === version) { failedLoad.current = "more"; setLoadError("Could not load more launches."); }
} finally {
if (generation.current === version) setLoadingMore(false);
}
Expand Down Expand Up @@ -342,7 +354,10 @@ export default function LaunchList({ initial, initialHasMore = false, initialSor
{nq || filter || chain ? <button type="button" onClick={reset} className={btn.secondarySm}>Clear search & filters</button> : <Link href="/launch" className={btn.secondarySm}>Launch the first token <ArrowRight size={13} aria-hidden="true" /></Link>}
</li> : null}
</ul>
{loadError || searchResult?.error ? <p role="alert" className={`py-3 text-xs text-warm-ink ${pad}`}>{loadError || "Search is unavailable. Showing matches from loaded launches."}</p> : null}
{loadError || searchResult?.error ? <p role="alert" className={`flex flex-wrap items-center gap-x-3 gap-y-1 py-3 text-xs text-warm-ink ${pad}`}>
{loadError || "Search is unavailable. Showing matches from loaded launches."}
{loadError ? <button type="button" onClick={() => void (failedLoad.current === "more" ? loadMore() : loadSelection(selectionRef.current))} disabled={loadingMore || updating} className="font-medium underline underline-offset-2 hover:text-ink disabled:opacity-60">Try again</button> : null}
</p> : null}
<div className={`flex min-h-14 items-center justify-center py-3 ${pad}`}>
{!nq && hasMore && rows.length < 200 ? <button type="button" onClick={() => void loadMore()} disabled={loadingMore || updating} className={btn.secondarySm}>{loadingMore ? <><Spinner size={13} /> Loading…</> : <>Load more <ArrowRight size={13} aria-hidden="true" /></>}</button> : <p className="text-center text-[11px] text-muted">{nq ? "Search includes older launches." : rows.length >= 200 && hasMore ? "Showing the first 200. Search or filter to narrow the list." : shown.length ? "You're all caught up." : "One transaction. Zero platform fee."}</p>}
</div>
Expand Down
2 changes: 1 addition & 1 deletion app/src/components/launchpad/TokenTrades.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export default function TokenTrades({ chain, symbol, quote, swaps, now }: { chai
return <section aria-label="Indexed trades">
<div className="flex min-h-11 items-center justify-between gap-3 px-5 text-[11px] text-muted"><span>Direct from the pool</span><span className="font-mono tnum">{swaps.length} recent swaps</span></div>
{swaps.length === 0 ? <div className="flex min-h-48 flex-col items-center justify-center gap-2 border-t border-line px-5 text-center"><Activity size={22} strokeWidth={1.4} className="mb-1 text-muted" /><h3 className="text-sm font-medium text-ink">The tape is quiet.</h3><p className="max-w-xs text-xs leading-relaxed text-muted text-pretty">No swaps have been indexed for this token yet. Confirmed trades appear here with an explorer link.</p></div> :
<div className="max-h-[480px] overflow-auto bb-scroll" tabIndex={0} aria-label="Trade history">
<div className="relative max-h-[480px] overflow-auto bb-scroll" tabIndex={0} aria-label="Trade history">
<table className="w-full text-xs">
<thead className="sticky top-0 z-10 border-y border-line bg-card text-[11px] text-muted">
<tr>
Expand Down
3 changes: 2 additions & 1 deletion app/src/components/launchpad/home-floor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,8 @@ test("the watchlist star sits outside the row link, and the tab survives a back
assert.match(list, /if \(v === "watchlist"\) p\.set\("view", "watchlist"\);/);
assert.match(home, /initialView=\{sp\.view === "watchlist" \? "watchlist" : "market"\}/);
assert.match(list, /setListParams\(view === "market" \? \{ \.\.\.selection, limit \} : null\)/, "the shared poll skips the market list while the watchlist is open");
assert.match(list, /request\.delete\("view"\);/, "the list API never receives the view");
assert.match(list, /const request = new URLSearchParams\(\{ sort: next\.sort, window: next\.window, limit: String\(PAGE_SIZE\) \}\);/, "the list API request is built from the selection, so it never receives the view");
assert.doesNotMatch(list.slice(list.indexOf("function loadSelection"), list.indexOf("const nq = normalizeQuery(q);")), /request\.set\("view"/);
});

test("the bubble map lays out once per update with no animation timer, and stays decorative like the river", () => {
Expand Down
5 changes: 5 additions & 0 deletions app/src/components/launchpad/launch-form.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,3 +178,8 @@ test("the preview draws the real board card from the form, with no link and noth
assert.match(preview, /col-start-1 row-start-1/);
assert.doesNotMatch(preview, /\bfetch\s*\(/);
});

test("a first-buy chip is labelled by the one formatter, so its label never rounds away from its value", () => {
assert.match(source, /\{fmtQuoteUnits\(Number\(v\), quote\.decimals\)\} \{quote\.symbol\}<\/span>/);
assert.doesNotMatch(source, /fmtCompact\(Number\(v\), 0\)/);
});
33 changes: 33 additions & 0 deletions app/src/components/launchpad/list-load-errors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";

/**
* Source contracts for how the home board survives a failing list: Volume with a window once answered 502, and the
* whole page unmounted. A failed read now stays inline, offers a retry, and picking a view never re-renders the page on the server.
*/
const read = (file: string) => readFileSync(new URL(file, import.meta.url), "utf8").replaceAll("\r\n", "\n");
const list = read("./LaunchList.tsx");
const live = read("./LiveProvider.tsx");

test("a non-OK list or live response is caught, never thrown into render", () => {
const selection = list.slice(list.indexOf("function loadSelection"), list.indexOf("const nq = normalizeQuery(q);"));
assert.match(selection, /if \(!res\.ok\) throw new Error\("List unavailable"\);/);
assert.match(selection, /\.catch\(\(\) => \{[^\n]*setLoadError\(/, "the rejection lands in the inline error");
assert.match(live, /if \(!res\.ok\) return;/, "the shared poll keeps the last snapshot");
assert.match(live, /catch \{\s*\/\* offline; keep last \*\//);
});

test("the inline error is an alert with a retry for both a failed view and a failed page", () => {
assert.match(list, /<p role="alert"[^\n]*\n[^\n]*loadError/);
assert.match(list, /failedLoad\.current === "more" \? loadMore\(\) : loadSelection\(selectionRef\.current\)/);
assert.match(list, />Try again<\/button>/);
assert.match(list, /failedLoad\.current = "view"/);
assert.match(list, /failedLoad\.current = "more"/);
});

test("on the home page a selection changes the address only; it does not navigate through the server", () => {
const sync = list.slice(list.indexOf("function syncUrl"), list.indexOf("function pick("));
assert.match(sync, /window\.location\.pathname === "\/"\) window\.history\.replaceState\(null, "", href\)/);
assert.match(sync, /else router\.replace\(href/, "a chain page still hands over to the home page");
});
7 changes: 7 additions & 0 deletions app/src/components/launchpad/token-page.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,10 @@ test("the About card sits under the trade box and links only checked socials", (
assert.match(about, /const \{ x, site, host \} = safeSocials\(\{ website, x_handle \}\);/);
assert.match(about, /rel="noopener noreferrer nofollow"/);
});

test("the trades scroller is the containing block for its sr-only labels, so a long tape adds no blank page height", () => {
const trades = read("./TokenTrades.tsx");
// every row carries an absolutely positioned sr-only label; without a positioned scroller they escape its clipping and stretch the page
assert.match(trades, /<span className="sr-only">View transaction, <\/span>/);
assert.match(trades, /<div className="relative max-h-\[480px\] overflow-auto bb-scroll"/);
});
2 changes: 2 additions & 0 deletions app/src/components/launchpad/trending-strip.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ test("the home page survives a failed tape read, and the feed marks launches tha
assert.match(queries, /return tape \? \{ \.\.\.snap, tape \} : snap;/);
assert.match(queries, /const reused = await pictureCopies\(db, launches\);/);
assert.match(queries, /image_reused: true as const/);
// the ownership read is a scan of bb_launch_meta that every home render would repeat: it is cached per set of pictures (a failure is not cached: memo clears it)
assert.match(queries, /const all = await memo\(`picture-owners:\$\{sorted\.join\(","\)\}`, 30_000, \(\) => db<PictureUse\[\]>/);
// a failed ownership read must not take the feed down, and must not let a picture through unchecked: every stored picture counts as a copy
assert.match(queries.slice(queries.indexOf("async function pictureCopies")), /catch \(error\) \{\n\s*console\.warn\("\[feed\] picture ownership read failed:"[^\n]*\n\s*return new Set\(launches\.filter\(\(r\) => pictureKey\(r\.image_url\) !== null\)/);
});
Expand Down
30 changes: 30 additions & 0 deletions app/src/lib/launchpad/image-store.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { readObject } from "./imageStore.ts";

const KEY = `t/${"a".repeat(48)}.webp`;
const body = (bytes: number[]) => ({ transformToByteArray: async () => Uint8Array.from(bytes) });
const client = (send: (command: unknown, options?: { abortSignal?: AbortSignal }) => Promise<unknown>) => ({ send }) as unknown as Parameters<typeof readObject>[0];

test("a read that never answers is a miss within the timeout, and the request is aborted", async () => {
let signal: AbortSignal | undefined;
const hang = client((_command, options) => { signal = options?.abortSignal; return new Promise(() => {}); });
const started = Date.now();
assert.equal(await readObject(hang, "bucket", KEY, 40), null);
assert.ok(Date.now() - started < 1_000, "returned about when the timeout fired");
assert.equal(signal?.aborted, true, "the SDK call was handed a signal and it fired");
});

test("a body that stalls after the headers is also a miss", async () => {
const stalled = client(async () => ({ Body: { transformToByteArray: () => new Promise(() => {}) } }));
assert.equal(await readObject(stalled, "bucket", KEY, 40), null);
});

test("a rejected read is a miss, never a throw", async () => {
assert.equal(await readObject(client(async () => { throw new Error("NoSuchKey"); }), "bucket", KEY, 40), null);
});

test("a fast read returns its bytes", async () => {
const got = await readObject(client(async () => ({ Body: body([1, 2, 3]) })), "bucket", KEY, 1_000);
assert.deepEqual([...got!], [1, 2, 3]);
});
27 changes: 24 additions & 3 deletions app/src/lib/launchpad/imageStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,29 @@ export async function putImage(key: string, body: Buffer): Promise<string> {
return imageUrlFor(c.publicBase, key);
}

const READ_TIMEOUT_MS = 3_000;
type ObjectClient = Pick<S3Client, "send">;

/**
* One bucket read that gives up after `timeoutMs`: the abort signal cancels the request, and the race also covers a body
* that stalls after the headers arrive. A timeout is a miss (null), like any other failed read, and never throws.
*/
export async function readObject(client: ObjectClient, bucket: string, key: string, timeoutMs = READ_TIMEOUT_MS): Promise<Buffer | null> {
const abort = new AbortController();
const timer = setTimeout(() => abort.abort(), timeoutMs);
try {
const read = (async () => {
const r = await client.send(new GetObjectCommand({ Bucket: bucket, Key: key }), { abortSignal: abort.signal });
const bytes = await r.Body?.transformToByteArray();
return bytes ? Buffer.from(bytes) : null;
})().catch(() => null);
const timedOut = new Promise<null>((resolve) => abort.signal.addEventListener("abort", () => resolve(null), { once: true }));
return await Promise.race([read, timedOut]);
} finally {
clearTimeout(timer);
}
}

const KEY_RE = /^t\/[0-9a-f]{48}\.webp$/;

/**
Expand All @@ -79,9 +102,7 @@ export async function readImage(key: string): Promise<Buffer | null> {
if (c.kind === "off" || !KEY_RE.test(key)) return null;
try {
if (c.kind === "local") return await readFile(path.join(c.dir, key));
const r = await c.client.send(new GetObjectCommand({ Bucket: c.bucket, Key: key }));
const bytes = await r.Body?.transformToByteArray();
return bytes ? Buffer.from(bytes) : null;
return await readObject(c.client, c.bucket, key);
} catch {
return null;
}
Expand Down
9 changes: 9 additions & 0 deletions app/src/lib/launchpad/math.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,15 @@ test("fmtQuoteUnits: stables 2dp, 18-dec ETH-style below 100K, compact above wit
assert.equal(fmtQuoteUnits(594_540_000, 18), "594.54M");
});

test("fmtQuoteUnits labels a first-buy chip with the amount it sets", () => {
assert.equal(fmtQuoteUnits(1_000_000, 18), "1M");
assert.equal(fmtQuoteUnits(2_500_000, 18), "2.5M", "the middle TWIG chip once read 3M");
assert.equal(fmtQuoteUnits(5_000_000, 18), "5M");
assert.equal(fmtQuoteUnits(750_000, 18), "750K");
assert.equal(fmtQuoteUnits(100_000, 18), "100K");
assert.equal(fmtQuoteUnits(12_345_678, 18), "12.35M");
});

test("fmtUnitsExact keeps every digit of a raw amount: no float, no rounding, trailing zeros trimmed", () => {
assert.equal(fmtUnitsExact("0", 18), "0");
assert.equal(fmtUnitsExact("1", 18), "0.000000000000000001");
Expand Down
Loading
Loading