755 lines
31 KiB
TypeScript
755 lines
31 KiB
TypeScript
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<string>();
|
|
|
|
return mx.getRooms().reduce<RoomMetadataMap>((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<void> => {
|
|
// 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<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);
|
|
|
|
// 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<string, Record<string, { ts?: number }>>
|
|
>;
|
|
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';
|