366 lines
13 KiB
TypeScript
366 lines
13 KiB
TypeScript
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<boolean>(() => 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<PushStatus>(() =>
|
|
isNativePlatform() ? 'prompt' : webStatus()
|
|
);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
|
|
const refresh = async (): Promise<void> => {
|
|
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<PushSubscription> {
|
|
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<void> {
|
|
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<void> } | undefined;
|
|
let errHandle: { remove: () => Promise<void> } | 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<void> {
|
|
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';
|