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