vojo/src/app/utils/push.ts
2026-04-17 23:31:21 +03:00

176 lines
5.7 KiB
TypeScript

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<T>(fn: () => Promise<T>, attempts = 3): Promise<T> {
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<PusherIds> {
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<PusherIds> {
// 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<void> {
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);
};