| 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 | */ |
| 14 | import type { ChatMessage, FeedNotification } from "@g1t/contracts"; |
| 15 | import { type SoundCue, cueOn } from "@g1t/contracts/sounds"; |
| 16 | |
| 17 | import { type Arrival, type FocusState, SoundGate, arrivalKey, cueFor, wantsDesktopToast } from "./chat-sounds"; |
| 18 | import { play, soundSettings } from "./sounds"; |
| 19 | |
| 20 | let me: { id: string; username: string } | null = null; |
| 21 | let viewing: string | null = null; |
| 22 | let muted: ReadonlySet<string> = new Set(); |
| 23 | const gate = new SoundGate(); |
| 24 | |
| 25 | /** Who is signed in, from the root with the page: their own messages never sound. */ |
| 26 | export 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. */ |
| 31 | export function viewingConversation(channelId: string | null): void { |
| 32 | viewing = channelId; |
| 33 | } |
| 34 | |
| 35 | export function viewingChannel(): string | null { |
| 36 | return viewing; |
| 37 | } |
| 38 | |
| 39 | /** The conversations muted in the sidebar, as it last read them. */ |
| 40 | export function setMutedConversations(channelIds: Iterable<string>): void { |
| 41 | muted = new Set(channelIds); |
| 42 | } |
| 43 | |
| 44 | /** What the tab can see of itself right now. */ |
| 45 | export 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 | |
| 54 | function 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 | */ |
| 69 | 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); |
| 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 | */ |
| 78 | export 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. */ |
| 101 | export 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 | */ |
| 113 | export 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. */ |
| 138 | export 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 | } |