| 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. */ |
| 16 | export const BACKGROUND_WAIT_MS = 400; |
| 17 | /** How long a mark is kept: long enough for every tab to have heard. */ |
| 18 | export const MARK_TTL_MS = 10 * 60_000; |
| 19 | const PREFIX = "g1t:pinged:"; |
| 20 | |
| 21 | /** Storage as a claim reads and writes it. */ |
| 22 | export 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 | */ |
| 33 | export 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 | |
| 45 | function 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. */ |
| 63 | export 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 | } |