import { useCallback, useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useMatrixClient } from './useMatrixClient'; import { useClientConfig } from './useClientConfig'; import { isNativePlatform } from '../utils/capacitor'; import { PUSH_APP_IDS, PUSH_ENABLED_KEY, PUSH_STATE_CHANGE_EVENT, clearPusherIds, isPushEnabled, loadPusherIds, registerFcmPusher, registerWebPusher, savePusherIds, setPushEnabled, unregisterPusher, urlBase64ToUint8Array, } from '../utils/push'; import { getHomeRoomPath } from '../pages/pathUtils'; const noop = (): void => undefined; export type PushStatus = 'unavailable' | 'prompt' | 'granted' | 'denied'; /** * Reactive mirror of isPushEnabled(). Source of truth lives in localStorage * (see PUSH_ENABLED_KEY); this hook just subscribes to same-tab changes via * our custom event and cross-tab changes via the `storage` event. */ export function usePushEnabled(): boolean { const [enabled, setEnabled] = useState(() => isPushEnabled()); useEffect(() => { const sync = () => setEnabled(isPushEnabled()); const onStorage = (ev: StorageEvent) => { if (ev.key === PUSH_ENABLED_KEY) sync(); }; window.addEventListener(PUSH_STATE_CHANGE_EVENT, sync); window.addEventListener('storage', onStorage); return () => { window.removeEventListener(PUSH_STATE_CHANGE_EVENT, sync); window.removeEventListener('storage', onStorage); }; }, []); return enabled; } const webStatus = (): PushStatus => { if (typeof window === 'undefined') return 'unavailable'; if (!('PushManager' in window) || !('serviceWorker' in navigator)) return 'unavailable'; if (!('Notification' in window)) return 'unavailable'; if (Notification.permission === 'granted') return 'granted'; if (Notification.permission === 'denied') return 'denied'; return 'prompt'; }; export function usePushNotificationStatus(): PushStatus { const [status, setStatus] = useState(() => isNativePlatform() ? 'prompt' : webStatus() ); useEffect(() => { let cancelled = false; const refresh = async (): Promise => { if (!isNativePlatform()) { if (!cancelled) setStatus(webStatus()); return; } try { const { PushNotifications } = await import('@capacitor/push-notifications'); const result = await PushNotifications.checkPermissions(); if (cancelled) return; if (result.receive === 'granted') setStatus('granted'); else if (result.receive === 'denied') setStatus('denied'); else setStatus('prompt'); } catch { if (!cancelled) setStatus('unavailable'); } }; refresh(); // Re-check when: // - user returns from OS settings (visibilitychange / focus) // - register/disable flow explicitly signals a permission change // - web Permissions API reports a change (best-effort, no native equivalent) const onVisibility = () => { if (typeof document !== 'undefined' && document.visibilityState === 'visible') refresh(); }; const onFocus = () => refresh(); const onPermChange = () => refresh(); document.addEventListener('visibilitychange', onVisibility); window.addEventListener('focus', onFocus); window.addEventListener('vojo:pushPermissionChange', onPermChange); let permStatus: PermissionStatus | undefined; if (!isNativePlatform() && 'permissions' in navigator) { navigator.permissions // notifications is not in the canonical PermissionName union in some TS libs .query({ name: 'notifications' as PermissionName }) .then((p) => { if (cancelled) return; permStatus = p; p.addEventListener('change', onPermChange); }) .catch(() => undefined); } return () => { cancelled = true; document.removeEventListener('visibilitychange', onVisibility); window.removeEventListener('focus', onFocus); window.removeEventListener('vojo:pushPermissionChange', onPermChange); permStatus?.removeEventListener('change', onPermChange); }; }, []); return status; } async function getWebSubscription(vapidKey: string): Promise { const registration = await navigator.serviceWorker.ready; const existing = await registration.pushManager.getSubscription(); if (existing) return existing; return registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(vapidKey), }); } export function useRegisterPushNotifications(): () => Promise { const mx = useMatrixClient(); const clientConfig = useClientConfig(); return useCallback(async () => { const cfg = clientConfig.push; if (!cfg?.gatewayUrl) { throw new Error('Push gateway is not configured'); } if (isNativePlatform()) { const { PushNotifications } = await import('@capacitor/push-notifications'); const perm = await PushNotifications.requestPermissions(); // Notify status listeners of the new permission state regardless of grant/deny // so the UI swaps from 'prompt' → 'granted'/'denied' without waiting for a remount. window.dispatchEvent(new CustomEvent('vojo:pushPermissionChange')); if (perm.receive !== 'granted') throw new Error('permission_denied'); const appId = cfg.fcmAppId ?? PUSH_APP_IDS.fcm; const ids = await new Promise<{ pushkey: string; appId: string }>((resolve, reject) => { const timeout = setTimeout(() => reject(new Error('fcm_token_timeout')), 15000); let regHandle: { remove: () => Promise } | undefined; let errHandle: { remove: () => Promise } | undefined; const cleanup = () => { clearTimeout(timeout); regHandle?.remove(); errHandle?.remove(); }; PushNotifications.addListener('registration', async (token) => { try { const result = await registerFcmPusher(mx, token.value, cfg.gatewayUrl, appId); cleanup(); resolve(result); } catch (err) { cleanup(); reject(err); } }).then((h) => { regHandle = h; }); PushNotifications.addListener('registrationError', (e) => { cleanup(); reject(new Error(e.error || 'fcm_registration_error')); }).then((h) => { errHandle = h; }); PushNotifications.register().catch((err) => { cleanup(); reject(err); }); }); savePusherIds(ids); setPushEnabled(true); return; } if (!cfg.vapidPublicKey) throw new Error('VAPID key is not configured'); if (webStatus() === 'unavailable') throw new Error('Push is not supported'); const permission = await Notification.requestPermission(); window.dispatchEvent(new CustomEvent('vojo:pushPermissionChange')); if (permission !== 'granted') throw new Error('permission_denied'); const subscription = await getWebSubscription(cfg.vapidPublicKey); const appId = cfg.webAppId ?? PUSH_APP_IDS.web; const ids = await registerWebPusher(mx, subscription, cfg.gatewayUrl, appId); savePusherIds(ids); setPushEnabled(true); }, [mx, clientConfig]); } export function useDisablePushNotifications(): () => Promise { const mx = useMatrixClient(); return useCallback(async () => { const ids = loadPusherIds(); // 1. Deactivate on server first — avoids dead pushers + token leakage if (ids) { try { await unregisterPusher(mx, ids.pushkey, ids.appId); } catch { /* ignore */ } } // 2. Invalidate the device-side token/subscription — a local safety net // so pushes stop even if the server-side setPusher(kind: null) failed. // Do NOT remove Capacitor JS listeners — they're owned by // usePushNotificationsLifecycle and guarded by isPushEnabled() checks, // so they become no-ops when disabled. Removing them would break // re-enable in the same session until full app restart. if (isNativePlatform()) { try { const { PushNotifications } = await import('@capacitor/push-notifications'); await PushNotifications.unregister(); } catch { /* ignore */ } } else if ('serviceWorker' in navigator) { try { const reg = await navigator.serviceWorker.ready; const sub = await reg.pushManager.getSubscription(); if (sub) await sub.unsubscribe(); } catch { /* ignore */ } } clearPusherIds(); setPushEnabled(false); }, [mx]); } /** * Runs the app-level side effects for push notifications: * - web: auto re-register on startup if previously enabled (subscription may rotate) * - native: handle FCM token delivery + rotation without recursing into register() * - navigate to the tapped notification's room (web + native) */ export function usePushNotificationsLifecycle(): void { const register = useRegisterPushNotifications(); const mx = useMatrixClient(); const clientConfig = useClientConfig(); const navigate = useNavigate(); useEffect(() => { if (isNativePlatform()) return; if (!isPushEnabled()) return; register().catch(noop); }, [register]); useEffect(() => { const onNavigate = (ev: Event) => { const detail = (ev as CustomEvent).detail as { roomId?: string } | undefined; if (detail?.roomId) navigate(getHomeRoomPath(detail.roomId)); }; const onSubChange = () => { if (isPushEnabled()) register().catch(noop); }; window.addEventListener('vojo:pushNavigate', onNavigate); window.addEventListener('vojo:pushSubscriptionChange', onSubChange); return () => { window.removeEventListener('vojo:pushNavigate', onNavigate); window.removeEventListener('vojo:pushSubscriptionChange', onSubChange); }; }, [navigate, register]); useEffect(() => { if (!isNativePlatform()) return undefined; let cleanup: (() => void) | undefined; (async () => { const { PushNotifications } = await import('@capacitor/push-notifications'); const cfg = clientConfig.push; // Android 8+ requires a notification channel before any system notification can appear, // and apps with no channels aren't listed in Settings → Notifications. Sygnal sends // data-only FCM (event_id_only format), so the plugin's auto-channel-on-notification-payload // path never triggers — we must create the channel explicitly. try { await PushNotifications.createChannel({ id: 'vojo_messages', name: 'Messages', description: 'New chat messages and invites', importance: 5, visibility: 1, sound: 'default', vibration: true, lights: true, }); } catch { /* channel may already exist */ } // Persistent listener: update pusher on the server with the (possibly rotated) token. // MUST NOT call the full register() flow here — that would call // PushNotifications.register() again and re-fire this listener → infinite loop. const regHandle = await PushNotifications.addListener('registration', (token) => { if (!isPushEnabled()) return; if (!cfg?.gatewayUrl) return; const appId = cfg.fcmAppId ?? PUSH_APP_IDS.fcm; registerFcmPusher(mx, token.value, cfg.gatewayUrl, appId) .then((ids) => savePusherIds(ids)) .catch(noop); }); const actionHandle = await PushNotifications.addListener( 'pushNotificationActionPerformed', (action) => { const roomId = (action.notification.data as { room_id?: string })?.room_id; if (roomId) navigate(getHomeRoomPath(roomId)); } ); // Display is handled entirely by the native VojoFirebaseMessagingService // (which fires for both backgrounded and killed process states via FCM's // direct delivery). JS-side pushNotificationReceived would double-fire // alongside native when the bridge is alive but backgrounded, so we don't // subscribe to it here — foreground dedup stays the in-app responsibility // (MessageNotifications cards), everything else goes through the native path. // Kick off token delivery on startup if push was previously enabled and // permission is still granted. The listener above picks up the token. if (isPushEnabled()) { try { const perm = await PushNotifications.checkPermissions(); if (perm.receive === 'granted') { await PushNotifications.register(); } } catch { /* ignore */ } } cleanup = () => { regHandle.remove(); actionHandle.remove(); }; })().catch(noop); return () => cleanup?.(); }, [navigate, mx, clientConfig]); } export { isPushEnabled, getPushPlatform } from '../utils/push';