v0.12.30 add notifications for iOS

This commit is contained in:
2026-03-26 14:49:17 -04:00
parent 6e5c39607c
commit d6a37d5948
11 changed files with 386 additions and 149 deletions

View File

@@ -97,14 +97,71 @@ export default function Chat() {
return () => window.removeEventListener('rosterchirp:settings-changed', loadFeatures);
}, [loadFeatures]);
// Register / refresh FCM push subscription
// Register / refresh push subscription — FCM for Android/Chrome, Web Push for iOS
useEffect(() => {
if (!('serviceWorker' in navigator)) return;
const registerPush = async () => {
try {
if (Notification.permission === 'denied') return;
// Convert a URL-safe base64 string to Uint8Array for the VAPID applicationServerKey
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const raw = atob(base64);
return Uint8Array.from(raw, c => c.charCodeAt(0));
}
// ── iOS / Web Push path ───────────────────────────────────────────────────
// iOS 16.4+ PWAs use the standard W3C Web Push API via pushManager.subscribe().
// FCM tokens are Google-specific and are not accepted by Apple's push service.
const registerWebPush = async () => {
try {
const configRes = await fetch('/api/push/vapid-public-key');
if (!configRes.ok) { console.warn('[Push] VAPID key not available'); return; }
const { vapidPublicKey } = await configRes.json();
const reg = await navigator.serviceWorker.ready;
// Re-use any existing subscription so we don't lose it on every page load
let subscription = await reg.pushManager.getSubscription();
if (subscription) {
// Check if it's already registered with the server
const cachedEndpoint = localStorage.getItem('rc_webpush_endpoint');
if (cachedEndpoint === subscription.endpoint) {
console.log('[Push] WebPush subscription unchanged — skipping subscribe');
return;
}
} else {
subscription = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
});
}
console.log('[Push] WebPush subscription obtained');
const subJson = subscription.toJSON();
const token = localStorage.getItem('tc_token') || sessionStorage.getItem('tc_token');
const subRes = await fetch('/api/push/subscribe-webpush', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
body: JSON.stringify({ endpoint: subJson.endpoint, keys: subJson.keys }),
});
if (!subRes.ok) {
const err = await subRes.json().catch(() => ({}));
console.warn('[Push] WebPush subscribe failed:', err.error || subRes.status);
localStorage.setItem('rc_fcm_error', `WebPush subscribe failed: ${err.error || subRes.status}`);
} else {
localStorage.setItem('rc_webpush_endpoint', subJson.endpoint);
localStorage.removeItem('rc_fcm_error');
console.log('[Push] WebPush subscription registered successfully');
}
} catch (e) {
console.warn('[Push] WebPush registration failed:', e.message);
localStorage.setItem('rc_fcm_error', e.message);
}
};
// ── Android / Chrome FCM path ─────────────────────────────────────────────
const registerFCM = async () => {
try {
// Fetch Firebase config from backend (returns 503 if FCM not configured)
const configRes = await fetch('/api/push/firebase-config');
if (!configRes.ok) return;
@@ -121,10 +178,6 @@ export default function Chat() {
const reg = await navigator.serviceWorker.ready;
// Never auto-request permission — that triggers a dialog on PWA launch.
// Permission is requested explicitly from the Notifications tab in the profile modal.
if (Notification.permission !== 'granted') return;
// Do NOT call deleteToken() here. Deleting the token on every page load (or
// every visibility-change) forces Chrome to create a new Web Push subscription
// each time. During the brief window between delete and re-register the server
@@ -183,6 +236,26 @@ export default function Chat() {
}
};
const registerPush = async () => {
try {
if (Notification.permission === 'denied') return;
// Never auto-request permission — that triggers a dialog on PWA launch.
// Permission is requested explicitly from the Notifications tab in the profile modal.
if (Notification.permission !== 'granted') return;
// Respect the user's explicit opt-out from the user menu toggle
if (localStorage.getItem('rc_push_enabled') === 'false') return;
const isIOS = /iphone|ipad/i.test(navigator.userAgent);
if (isIOS) {
await registerWebPush();
} else {
await registerFCM();
}
} catch (e) {
console.warn('[Push] registerPush failed:', e.message);
}
};
registerPush();
const handleVisibility = () => {