pwa: installable manifest and Web Push stages 0-1 (dark by default) #121
21 changed files with 1105 additions and 1 deletions
|
|
@ -28,6 +28,11 @@
|
|||
"fileZoneShare": {
|
||||
"enabled": false
|
||||
},
|
||||
"push": {
|
||||
"vapidPublicKey": "",
|
||||
"gatewayUrl": "",
|
||||
"appId": ""
|
||||
},
|
||||
"registration": {
|
||||
"enabled": false,
|
||||
"url": ""
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
{
|
||||
"name": "Ratatoskr",
|
||||
"short_name": "Ratatoskr",
|
||||
"id": "/",
|
||||
"description": "GameCult's Matrix client",
|
||||
"dir": "auto",
|
||||
"lang": "en-US",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait",
|
||||
"start_url": "./",
|
||||
"scope": "./",
|
||||
"background_color": "#07111a",
|
||||
"theme_color": "#07111a",
|
||||
"icons": [
|
||||
|
|
@ -54,6 +55,12 @@
|
|||
"src": "./public/android/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "./public/android/android-chrome-maskable-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
BIN
public/res/android/android-chrome-maskable-512x512.png
Normal file
BIN
public/res/android/android-chrome-maskable-512x512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
72
src/app/features/push/PushNotification.tsx
Normal file
72
src/app/features/push/PushNotification.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Switch, Text, color } from 'folds';
|
||||
import { SettingTile } from '../../components/setting-tile';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { getNotificationState, usePermissionState } from '../../hooks/usePermission';
|
||||
import type { ActivePushConfig } from './config';
|
||||
import { pushSessionToSW } from '../../../sw-session';
|
||||
import { disablePush, enablePush, pushIsOn } from './pusher';
|
||||
|
||||
export const pushSupported = (): boolean =>
|
||||
'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window;
|
||||
|
||||
const readyManager = async () => (await navigator.serviceWorker.ready).pushManager;
|
||||
|
||||
/** Background notifications through the installed app's service worker. The caller renders it only when the host's config fills the push block and the browser can. */
|
||||
export function PushNotification({ cfg }: { cfg: ActivePushConfig }) {
|
||||
const mx = useMatrixClient();
|
||||
const permission = usePermissionState('notifications', getNotificationState());
|
||||
const [on, setOn] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
readyManager()
|
||||
.then((m) => pushIsOn(mx, m, cfg))
|
||||
.then(setOn)
|
||||
.catch(() => undefined);
|
||||
}, [cfg, mx]);
|
||||
|
||||
const toggle = useCallback(
|
||||
async (enable: boolean) => {
|
||||
setBusy(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
if (enable) {
|
||||
if ((await window.Notification.requestPermission()) !== 'granted') {
|
||||
throw new Error('Notification permission was not granted.');
|
||||
}
|
||||
await enablePush(mx, await readyManager(), cfg, navigator.userAgent.slice(0, 64));
|
||||
} else {
|
||||
await disablePush(mx, await readyManager(), cfg);
|
||||
}
|
||||
// The worker keeps the token only while a subscription exists: tell it the new state.
|
||||
pushSessionToSW(mx.baseUrl, mx.getAccessToken() ?? undefined);
|
||||
setOn(enable);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Could not change background notifications.');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[cfg, mx]
|
||||
);
|
||||
|
||||
return (
|
||||
<SettingTile
|
||||
title="Background Notifications"
|
||||
description={
|
||||
error || permission === 'denied' ? (
|
||||
<Text as="span" style={{ color: color.Critical.Main }} size="T200">
|
||||
{error ?? 'Notification permission is blocked. Allow it from the browser address bar.'}
|
||||
</Text>
|
||||
) : (
|
||||
<span>
|
||||
Notify when the app is closed. Installing the app to the home screen helps on iOS.
|
||||
</span>
|
||||
)
|
||||
}
|
||||
after={<Switch disabled={busy || permission === 'denied'} value={on} onChange={toggle} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
24
src/app/features/push/config.test.ts
Normal file
24
src/app/features/push/config.test.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { activePushConfig } from './config';
|
||||
|
||||
describe('activePushConfig', () => {
|
||||
it('is off unless all three keys are filled', () => {
|
||||
expect(activePushConfig(undefined)).toBeUndefined();
|
||||
expect(activePushConfig({})).toBeUndefined();
|
||||
expect(
|
||||
activePushConfig({ vapidPublicKey: 'k', gatewayUrl: 'https://g/_matrix/push/v1/notify' })
|
||||
).toBeUndefined();
|
||||
expect(activePushConfig({ vapidPublicKey: 'k', gatewayUrl: 'u', appId: 'a' })).toEqual({
|
||||
vapidPublicKey: 'k',
|
||||
gatewayUrl: 'u',
|
||||
appId: 'a',
|
||||
});
|
||||
});
|
||||
it('the shipped config.json carries the block empty, so push is off', () => {
|
||||
const cfg = JSON.parse(readFileSync(join(__dirname, '../../../../config.json'), 'utf8'));
|
||||
expect(cfg.push).toEqual({ vapidPublicKey: '', gatewayUrl: '', appId: '' });
|
||||
expect(activePushConfig(cfg.push)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
12
src/app/features/push/config.ts
Normal file
12
src/app/features/push/config.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
// Web Push is off unless the host's config.json fills all three keys (ships empty). The public key is not a secret;
|
||||
// the gateway URL is the push gateway's notify endpoint the homeserver calls (`/_matrix/push/v1/notify`).
|
||||
export type PushConfig = {
|
||||
vapidPublicKey?: string;
|
||||
gatewayUrl?: string;
|
||||
appId?: string;
|
||||
};
|
||||
|
||||
export type ActivePushConfig = Required<PushConfig>;
|
||||
|
||||
export const activePushConfig = (c?: PushConfig): ActivePushConfig | undefined =>
|
||||
c?.vapidPublicKey && c.gatewayUrl && c.appId ? (c as ActivePushConfig) : undefined;
|
||||
29
src/app/features/push/manifest.test.ts
Normal file
29
src/app/features/push/manifest.test.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const ROOT = join(__dirname, '../../../..');
|
||||
const manifest = JSON.parse(readFileSync(join(ROOT, 'public/manifest.json'), 'utf8'));
|
||||
|
||||
describe('web app manifest', () => {
|
||||
it('has a stable identity and scope, and no orientation lock', () => {
|
||||
expect(manifest.id).toBe('/'); // what Chrome already derives from start_url "./" at the site root
|
||||
expect(manifest.scope).toBe('./');
|
||||
expect(manifest.start_url).toBe('./');
|
||||
expect(manifest.display).toBe('standalone');
|
||||
expect(manifest.orientation).toBeUndefined();
|
||||
});
|
||||
it('offers 192 and 512 icons for any use and a 512 maskable one, every file present', () => {
|
||||
const icons = manifest.icons as { src: string; sizes: string; purpose?: string }[];
|
||||
const any = icons.filter((i) => !i.purpose);
|
||||
expect(any.map((i) => i.sizes)).toEqual(expect.arrayContaining(['192x192', '512x512']));
|
||||
const maskable = icons.filter((i) => i.purpose === 'maskable');
|
||||
expect(maskable.map((i) => i.sizes)).toEqual(['512x512']);
|
||||
// the build copies public/res/android to public/android (vite.config.js)
|
||||
icons.forEach((i) =>
|
||||
expect(
|
||||
existsSync(join(ROOT, i.src.replace('./public/android/', 'public/res/android/')))
|
||||
).toBe(true)
|
||||
);
|
||||
});
|
||||
});
|
||||
108
src/app/features/push/payload.test.ts
Normal file
108
src/app/features/push/payload.test.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { describePush, GENERIC, parsePush } from './payload';
|
||||
import { matrixNotify, mockHomeserver, SESSION } from './testkit';
|
||||
|
||||
describe('parsePush', () => {
|
||||
it('reads the Matrix gateway body and the bare inner object', () => {
|
||||
const a = parsePush(matrixNotify('!r:x', '$e'));
|
||||
expect(a).toMatchObject({ roomId: '!r:x', eventId: '$e' });
|
||||
expect(parsePush({ room_id: '!r:x', event_id: '$e' })).toMatchObject({ roomId: '!r:x' });
|
||||
});
|
||||
it('rejects a body with no room id', () => {
|
||||
expect(parsePush({ notification: { event_id: '$e' } })).toBeUndefined();
|
||||
expect(parsePush(undefined)).toBeUndefined();
|
||||
expect(parsePush('x')).toBeUndefined();
|
||||
});
|
||||
it('carries text the gateway added', () => {
|
||||
expect(
|
||||
parsePush({
|
||||
notification: {
|
||||
room_id: '!r:x',
|
||||
room_name: 'Lobby',
|
||||
sender_display_name: 'Ann',
|
||||
content: { body: 'hi' },
|
||||
},
|
||||
})
|
||||
).toMatchObject({ roomName: 'Lobby', sender: 'Ann', text: 'hi' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('describePush', () => {
|
||||
const ids = { roomId: '!r:x', eventId: '$e' };
|
||||
const event = {
|
||||
type: 'm.room.message',
|
||||
sender: '@ann:x',
|
||||
content: { msgtype: 'm.text', body: 'hello there' },
|
||||
};
|
||||
|
||||
it('resolves ids through the homeserver with the stored token', async () => {
|
||||
const hs = mockHomeserver({
|
||||
token: 'tok-1',
|
||||
events: { $e: event },
|
||||
roomNames: { '!r:x': 'Lobby' },
|
||||
});
|
||||
expect(await describePush(ids, SESSION, hs.fetchFn)).toEqual({
|
||||
title: 'Lobby',
|
||||
body: 'ann: hello there',
|
||||
});
|
||||
expect(hs.seen).toHaveLength(2);
|
||||
expect(
|
||||
hs.seen.every(
|
||||
(r) => r.auth === 'Bearer tok-1' && r.url.startsWith('https://matrix.example/_matrix/')
|
||||
)
|
||||
).toBe(true);
|
||||
expect(hs.seen[0].url).toContain('/rooms/!r%3Ax/');
|
||||
});
|
||||
it('shows the generic notification with no session, and asks nobody', async () => {
|
||||
const hs = mockHomeserver({ token: 'tok-1' });
|
||||
expect(await describePush(ids, undefined, hs.fetchFn)).toEqual({
|
||||
title: 'Ratatoskr',
|
||||
body: 'New message',
|
||||
});
|
||||
expect(hs.seen).toHaveLength(0);
|
||||
});
|
||||
it('falls back when the token is refused, the network is down, or the event is encrypted', async () => {
|
||||
const stale = mockHomeserver({ token: 'other', events: { $e: event } });
|
||||
expect(await describePush(ids, SESSION, stale.fetchFn)).toEqual(GENERIC);
|
||||
const down = mockHomeserver({ token: 'tok-1', failAll: true });
|
||||
expect(await describePush(ids, SESSION, down.fetchFn)).toEqual(GENERIC);
|
||||
const enc = mockHomeserver({
|
||||
token: 'tok-1',
|
||||
events: {
|
||||
$e: {
|
||||
type: 'm.room.encrypted',
|
||||
sender: '@ann:x',
|
||||
content: { algorithm: 'm.megolm.v1.aes-sha2' },
|
||||
},
|
||||
},
|
||||
roomNames: { '!r:x': 'Lobby' },
|
||||
});
|
||||
expect(await describePush(ids, SESSION, enc.fetchFn)).toEqual({
|
||||
title: 'Lobby',
|
||||
body: 'New message',
|
||||
});
|
||||
});
|
||||
it('does not fetch what the push already carried', async () => {
|
||||
const hs = mockHomeserver({ token: 'tok-1' });
|
||||
const shown = await describePush(
|
||||
{ ...ids, roomName: 'R', sender: 'Bo', text: 'yo' },
|
||||
SESSION,
|
||||
hs.fetchFn
|
||||
);
|
||||
expect(shown).toEqual({ title: 'R', body: 'Bo: yo' });
|
||||
expect(hs.seen).toHaveLength(0);
|
||||
});
|
||||
it('clips a long body', async () => {
|
||||
const hs = mockHomeserver({
|
||||
token: 'tok-1',
|
||||
events: { $e: { ...event, content: { body: 'x'.repeat(500) } } },
|
||||
});
|
||||
const shown = await describePush(ids, SESSION, hs.fetchFn);
|
||||
expect(shown.body.length).toBeLessThanOrEqual(200);
|
||||
expect(shown.body.endsWith('…')).toBe(true);
|
||||
});
|
||||
it('shows the generic notification when the push names no room', async () => {
|
||||
const hs = mockHomeserver({ token: 'tok-1' });
|
||||
expect(await describePush(undefined, SESSION, hs.fetchFn)).toEqual(GENERIC);
|
||||
});
|
||||
});
|
||||
95
src/app/features/push/payload.ts
Normal file
95
src/app/features/push/payload.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import type { SessionRecord } from './sessionStore';
|
||||
|
||||
/** What a push names. The homeserver sends ids only (`push.include_content: false`); a gateway may add text. */
|
||||
export interface PushIds {
|
||||
roomId: string;
|
||||
eventId?: string;
|
||||
roomName?: string;
|
||||
sender?: string;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export interface Shown {
|
||||
title: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
const str = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined);
|
||||
|
||||
/** The Matrix push body is `{ notification: {...} }`; a gateway may forward the inner object. No room id: not ours to show. */
|
||||
export function parsePush(raw: unknown): PushIds | undefined {
|
||||
const top = (raw ?? {}) as Record<string, unknown>;
|
||||
const n = (top.notification ?? top) as Record<string, unknown>;
|
||||
const roomId = str(n.room_id);
|
||||
if (!roomId) return undefined;
|
||||
const content = (n.content ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
roomId,
|
||||
eventId: str(n.event_id),
|
||||
roomName: str(n.room_name),
|
||||
sender: str(n.sender_display_name) ?? str(n.sender),
|
||||
text: str(content.body),
|
||||
};
|
||||
}
|
||||
|
||||
export const GENERIC: Shown = { title: 'Ratatoskr', body: 'New message' };
|
||||
const MAX_BODY = 200;
|
||||
const clip = (s: string): string => (s.length > MAX_BODY ? `${s.slice(0, MAX_BODY - 1)}…` : s);
|
||||
const localpart = (userId: string): string => userId.replace(/^@/, '').split(':')[0];
|
||||
|
||||
type Fetch = (url: string, init: RequestInit) => Promise<Response>;
|
||||
|
||||
async function getJson(
|
||||
f: Fetch,
|
||||
session: SessionRecord,
|
||||
path: string,
|
||||
timeoutMs: number
|
||||
): Promise<Record<string, unknown> | undefined> {
|
||||
try {
|
||||
const res = await f(`${session.baseUrl.replace(/\/+$/, '')}${path}`, {
|
||||
headers: { Authorization: `Bearer ${session.accessToken}` },
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
return res.ok ? ((await res.json()) as Record<string, unknown>) : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Title and body for a push. Text the push carried wins. Otherwise, with a session, the event and the room's name are
|
||||
* fetched (clear-text rooms; an encrypted event shows the generic body). Every failure ends at the generic notification:
|
||||
* a push must always show something.
|
||||
*/
|
||||
export async function describePush(
|
||||
ids: PushIds | undefined,
|
||||
session: SessionRecord | undefined,
|
||||
f: Fetch,
|
||||
timeoutMs = 4000
|
||||
): Promise<Shown> {
|
||||
if (!ids) return GENERIC;
|
||||
let { roomName, sender, text } = ids;
|
||||
if (session && (!text || !roomName)) {
|
||||
const room = encodeURIComponent(ids.roomId);
|
||||
const [event, name] = await Promise.all([
|
||||
!text && ids.eventId
|
||||
? getJson(
|
||||
f,
|
||||
session,
|
||||
`/_matrix/client/v3/rooms/${room}/event/${encodeURIComponent(ids.eventId)}`,
|
||||
timeoutMs
|
||||
)
|
||||
: undefined,
|
||||
!roomName
|
||||
? getJson(f, session, `/_matrix/client/v3/rooms/${room}/state/m.room.name`, timeoutMs)
|
||||
: undefined,
|
||||
]);
|
||||
const content = (event?.content ?? {}) as Record<string, unknown>;
|
||||
if (event?.type === 'm.room.message') text = str(content.body);
|
||||
if (!sender && str(event?.sender)) sender = localpart(event?.sender as string);
|
||||
roomName = roomName ?? str(name?.name);
|
||||
}
|
||||
let { body } = GENERIC;
|
||||
if (text) body = sender ? `${sender}: ${text}` : text;
|
||||
return { title: roomName ?? GENERIC.title, body: clip(body) };
|
||||
}
|
||||
143
src/app/features/push/pusher.test.ts
Normal file
143
src/app/features/push/pusher.test.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import type { IPusherRequest } from 'matrix-js-sdk';
|
||||
import {
|
||||
disablePush,
|
||||
enablePush,
|
||||
PushManagerPort,
|
||||
pushIsOn,
|
||||
vapidKeyBytes,
|
||||
webPushPusher,
|
||||
} from './pusher';
|
||||
|
||||
const cfg = {
|
||||
vapidPublicKey: 'BAUD',
|
||||
gatewayUrl: 'https://matrix.example/_matrix/push/v1/notify',
|
||||
appId: 'org.gamecult.ratatoskr.web',
|
||||
};
|
||||
const key = (...b: number[]) => Uint8Array.from(b).buffer;
|
||||
|
||||
function subscription(unsubscribed: string[], serverKey?: ArrayBuffer): PushSubscription {
|
||||
return {
|
||||
options: { applicationServerKey: serverKey ?? vapidKeyBytes(cfg.vapidPublicKey).buffer },
|
||||
endpoint: 'https://push.example/send/abc',
|
||||
getKey: (n: string) => (n === 'p256dh' ? key(251, 255, 1) : key(250, 2)),
|
||||
unsubscribe: async () => {
|
||||
unsubscribed.push('x');
|
||||
return true;
|
||||
},
|
||||
} as unknown as PushSubscription;
|
||||
}
|
||||
|
||||
function world() {
|
||||
const calls: string[] = [];
|
||||
const pushers: IPusherRequest[] = [];
|
||||
let sub: PushSubscription | null = null;
|
||||
const unsub: string[] = [];
|
||||
const mx = {
|
||||
setPusher: async (p: IPusherRequest) => {
|
||||
calls.push(p.kind === null ? 'delete-pusher' : 'set-pusher');
|
||||
pushers.push(p);
|
||||
},
|
||||
// The account's pushers as the homeserver would list them: what setPusher last wrote, minus deletions.
|
||||
getPushers: async () => ({
|
||||
pushers: pushers
|
||||
.filter(
|
||||
(p, i) =>
|
||||
p.kind !== null &&
|
||||
!pushers.slice(i + 1).some((q) => q.kind === null && q.pushkey === p.pushkey)
|
||||
)
|
||||
.map((p) => ({ app_id: p.app_id, pushkey: p.pushkey })),
|
||||
}),
|
||||
};
|
||||
const push: PushManagerPort = {
|
||||
getSubscription: async () => sub,
|
||||
subscribe: async (o) => {
|
||||
calls.push(`subscribe:${o.userVisibleOnly}:${o.applicationServerKey.length}`);
|
||||
sub = subscription(unsub);
|
||||
return sub;
|
||||
},
|
||||
};
|
||||
const swapSub = (s: PushSubscription | null) => {
|
||||
sub = s;
|
||||
};
|
||||
return { mx, push, calls, pushers, unsub, swapSub };
|
||||
}
|
||||
|
||||
describe('web push pusher', () => {
|
||||
it('decodes the URL-safe VAPID key', () => {
|
||||
expect([...vapidKeyBytes('-_8')]).toEqual([251, 255]);
|
||||
});
|
||||
it('is the Sygnal webpush shape: app id, p256dh pushkey, subscription in data, ids only', () => {
|
||||
const p = webPushPusher(cfg, subscription([]), 'phone') as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
> & {
|
||||
data: Record<string, unknown>;
|
||||
};
|
||||
expect(p).toMatchObject({
|
||||
kind: 'http',
|
||||
app_id: cfg.appId,
|
||||
pushkey: '-_8B',
|
||||
device_display_name: 'phone',
|
||||
append: false,
|
||||
});
|
||||
expect(p.data).toMatchObject({
|
||||
url: cfg.gatewayUrl,
|
||||
format: 'event_id_only',
|
||||
endpoint: 'https://push.example/send/abc',
|
||||
auth: '-gI',
|
||||
p256dh: '-_8B',
|
||||
});
|
||||
});
|
||||
it('enable subscribes with userVisibleOnly, then registers the pusher; a second enable reuses the subscription', async () => {
|
||||
const w = world();
|
||||
await enablePush(w.mx, w.push, cfg, 'phone');
|
||||
expect(w.calls).toEqual(['subscribe:true:3', 'set-pusher']);
|
||||
await enablePush(w.mx, w.push, cfg, 'phone');
|
||||
expect(w.calls).toEqual(['subscribe:true:3', 'set-pusher', 'set-pusher']);
|
||||
});
|
||||
it('disable deletes the pusher before the subscription, and does nothing when there is none', async () => {
|
||||
const w = world();
|
||||
await disablePush(w.mx, w.push, cfg);
|
||||
expect(w.calls).toEqual([]);
|
||||
await enablePush(w.mx, w.push, cfg, 'phone');
|
||||
await disablePush(w.mx, w.push, cfg);
|
||||
expect(w.calls.slice(-1)).toEqual(['delete-pusher']);
|
||||
expect(w.pushers.at(-1)).toMatchObject({ kind: null, app_id: cfg.appId, pushkey: '-_8B' });
|
||||
expect(w.unsub).toEqual(['x']);
|
||||
});
|
||||
it('a failed pusher write leaves the subscription alone', async () => {
|
||||
const w = world();
|
||||
await enablePush(w.mx, w.push, cfg, 'phone');
|
||||
w.mx.setPusher = async () => {
|
||||
throw new Error('homeserver said no');
|
||||
};
|
||||
await expect(disablePush(w.mx, w.push, cfg)).rejects.toThrow('homeserver said no');
|
||||
expect(w.unsub).toEqual([]);
|
||||
});
|
||||
it('the toggle is on only with a subscription and the pusher of this account for it', async () => {
|
||||
const w = world();
|
||||
expect(await pushIsOn(w.mx, w.push, cfg)).toBe(false);
|
||||
await enablePush(w.mx, w.push, cfg, 'phone');
|
||||
expect(await pushIsOn(w.mx, w.push, cfg)).toBe(true);
|
||||
// another account on the same browser: the subscription exists, its pusher does not
|
||||
const other = { ...w.mx, getPushers: async () => ({ pushers: [] }) };
|
||||
expect(await pushIsOn(other, w.push, cfg)).toBe(false);
|
||||
await disablePush(w.mx, w.push, cfg);
|
||||
expect(await pushIsOn(w.mx, w.push, cfg)).toBe(false);
|
||||
});
|
||||
it('a subscription made under another VAPID key is replaced, not reused', async () => {
|
||||
const w = world();
|
||||
const old = [] as string[];
|
||||
w.swapSub(subscription(old, Uint8Array.from([9, 9, 9]).buffer));
|
||||
await enablePush(w.mx, w.push, cfg, 'phone');
|
||||
expect(old).toEqual(['x']);
|
||||
expect(w.calls).toEqual(['subscribe:true:3', 'set-pusher']);
|
||||
});
|
||||
it('a failed pusher write after subscribing leaves the toggle off', async () => {
|
||||
const w = world();
|
||||
const fail = { ...w.mx, setPusher: async () => Promise.reject(new Error('no')) };
|
||||
await expect(enablePush(fail, w.push, cfg, 'phone')).rejects.toThrow('no');
|
||||
expect(await pushIsOn(w.mx, w.push, cfg)).toBe(false);
|
||||
});
|
||||
});
|
||||
107
src/app/features/push/pusher.ts
Normal file
107
src/app/features/push/pusher.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import type { IPusherRequest } from 'matrix-js-sdk';
|
||||
import type { ActivePushConfig } from './config';
|
||||
|
||||
/** What the page needs of the homeserver client and the browser's push manager, so tests supply fakes. */
|
||||
export interface PusherClient {
|
||||
setPusher(p: IPusherRequest): Promise<unknown>;
|
||||
getPushers(): Promise<{ pushers: { app_id: string; pushkey: string }[] }>;
|
||||
}
|
||||
export interface PushManagerPort {
|
||||
getSubscription(): Promise<PushSubscription | null>;
|
||||
subscribe(o: {
|
||||
userVisibleOnly: boolean;
|
||||
applicationServerKey: Uint8Array;
|
||||
}): Promise<PushSubscription>;
|
||||
}
|
||||
|
||||
export function vapidKeyBytes(b64url: string): Uint8Array {
|
||||
const pad = '='.repeat((4 - (b64url.length % 4)) % 4);
|
||||
const bin = atob((b64url + pad).replace(/-/g, '+').replace(/_/g, '/'));
|
||||
return Uint8Array.from(bin, (c) => c.charCodeAt(0));
|
||||
}
|
||||
|
||||
const b64url = (buf: ArrayBuffer | null): string =>
|
||||
btoa(String.fromCharCode(...new Uint8Array(buf ?? new ArrayBuffer(0))))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
|
||||
/**
|
||||
* The Matrix pusher for a browser subscription (Sygnal's `webpush` shape: the subscription's keys ride in `data`; the
|
||||
* pushkey is the subscription's p256dh key, as Element Web does). `event_id_only` asks the homeserver for ids, which
|
||||
* the worker resolves itself.
|
||||
*/
|
||||
export function webPushPusher(
|
||||
cfg: ActivePushConfig,
|
||||
sub: PushSubscription,
|
||||
deviceName: string
|
||||
): IPusherRequest {
|
||||
const p256dh = b64url(sub.getKey('p256dh'));
|
||||
return {
|
||||
kind: 'http',
|
||||
app_id: cfg.appId,
|
||||
pushkey: p256dh,
|
||||
app_display_name: 'Ratatoskr',
|
||||
device_display_name: deviceName,
|
||||
lang: navigator.language || 'en',
|
||||
data: {
|
||||
url: cfg.gatewayUrl,
|
||||
format: 'event_id_only',
|
||||
endpoint: sub.endpoint,
|
||||
auth: b64url(sub.getKey('auth')),
|
||||
p256dh,
|
||||
},
|
||||
append: false,
|
||||
} as IPusherRequest;
|
||||
}
|
||||
|
||||
const sameKey = (sub: PushSubscription, vapid: Uint8Array): boolean => {
|
||||
const have = sub.options?.applicationServerKey;
|
||||
return !have || b64url(have) === b64url(vapid.buffer as ArrayBuffer);
|
||||
};
|
||||
|
||||
/** Subscribe (replacing a subscription made under another VAPID key), then register the pusher for this account. */
|
||||
export async function enablePush(
|
||||
mx: PusherClient,
|
||||
push: PushManagerPort,
|
||||
cfg: ActivePushConfig,
|
||||
deviceName: string
|
||||
): Promise<void> {
|
||||
const key = vapidKeyBytes(cfg.vapidPublicKey);
|
||||
let sub = await push.getSubscription();
|
||||
if (sub && !sameKey(sub, key)) {
|
||||
await sub.unsubscribe();
|
||||
sub = null;
|
||||
}
|
||||
sub = sub ?? (await push.subscribe({ userVisibleOnly: true, applicationServerKey: key }));
|
||||
await mx.setPusher(webPushPusher(cfg, sub, deviceName));
|
||||
}
|
||||
|
||||
/** On only when this browser holds a subscription AND this account has a pusher for it (the two live in different places). */
|
||||
export async function pushIsOn(
|
||||
mx: PusherClient,
|
||||
push: PushManagerPort,
|
||||
cfg: ActivePushConfig
|
||||
): Promise<boolean> {
|
||||
const sub = await push.getSubscription();
|
||||
if (!sub) return false;
|
||||
const pushkey = b64url(sub.getKey('p256dh'));
|
||||
const { pushers } = await mx.getPushers();
|
||||
return pushers.some((p) => p.app_id === cfg.appId && p.pushkey === pushkey);
|
||||
}
|
||||
|
||||
/** Delete this account's pusher, then the subscription. The order matters: a pusher with no subscription would keep failing at the gateway. */
|
||||
export async function disablePush(
|
||||
mx: PusherClient,
|
||||
push: PushManagerPort,
|
||||
cfg: ActivePushConfig
|
||||
): Promise<void> {
|
||||
const sub = await push.getSubscription();
|
||||
if (!sub) return;
|
||||
await mx.setPusher({
|
||||
kind: null,
|
||||
app_id: cfg.appId,
|
||||
pushkey: b64url(sub.getKey('p256dh')),
|
||||
} as unknown as IPusherRequest);
|
||||
await sub.unsubscribe();
|
||||
}
|
||||
111
src/app/features/push/sessionStore.test.ts
Normal file
111
src/app/features/push/sessionStore.test.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { idbPort, RecordPort, SessionRecord, sessionStore } from './sessionStore';
|
||||
|
||||
const memory = (): RecordPort & { held?: unknown } => {
|
||||
const m: RecordPort & { held?: unknown } = {
|
||||
async read() {
|
||||
return m.held;
|
||||
},
|
||||
async write(v) {
|
||||
m.held = v;
|
||||
},
|
||||
};
|
||||
return m;
|
||||
};
|
||||
|
||||
describe('sessionStore', () => {
|
||||
it('saves a valid session and loads it back', async () => {
|
||||
const s = sessionStore(memory());
|
||||
await s.save('https://hs/', 'tok');
|
||||
expect(await s.load()).toEqual({ baseUrl: 'https://hs/', accessToken: 'tok' });
|
||||
});
|
||||
it('deletes the record on logout, and on anything that is not two strings', async () => {
|
||||
const port = memory();
|
||||
const s = sessionStore(port);
|
||||
await s.save('https://hs/', 'tok');
|
||||
await s.save(undefined, undefined);
|
||||
expect(port.held).toBeUndefined();
|
||||
await s.save('https://hs/', 'tok');
|
||||
await s.save('https://hs/', 5);
|
||||
expect(await s.load()).toBeUndefined();
|
||||
});
|
||||
it('keeps the token only while a push subscription exists', async () => {
|
||||
const port = memory();
|
||||
const s = sessionStore(port);
|
||||
await s.saveIfSubscribed('https://hs/', 'tok', async () => false);
|
||||
expect(port.held).toBeUndefined();
|
||||
await s.saveIfSubscribed('https://hs/', 'tok', async () => true);
|
||||
expect(await s.load()).toEqual({ baseUrl: 'https://hs/', accessToken: 'tok' });
|
||||
await s.saveIfSubscribed('https://hs/', 'tok', async () => false);
|
||||
expect(port.held).toBeUndefined();
|
||||
await s.saveIfSubscribed('https://hs/', 'tok', async () => true);
|
||||
await s.saveIfSubscribed('https://hs/', 'tok', () =>
|
||||
Promise.reject(new Error('no push manager'))
|
||||
);
|
||||
expect(port.held).toBeUndefined();
|
||||
});
|
||||
it('ignores a record that is not a session', async () => {
|
||||
const port = memory();
|
||||
port.held = { baseUrl: 1 };
|
||||
expect(await sessionStore(port).load()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// Just enough of IndexedDB for the port: one database, object stores of key -> value, async request callbacks.
|
||||
function fakeIndexedDB(): IDBFactory {
|
||||
const dbs = new Map<string, Map<string, Map<string, unknown>>>();
|
||||
const request = <T>(run: () => T) => {
|
||||
const r = {} as IDBRequest<T>;
|
||||
setTimeout(() => {
|
||||
(r as { result: T }).result = run();
|
||||
r.onsuccess?.(new Event('success'));
|
||||
}, 0);
|
||||
return r;
|
||||
};
|
||||
return {
|
||||
open(name: string) {
|
||||
const r = {} as IDBOpenDBRequest;
|
||||
setTimeout(() => {
|
||||
const fresh = !dbs.has(name);
|
||||
const stores = dbs.get(name) ?? new Map();
|
||||
dbs.set(name, stores);
|
||||
const db = {
|
||||
createObjectStore: (n: string) => stores.set(n, new Map()),
|
||||
close: () => undefined,
|
||||
transaction: (n: string) => {
|
||||
const tx = {} as IDBTransaction;
|
||||
const store = stores.get(n) as Map<string, unknown>;
|
||||
const api = {
|
||||
get: (k: string) => request(() => store.get(k)),
|
||||
put: (v: unknown, k: string) => {
|
||||
store.set(k, v);
|
||||
setTimeout(() => tx.oncomplete?.(new Event('complete')), 0);
|
||||
},
|
||||
delete: (k: string) => {
|
||||
store.delete(k);
|
||||
setTimeout(() => tx.oncomplete?.(new Event('complete')), 0);
|
||||
},
|
||||
};
|
||||
(tx as { objectStore: unknown }).objectStore = () => api;
|
||||
return tx;
|
||||
},
|
||||
} as unknown as IDBDatabase;
|
||||
(r as { result: IDBDatabase }).result = db;
|
||||
if (fresh) r.onupgradeneeded?.(new Event('upgradeneeded') as IDBVersionChangeEvent);
|
||||
r.onsuccess?.(new Event('success'));
|
||||
}, 0);
|
||||
return r;
|
||||
},
|
||||
} as unknown as IDBFactory;
|
||||
}
|
||||
|
||||
describe('idbPort', () => {
|
||||
it('round-trips the one record through IndexedDB and deletes it', async () => {
|
||||
const s = sessionStore(idbPort(fakeIndexedDB()));
|
||||
expect(await s.load()).toBeUndefined();
|
||||
await s.save('https://hs/', 'tok');
|
||||
expect(await s.load()).toEqual<SessionRecord>({ baseUrl: 'https://hs/', accessToken: 'tok' });
|
||||
await s.save(undefined, undefined);
|
||||
expect(await s.load()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
88
src/app/features/push/sessionStore.ts
Normal file
88
src/app/features/push/sessionStore.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
// The one thing a push needs that a closed app cannot give: the access token, to fetch the event a push names.
|
||||
// Stored: `{ baseUrl, accessToken }`, one record, in this origin's IndexedDB `ratatoskr-sw` (not reachable by other
|
||||
// origins; the token is already in the page's own storage). Written when the page hands the worker its session
|
||||
// while this browser holds a push subscription; deleted when the page hands it none (logout) or the subscription is
|
||||
// gone. A browser that never turned push on stores nothing. Nothing else reads it but the push handler.
|
||||
export interface SessionRecord {
|
||||
baseUrl: string;
|
||||
accessToken: string;
|
||||
}
|
||||
|
||||
/** The two operations the store needs from a key-value backend, so tests run without IndexedDB. */
|
||||
export interface RecordPort {
|
||||
read(): Promise<unknown>;
|
||||
write(value: SessionRecord | undefined): Promise<void>;
|
||||
}
|
||||
|
||||
const DB = 'ratatoskr-sw';
|
||||
const STORE = 'session';
|
||||
const KEY = 'session';
|
||||
|
||||
const done = <T>(req: IDBRequest<T>): Promise<T> =>
|
||||
new Promise((resolve, reject) => {
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
|
||||
export function idbPort(factory: IDBFactory): RecordPort {
|
||||
const open = (): Promise<IDBDatabase> => {
|
||||
const req = factory.open(DB, 1);
|
||||
req.onupgradeneeded = () => req.result.createObjectStore(STORE);
|
||||
return done(req);
|
||||
};
|
||||
return {
|
||||
async read() {
|
||||
const db = await open();
|
||||
try {
|
||||
return await done(db.transaction(STORE).objectStore(STORE).get(KEY));
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
},
|
||||
async write(value) {
|
||||
const db = await open();
|
||||
try {
|
||||
const tx = db.transaction(STORE, 'readwrite');
|
||||
const store = tx.objectStore(STORE);
|
||||
const finished = new Promise<void>((resolve, reject) => {
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
tx.onabort = () => reject(tx.error);
|
||||
});
|
||||
if (value) store.put(value, KEY);
|
||||
else store.delete(KEY);
|
||||
await finished;
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function sessionStore(port: RecordPort) {
|
||||
return {
|
||||
/** A valid session replaces the record; anything else (logout) deletes it. */
|
||||
async save(baseUrl: unknown, accessToken: unknown): Promise<void> {
|
||||
await port.write(
|
||||
typeof baseUrl === 'string' && typeof accessToken === 'string' && baseUrl && accessToken
|
||||
? { baseUrl, accessToken }
|
||||
: undefined
|
||||
);
|
||||
},
|
||||
/** The token is kept only while this browser holds a push subscription; without one the record is deleted. */
|
||||
async saveIfSubscribed(
|
||||
baseUrl: unknown,
|
||||
accessToken: unknown,
|
||||
subscribed: () => Promise<boolean>
|
||||
): Promise<void> {
|
||||
const keep = await subscribed().catch(() => false);
|
||||
await this.save(keep ? baseUrl : undefined, keep ? accessToken : undefined);
|
||||
},
|
||||
async load(): Promise<SessionRecord | undefined> {
|
||||
const v = (await port.read()) as Partial<SessionRecord> | undefined;
|
||||
return v && typeof v.baseUrl === 'string' && typeof v.accessToken === 'string'
|
||||
? { baseUrl: v.baseUrl, accessToken: v.accessToken }
|
||||
: undefined;
|
||||
},
|
||||
};
|
||||
}
|
||||
41
src/app/features/push/testkit.ts
Normal file
41
src/app/features/push/testkit.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import type { SessionRecord } from './sessionStore';
|
||||
|
||||
/** A mock homeserver: answers the two reads the push handler makes, and records every request it saw. */
|
||||
export function mockHomeserver(opts: {
|
||||
token: string;
|
||||
events?: Record<string, unknown>;
|
||||
roomNames?: Record<string, string>;
|
||||
failAll?: boolean;
|
||||
}) {
|
||||
const seen: { url: string; auth: string | null }[] = [];
|
||||
const fetchFn = async (url: string, init: RequestInit): Promise<Response> => {
|
||||
const auth = new Headers(init.headers).get('Authorization');
|
||||
seen.push({ url, auth });
|
||||
if (opts.failAll) throw new Error('network down');
|
||||
if (auth !== `Bearer ${opts.token}`) return new Response('{}', { status: 401 });
|
||||
const ev = /\/rooms\/([^/]+)\/event\/([^/]+)$/.exec(url);
|
||||
if (ev) {
|
||||
const e = opts.events?.[decodeURIComponent(ev[2])];
|
||||
return e ? new Response(JSON.stringify(e)) : new Response('{}', { status: 404 });
|
||||
}
|
||||
const nm = /\/rooms\/([^/]+)\/state\/m\.room\.name$/.exec(url);
|
||||
if (nm) {
|
||||
const name = opts.roomNames?.[decodeURIComponent(nm[1])];
|
||||
return name ? new Response(JSON.stringify({ name })) : new Response('{}', { status: 404 });
|
||||
}
|
||||
return new Response('{}', { status: 404 });
|
||||
};
|
||||
return { fetchFn, seen };
|
||||
}
|
||||
|
||||
export const SESSION: SessionRecord = { baseUrl: 'https://matrix.example/', accessToken: 'tok-1' };
|
||||
|
||||
/** The body a homeserver posts to a push gateway for `format: event_id_only` (Matrix push gateway API). */
|
||||
export const matrixNotify = (roomId: string, eventId: string) => ({
|
||||
notification: {
|
||||
event_id: eventId,
|
||||
room_id: roomId,
|
||||
counts: { unread: 1 },
|
||||
devices: [{ app_id: 'org.gamecult.ratatoskr.web', pushkey: 'k', data: {} }],
|
||||
},
|
||||
});
|
||||
33
src/app/features/push/usePushOpen.ts
Normal file
33
src/app/features/push/usePushOpen.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { useEffect } from 'react';
|
||||
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||
import { OPEN_ROOM, PUSH_EVENT_PARAM, PUSH_ROOM_PARAM } from './worker';
|
||||
|
||||
/** Opens the room a push notification named: from the worker's message (app open) or the start URL (app was closed). */
|
||||
export function usePushOpen(): void {
|
||||
const { navigateRoom } = useRoomNavigate();
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const roomId = params.get(PUSH_ROOM_PARAM);
|
||||
if (roomId) {
|
||||
navigateRoom(roomId, params.get(PUSH_EVENT_PARAM) ?? undefined);
|
||||
params.delete(PUSH_ROOM_PARAM);
|
||||
params.delete(PUSH_EVENT_PARAM);
|
||||
const rest = params.toString();
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
`${window.location.pathname}${rest ? `?${rest}` : ''}${window.location.hash}`
|
||||
);
|
||||
}
|
||||
const onMessage = (ev: MessageEvent) => {
|
||||
if (ev.data?.type === OPEN_ROOM && typeof ev.data.roomId === 'string') {
|
||||
navigateRoom(
|
||||
ev.data.roomId,
|
||||
typeof ev.data.eventId === 'string' ? ev.data.eventId : undefined
|
||||
);
|
||||
}
|
||||
};
|
||||
navigator.serviceWorker?.addEventListener('message', onMessage);
|
||||
return () => navigator.serviceWorker?.removeEventListener('message', onMessage);
|
||||
}, [navigateRoom]);
|
||||
}
|
||||
108
src/app/features/push/worker.test.ts
Normal file
108
src/app/features/push/worker.test.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { handleClick, handlePush, OPEN_ROOM, WorkerPort } from './worker';
|
||||
import { matrixNotify, mockHomeserver, SESSION } from './testkit';
|
||||
|
||||
type Shown = { title: string; options: NotificationOptions & { renotify?: boolean } };
|
||||
|
||||
function port(over: Partial<WorkerPort> = {}) {
|
||||
const shown: Shown[] = [];
|
||||
const opened: string[] = [];
|
||||
const w: WorkerPort = {
|
||||
windows: async () => [],
|
||||
openWindow: async (u) => {
|
||||
opened.push(u);
|
||||
},
|
||||
show: async (title, options) => {
|
||||
shown.push({ title, options });
|
||||
},
|
||||
loadSession: async () => SESSION,
|
||||
fetch: mockHomeserver({ token: 'tok-1' }).fetchFn,
|
||||
scope: 'https://chat.example/',
|
||||
...over,
|
||||
};
|
||||
return { w, shown, opened };
|
||||
}
|
||||
|
||||
const msg = {
|
||||
type: 'm.room.message',
|
||||
sender: '@ann:x',
|
||||
content: { msgtype: 'm.text', body: 'hello' },
|
||||
};
|
||||
|
||||
describe('push against a mock gateway and homeserver', () => {
|
||||
it('a push the homeserver sent for event_id_only becomes a notification naming room, sender and text', async () => {
|
||||
const hs = mockHomeserver({
|
||||
token: 'tok-1',
|
||||
events: { $e: msg },
|
||||
roomNames: { '!r:x': 'Lobby' },
|
||||
});
|
||||
const { w, shown } = port({ fetch: hs.fetchFn });
|
||||
await handlePush(matrixNotify('!r:x', '$e'), w);
|
||||
expect(shown).toHaveLength(1);
|
||||
expect(shown[0].title).toBe('Lobby');
|
||||
expect(shown[0].options.body).toBe('ann: hello');
|
||||
expect(shown[0].options.tag).toBe('!r:x');
|
||||
expect(shown[0].options.renotify).toBe(true);
|
||||
expect(shown[0].options.data).toEqual({ roomId: '!r:x', eventId: '$e' });
|
||||
});
|
||||
it('with no stored session it still shows (a push must always show) and sends the token nowhere', async () => {
|
||||
const hs = mockHomeserver({ token: 'tok-1', events: { $e: msg } });
|
||||
const { w, shown } = port({ fetch: hs.fetchFn, loadSession: async () => undefined });
|
||||
await handlePush(matrixNotify('!r:x', '$e'), w);
|
||||
expect(shown[0]).toMatchObject({ title: 'Ratatoskr', options: { body: 'New message' } });
|
||||
expect(hs.seen).toHaveLength(0);
|
||||
});
|
||||
it('shows the generic notification for a body it cannot read', async () => {
|
||||
const { w, shown } = port();
|
||||
await handlePush(undefined, w);
|
||||
expect(shown[0].title).toBe('Ratatoskr');
|
||||
expect(shown[0].options.tag).toBeUndefined();
|
||||
expect(shown[0].options.data).toBeUndefined();
|
||||
});
|
||||
it('shows even when the window list cannot be read', async () => {
|
||||
const { w, shown } = port({ windows: () => Promise.reject(new Error('no clients')) });
|
||||
await handlePush(matrixNotify('!r:x', '$e'), w);
|
||||
expect(shown).toHaveLength(1);
|
||||
});
|
||||
it('shows nothing while a window is visible and focused, and shows when the window is hidden', async () => {
|
||||
const win = (visibilityState: string, focused: boolean) => ({
|
||||
visibilityState,
|
||||
focused,
|
||||
focus: async () => undefined,
|
||||
postMessage: () => undefined,
|
||||
});
|
||||
const a = port({ windows: async () => [win('visible', true)] });
|
||||
await handlePush(matrixNotify('!r:x', '$e'), a.w);
|
||||
expect(a.shown).toHaveLength(0);
|
||||
const b = port({ windows: async () => [win('hidden', false), win('visible', false)] });
|
||||
await handlePush(matrixNotify('!r:x', '$e'), b.w);
|
||||
expect(b.shown).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('notification click', () => {
|
||||
it('focuses the open window and tells it the room', async () => {
|
||||
const focus = vi.fn(async () => undefined);
|
||||
const postMessage = vi.fn();
|
||||
const { w, opened } = port({
|
||||
windows: async () => [{ visibilityState: 'hidden', focused: false, focus, postMessage }],
|
||||
});
|
||||
await handleClick({ roomId: '!r:x', eventId: '$e' }, w);
|
||||
expect(focus).toHaveBeenCalled();
|
||||
expect(postMessage).toHaveBeenCalledWith({ type: OPEN_ROOM, roomId: '!r:x', eventId: '$e' });
|
||||
expect(opened).toHaveLength(0);
|
||||
});
|
||||
it('with no window, starts the app with the room in the URL', async () => {
|
||||
const { w, opened } = port();
|
||||
await handleClick({ roomId: '!r:x', eventId: '$e' }, w);
|
||||
const u = new URL(opened[0]);
|
||||
expect(u.origin + u.pathname).toBe('https://chat.example/');
|
||||
expect(u.searchParams.get('pushRoom')).toBe('!r:x');
|
||||
expect(u.searchParams.get('pushEvent')).toBe('$e');
|
||||
});
|
||||
it('with no window and no target, just opens the app', async () => {
|
||||
const { w, opened } = port();
|
||||
await handleClick(undefined, w);
|
||||
expect(opened).toEqual(['https://chat.example/']);
|
||||
});
|
||||
});
|
||||
62
src/app/features/push/worker.ts
Normal file
62
src/app/features/push/worker.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { describePush, parsePush } from './payload';
|
||||
import type { SessionRecord } from './sessionStore';
|
||||
|
||||
/** The slice of the service worker's scope the handlers use, so they run in tests without a worker. */
|
||||
export interface WorkerPort {
|
||||
windows(): Promise<
|
||||
{
|
||||
visibilityState: string;
|
||||
focused: boolean;
|
||||
focus(): Promise<unknown>;
|
||||
postMessage(m: unknown): void;
|
||||
}[]
|
||||
>;
|
||||
openWindow(url: string): Promise<unknown>;
|
||||
show(title: string, options: NotificationOptions): Promise<void>;
|
||||
loadSession(): Promise<SessionRecord | undefined>;
|
||||
fetch(url: string, init: RequestInit): Promise<Response>;
|
||||
/** The worker's scope URL: where a cold click opens the app. */
|
||||
scope: string;
|
||||
}
|
||||
|
||||
export interface PushTarget {
|
||||
roomId: string;
|
||||
eventId?: string;
|
||||
}
|
||||
|
||||
/** A push shows a notification, unless the app is open and focused (browsers allow that, and the page notifies itself). */
|
||||
export async function handlePush(raw: unknown, w: WorkerPort): Promise<void> {
|
||||
const ids = parsePush(raw);
|
||||
const open = await w.windows().catch(() => []);
|
||||
if (open.some((c) => c.visibilityState === 'visible' && c.focused)) return;
|
||||
const shown = await describePush(ids, await w.loadSession(), w.fetch);
|
||||
await w.show(shown.title, {
|
||||
body: shown.body,
|
||||
// One notification per room, replaced (and re-alerted) by the next message there.
|
||||
tag: ids?.roomId,
|
||||
renotify: !!ids?.roomId,
|
||||
icon: './public/android/android-chrome-192x192.png',
|
||||
data: ids ? ({ roomId: ids.roomId, eventId: ids.eventId } satisfies PushTarget) : undefined,
|
||||
} as NotificationOptions);
|
||||
}
|
||||
|
||||
export const OPEN_ROOM = 'openRoom';
|
||||
export const PUSH_ROOM_PARAM = 'pushRoom';
|
||||
export const PUSH_EVENT_PARAM = 'pushEvent';
|
||||
|
||||
/** A click focuses an open window and tells it which room; with none open it starts the app with the room in the URL. */
|
||||
export async function handleClick(target: PushTarget | undefined, w: WorkerPort): Promise<void> {
|
||||
const windows = await w.windows();
|
||||
const open = windows[0];
|
||||
if (open) {
|
||||
await open.focus();
|
||||
if (target?.roomId) open.postMessage({ type: OPEN_ROOM, ...target });
|
||||
return;
|
||||
}
|
||||
const url = new URL(w.scope);
|
||||
if (target?.roomId) {
|
||||
url.searchParams.set(PUSH_ROOM_PARAM, target.roomId);
|
||||
if (target.eventId) url.searchParams.set(PUSH_EVENT_PARAM, target.eventId);
|
||||
}
|
||||
await w.openWindow(url.href);
|
||||
}
|
||||
|
|
@ -10,6 +10,9 @@ import { getNotificationState, usePermissionState } from '../../../hooks/usePerm
|
|||
import { useEmailNotifications } from '../../../hooks/useEmailNotifications';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { PushNotification, pushSupported } from '../../push/PushNotification';
|
||||
import { activePushConfig } from '../../push/config';
|
||||
import { useClientConfig } from '../../../hooks/useClientConfig';
|
||||
|
||||
function EmailNotification() {
|
||||
const mx = useMatrixClient();
|
||||
|
|
@ -85,6 +88,7 @@ function EmailNotification() {
|
|||
}
|
||||
|
||||
export function SystemNotification() {
|
||||
const pushCfg = activePushConfig(useClientConfig().push);
|
||||
const notifPermission = usePermissionState('notifications', getNotificationState());
|
||||
const [showNotifications, setShowNotifications] = useSetting(settingsAtom, 'showNotifications');
|
||||
const [isNotificationSounds, setIsNotificationSounds] = useSetting(
|
||||
|
|
@ -133,6 +137,16 @@ export function SystemNotification() {
|
|||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
{pushCfg && pushSupported() && (
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<PushNotification cfg={pushCfg} />
|
||||
</SequenceCard>
|
||||
)}
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { createContext, useContext } from 'react';
|
||||
import type { DingsConfig } from '../features/dings/dings';
|
||||
import type { BackgroundSceneConfig } from '../features/scene/tier';
|
||||
import type { PushConfig } from '../features/push/config';
|
||||
|
||||
export type HashRouterConfig = {
|
||||
enabled?: boolean;
|
||||
|
|
@ -41,6 +42,9 @@ export type ClientConfig = {
|
|||
// service's own registration page (a value, never a route this client invents).
|
||||
registration?: RegistrationConfig;
|
||||
|
||||
// Web Push to the installed app (src/app/features/push). Off unless all three keys are filled; ships empty.
|
||||
push?: PushConfig;
|
||||
|
||||
// Dev preview only: the build answers a pretend homeserver in-page (src/preview-demo).
|
||||
previewDemo?: boolean;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
|||
import { mDirectAtom } from '../../state/mDirectList';
|
||||
import { motifFor } from '../../features/dings/dings';
|
||||
import { useDings } from '../../features/dings/useDings';
|
||||
import { usePushOpen } from '../../features/push/usePushOpen';
|
||||
|
||||
function SystemEmojiFeature() {
|
||||
const [twitterEmoji] = useSetting(settingsAtom, 'twitterEmoji');
|
||||
|
|
@ -280,6 +281,7 @@ type ClientNonUIFeaturesProps = {
|
|||
};
|
||||
|
||||
export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
|
||||
usePushOpen();
|
||||
return (
|
||||
<>
|
||||
<SystemEmojiFeature />
|
||||
|
|
|
|||
39
src/sw.ts
39
src/sw.ts
|
|
@ -1,8 +1,14 @@
|
|||
/// <reference lib="WebWorker" />
|
||||
|
||||
import { handleClick, handlePush, PushTarget, WorkerPort } from './app/features/push/worker';
|
||||
import { idbPort, sessionStore } from './app/features/push/sessionStore';
|
||||
|
||||
export type {};
|
||||
declare const self: ServiceWorkerGlobalScope;
|
||||
|
||||
// What a push can do with no tab open: the session is kept in IndexedDB (sessionStore.ts), not only in `sessions` below.
|
||||
const persisted = sessionStore(idbPort(self.indexedDB));
|
||||
|
||||
type SessionInfo = {
|
||||
accessToken: string;
|
||||
baseUrl: string;
|
||||
|
|
@ -30,6 +36,13 @@ async function cleanupDeadClients() {
|
|||
}
|
||||
|
||||
function setSession(clientId: string, accessToken: any, baseUrl: any) {
|
||||
persisted
|
||||
.saveIfSubscribed(
|
||||
baseUrl,
|
||||
accessToken,
|
||||
async () => !!(await self.registration.pushManager.getSubscription())
|
||||
)
|
||||
.catch(() => undefined);
|
||||
if (typeof accessToken === 'string' && typeof baseUrl === 'string') {
|
||||
sessions.set(clientId, { accessToken, baseUrl });
|
||||
} else {
|
||||
|
|
@ -156,3 +169,29 @@ self.addEventListener('fetch', (event: FetchEvent) => {
|
|||
})
|
||||
);
|
||||
});
|
||||
|
||||
const workerPort: WorkerPort = {
|
||||
windows: async () => [
|
||||
...(await self.clients.matchAll({ type: 'window', includeUncontrolled: true })),
|
||||
],
|
||||
openWindow: (url) => self.clients.openWindow(url),
|
||||
show: (title, options) => self.registration.showNotification(title, options),
|
||||
loadSession: () => persisted.load().catch(() => undefined),
|
||||
fetch: (url, init) => fetch(url, init),
|
||||
scope: self.registration.scope,
|
||||
};
|
||||
|
||||
self.addEventListener('push', (event: PushEvent) => {
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = event.data?.json();
|
||||
} catch {
|
||||
raw = undefined;
|
||||
}
|
||||
event.waitUntil(handlePush(raw, workerPort));
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclick', (event: NotificationEvent) => {
|
||||
event.notification.close();
|
||||
event.waitUntil(handleClick(event.notification.data as PushTarget | undefined, workerPort));
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue