Skip to content
141 linesCodeBlameRaw
1/**
2 * Where chat's events meet the sound rules: a message over the open
3 * conversation's socket (components/chat/channel.tsx) or a notification
4 * over the feed (lib/notify-client.ts) comes here with what the page knows
5 * of itself, lib/chat-sounds.ts decides, and lib/sounds.ts plays. One gate
6 * keeps sounds apart. Browser-only; the rules it runs are tested in
7 * chat-sounds.test.ts.
8 *
9 * It also shows the system notification (the browser's Notifications API)
10 * a person asked for under Desktop notifications: for a message to them
11 * while the window is not in front, from the open tab itself, with no
12 * service worker and no push involved.
13 */
14import type { ChatMessage, FeedNotification } from "@g1t/contracts";
15import { type SoundCue, cueOn } from "@g1t/contracts/sounds";
16
17import { type Arrival, type FocusState, SoundGate, arrivalKey, cueFor, wantsDesktopToast } from "./chat-sounds";
18import { play, soundSettings } from "./sounds";
19
20let me: { id: string; username: string } | null = null;
21let viewing: string | null = null;
22let muted: ReadonlySet<string> = new Set();
23const gate = new SoundGate();
24
25/** Who is signed in, from the root with the page: their own messages never sound. */
26export function setSoundViewer(viewer: { id: string; username: string } | null): void {
27 me = viewer;
28}
29
30/** The conversation on screen, from its page: null when leaving it. */
31export function viewingConversation(channelId: string | null): void {
32 viewing = channelId;
33}
34
35export function viewingChannel(): string | null {
36 return viewing;
37}
38
39/** The conversations muted in the sidebar, as it last read them. */
40export function setMutedConversations(channelIds: Iterable<string>): void {
41 muted = new Set(channelIds);
42}
43
44/** What the tab can see of itself right now. */
45export function focusState(): FocusState {
46 const has = typeof document !== "undefined";
47 return {
48 visible: has && document.visibilityState === "visible",
49 focused: has && document.hasFocus(),
50 viewingChannel: viewing,
51 };
52}
53
54function sound(arrival: Arrival, dnd: boolean, mutedHere?: boolean): SoundCue | null {
55 const all = mutedHere ? new Set([...muted, arrival.source === "chat" ? arrival.message.channel_id : (arrival.notification.channel_id ?? "")]) : muted;
56 const cue = cueFor(arrival, { me, focus: focusState(), muted: all, dnd, settings: soundSettings() });
57 // Heard of either way, so the same message over the other door stays quiet.
58 const allowed = gate.allow(arrivalKey(arrival), Date.now());
59 if (!cue || !allowed) return null;
60 play(cue);
61 return cue;
62}
63
64/**
65 * A new message in the conversation open on this page. `muted` is that
66 * conversation's own setting, which the sidebar may not have told us yet.
67 * Returns what played, for the harness.
68 */
69export 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);
71}
72
73/**
74 * A notification over the feed. `toast` is the feed's own decision about a
75 * pop-up; the sound is decided here from the same facts. Shows the system
76 * notification too, when wanted (`wantsDesktopToast`).
77 */
78export function notificationArrived(
79 notification: FeedNotification,
80 options: {
81 toast: boolean;
82 dnd: boolean;
83 permission: "default" | "granted" | "denied";
84 pushOn: boolean;
85 open: (href: string) => void;
86 },
87): { cue: SoundCue | null; desktopToast: boolean } {
88 const cue = sound({ source: "feed", notification }, options.dnd);
89 const desktopToast = wantsDesktopToast({
90 notification,
91 toast: options.toast,
92 ctx: { focus: focusState(), dnd: options.dnd, settings: soundSettings() },
93 permission: options.permission,
94 pushOn: options.pushOn,
95 });
96 if (desktopToast) showDesktopToast(notification, options.open);
97 return { cue, desktopToast };
98}
99
100/** The soft tick as you send, if turned on (it is off until then), and never while not to be disturbed. */
101export function messageSent(options: { dnd: boolean }): void {
102 const settings = soundSettings();
103 if (!settings.sounds_enabled || options.dnd || !cueOn(settings, "sent")) return;
104 play("sent");
105}
106
107/**
108 * The browser's own notification, from this tab: it closes itself after a
109 * while, a newer one about the same conversation replaces it (`tag`), and a
110 * click brings the window forward and opens the conversation. Never asks
111 * for permission: that is the settings page's button alone.
112 */
113export function showDesktopToast(notification: FeedNotification, open: (href: string) => void): void {
114 try {
115 if (typeof Notification === "undefined" || Notification.permission !== "granted") return;
116 const shown = new Notification(notification.title, {
117 body: notification.body,
118 tag: notification.channel_id ? `chat:${notification.channel_id}` : `${notification.kind}:${notification.id}`,
119 icon: "/icon-192.png",
120 silent: true,
121 });
122 shown.onclick = () => {
123 try {
124 window.focus();
125 } catch {
126 // Some browsers refuse; the page still moves.
127 }
128 open(notification.href);
129 shown.close();
130 };
131 setTimeout(() => shown.close(), 8_000);
132 } catch {
133 // A browser that has the API but not here (an iframe, say): nothing shown.
134 }
135}
136
137/** Test seam: the gate forgets everything, so a harness can play cue after cue. */
138export function resetSoundGateForTest(): void {
139 (gate as unknown as { lastAt: number; seen: Map<string, number> }).lastAt = Number.NEGATIVE_INFINITY;
140 (gate as unknown as { seen: Map<string, number> }).seen.clear();
141}