style(13-03): apply Prettier formatting across repo
Mechanical reformat — no logic changes. 398 files changed, 19125 insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc (singleQuote:true, semi:true, tabWidth:2, trailingComma:all, printWidth:100). Isolated per D-13-08 for reviewability.
This commit is contained in:
@@ -30,7 +30,7 @@
|
||||
* setEnabled(false) — unsubscribe + set '0'.
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
@@ -42,15 +42,15 @@ import { useState, useEffect } from 'react'
|
||||
* applicationServerKey (Web Push spec — key must be a BufferSource).
|
||||
*/
|
||||
function urlBase64ToUint8Array(base64String: string): Uint8Array<ArrayBuffer> {
|
||||
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
|
||||
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
|
||||
const rawData = atob(base64)
|
||||
const buffer = new ArrayBuffer(rawData.length)
|
||||
const outputArray = new Uint8Array(buffer)
|
||||
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
|
||||
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||||
const rawData = atob(base64);
|
||||
const buffer = new ArrayBuffer(rawData.length);
|
||||
const outputArray = new Uint8Array(buffer);
|
||||
for (let i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i)
|
||||
outputArray[i] = rawData.charCodeAt(i);
|
||||
}
|
||||
return outputArray
|
||||
return outputArray;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,18 +59,18 @@ function urlBase64ToUint8Array(base64String: string): Uint8Array<ArrayBuffer> {
|
||||
* are instant — the tap handler can proceed without an extra round-trip.
|
||||
*/
|
||||
async function fetchVapidKey(): Promise<string> {
|
||||
const cached = sessionStorage.getItem('vapidPublicKey')
|
||||
if (cached) return cached
|
||||
const cached = sessionStorage.getItem('vapidPublicKey');
|
||||
if (cached) return cached;
|
||||
|
||||
const res = await fetch('/api/push/vapid-public-key', {
|
||||
credentials: 'include',
|
||||
})
|
||||
if (!res.ok) throw new Error(`Failed to fetch VAPID key: ${res.status}`)
|
||||
const data = (await res.json()) as { publicKey: string }
|
||||
});
|
||||
if (!res.ok) throw new Error(`Failed to fetch VAPID key: ${res.status}`);
|
||||
const data = (await res.json()) as { publicKey: string };
|
||||
if (data.publicKey) {
|
||||
sessionStorage.setItem('vapidPublicKey', data.publicKey)
|
||||
sessionStorage.setItem('vapidPublicKey', data.publicKey);
|
||||
}
|
||||
return data.publicKey
|
||||
return data.publicKey;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -79,9 +79,9 @@ async function fetchVapidKey(): Promise<string> {
|
||||
|
||||
function readNotificationsEnabled(): boolean {
|
||||
try {
|
||||
return localStorage.getItem('notificationsEnabled') === '1'
|
||||
return localStorage.getItem('notificationsEnabled') === '1';
|
||||
} catch {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,18 +91,18 @@ function readNotificationsEnabled(): boolean {
|
||||
*/
|
||||
function readNotificationsDisabled(): boolean {
|
||||
try {
|
||||
return localStorage.getItem('notificationsEnabled') === '0'
|
||||
return localStorage.getItem('notificationsEnabled') === '0';
|
||||
} catch {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function persistNotificationsEnabled(value: boolean): void {
|
||||
try {
|
||||
if (value) {
|
||||
localStorage.setItem('notificationsEnabled', '1')
|
||||
localStorage.setItem('notificationsEnabled', '1');
|
||||
} else {
|
||||
localStorage.setItem('notificationsEnabled', '0')
|
||||
localStorage.setItem('notificationsEnabled', '0');
|
||||
}
|
||||
} catch {
|
||||
// Private mode / storage disabled — ignore
|
||||
@@ -121,12 +121,12 @@ export interface UsePushSubscriptionReturn {
|
||||
* in a useEffect) and passed in here. This eliminates the network round-trip inside
|
||||
* the tap handler, preserving the iOS user-gesture requirement for pushManager.subscribe().
|
||||
*/
|
||||
subscribe: (registration: ServiceWorkerRegistration, vapidKey: string) => Promise<void>
|
||||
unsubscribe: () => Promise<void>
|
||||
subscribe: (registration: ServiceWorkerRegistration, vapidKey: string) => Promise<void>;
|
||||
unsubscribe: () => Promise<void>;
|
||||
/** Current Notification.permission value ('default' | 'granted' | 'denied') */
|
||||
permission: NotificationPermission
|
||||
permission: NotificationPermission;
|
||||
/** True when an active push subscription exists in pushManager. */
|
||||
isSubscribed: boolean
|
||||
isSubscribed: boolean;
|
||||
/**
|
||||
* Master on/off toggle (D-09).
|
||||
*
|
||||
@@ -138,27 +138,27 @@ export interface UsePushSubscriptionReturn {
|
||||
* setEnabled(false):
|
||||
* - Calls unsubscribe() (DELETE server + browser) + persists '0'.
|
||||
*/
|
||||
setEnabled: (on: boolean) => Promise<void>
|
||||
setEnabled: (on: boolean) => Promise<void>;
|
||||
}
|
||||
|
||||
export function usePushSubscription(): UsePushSubscriptionReturn {
|
||||
const [permission, setPermission] = useState<NotificationPermission>(
|
||||
typeof Notification !== 'undefined' ? Notification.permission : 'default',
|
||||
)
|
||||
const [isSubscribed, setIsSubscribed] = useState(false)
|
||||
);
|
||||
const [isSubscribed, setIsSubscribed] = useState(false);
|
||||
|
||||
// Health-check on mount (D-10): if OS permission is granted but no active
|
||||
// subscription exists (expired/cleared) AND user hasn't explicitly disabled,
|
||||
// silently re-subscribe in background.
|
||||
useEffect(() => {
|
||||
if (typeof Notification === 'undefined') return
|
||||
if (Notification.permission !== 'granted') return
|
||||
if (!navigator.serviceWorker) return
|
||||
if (typeof Notification === 'undefined') return;
|
||||
if (Notification.permission !== 'granted') return;
|
||||
if (!navigator.serviceWorker) return;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.ready
|
||||
const existingSub = await registration.pushManager.getSubscription()
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const existingSub = await registration.pushManager.getSubscription();
|
||||
|
||||
if (existingSub) {
|
||||
// Re-confirm server-side record (handles 410-prune recovery — D-10).
|
||||
@@ -168,33 +168,33 @@ export function usePushSubscription(): UsePushSubscriptionReturn {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(existingSub.toJSON()),
|
||||
}).catch(() => {}) // non-blocking — ignore network failures
|
||||
setIsSubscribed(true)
|
||||
return
|
||||
}).catch(() => {}); // non-blocking — ignore network failures
|
||||
setIsSubscribed(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// No active subscription — only silently re-subscribe if the user
|
||||
// hasn't explicitly turned notifications off (D-10).
|
||||
if (readNotificationsDisabled()) return
|
||||
if (readNotificationsDisabled()) return;
|
||||
|
||||
const vapidKey = await fetchVapidKey()
|
||||
const vapidKey = await fetchVapidKey();
|
||||
const sub = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(vapidKey),
|
||||
})
|
||||
});
|
||||
await fetch('/api/push/subscription', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(sub.toJSON()),
|
||||
})
|
||||
persistNotificationsEnabled(true)
|
||||
setIsSubscribed(true)
|
||||
});
|
||||
persistNotificationsEnabled(true);
|
||||
setIsSubscribed(true);
|
||||
} catch {
|
||||
// Ignore health-check errors — non-blocking background task
|
||||
}
|
||||
})()
|
||||
}, [])
|
||||
})();
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* subscribe — MUST be called inside an onClick handler.
|
||||
@@ -219,7 +219,7 @@ export function usePushSubscription(): UsePushSubscriptionReturn {
|
||||
const sub = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(vapidKey),
|
||||
})
|
||||
});
|
||||
|
||||
// Persist the subscription to the server
|
||||
const res = await fetch('/api/push/subscription', {
|
||||
@@ -227,39 +227,39 @@ export function usePushSubscription(): UsePushSubscriptionReturn {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(sub.toJSON()),
|
||||
})
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to persist subscription: ${res.status}`)
|
||||
throw new Error(`Failed to persist subscription: ${res.status}`);
|
||||
}
|
||||
|
||||
persistNotificationsEnabled(true)
|
||||
setPermission(Notification.permission)
|
||||
setIsSubscribed(true)
|
||||
}
|
||||
persistNotificationsEnabled(true);
|
||||
setPermission(Notification.permission);
|
||||
setIsSubscribed(true);
|
||||
};
|
||||
|
||||
/**
|
||||
* unsubscribe — retrieve the active subscription and cancel it.
|
||||
* Also removes the server-side row via DELETE /api/push/subscription.
|
||||
*/
|
||||
const unsubscribe = async (): Promise<void> => {
|
||||
if (!navigator.serviceWorker) return
|
||||
if (!navigator.serviceWorker) return;
|
||||
|
||||
const registration = await navigator.serviceWorker.ready
|
||||
const sub = await registration.pushManager.getSubscription()
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const sub = await registration.pushManager.getSubscription();
|
||||
if (sub) {
|
||||
await sub.unsubscribe()
|
||||
await sub.unsubscribe();
|
||||
}
|
||||
|
||||
await fetch('/api/push/subscription', {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
})
|
||||
});
|
||||
|
||||
persistNotificationsEnabled(false)
|
||||
setPermission(Notification.permission)
|
||||
setIsSubscribed(false)
|
||||
}
|
||||
persistNotificationsEnabled(false);
|
||||
setPermission(Notification.permission);
|
||||
setIsSubscribed(false);
|
||||
};
|
||||
|
||||
/**
|
||||
* setEnabled — master on/off for the settings toggle (D-09).
|
||||
@@ -271,52 +271,52 @@ export function usePushSubscription(): UsePushSubscriptionReturn {
|
||||
*/
|
||||
const setEnabled = async (on: boolean): Promise<void> => {
|
||||
if (!on) {
|
||||
await unsubscribe()
|
||||
return
|
||||
await unsubscribe();
|
||||
return;
|
||||
}
|
||||
|
||||
// on=true path
|
||||
const currentPermission =
|
||||
typeof Notification !== 'undefined' ? Notification.permission : 'default'
|
||||
typeof Notification !== 'undefined' ? Notification.permission : 'default';
|
||||
if (currentPermission !== 'granted') {
|
||||
// 'default' → caller must use tap-gated subscribe(); 'denied' → no-op
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
// Permission is granted — silently subscribe without a tap gesture
|
||||
// (already have OS permission, no dialog required).
|
||||
if (!navigator.serviceWorker) return
|
||||
if (!navigator.serviceWorker) return;
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.ready
|
||||
const existingSub = await registration.pushManager.getSubscription()
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const existingSub = await registration.pushManager.getSubscription();
|
||||
if (existingSub) {
|
||||
// Already subscribed — just flip the stored flag back on.
|
||||
persistNotificationsEnabled(true)
|
||||
setIsSubscribed(true)
|
||||
return
|
||||
persistNotificationsEnabled(true);
|
||||
setIsSubscribed(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const vapidKey = await fetchVapidKey()
|
||||
const vapidKey = await fetchVapidKey();
|
||||
const sub = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(vapidKey),
|
||||
})
|
||||
});
|
||||
const res = await fetch('/api/push/subscription', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(sub.toJSON()),
|
||||
})
|
||||
});
|
||||
if (res.ok) {
|
||||
persistNotificationsEnabled(true)
|
||||
setIsSubscribed(true)
|
||||
persistNotificationsEnabled(true);
|
||||
setIsSubscribed(true);
|
||||
}
|
||||
} catch {
|
||||
// Ignore — non-fatal; user can retry via toggle
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return { subscribe, unsubscribe, permission, isSubscribed, setEnabled }
|
||||
return { subscribe, unsubscribe, permission, isSubscribed, setEnabled };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -326,7 +326,7 @@ export function usePushSubscription(): UsePushSubscriptionReturn {
|
||||
*/
|
||||
export async function prefetchVapidKey(): Promise<void> {
|
||||
try {
|
||||
await fetchVapidKey()
|
||||
await fetchVapidKey();
|
||||
} catch {
|
||||
// Non-fatal — the subscribe() call will retry if sessionStorage miss
|
||||
}
|
||||
@@ -337,4 +337,4 @@ export async function prefetchVapidKey(): Promise<void> {
|
||||
* (localStorage.notificationsEnabled === '1').
|
||||
* Exported for use in PermissionDeniedBanner and SettingsSheet initial state.
|
||||
*/
|
||||
export { readNotificationsEnabled }
|
||||
export { readNotificationsEnabled };
|
||||
|
||||
Reference in New Issue
Block a user