vojo/src/app/hooks/usePushNotifications.ts

506 lines
20 KiB
TypeScript

import { useCallback, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useSetAtom } from 'jotai';
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,
ensureRtcRingPushRule,
isPushEnabled,
loadPusherIds,
registerFcmPusher,
registerWebPusher,
removeRtcRingPushRule,
savePusherIds,
setPushEnabled,
unregisterPusher,
urlBase64ToUint8Array,
} from '../utils/push';
import { getDirectPath, getDirectRoomPath } from '../pages/pathUtils';
import { pendingCallActionAtom } from '../state/pendingCallAction';
import { useRoomNavigate } from './useRoomNavigate';
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);
await ensureRtcRingPushRule(mx);
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);
await ensureRtcRingPushRule(mx);
}, [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 */
}
}
// 3. Drop the account-scoped RTC ring push rule. It's added symmetrically in
// register flows; leaving it behind would mean other logged-in clients
// (Element, a second Vojo session) still apply ring tweaks after the
// user explicitly turned push off on this device.
await removeRtcRingPushRule(mx);
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();
const { navigateRoom } = useRoomNavigate();
const setPendingCallAction = useSetAtom(pendingCallActionAtom);
useEffect(() => {
if (isNativePlatform()) return;
if (!isPushEnabled()) return;
register().catch(noop);
}, [register]);
// Push rule for RTC ring is account-scoped and survives between sessions, but
// existing pushers that predate this feature won't have it — re-assert on
// every client startup (idempotent) so users with already-enabled push don't
// need to toggle push off/on to start receiving call pushes.
useEffect(() => {
if (!isPushEnabled()) return;
ensureRtcRingPushRule(mx).catch(noop);
}, [mx]);
useEffect(() => {
const onNavigate = (ev: Event) => {
const detail = (ev as CustomEvent).detail as
| { roomId?: string; isInvite?: boolean }
| undefined;
// Push-tap navigations are "switch to this", not "stack on top of where
// I was". Without replace, N notifications from the same chat plus tab
// hops accumulate as N+ entries in our app back-stack (see
// useAndroidBackButton) — user presses back many times to exit one chat.
if (detail?.isInvite) {
// Invites live inline in the Direct list — the row sits at the top
// until the user accepts/declines. Always land on the bare /direct/
// panel rather than /direct/{roomId}/ for an invite-state room: on
// mobile MobileFriendlyPageNav hides the panel for any non-root
// direct path, dropping the user into Room.tsx which has no
// membership gate and would render an empty stripped-state timeline
// with no Accept/Decline UI.
navigate(getDirectPath(), { replace: true });
return;
}
if (detail?.roomId) navigateRoom(detail.roomId, undefined, { replace: true });
};
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, navigateRoom, register]);
useEffect(() => {
if (!isNativePlatform()) return undefined;
let cancelled = false;
const cleanups: Array<() => void> = [];
const cfg = clientConfig.push;
const pnPromise = import('@capacitor/push-notifications');
// Attach the action listener on its own promise chain — no other awaits
// before it — so cold-start Decline/Answer events (fired by the native
// PushNotifications plugin via handleOnNewIntent the moment MainActivity
// boots) have a listener waiting. Sequential awaits behind channel
// creation / register() added ~100-300ms during which the event could be
// dropped entirely on a killed-process launch.
pnPromise
.then(({ PushNotifications }) => {
if (cancelled) return null;
return PushNotifications.addListener('pushNotificationActionPerformed', (action) => {
const data = action.notification.data as {
room_id?: string;
call_action?: 'answer' | 'decline';
notif_event_id?: string;
// Sygnal flattens nested fields with `_` separator; the Android
// FCM service forwards every data entry verbatim into the launch
// intent (VojoFirebaseMessagingService.java foreach), so these
// are reliably present for invite pushes.
type?: string;
content_membership?: string;
};
// Invite-state rooms must land on the bare /direct/ panel — the
// inline DirectInviteRow lives there with Accept/Decline. Native
// tap on an invite must NOT fall through to the generic room-id
// branch below (would route to /direct/{roomId}/, which has no
// membership gate path of its own; see DirectRouteRoomProvider for
// the second-line defence).
if (data.type === 'm.room.member' && data.content_membership === 'invite') {
navigate(getDirectPath(), { replace: true });
return;
}
// Native CallStyle Answer → open the room and queue a JS-side
// switch/start via
// pendingCallActionAtom. The consumer hook picks it up once the
// CallEmbedProvider tree is mounted. DM rooms live in the Direct tab
// route; `getHomeRoomPath` resolves to a Home-tab placeholder for IDs
// it doesn't have in its left-rail, hence the DM path here.
if (data.call_action === 'answer' && data.room_id) {
navigate(getDirectRoomPath(data.room_id), { replace: true });
setPendingCallAction({
kind: 'answer',
roomId: data.room_id,
notifEventId: data.notif_event_id,
});
return;
}
// Decline: queue the action and let the consumer (inside
// CallEmbedProvider) fire sendRtcDecline and then minimize the app.
// Doing the minimize here would race the setAtom — setAtom schedules
// a render-tick, minimize is synchronous, so we'd close the WebView
// before the consumer could pick up the atom and send the decline.
if (data.call_action === 'decline' && data.room_id && data.notif_event_id) {
setPendingCallAction({
kind: 'decline',
roomId: data.room_id,
notifEventId: data.notif_event_id,
});
return;
}
// FSI launch and body-tap on a call notification hit this path (no
// call_action extra, but notif_event_id is present — only call
// pushes carry it). DMs are currently the only call surface, so
// route to the Direct tab instead of the Home-tab fallback.
// Caveat: if group-call push is ever added, those events will also
// carry notif_event_id and this branch will route them to the DM
// tab incorrectly — revisit together with the group-call pipeline.
if (data.room_id && data.notif_event_id) {
navigate(getDirectRoomPath(data.room_id), { replace: true });
return;
}
if (data.room_id) navigateRoom(data.room_id, undefined, { replace: true });
});
})
.then((h) => {
if (!h) return;
cleanups.push(() => {
h.remove().catch(noop);
});
})
.catch(noop);
// Channel + registration listener + token-kickoff on a parallel chain —
// order doesn't matter for these, they just need to happen eventually.
(async () => {
const { PushNotifications } = await pnPromise;
if (cancelled) return;
// 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 */
}
// The call channel (vojo_calls_v2) is created lazily from the native
// VojoFirebaseMessagingService.ensureCallChannel() on first ring. Creating
// it here from JS would race with Java — whichever call wins freezes the
// vibration pattern / sound for the lifetime of the channel (immutable
// after creation on API 26+), and the Capacitor API can't set a long
// repeating vibrationPattern. Let Java own this channel exclusively.
// 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);
});
if (cancelled) {
regHandle.remove().catch(noop);
} else {
cleanups.push(() => {
regHandle.remove().catch(noop);
});
}
// Display is handled entirely by the native VojoFirebaseMessagingService
// (which fires for both backgrounded and killed process states via FCM's
// direct delivery). We intentionally do NOT subscribe to
// `pushNotificationReceived` here: doing so would create a second incoming
// call delivery path next to the native service / timeline listener pair.
// Foreground incoming-call UX is owned by the in-app strip via
// useIncomingRtcNotifications; background/killed UX is owned by native
// CallStyle. Keep this invariant unless the whole dedup model changes.
// 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 */
}
}
})().catch(noop);
return () => {
cancelled = true;
cleanups.forEach((c) => c());
};
}, [navigate, navigateRoom, mx, clientConfig, setPendingCallAction]);
}
export { isPushEnabled, getPushPlatform } from '../utils/push';