fuzzyworld

word-waltz / files / src / utils / notifications.ts

notifications.ts

6.1 KB · history · blame

1/**
2 * notifications.ts — service worker registration, push subscriptions, and
3 * in-tab notification helpers.
4 *
5 * Strategy:
6 * - Register /sw.js so we can use registration.showNotification() across all
7 * browsers (including Firefox and Safari which don't support new Notification
8 * without a service worker).
9 * - Subscribe to Web Push (VAPID) if VITE_VAPID_PUBLIC_KEY is configured.
10 * The subscription JSON is stored in Firestore so the Cloud Function can
11 * send a push when it's the player's turn, even if the tab is closed.
12 * - Always update document.title as a fallback visible in the browser tab.
13 * - Only fire the OS notification when the tab is not focused (no point
14 * interrupting someone already looking at the page).
15 *
16 * VAPID setup:
17 * 1. Run: npx web-push generate-vapid-keys
18 * 2. Set VITE_VAPID_PUBLIC_KEY in your .env (and as a GitLab CI variable).
19 * 3. Set VAPID_PRIVATE_KEY + VAPID_SUBJECT in your Firebase Cloud Function
20 * environment (see functions/index.js).
21 */
22
23const BASE_TITLE = 'Word Waltz';
24
25// -------------------------------------------------------------------------
26// Service Worker registration
27// -------------------------------------------------------------------------
28
29let swRegistration: ServiceWorkerRegistration | null = null;
30
31/**
32 * Register the service worker. Safe to call multiple times — subsequent calls
33 * are no-ops once registration is cached. Returns the registration or null if
34 * service workers are not supported.
35 */
36export async function registerServiceWorker(): Promise<ServiceWorkerRegistration | null> {
37 if (swRegistration) return swRegistration;
38 if (!('serviceWorker' in navigator)) return null;
39
40 try {
41 // BASE_URL is '/' in dev and '/word-waltz/' on GitLab Pages.
42 const swUrl = `${import.meta.env.BASE_URL}sw.js`;
43 swRegistration = await navigator.serviceWorker.register(swUrl, {
44 scope: import.meta.env.BASE_URL,
45 });
46 return swRegistration;
47 } catch (err) {
48 console.warn('Service worker registration failed:', err);
49 return null;
50 }
51}
52
53// -------------------------------------------------------------------------
54// Push subscription
55// -------------------------------------------------------------------------
56
57const OPT_OUT_KEY = 'word-waltz:notif-disabled';
58
59export function isNotifOptedOut(): boolean {
60 return localStorage.getItem(OPT_OUT_KEY) === '1';
61}
62
63export function setNotifOptOut(disabled: boolean): void {
64 if (disabled) localStorage.setItem(OPT_OUT_KEY, '1');
65 else localStorage.removeItem(OPT_OUT_KEY);
66}
67
68/**
69 * Unsubscribe this device from Web Push. The Cloud Function will receive a
70 * 410 Gone on the next delivery attempt and clear the subscription from
71 * Firestore automatically.
72 */
73export async function unsubscribeFromPush(): Promise<void> {
74 const reg = swRegistration ?? (await registerServiceWorker());
75 if (!reg) return;
76 const sub = await reg.pushManager.getSubscription();
77 if (sub) await sub.unsubscribe();
78}
79
80/**
81 * Subscribe this device to Web Push and return the serialised subscription
82 * JSON, or null if push is unavailable / not configured / opted out.
83 *
84 * Requires VITE_VAPID_PUBLIC_KEY to be defined at build time.
85 */
86export async function subscribeToPush(): Promise<string | null> {
87 const vapidPublicKey = import.meta.env.VITE_VAPID_PUBLIC_KEY as string | undefined;
88 if (!vapidPublicKey) return null;
89
90 const reg = await registerServiceWorker();
91 if (!reg) return null;
92 if (!('pushManager' in reg)) return null;
93
94 try {
95 // Request notification permission lazily
96 if (Notification.permission === 'default') {
97 await Notification.requestPermission();
98 }
99 if (Notification.permission !== 'granted') return null;
100 if (isNotifOptedOut()) return null;
101
102 const existing = await reg.pushManager.getSubscription();
103 if (existing) return JSON.stringify(existing);
104
105 const sub = await reg.pushManager.subscribe({
106 userVisibleOnly: true,
107 applicationServerKey: urlBase64ToUint8Array(vapidPublicKey).buffer as ArrayBuffer,
108 });
109 return JSON.stringify(sub);
110 } catch (err) {
111 console.warn('Push subscription failed:', err);
112 return null;
113 }
114}
115
116// VAPID public key is URL-safe base64; the browser needs a Uint8Array.
117function urlBase64ToUint8Array(base64String: string): Uint8Array {
118 const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
119 const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
120 const raw = atob(base64);
121 return Uint8Array.from(raw, (c) => c.charCodeAt(0));
122}
123
124// -------------------------------------------------------------------------
125// In-tab notification helpers
126// -------------------------------------------------------------------------
127
128/** Set the tab title and optionally send an OS notification. */
129export async function notifyMyTurn(message: string): Promise<void> {
130 document.title = `⚡ ${message} — ${BASE_TITLE}`;
131
132 if (!('Notification' in window)) return;
133
134 // Request permission lazily on first use
135 if (Notification.permission === 'default') {
136 await Notification.requestPermission();
137 }
138 if (Notification.permission !== 'granted') return;
139
140 // Only fire when the tab is hidden
141 if (document.visibilityState === 'visible') return;
142
143 const reg = swRegistration ?? (await registerServiceWorker());
144 if (reg) {
145 // Preferred: service-worker-backed notification (works in all browsers)
146 await reg.showNotification(BASE_TITLE, {
147 body: message,
148 icon: `${import.meta.env.BASE_URL}apple-touch-icon.png`,
149 tag: 'word-waltz-turn',
150 // renotify is supported at runtime but not in all TS lib versions
151 ...({ renotify: true } as object),
152 });
153 } else {
154 // Fallback for browsers without SW support
155 new Notification(BASE_TITLE, {
156 body: message,
157 icon: `${import.meta.env.BASE_URL}apple-touch-icon.png`,
158 });
159 }
160}
161
162/** Reset the title back to the default. */
163export function clearNotification(): void {
164 document.title = BASE_TITLE;
165}