| 1 | export const DEVICE_PUSH_APP_ID = 'dev.malice.device'; |
| 2 | export const PUSH_CHANNEL = 'malice-push'; |
| 3 | export const PUSH_CHANGED_MESSAGE = 'malice-push-changed'; |
| 4 | export const PUSH_NOTIFY_PATH = '/_matrix/push/v1/notify'; |
| 5 | |
| 6 | export interface PushDisplay { |
| 7 | title: string; |
| 8 | body: string; |
| 9 | tag: string; |
| 10 | navigate: string | null; |
| 11 | silent: boolean; |
| 12 | badge: number | null; |
| 13 | roomId: string | null; |
| 14 | eventId: string | null; |
| 15 | mention: boolean; |
| 16 | encrypted: boolean; |
| 17 | } |
| 18 | |
| 19 | export interface ResolvedPush { |
| 20 | title: string; |
| 21 | body: string; |
| 22 | } |
| 23 | |
| 24 | export interface PushSubscriptionKeys { |
| 25 | endpoint: string; |
| 26 | p256dh: string; |
| 27 | auth: string; |
| 28 | } |
| 29 | |
| 30 | const FALLBACK_TITLE = 'New message'; |
| 31 | const FALLBACK_TAG = 'malice'; |
| 32 | |
| 33 | function record(value: unknown): Record<string, unknown> { |
| 34 | return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : {}; |
| 35 | } |
| 36 | |
| 37 | function text(value: unknown): string | null { |
| 38 | return typeof value === 'string' && value.length > 0 ? value : null; |
| 39 | } |
| 40 | |
| 41 | function count(value: unknown): number | null { |
| 42 | const n = typeof value === 'string' ? Number(value) : value; |
| 43 | return typeof n === 'number' && Number.isInteger(n) && n >= 0 ? n : null; |
| 44 | } |
| 45 | |
| 46 | function sameOrigin(url: string | null, origin: string): string | null { |
| 47 | if (!url) return null; |
| 48 | try { |
| 49 | const parsed = new URL(url, origin); |
| 50 | return parsed.origin === origin ? parsed.href : null; |
| 51 | } catch { |
| 52 | return null; |
| 53 | } |
| 54 | } |
| 55 | |
| 56 | export function roomFromTag(tag: string | null | undefined): string | null { |
| 57 | return tag?.startsWith('room:') && tag.length > 5 ? tag.slice(5) : null; |
| 58 | } |
| 59 | |
| 60 | /** Never throws, so the push handler can always show something. */ |
| 61 | export function parsePush(raw: unknown, origin: string): PushDisplay { |
| 62 | let value = raw; |
| 63 | if (typeof raw === 'string') { |
| 64 | try { |
| 65 | value = JSON.parse(raw); |
| 66 | } catch { |
| 67 | value = null; |
| 68 | } |
| 69 | } |
| 70 | const payload = record(value); |
| 71 | const note = record(payload['notification']); |
| 72 | const malice = record(payload['malice']); |
| 73 | const roomId = text(malice['room_id']); |
| 74 | const tag = text(note['tag']) ?? (roomId ? `room:${roomId}` : FALLBACK_TAG); |
| 75 | const badge = count(note['app_badge']) ?? count(malice['unread']); |
| 76 | return { |
| 77 | title: text(note['title']) ?? FALLBACK_TITLE, |
| 78 | body: text(note['body']) ?? '', |
| 79 | tag, |
| 80 | navigate: sameOrigin(text(note['navigate']), origin), |
| 81 | silent: note['silent'] === true, |
| 82 | badge, |
| 83 | roomId: roomId ?? roomFromTag(tag), |
| 84 | eventId: text(malice['event_id']), |
| 85 | mention: malice['mention'] === true, |
| 86 | encrypted: malice['encrypted'] === true |
| 87 | }; |
| 88 | } |
| 89 | |
| 90 | export function roomTarget(roomId: string | null, navigate: string | null): string { |
| 91 | if (navigate) return navigate; |
| 92 | return roomId ? `/?room=${encodeURIComponent(roomId)}` : '/'; |
| 93 | } |
| 94 | |
| 95 | /** A visible focused window is already showing the app, so the banner stays but without sound or vibration. */ |
| 96 | export function shouldSilence(display: PushDisplay, windowFocused: boolean): boolean { |
| 97 | return display.silent || windowFocused; |
| 98 | } |
| 99 | |
| 100 | /** The decrypted reply replaces the tier 1 notification only when it adds something. */ |
| 101 | export function parseResolved(value: unknown, current: PushDisplay): ResolvedPush | null { |
| 102 | const reply = record(value); |
| 103 | const title = text(reply['title']); |
| 104 | const body = text(reply['body']); |
| 105 | if (!title && !body) return null; |
| 106 | const next = { title: title ?? current.title, body: body ?? current.body }; |
| 107 | return next.title === current.title && next.body === current.body ? null : next; |
| 108 | } |
| 109 | |
| 110 | export function urlBase64ToBytes(value: string): Uint8Array<ArrayBuffer> { |
| 111 | const padded = value.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(value.length / 4) * 4, '='); |
| 112 | const raw = atob(padded); |
| 113 | const bytes = new Uint8Array(raw.length); |
| 114 | for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i); |
| 115 | return bytes; |
| 116 | } |
| 117 | |
| 118 | export function bytesToUrlBase64(bytes: Uint8Array): string { |
| 119 | let raw = ''; |
| 120 | for (const byte of bytes) raw += String.fromCharCode(byte); |
| 121 | return btoa(raw).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); |
| 122 | } |
| 123 | |
| 124 | export function subscriptionKeys(sub: { endpoint: string; getKey(name: 'p256dh' | 'auth'): ArrayBuffer | null }): PushSubscriptionKeys | null { |
| 125 | const p256dh = sub.getKey('p256dh'); |
| 126 | const auth = sub.getKey('auth'); |
| 127 | if (!p256dh || !auth) return null; |
| 128 | return { endpoint: sub.endpoint, p256dh: bytesToUrlBase64(new Uint8Array(p256dh)), auth: bytesToUrlBase64(new Uint8Array(auth)) }; |
| 129 | } |
| 130 | |
| 131 | export interface StoredPush { |
| 132 | endpoint: string; |
| 133 | pushkey: string | null; |
| 134 | } |
| 135 | |
| 136 | export type SyncAction = 'none' | 'subscribe' | 'register'; |
| 137 | |
| 138 | /** What a launch check must repair, given whether the user wants push, the live subscription and what was last registered. */ |
| 139 | export function syncAction(wanted: boolean, live: string | null, stored: StoredPush | null): SyncAction { |
| 140 | if (!wanted) return 'none'; |
| 141 | if (!live) return 'subscribe'; |
| 142 | return stored?.endpoint === live ? 'none' : 'register'; |
| 143 | } |
| 144 | |
| 145 | export function parseStoredPush(raw: string | null): StoredPush | null { |
| 146 | if (!raw) return null; |
| 147 | try { |
| 148 | const value = record(JSON.parse(raw)); |
| 149 | const endpoint = text(value['endpoint']); |
| 150 | return endpoint ? { endpoint, pushkey: text(value['pushkey']) } : null; |
| 151 | } catch { |
| 152 | return null; |
| 153 | } |
| 154 | } |
| 155 | |
| 156 | export function devicePusherRequest(keys: PushSubscriptionKeys, origin: string, lang: string, browser: string) { |
| 157 | return { |
| 158 | op: 'set_pusher' as const, |
| 159 | app_id: DEVICE_PUSH_APP_ID, |
| 160 | pushkey: keys.p256dh, |
| 161 | app_display_name: 'Malice', |
| 162 | device_display_name: 'Malice web', |
| 163 | lang, |
| 164 | url: `${origin}${PUSH_NOTIFY_PATH}`, |
| 165 | format: 'Full' as const, |
| 166 | data: { endpoint: keys.endpoint, auth: keys.auth, browser } |
| 167 | }; |
| 168 | } |
| 169 | |
| 170 | /** Pushers this browser registered under an endpoint it no longer holds. Other browsers on the account share the app id, so only its own id matches. */ |
| 171 | export function stalePushers(reply: unknown, browser: string, endpoint: string): string[] { |
| 172 | const list = record(reply)['pushers']; |
| 173 | if (!Array.isArray(list)) return []; |
| 174 | const stale: string[] = []; |
| 175 | for (const entry of list) { |
| 176 | const pusher = record(entry); |
| 177 | const data = record(pusher['data']); |
| 178 | const pushkey = text(pusher['pushkey']); |
| 179 | if (pusher['app_id'] === DEVICE_PUSH_APP_ID && data['browser'] === browser && data['endpoint'] !== endpoint && pushkey) stale.push(pushkey); |
| 180 | } |
| 181 | return stale; |
| 182 | } |