import { useCallback, useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useSetAtom } from 'jotai'; import { ClientEvent, MatrixClient, MatrixEvent, NotificationCountType, Room, RoomEvent, } from 'matrix-js-sdk'; import { useMatrixClient } from './useMatrixClient'; import { useClientConfig } from './useClientConfig'; import { isAndroidPlatform, 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'; import { polling, type RoomMetadataMap, type UserAvatarsMap } from '../plugins/polling'; import { getAccountData, getMDirects } from '../utils/room'; import { AccountDataEvent } from '../../types/matrix/accountData'; const noop = (): void => undefined; const buildRoomMetadataSnapshot = (mx: MatrixClient): RoomMetadataMap => { // m.direct lists DMs per peer-user; flattened into a Set the Java side can // consult per room when picking the DM-vs-group notification channel. // Falls back to an empty Set when account data is not yet hydrated — in // that brief post-login window every room is treated as a group (quieter // channel). The next m.direct sync re-dumps via our ClientEvent.AccountData // listener with correct isDirect values, so the under-alerting is bounded // to seconds. const mDirectEvent = getAccountData(mx, AccountDataEvent.Direct); const dmRooms = mDirectEvent ? getMDirects(mDirectEvent) : new Set(); return mx.getRooms().reduce((acc, room) => { const { name } = room; if (typeof name !== 'string' || name.length === 0) return acc; // hasEncryptionStateEvent reads the m.room.encryption state event from // the live timeline — synchronous and cheap. Used by the Java side to // gate the inline reply action: encrypted rooms get a read-only // notification because the Java path has no key material to encrypt // outgoing replies with. // // Room avatar: prefer the explicit m.room.avatar mxc; for a DM with no // configured room avatar, fall through to the "other member" fallback // (mirrors getDirectRoomAvatarUrl). Lets the Java MessagingStyle // notification show the conversation avatar as largeIcon, the same // shape WhatsApp / Element use. const roomMxc = room.getMxcAvatarUrl(); const fallbackMxc = dmRooms.has(room.roomId) ? room.getAvatarFallbackMember()?.getMxcAvatarUrl() : undefined; const avatarMxc = roomMxc ?? fallbackMxc ?? undefined; acc[room.roomId] = { name, isDirect: dmRooms.has(room.roomId), isEncrypted: room.hasEncryptionStateEvent(), ...(avatarMxc ? { avatarMxc } : {}), }; return acc; }, {}); }; const MAX_BRIDGED_USER_AVATARS = 500; const buildUserAvatarsSnapshot = (mx: MatrixClient): UserAvatarsMap => { // user_id → mxc avatar URL, gathered from every room the user is in. The // Java FCM/WorkManager renderers can't fetch profiles themselves (would // need synchronous HTTP in the receive path), so this snapshot is the // sole source of per-sender avatar data on the native side. Bounded at // MAX_BRIDGED_USER_AVATARS to cap the serialised JSON; truncation drops // the latest iteration entries (essentially arbitrary which rooms get // their members covered, but the self user is always included first to // protect the MessagingStyle self-anchor icon). const out: UserAvatarsMap = {}; const myUserId = mx.getUserId(); if (myUserId) { const myMxc = mx.getUser(myUserId)?.avatarUrl; if (myMxc) out[myUserId] = myMxc; } // Sort by roomId for STABLE iteration across dumps. mx.getRooms() // returns rooms in matrix-js-sdk's internal Map order which is // insertion-time and can flip on a /sync that re-orders state. Without // a stable sort, the 500-entry cap could include peer X on dump A, // exclude X on dump B (different rooms iterated first), and the // notification for room R would show X's avatar one push and lose it // the next — visible flicker. const compareRoomId = (a: Room, b: Room): number => { if (a.roomId < b.roomId) return -1; if (a.roomId > b.roomId) return 1; return 0; }; // `.some` is the eslint-friendly way to express "iterate with early // exit" — returning true from the callback aborts the walk. Each room // appends its joined-member avatars into `out` until we hit the cap; // we abort the whole walk when the cap is reached. Members are added // imperatively into `out` (idiomatic for a bounded accumulator that // skips already-seen keys); the cap check is a simple length read. [...mx.getRooms()].sort(compareRoomId).some((room) => { room.getJoinedMembers().some((member) => { if (Object.keys(out).length >= MAX_BRIDGED_USER_AVATARS) return true; if (out[member.userId]) return false; const mxc = member.getMxcAvatarUrl(); if (mxc) out[member.userId] = mxc; return false; }); return Object.keys(out).length >= MAX_BRIDGED_USER_AVATARS; }); return out; }; const dumpRoomNamesToNative = async (mx: MatrixClient): Promise => { // Bridge both the room metadata snapshot AND the user avatars map. They // travel together because every trigger that invalidates one (visibility // resume, m.direct change, m.room.encryption flip) typically invalidates // the other — a fresh DM appears in m.direct alongside the new peer in // user avatars; a freshly-joined member affects both. Two sequential // bridge calls instead of bundling because the native plugin keeps the // two payloads in separate SharedPreferences keys for parsing simplicity. await polling.saveRoomNames(buildRoomMetadataSnapshot(mx)); await polling.saveUserAvatars(buildUserAvatarsSnapshot(mx)); }; 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); 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 { 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); // 4. Tear down the WorkManager polling fallback. Belt-and-suspenders with // the lifecycle effect's cleanup — without this, polling could keep // firing against a stale access_token between disable and the next // re-render that observes the new pushEnabled state. await polling.cancel(); await polling.clearSession(); 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); const pushEnabled = usePushEnabled(); 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]); // Receipt-driven dismiss: when a server-side read receipt drops a room's // unread count to zero, cancel the room's MessagingStyle notification // (and clear its native cache) so the shade matches reality. Mirrors // element-web's onRoomReceipt (src/Notifier.ts:486-500). Works only when // JS is alive — for killed-process / FCM-blocked scenarios this branch // is silent; acceptable because there's no client-visible read signal a // dead JS context could observe from server-side anyway. useEffect(() => { if (!isAndroidPlatform()) return undefined; const handleReceipt = (ev: MatrixEvent, room: Room) => { // Mirror useBindRoomToUnreadAtom: only act when the receipt is from // the current user. SDK fires this for every participant; we don't // want a peer reading on their device to dismiss our notification. const myUserId = mx.getUserId(); if (!myUserId) return; const content = ev.getContent() as Record< string, Record> >; const isMyReceipt = Object.keys(content ?? {}).some((eventId) => Object.keys(content[eventId] ?? {}).some( (rType) => myUserId in (content[eventId]?.[rType] ?? {}) ) ); if (!isMyReceipt) return; // `room.getUnreadNotificationCount()` is post-receipt; element-web // gates dismissal on it == 0. Per-thread unread does not split: Total // sums across main + threads, which is the right "everything read" // signal for the shade dismiss. const total = room.getUnreadNotificationCount(NotificationCountType.Total); if (total === 0) { polling.dismissRoom(room.roomId).catch(noop); } }; mx.on(RoomEvent.Receipt, handleReceipt); return () => { mx.removeListener(RoomEvent.Receipt, handleReceipt); }; }, [mx]); // Re-dump the room metadata snapshot whenever m.direct changes — without // this, freshly-created DMs would land on the louder group-message // channel until the next visibilitychange re-bridge. The dump is small // (room id + name + isDirect + isEncrypted) and account-data updates are // rare. // // The same effect also re-dumps on m.room.encryption state events so the // inline-reply action is dropped within seconds of a room being switched // to E2EE. Without this re-dump, a cleartext-by-prefs notification could // post the reply action onto a freshly-encrypted room and the receiver // would send a cleartext reply (Synapse does not enforce the // "encrypted-only" rule, so the leak is real). useEffect(() => { if (!isAndroidPlatform()) return undefined; if (!pushEnabled) return undefined; const handleAccountData = (event: MatrixEvent) => { if (event.getType() !== AccountDataEvent.Direct) return; dumpRoomNamesToNative(mx).catch(noop); }; const handleTimeline = (event: MatrixEvent) => { if (event.getType() === 'm.room.encryption') { dumpRoomNamesToNative(mx).catch(noop); } }; mx.on(ClientEvent.AccountData, handleAccountData); mx.on(RoomEvent.Timeline, handleTimeline); return () => { mx.removeListener(ClientEvent.AccountData, handleAccountData); mx.removeListener(RoomEvent.Timeline, handleTimeline); }; }, [mx, pushEnabled]); // WorkManager-based polling fallback for users where FCM is blocked. // Runs in parallel with FCM — the renderer dedupes by event_id.hashCode() // notification id, so a double-delivery (FCM in seconds + polling at the // next 15-min cycle) collapses to one entry in the shade. Web has no // equivalent; the Service Worker already handles all push wakeups. useEffect(() => { if (!isAndroidPlatform()) return undefined; if (!pushEnabled) return undefined; const token = mx.getAccessToken(); const homeserverUrl = mx.baseUrl; if (!token || !homeserverUrl) return undefined; const userId = mx.getUserId() ?? undefined; let cancelled = false; (async () => { try { await polling.saveSession({ accessToken: token, homeserverUrl, userId }); if (cancelled) return; await polling.schedule(15); if (cancelled) return; await dumpRoomNamesToNative(mx); } catch (err) { // eslint-disable-next-line no-console console.warn('[polling] lifecycle setup failed:', err); } })(); const onVisibility = () => { if (cancelled) return; if (typeof document === 'undefined') return; if (document.visibilityState !== 'visible') return; // Re-save credentials so a 401 clear inside the Worker (see // VojoPollWorker.doWork) recovers as soon as the user comes back to // the app — not only after a full remount. Then refresh the // room-name snapshot. Both calls are idempotent overwrites. (async () => { try { const currentToken = mx.getAccessToken(); if (cancelled) return; if (currentToken) { await polling.saveSession({ accessToken: currentToken, homeserverUrl: mx.baseUrl, userId: mx.getUserId() ?? undefined, }); if (cancelled) return; } await dumpRoomNamesToNative(mx); } catch (err) { // eslint-disable-next-line no-console console.warn('[polling] visibility re-bridge failed:', err); } })(); }; document.addEventListener('visibilitychange', onVisibility); return () => { cancelled = true; document.removeEventListener('visibilitychange', onVisibility); // Only stop the scheduled Worker here — DO NOT clearSession. The // destructive wipe of access_token / watermark / NotificationDedup / // room_names belongs only on real disable / logout (handled by // useDisablePushNotifications, logoutClient, clearLocalSessionAndReload, // and SessionLoggedOut). A bare effect cleanup runs on any mx-instance // swap or unmount; clearing native state from there would silently // erase the LRU and re-render events the next poll cycle. polling.cancel().catch(noop); }; }, [pushEnabled, mx]); 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; // All notification channels (vojo_messages_dm_v1, vojo_messages_group_v1, // vojo_calls_v2) are created lazily from native code on first use — // VojoFirebaseMessagingService.ensureMessageChannels / ensureCallChannel. // Creating them here from JS would race with Java; whichever call wins // freezes the channel config for the lifetime of the channel (immutable // after creation on API 26+) and the Capacitor API can't express the // long repeating vibration pattern the call channel needs. // 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';