import { MatrixClient, IPusherRequest, IPushRule, PushRuleKind, ConditionKind, PushRuleActionName, TweakName, } from 'matrix-js-sdk'; import { isNativePlatform } from './capacitor'; export type PushPlatform = 'web' | 'fcm'; export const getPushPlatform = (): PushPlatform => (isNativePlatform() ? 'fcm' : 'web'); export const PUSH_APP_IDS = { web: 'chat.vojo.app.web', fcm: 'chat.vojo.app.fcm', } as const; export const PUSH_ENABLED_KEY = 'vojo_push_enabled'; export const PUSH_STATE_CHANGE_EVENT = 'vojo:pushStateChange'; export const isPushEnabled = (): boolean => localStorage.getItem(PUSH_ENABLED_KEY) === 'true'; export const setPushEnabled = (enabled: boolean): void => { const prev = isPushEnabled(); if (enabled) localStorage.setItem(PUSH_ENABLED_KEY, 'true'); else localStorage.removeItem(PUSH_ENABLED_KEY); // Fan out to any UI that mirrors the flag (settings toggle, soft prompt, etc.) // The `storage` event only fires cross-tab, so in-tab observers need this. if (prev !== enabled && typeof window !== 'undefined') { window.dispatchEvent(new CustomEvent(PUSH_STATE_CHANGE_EVENT)); } }; export function urlBase64ToUint8Array(base64String: string): Uint8Array { const padding = '='.repeat((4 - (base64String.length % 4)) % 4); const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); const rawData = atob(base64); const output = new Uint8Array(rawData.length); for (let i = 0; i < rawData.length; i += 1) output[i] = rawData.charCodeAt(i); return output; } const arrayBufferToBase64Url = (buf: ArrayBuffer): string => { const bytes = new Uint8Array(buf); let binary = ''; for (let i = 0; i < bytes.byteLength; i += 1) binary += String.fromCharCode(bytes[i]); return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); }; /* eslint-disable no-await-in-loop */ async function withRetry(fn: () => Promise, attempts = 3): Promise { let lastErr: unknown; for (let i = 0; i < attempts; i += 1) { try { return await fn(); } catch (err) { lastErr = err; if (i < attempts - 1) { await new Promise((r) => { setTimeout(r, 2 ** i * 500); }); } } } throw lastErr; } /* eslint-enable no-await-in-loop */ export type PusherIds = { pushkey: string; appId: string; }; export const RTC_RING_PUSH_RULE_ID = 'chat.vojo.rtc.ring'; export const RTC_RING_PUSH_RULE_ID_STABLE = 'chat.vojo.rtc.ring.stable'; // Matrix default push rules don't cover MSC4075 RTC ring events, so without // an explicit Override rule the homeserver never dispatches RTC ring events // to Sygnal — background/killed devices silently miss incoming DM calls. // // Two rules are registered in parallel: one against the unstable // `org.matrix.msc4075.rtc.notification` event type that current matrix-js-sdk // (and our send-side cleartext bypass in `CallWidgetDriver`) emits, and one // against the stable `m.rtc.notification` name so peers running an upgraded // SDK with the stable promotion don't silently miss us. EventMatch only // matches the outer cleartext event type, which is why our send-side relies // on the cleartext bypass — this rule only fires on cleartext ring events. // // Idempotent: Synapse returns 200 OK on PUT with an identical body, so calling // these on every pusher (re)registration and on lifecycle startup is cheap. // Failures are logged but not thrown — a transient /pushrules 5xx shouldn't // break pusher setup (foreground path via useIncomingRtcNotifications keeps // working regardless of whether the rule made it to the server). const ringRuleBody = ( eventTypePattern: string ): Pick => ({ conditions: [ { kind: ConditionKind.EventMatch, key: 'type', pattern: eventTypePattern, }, { kind: ConditionKind.EventMatch, key: 'content.notification_type', pattern: 'ring', }, ], actions: [ PushRuleActionName.Notify, { set_tweak: TweakName.Sound, value: 'ring' }, { set_tweak: TweakName.Highlight, value: true }, ], }); export async function ensureRtcRingPushRule(mx: MatrixClient): Promise { try { await mx.addPushRule( 'global', PushRuleKind.Override, RTC_RING_PUSH_RULE_ID, ringRuleBody('org.matrix.msc4075.rtc.notification') ); } catch (err) { // eslint-disable-next-line no-console console.warn('[push] ensureRtcRingPushRule (unstable) failed:', err); } try { await mx.addPushRule( 'global', PushRuleKind.Override, RTC_RING_PUSH_RULE_ID_STABLE, ringRuleBody('m.rtc.notification') ); } catch (err) { // eslint-disable-next-line no-console console.warn('[push] ensureRtcRingPushRule (stable) failed:', err); } } // Symmetric cleanup for `useDisablePushNotifications`. The rules live in // account data so they would otherwise outlive the pusher — and other logged-in // clients (Element, a second Vojo session) would keep applying the ring // tweaks after the user explicitly turned push off here. 404 on delete of an // absent rule lands in the same warn path — that's the "already gone" case, // not a failure worth surfacing. export async function removeRtcRingPushRule(mx: MatrixClient): Promise { try { await mx.deletePushRule('global', PushRuleKind.Override, RTC_RING_PUSH_RULE_ID); } catch (err) { // eslint-disable-next-line no-console console.warn('[push] removeRtcRingPushRule (unstable) failed:', err); } try { await mx.deletePushRule('global', PushRuleKind.Override, RTC_RING_PUSH_RULE_ID_STABLE); } catch (err) { // eslint-disable-next-line no-console console.warn('[push] removeRtcRingPushRule (stable) failed:', err); } } export async function registerWebPusher( mx: MatrixClient, subscription: PushSubscription, gatewayUrl: string, appId: string ): Promise { const p256dh = subscription.getKey('p256dh'); const auth = subscription.getKey('auth'); if (!p256dh || !auth) throw new Error('PushSubscription is missing required keys'); // Sygnal webpush spec (https://github.com/matrix-org/sygnal/blob/main/docs/applications.md): // pushkey = p256dh (base64url), endpoint/auth go inside data. // `format: 'event_id_only'` belongs at top-level of `data` per Matrix pusher spec — // Sygnal reads it there to strip content before shipping the push. The SW then pulls // content via authed Matrix API, so nothing sensitive flows through the push gateway. // `events_only: true` tells Sygnal to skip unread-count-only pushes (no event_id → // nothing useful for the SW to display, would produce generic "New message" spam). // append: true so we don't clobber pushers from other accounts that share the same // browser-level push subscription (one SW → one p256dh across accounts). const pushkey = arrayBufferToBase64Url(p256dh); await withRetry(() => mx.setPusher({ kind: 'http', app_id: appId, pushkey, app_display_name: 'Vojo Web', device_display_name: navigator.userAgent.slice(0, 120), lang: navigator.language || 'en', data: { url: gatewayUrl, endpoint: subscription.endpoint, auth: arrayBufferToBase64Url(auth), format: 'event_id_only', events_only: true, }, append: true, }) ); return { pushkey, appId }; } export async function registerFcmPusher( mx: MatrixClient, fcmToken: string, gatewayUrl: string, appId: string, deviceName = 'Android' ): Promise { // Intentionally NOT sending `format: 'event_id_only'` — we need the full payload // fields (room_name, sender_display_name, content_body, …) in FCM data. When the // app is killed, VojoFirebaseMessagingService (native Java) builds the system // notification straight from the data map; it can't easily re-auth against the // Matrix API to fetch event content the way the web Service Worker does. // Sygnal ships Matrix pushes as data-only FCM regardless of `format` — the field // controls which keys are present, not FCM priority/delivery. await withRetry(() => mx.setPusher({ kind: 'http', app_id: appId, pushkey: fcmToken, app_display_name: 'Vojo Android', device_display_name: deviceName, lang: navigator.language || 'en', data: { url: gatewayUrl, }, append: false, }) ); return { pushkey: fcmToken, appId }; } export async function unregisterPusher( mx: MatrixClient, pushkey: string, appId: string ): Promise { await mx.setPusher({ kind: null, app_id: appId, pushkey, } as unknown as IPusherRequest); } const PUSHER_IDS_KEY = 'vojo_push_pusher_ids'; export const savePusherIds = (ids: PusherIds): void => { localStorage.setItem(PUSHER_IDS_KEY, JSON.stringify(ids)); }; export const loadPusherIds = (): PusherIds | undefined => { const raw = localStorage.getItem(PUSHER_IDS_KEY); if (!raw) return undefined; try { const parsed = JSON.parse(raw); if (typeof parsed?.pushkey === 'string' && typeof parsed?.appId === 'string') return parsed; } catch { /* noop */ } return undefined; }; export const clearPusherIds = (): void => { localStorage.removeItem(PUSHER_IDS_KEY); };