atagen

malice

public
Star0
Watch0
38a9760b78e47d373dfb35d3882a586dc9587211
2
ssh://brewery@pls.lobotomise.me:33/atagen/malice.git
web/src/lib/push-model.tsTypescript182 lines6.2 KB
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
}