Skip to content
Source
Sign inSign up

threadNotifications.ts

135 lines5.0 KB

1import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
2import type { ClientSettings } from "@t3tools/contracts/settings";
3​
4import completionUrl from "./assets/notification-completion.mp3";
5import inputUrl from "./assets/notification-input.mp3";
6​
7type NotificationMode = ClientSettings["notificationMode"];
8export const NOTIFICATION_MODE_LABELS = {
9 off: "Off",
10 notifications: "Notifications only",
11 sound: "Sound only",
12 "notifications-and-sound": "Notifications with sound",
13} satisfies Record<NotificationMode, string>;
14​
15export function hasNotificationSound(mode: NotificationMode) {
16 return mode === "sound" || mode === "notifications-and-sound";
18​
19export function hasDesktopNotifications(mode: NotificationMode) {
20 return mode === "notifications" || mode === "notifications-and-sound";
22​
23const TAG_HASH_OFFSET_BASIS = 0xcbf29ce484222325n;
24const TAG_HASH_PRIME = 0x100000001b3n;
25const TAG_HASH_MASK = 0xffffffffffffffffn;
26​
27/** Windows drops toast notifications with the 73-character environment/thread tag. */
28export function threadNotificationTag(environmentId: EnvironmentId, threadId: ThreadId): string {
29 const key = `${environmentId}:${threadId}`;
30 let hash = TAG_HASH_OFFSET_BASIS;
31 for (let index = 0; index < key.length; index += 1) {
32 hash ^= BigInt(key.charCodeAt(index));
33 hash = (hash * TAG_HASH_PRIME) & TAG_HASH_MASK;
34 }
35 return hash.toString(16).padStart(16, "0");
37​
38let originalFavicon: HTMLLinkElement | undefined;
39let badgeFavicon: HTMLLinkElement | undefined;
40​
41export function setNotificationBadge(count: number) {
42 const bridge = window.desktopBridge;
43 let image: string | null = null;
44 if (count > 0 && (!bridge || bridge.getClientPlatform() === "win32")) {
45 const canvas = document.createElement("canvas");
46 canvas.width = canvas.height = 64;
47 const context = canvas.getContext("2d");
48 if (context) {
49 context.fillStyle = "#e5484d";
50 context.beginPath();
51 context.arc(32, 32, 28, 0, Math.PI * 2);
52 context.fill();
53 context.fillStyle = "white";
54 context.font = `600 ${count > 9 ? 30 : 40}px "Segoe UI", sans-serif`;
55 context.textAlign = "center";
56 context.textBaseline = "middle";
57 context.fillText(count > 9 ? "9+" : String(count), 32, 34);
58 image = canvas.toDataURL("image/png");
59 }
60 }
61 if (!bridge) {
62 if (image) {
63 if (!badgeFavicon) {
64 originalFavicon = document.querySelector<HTMLLinkElement>('link[rel="icon"]') ?? undefined;
65 badgeFavicon = document.createElement("link");
66 badgeFavicon.rel = "icon";
67 badgeFavicon.type = "image/png";
68 badgeFavicon.sizes.value = "64x64";
69 originalFavicon?.remove();
70 document.head.append(badgeFavicon);
71 }
72 badgeFavicon.href = image;
73 } else if (badgeFavicon) {
74 badgeFavicon.remove();
75 badgeFavicon = undefined;
76 if (originalFavicon) document.head.append(originalFavicon);
77 originalFavicon = undefined;
78 }
79 }
80 void bridge?.setNotificationBadge({ count, image }).catch(() => undefined);
82​
83let audioContext: AudioContext | undefined;
84const buffers = new Map<string, Promise<AudioBuffer>>();
85/** When each sound's current playback ends, on the audio context's clock. */
86const playingUntil = new Map<string, number>();
87​
88function loadNotificationBuffer(context: AudioContext, url: string): Promise<AudioBuffer> {
89 let buffer = buffers.get(url);
90 if (!buffer) {
91 buffer = fetch(url)
92 .then((response) => response.arrayBuffer())
93 .then((data) => context.decodeAudioData(data))
94 .catch((error: unknown) => {
95 buffers.delete(url);
96 throw error;
97 });
98 buffers.set(url, buffer);
99 }
100 return buffer;
102​
103/** Called from a gesture so browsers allow later background playback. */
104export function unlockNotificationAudio() {
105 const firstUnlock = audioContext === undefined;
106 audioContext ??= new AudioContext();
107 void audioContext.resume().catch(() => undefined);
108 if (!firstUnlock) return;
109 // Fetch and decode during interaction so the first alert is ready when the thread finishes.
110 for (const url of [completionUrl, inputUrl]) {
111 void loadNotificationBuffer(audioContext, url).catch(() => undefined);
112 }
114​
115export async function playNotificationSound(
116 kind: "completion" | "input",
117 shouldPlay: () => boolean,
118) {
119 if (!audioContext || audioContext.state !== "running") return;
120 const context = audioContext;
121 const url = kind === "completion" ? completionUrl : inputUrl;
122 try {
123 const decoded = await loadNotificationBuffer(context, url);
124 if (!shouldPlay() || context.state !== "running") return;
125 // Coalesce same-sound bursts after decode to avoid clipping; input and completion alerts keep separate slots.
126 if (context.currentTime < (playingUntil.get(url) ?? 0)) return;
127 const source = context.createBufferSource();
128 source.buffer = decoded;
129 source.connect(context.destination);
130 source.start();
131 playingUntil.set(url, context.currentTime + decoded.duration);
132 } catch {
133 // A failed load is retried on the next alert.
134 }