Skip to content

Commit

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.

syntaqxcommitted Parent8702a2dBrowse files
5 files+136−60/5 viewed
+8−0
210210 assert.ok(wantsDesktopToast({ ...base, ctx: { ...base.ctx, focus: { visible: false, focused: false, viewingChannel: null } } }));
211211 assert.ok(!wantsDesktopToast({ ...base, notification: { kind: "inbox" } }));
212212 });
213+
214+test("g1t's answer is silent while it thinks and streams, and pings once it is written", () => {
215+ const away = ctx({ focus: { visible: true, focused: false, viewingChannel: "chn_general" } });
216+ const answer = (state: "thinking" | "streaming" | "done") => chat(message({ author: { kind: "system", id: "g1t", name: "g1t", display_name: "g1t", avatar: null, role: null }, answer: { state } }), "dm");
217+ assert.equal(cueFor(answer("thinking"), away), null);
218+ assert.equal(cueFor(answer("streaming"), away), null);
219+ assert.equal(cueFor(answer("done"), away), "direct");
220+});
+3−0
108108 if (arrival.source === "chat") {
109109 const { message } = arrival;
110110 if (message.deleted_at) return null;
111+ // g1t's answer arrives empty, thinking, the moment a question is asked:
112+ // it pings once, when it is written (over the feed), not as it starts.
113+ if (message.answer && message.answer.state !== "done") return null;
111114 if (ctx.me && mentionsMe(message.body, ctx.me.username)) cue = "mention";
112115 else cue = arrival.channelKind === "dm" ? "direct" : "message";
113116 } else {
+25−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { MARK_TTL_MS, type Marks, takeMark } from "./one-tab.ts";
5+
6+function memory(): Marks & { data: Map<string, string> } {
7+ const data = new Map<string, string>();
8+ return { data, get: (k) => data.get(k) ?? null, set: (k, v) => void data.set(k, v), keys: () => [...data.keys()], remove: (k) => void data.delete(k) };
9+}
10+
11+test("the first tab to claim an arrival pings, and every other tab stays quiet", () => {
12+ const shared = memory();
13+ assert.equal(takeMark(shared, "msg:1", 1_000), true);
14+ assert.equal(takeMark(shared, "msg:1", 1_200), false);
15+ assert.equal(takeMark(shared, "msg:2", 1_300), true);
16+});
17+
18+test("old marks are cleared as tabs claim, and other keys are left alone", () => {
19+ const shared = memory();
20+ shared.set("g1t:pinged:msg:old", "0");
21+ shared.set("g1t:pinged:msg:bad", "nope");
22+ shared.set("g1t_theme", "dark");
23+ takeMark(shared, "msg:new", MARK_TTL_MS + 1);
24+ assert.deepEqual([...shared.data.keys()].sort(), ["g1t:pinged:msg:new", "g1t_theme"]);
25+});
+74−0
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+}
+26−6
1010 * a person asked for under Desktop notifications: for a message to them
1111 * while the window is not in front, from the open tab itself, with no
1212 * service worker and no push involved.
13+ *
14+ * Every open tab hears the same arrival, so one of them pings: a sound and
15+ * a toast are played only by the tab that claims the arrival first across
16+ * the browser (lib/one-tab.ts), the one in front when there is one.
1317 */
1418 import type { ChatMessage, FeedNotification } from "@g1t/contracts";
1519 import { type SoundCue, cueOn } from "@g1t/contracts/sounds";
1620
1721 import { type Arrival, type FocusState, SoundGate, arrivalKey, cueFor, wantsDesktopToast } from "./chat-sounds";
22+import { claimPing } from "./one-tab";
1823 import { play, soundSettings } from "./sounds";
1924
2025 let me: { id: string; username: string } | null = null;
5156 };
5257 }
5358
59+/** The cue this arrival earns in this tab, through the tab's own gate; nothing is played yet. */
5460 function sound(arrival: Arrival, dnd: boolean, mutedHere?: boolean): SoundCue | null {
5561 const all = mutedHere ? new Set([...muted, arrival.source === "chat" ? arrival.message.channel_id : (arrival.notification.channel_id ?? "")]) : muted;
5662 const cue = cueFor(arrival, { me, focus: focusState(), muted: all, dnd, settings: soundSettings() });
5763 // Heard of either way, so the same message over the other door stays quiet.
5864 const allowed = gate.allow(arrivalKey(arrival), Date.now());
59− if (!cue || !allowed) return null;
60− play(cue);
61− return cue;
65+ return cue && allowed ? cue : null;
66+}
67+
68+/** Pings once across the browser: the tab that claims the arrival plays the sound and shows the toast. */
69+function ping(arrival: Arrival, cue: SoundCue | null, toast: (() => void) | null): void {
70+ if (!cue && !toast) return;
71+ const key = arrivalKey(arrival) ?? `${arrival.source}:${Date.now()}`;
72+ const focus = focusState();
73+ void claimPing(key, focus.visible && focus.focused).then((mine) => {
74+ if (!mine) return;
75+ if (cue) play(cue);
76+ toast?.();
77+ });
6278 }
6379
6480 /**
6783 * Returns what played, for the harness.
6884 */
6985 export function chatMessageArrived(message: ChatMessage, channelKind: "channel" | "dm", options: { dnd: boolean; muted?: boolean }): SoundCue | null {
70− return sound({ source: "chat", message, channelKind }, options.dnd, options.muted);
86+ const arrival: Arrival = { source: "chat", message, channelKind };
87+ const cue = sound(arrival, options.dnd, options.muted);
88+ ping(arrival, cue, null);
89+ return cue;
7190 }
7291
7392 /**
85104 open: (href: string) => void;
86105 },
87106 ): { cue: SoundCue | null; desktopToast: boolean } {
88− const cue = sound({ source: "feed", notification }, options.dnd);
107+ const arrival: Arrival = { source: "feed", notification };
108+ const cue = sound(arrival, options.dnd);
89109 const desktopToast = wantsDesktopToast({
90110 notification,
91111 toast: options.toast,
93113 permission: options.permission,
94114 pushOn: options.pushOn,
95115 });
96− if (desktopToast) showDesktopToast(notification, options.open);
116+ ping(arrival, cue, desktopToast ? () => showDesktopToast(notification, options.open) : null);
97117 return { cue, desktopToast };
98118 }
99119