Skip to content
74 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

One tab pings, once, and only for what is for you. Every open tab of g1t heard the same arrival, so each played its sound and showed its desktop toast; now a tab claims the arrival across the browser before pinging (lib/one-tab.ts: a Web Lock held while a ten-minute mark is written to the localStorage every tab shares, so a tab that comes second finds the mark), a tab in front claims at once and the others wait 400 ms, so the one you are looking at is the one that pings, and the sound and the toast for an arrival go together. A message heard over both the conversation's socket and the feed is claimed once, by its id. g1t's answer no longer pings as it starts: it arrives empty and thinking the moment a question is asked, which pinged right after your own message; it now pings once, when it is written. Where Web Locks or storage are missing, every tab pings as before. Checked: the site's typecheck and its 893 tests (new: the claim's rule and the answer's silence until it is written), and a production build whose chunks load first without error.1/**
2 * One tab pings. Every open tab of g1t hears the same notification over the
3 * feed, so each would play its sound and show its desktop toast: three tabs,
4 * three pings. Before pinging, a tab claims the arrival across the browser
5 * (lib/sound-events.ts): the first claim wins and the rest stay quiet. A tab
6 * the person is looking at claims at once and the others wait a beat, so the
7 * one in front is the one that pings when there is one.
8 *
9 * The claim is a Web Lock held while a short-lived mark is written to
10 * localStorage, the storage every tab of the site shares: a tab that gets the
11 * lock after the winner finds the mark. Where neither exists (an old browser,
12 * storage switched off), every tab pings as before.
13 */
14
15/** How long a background tab waits before claiming, so a tab in front wins. */
16export const BACKGROUND_WAIT_MS = 400;
17/** How long a mark is kept: long enough for every tab to have heard. */
18export const MARK_TTL_MS = 10 * 60_000;
19const PREFIX = "g1t:pinged:";
20
21/** Storage as a claim reads and writes it. */
22export type Marks = {
23 get(key: string): string | null;
24 set(key: string, value: string): void;
25 keys(): string[];
26 remove(key: string): void;
27};
28
29/**
30 * With the lock held: whether this tab is the first to claim `key`. Marks
31 * it claimed, and clears marks older than the TTL as it goes.
32 */
33export function takeMark(marks: Marks, key: string, now: number): boolean {
34 for (const name of marks.keys()) {
35 if (!name.startsWith(PREFIX)) continue;
36 const at = Number(marks.get(name));
37 if (!Number.isFinite(at) || now - at > MARK_TTL_MS) marks.remove(name);
38 }
39 const name = PREFIX + key;
40 if (marks.get(name) !== null) return false;
41 marks.set(name, String(now));
42 return true;
43}
44
45function localMarks(): Marks | null {
46 try {
47 const storage = window.localStorage;
48 // Some browsers throw only on the first write.
49 storage.setItem(`${PREFIX}probe`, "0");
50 storage.removeItem(`${PREFIX}probe`);
51 return {
52 get: (key) => storage.getItem(key),
53 set: (key, value) => storage.setItem(key, value),
54 keys: () => Array.from({ length: storage.length }, (_, at) => storage.key(at) ?? ""),
55 remove: (key) => storage.removeItem(key),
56 };
57 } catch {
58 return null;
59 }
60}
61
62/** Whether this tab should ping for `key`: true for exactly one tab of the browser. */
63export async function claimPing(key: string, inFront: boolean): Promise<boolean> {
64 if (typeof window === "undefined") return false;
65 const marks = localMarks();
66 const locks = typeof navigator !== "undefined" ? navigator.locks : undefined;
67 if (!marks || !locks) return true;
68 if (!inFront) await new Promise((resolve) => setTimeout(resolve, BACKGROUND_WAIT_MS));
69 try {
70 return await locks.request(`${PREFIX}${key}`, async () => takeMark(marks, key, Date.now()));
71 } catch {
72 return true;
73 }
74}