import { MatrixClient, IPusherRequest } 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 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); };