vojo/src/app/utils/push.ts

274 lines
9.1 KiB
TypeScript

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<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 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<IPushRule, 'actions' | 'conditions' | 'pattern'> => ({
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<void> {
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<void> {
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<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);
};