fuzzyworld

word-waltz / commit

added a way to disable push notifications

67bcfc6d28a8f98fd268a0949c6e915ae199bb77 ยท Pavel Safronov ยท 2026-03-25 22:53:12 -0700 ยท parents 26f9fd725534

added a way to disable push notifications
 src/main.ts                | 53 +++++++++++++++++++++++++++++-----------------
 src/styles/main.css        |  4 ++++
 src/utils/notifications.ts | 26 ++++++++++++++++++++++-
 3 files changed, 62 insertions(+), 21 deletions(-)

diff --git a/src/main.ts b/src/main.ts
index 4087c2b..7620672 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -18,7 +18,7 @@ import { mountWordleBoard } from './components/WordleBoard.js';
 import { mountScoreboard } from './components/Scoreboard.js';
 import { mountSoloScoreboard } from './components/SoloScoreboard.js';
 import { mountShareDialog } from './components/ShareDialog.js';
-import { notifyMyTurn, clearNotification, registerServiceWorker, subscribeToPush } from './utils/notifications.js';
+import { notifyMyTurn, clearNotification, registerServiceWorker, subscribeToPush, isNotifOptedOut, setNotifOptOut, unsubscribeFromPush } from './utils/notifications.js';
 import { getRandomWord } from './utils/wordList.js';
 import type { GameState, PlayerId, Round, SoloGameState, SoloRound, PendingNotification } from './types.js';
 
@@ -809,31 +809,26 @@ function initNotifButton(): void {
 
     function updateBtn(): void {
         const perm = Notification.permission;
-        if (perm === 'granted') {
+        const optedOut = isNotifOptedOut();
+        if (perm === 'granted' && !optedOut) {
             btn!.textContent = '๐Ÿ””';
-            btn!.title = 'Notifications enabled โ€” click to test';
+            btn!.title = 'Notifications enabled โ€” click to disable';
             btn!.classList.add('header__notif-btn--on');
-            btn!.classList.remove('header__notif-btn--blocked');
+            btn!.classList.remove('header__notif-btn--off', 'header__notif-btn--blocked');
         } else if (perm === 'denied') {
             btn!.textContent = '๐Ÿ”•';
-            btn!.title = 'Notifications blocked';
-            btn!.classList.remove('header__notif-btn--on');
+            btn!.title = 'Notifications blocked by browser';
+            btn!.classList.remove('header__notif-btn--on', 'header__notif-btn--off');
             btn!.classList.add('header__notif-btn--blocked');
+        } else if (perm === 'granted' && optedOut) {
+            btn!.textContent = '๐Ÿ”•';
+            btn!.title = 'Notifications disabled โ€” click to enable';
+            btn!.classList.remove('header__notif-btn--on', 'header__notif-btn--blocked');
+            btn!.classList.add('header__notif-btn--off');
         } else {
             btn!.textContent = '๐Ÿ””';
             btn!.title = 'Enable notifications';
-            btn!.classList.remove('header__notif-btn--on', 'header__notif-btn--blocked');
-        }
-    }
-
-    async function showTestNotification(): Promise<void> {
-        const reg = await registerServiceWorker();
-        if (reg) {
-            await reg.showNotification('Word Waltz', {
-                body: "Notifications are working! You'll be alerted when it's your turn.",
-                icon: `${import.meta.env.BASE_URL}apple-touch-icon.png`,
-                tag: 'word-waltz-test',
-            });
+            btn!.classList.remove('header__notif-btn--on', 'header__notif-btn--off', 'header__notif-btn--blocked');
         }
     }
 
@@ -848,16 +843,34 @@ function initNotifButton(): void {
         }
 
         if (perm === 'granted') {
-            await showTestNotification();
+            if (isNotifOptedOut()) {
+                // Re-enable
+                setNotifOptOut(false);
+                updateBtn();
+                await subscribeToPush();
+            } else {
+                // Disable
+                setNotifOptOut(true);
+                updateBtn();
+                await unsubscribeFromPush();
+            }
             return;
         }
 
         // 'default' โ€” request permission
         await registerServiceWorker();
         const result = await Notification.requestPermission();
+        setNotifOptOut(false);
         updateBtn();
         if (result === 'granted') {
-            await showTestNotification();
+            const reg = await registerServiceWorker();
+            if (reg) {
+                await reg.showNotification('Word Waltz', {
+                    body: "Notifications are working! You'll be alerted when it's your turn.",
+                    icon: `${import.meta.env.BASE_URL}apple-touch-icon.png`,
+                    tag: 'word-waltz-test',
+                });
+            }
         }
     });
 }
diff --git a/src/styles/main.css b/src/styles/main.css
index 775f007..a02ecf3 100644
--- a/src/styles/main.css
+++ b/src/styles/main.css
@@ -129,6 +129,10 @@ header {
     opacity: 1;
 }
 
+.header__notif-btn--off {
+    opacity: 0.6;
+}
+
 .header__notif-btn--blocked {
     opacity: 0.3;
     cursor: default;
diff --git a/src/utils/notifications.ts b/src/utils/notifications.ts
index 31f92e2..5e3a144 100644
--- a/src/utils/notifications.ts
+++ b/src/utils/notifications.ts
@@ -54,9 +54,32 @@ export async function registerServiceWorker(): Promise<ServiceWorkerRegistration
 // Push subscription
 // -------------------------------------------------------------------------
 
+const OPT_OUT_KEY = 'word-waltz:notif-disabled';
+
+export function isNotifOptedOut(): boolean {
+    return localStorage.getItem(OPT_OUT_KEY) === '1';
+}
+
+export function setNotifOptOut(disabled: boolean): void {
+    if (disabled) localStorage.setItem(OPT_OUT_KEY, '1');
+    else localStorage.removeItem(OPT_OUT_KEY);
+}
+
+/**
+ * Unsubscribe this device from Web Push. The Cloud Function will receive a
+ * 410 Gone on the next delivery attempt and clear the subscription from
+ * Firestore automatically.
+ */
+export async function unsubscribeFromPush(): Promise<void> {
+    const reg = swRegistration ?? (await registerServiceWorker());
+    if (!reg) return;
+    const sub = await reg.pushManager.getSubscription();
+    if (sub) await sub.unsubscribe();
+}
+
 /**
  * Subscribe this device to Web Push and return the serialised subscription
- * JSON, or null if push is unavailable / not configured.
+ * JSON, or null if push is unavailable / not configured / opted out.
  *
  * Requires VITE_VAPID_PUBLIC_KEY to be defined at build time.
  */
@@ -74,6 +97,7 @@ export async function subscribeToPush(): Promise<string | null> {
             await Notification.requestPermission();
         }
         if (Notification.permission !== 'granted') return null;
+        if (isNotifOptedOut()) return null;
 
         const existing = await reg.pushManager.getSubscription();
         if (existing) return JSON.stringify(existing);