atagen

malice

public
Star0
Watch0
984f84f9bf6c54c04625b38858111a7760036f51
2
ssh://brewery@pls.lobotomise.me:33/atagen/malice.git

Blame: web/src/lib/push-model.ts

235ce394f90datagen2026-10-26 06:54 UTC1
export const DEVICE_PUSH_APP_ID = 'dev.malice.device';
235ce394f90datagen2026-10-26 06:54 UTC2
export const PUSH_CHANNEL = 'malice-push';
235ce394f90datagen2026-10-26 06:54 UTC3
export const PUSH_CHANGED_MESSAGE = 'malice-push-changed';
235ce394f90datagen2026-10-26 06:54 UTC4
export const PUSH_NOTIFY_PATH = '/_matrix/push/v1/notify';
235ce394f90datagen2026-10-26 06:54 UTC5
235ce394f90datagen2026-10-26 06:54 UTC6
export interface PushDisplay {
235ce394f90datagen2026-10-26 06:54 UTC7
  title: string;
235ce394f90datagen2026-10-26 06:54 UTC8
  body: string;
235ce394f90datagen2026-10-26 06:54 UTC9
  tag: string;
235ce394f90datagen2026-10-26 06:54 UTC10
  navigate: string | null;
235ce394f90datagen2026-10-26 06:54 UTC11
  silent: boolean;
235ce394f90datagen2026-10-26 06:54 UTC12
  badge: number | null;
235ce394f90datagen2026-10-26 06:54 UTC13
  roomId: string | null;
235ce394f90datagen2026-10-26 06:54 UTC14
  eventId: string | null;
235ce394f90datagen2026-10-26 06:54 UTC15
  mention: boolean;
235ce394f90datagen2026-10-26 06:54 UTC16
  encrypted: boolean;
235ce394f90datagen2026-10-26 06:54 UTC17
}
235ce394f90datagen2026-10-26 06:54 UTC18
235ce394f90datagen2026-10-26 06:54 UTC19
export interface ResolvedPush {
235ce394f90datagen2026-10-26 06:54 UTC20
  title: string;
235ce394f90datagen2026-10-26 06:54 UTC21
  body: string;
235ce394f90datagen2026-10-26 06:54 UTC22
}
235ce394f90datagen2026-10-26 06:54 UTC23
235ce394f90datagen2026-10-26 06:54 UTC24
export interface PushSubscriptionKeys {
235ce394f90datagen2026-10-26 06:54 UTC25
  endpoint: string;
235ce394f90datagen2026-10-26 06:54 UTC26
  p256dh: string;
235ce394f90datagen2026-10-26 06:54 UTC27
  auth: string;
235ce394f90datagen2026-10-26 06:54 UTC28
}
235ce394f90datagen2026-10-26 06:54 UTC29
235ce394f90datagen2026-10-26 06:54 UTC30
const FALLBACK_TITLE = 'New message';
235ce394f90datagen2026-10-26 06:54 UTC31
const FALLBACK_TAG = 'malice';
235ce394f90datagen2026-10-26 06:54 UTC32
235ce394f90datagen2026-10-26 06:54 UTC33
function record(value: unknown): Record<string, unknown> {
235ce394f90datagen2026-10-26 06:54 UTC34
  return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
235ce394f90datagen2026-10-26 06:54 UTC35
}
235ce394f90datagen2026-10-26 06:54 UTC36
235ce394f90datagen2026-10-26 06:54 UTC37
function text(value: unknown): string | null {
235ce394f90datagen2026-10-26 06:54 UTC38
  return typeof value === 'string' && value.length > 0 ? value : null;
235ce394f90datagen2026-10-26 06:54 UTC39
}
235ce394f90datagen2026-10-26 06:54 UTC40
235ce394f90datagen2026-10-26 06:54 UTC41
function count(value: unknown): number | null {
235ce394f90datagen2026-10-26 06:54 UTC42
  const n = typeof value === 'string' ? Number(value) : value;
235ce394f90datagen2026-10-26 06:54 UTC43
  return typeof n === 'number' && Number.isInteger(n) && n >= 0 ? n : null;
235ce394f90datagen2026-10-26 06:54 UTC44
}
235ce394f90datagen2026-10-26 06:54 UTC45
235ce394f90datagen2026-10-26 06:54 UTC46
function sameOrigin(url: string | null, origin: string): string | null {
235ce394f90datagen2026-10-26 06:54 UTC47
  if (!url) return null;
235ce394f90datagen2026-10-26 06:54 UTC48
  try {
235ce394f90datagen2026-10-26 06:54 UTC49
    const parsed = new URL(url, origin);
235ce394f90datagen2026-10-26 06:54 UTC50
    return parsed.origin === origin ? parsed.href : null;
235ce394f90datagen2026-10-26 06:54 UTC51
  } catch {
235ce394f90datagen2026-10-26 06:54 UTC52
    return null;
235ce394f90datagen2026-10-26 06:54 UTC53
  }
235ce394f90datagen2026-10-26 06:54 UTC54
}
235ce394f90datagen2026-10-26 06:54 UTC55
235ce394f90datagen2026-10-26 06:54 UTC56
export function roomFromTag(tag: string | null | undefined): string | null {
235ce394f90datagen2026-10-26 06:54 UTC57
  return tag?.startsWith('room:') && tag.length > 5 ? tag.slice(5) : null;
235ce394f90datagen2026-10-26 06:54 UTC58
}
235ce394f90datagen2026-10-26 06:54 UTC59
235ce394f90datagen2026-10-26 06:54 UTC60
/** Never throws, so the push handler can always show something. */
235ce394f90datagen2026-10-26 06:54 UTC61
export function parsePush(raw: unknown, origin: string): PushDisplay {
235ce394f90datagen2026-10-26 06:54 UTC62
  let value = raw;
235ce394f90datagen2026-10-26 06:54 UTC63
  if (typeof raw === 'string') {
235ce394f90datagen2026-10-26 06:54 UTC64
    try {
235ce394f90datagen2026-10-26 06:54 UTC65
      value = JSON.parse(raw);
235ce394f90datagen2026-10-26 06:54 UTC66
    } catch {
235ce394f90datagen2026-10-26 06:54 UTC67
      value = null;
235ce394f90datagen2026-10-26 06:54 UTC68
    }
235ce394f90datagen2026-10-26 06:54 UTC69
  }
235ce394f90datagen2026-10-26 06:54 UTC70
  const payload = record(value);
235ce394f90datagen2026-10-26 06:54 UTC71
  const note = record(payload['notification']);
235ce394f90datagen2026-10-26 06:54 UTC72
  const malice = record(payload['malice']);
235ce394f90datagen2026-10-26 06:54 UTC73
  const roomId = text(malice['room_id']);
235ce394f90datagen2026-10-26 06:54 UTC74
  const tag = text(note['tag']) ?? (roomId ? `room:${roomId}` : FALLBACK_TAG);
235ce394f90datagen2026-10-26 06:54 UTC75
  const badge = count(note['app_badge']) ?? count(malice['unread']);
235ce394f90datagen2026-10-26 06:54 UTC76
  return {
235ce394f90datagen2026-10-26 06:54 UTC77
    title: text(note['title']) ?? FALLBACK_TITLE,
235ce394f90datagen2026-10-26 06:54 UTC78
    body: text(note['body']) ?? '',
235ce394f90datagen2026-10-26 06:54 UTC79
    tag,
235ce394f90datagen2026-10-26 06:54 UTC80
    navigate: sameOrigin(text(note['navigate']), origin),
235ce394f90datagen2026-10-26 06:54 UTC81
    silent: note['silent'] === true,
235ce394f90datagen2026-10-26 06:54 UTC82
    badge,
235ce394f90datagen2026-10-26 06:54 UTC83
    roomId: roomId ?? roomFromTag(tag),
235ce394f90datagen2026-10-26 06:54 UTC84
    eventId: text(malice['event_id']),
235ce394f90datagen2026-10-26 06:54 UTC85
    mention: malice['mention'] === true,
235ce394f90datagen2026-10-26 06:54 UTC86
    encrypted: malice['encrypted'] === true
235ce394f90datagen2026-10-26 06:54 UTC87
  };
235ce394f90datagen2026-10-26 06:54 UTC88
}
235ce394f90datagen2026-10-26 06:54 UTC89
235ce394f90datagen2026-10-26 06:54 UTC90
export function roomTarget(roomId: string | null, navigate: string | null): string {
235ce394f90datagen2026-10-26 06:54 UTC91
  if (navigate) return navigate;
235ce394f90datagen2026-10-26 06:54 UTC92
  return roomId ? `/?room=${encodeURIComponent(roomId)}` : '/';
235ce394f90datagen2026-10-26 06:54 UTC93
}
235ce394f90datagen2026-10-26 06:54 UTC94
235ce394f90datagen2026-10-26 06:54 UTC95
/** A visible focused window is already showing the app, so the banner stays but without sound or vibration. */
235ce394f90datagen2026-10-26 06:54 UTC96
export function shouldSilence(display: PushDisplay, windowFocused: boolean): boolean {
235ce394f90datagen2026-10-26 06:54 UTC97
  return display.silent || windowFocused;
235ce394f90datagen2026-10-26 06:54 UTC98
}
235ce394f90datagen2026-10-26 06:54 UTC99
235ce394f90datagen2026-10-26 06:54 UTC100
/** The decrypted reply replaces the tier 1 notification only when it adds something. */
235ce394f90datagen2026-10-26 06:54 UTC101
export function parseResolved(value: unknown, current: PushDisplay): ResolvedPush | null {
235ce394f90datagen2026-10-26 06:54 UTC102
  const reply = record(value);
235ce394f90datagen2026-10-26 06:54 UTC103
  const title = text(reply['title']);
235ce394f90datagen2026-10-26 06:54 UTC104
  const body = text(reply['body']);
235ce394f90datagen2026-10-26 06:54 UTC105
  if (!title && !body) return null;
235ce394f90datagen2026-10-26 06:54 UTC106
  const next = { title: title ?? current.title, body: body ?? current.body };
235ce394f90datagen2026-10-26 06:54 UTC107
  return next.title === current.title && next.body === current.body ? null : next;
235ce394f90datagen2026-10-26 06:54 UTC108
}
235ce394f90datagen2026-10-26 06:54 UTC109
235ce394f90datagen2026-10-26 06:54 UTC110
export function urlBase64ToBytes(value: string): Uint8Array<ArrayBuffer> {
235ce394f90datagen2026-10-26 06:54 UTC111
  const padded = value.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(value.length / 4) * 4, '=');
235ce394f90datagen2026-10-26 06:54 UTC112
  const raw = atob(padded);
235ce394f90datagen2026-10-26 06:54 UTC113
  const bytes = new Uint8Array(raw.length);
235ce394f90datagen2026-10-26 06:54 UTC114
  for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i);
235ce394f90datagen2026-10-26 06:54 UTC115
  return bytes;
235ce394f90datagen2026-10-26 06:54 UTC116
}
235ce394f90datagen2026-10-26 06:54 UTC117
235ce394f90datagen2026-10-26 06:54 UTC118
export function bytesToUrlBase64(bytes: Uint8Array): string {
235ce394f90datagen2026-10-26 06:54 UTC119
  let raw = '';
235ce394f90datagen2026-10-26 06:54 UTC120
  for (const byte of bytes) raw += String.fromCharCode(byte);
235ce394f90datagen2026-10-26 06:54 UTC121
  return btoa(raw).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
235ce394f90datagen2026-10-26 06:54 UTC122
}
235ce394f90datagen2026-10-26 06:54 UTC123
235ce394f90datagen2026-10-26 06:54 UTC124
export function subscriptionKeys(sub: { endpoint: string; getKey(name: 'p256dh' | 'auth'): ArrayBuffer | null }): PushSubscriptionKeys | null {
235ce394f90datagen2026-10-26 06:54 UTC125
  const p256dh = sub.getKey('p256dh');
235ce394f90datagen2026-10-26 06:54 UTC126
  const auth = sub.getKey('auth');
235ce394f90datagen2026-10-26 06:54 UTC127
  if (!p256dh || !auth) return null;
235ce394f90datagen2026-10-26 06:54 UTC128
  return { endpoint: sub.endpoint, p256dh: bytesToUrlBase64(new Uint8Array(p256dh)), auth: bytesToUrlBase64(new Uint8Array(auth)) };
235ce394f90datagen2026-10-26 06:54 UTC129
}
235ce394f90datagen2026-10-26 06:54 UTC130
235ce394f90datagen2026-10-26 06:54 UTC131
export interface StoredPush {
235ce394f90datagen2026-10-26 06:54 UTC132
  endpoint: string;
235ce394f90datagen2026-10-26 06:54 UTC133
  pushkey: string | null;
235ce394f90datagen2026-10-26 06:54 UTC134
}
235ce394f90datagen2026-10-26 06:54 UTC135
235ce394f90datagen2026-10-26 06:54 UTC136
export type SyncAction = 'none' | 'subscribe' | 'register';
235ce394f90datagen2026-10-26 06:54 UTC137
235ce394f90datagen2026-10-26 06:54 UTC138
/** What a launch check must repair, given whether the user wants push, the live subscription and what was last registered. */
235ce394f90datagen2026-10-26 06:54 UTC139
export function syncAction(wanted: boolean, live: string | null, stored: StoredPush | null): SyncAction {
235ce394f90datagen2026-10-26 06:54 UTC140
  if (!wanted) return 'none';
235ce394f90datagen2026-10-26 06:54 UTC141
  if (!live) return 'subscribe';
235ce394f90datagen2026-10-26 06:54 UTC142
  return stored?.endpoint === live ? 'none' : 'register';
235ce394f90datagen2026-10-26 06:54 UTC143
}
235ce394f90datagen2026-10-26 06:54 UTC144
235ce394f90datagen2026-10-26 06:54 UTC145
export function parseStoredPush(raw: string | null): StoredPush | null {
235ce394f90datagen2026-10-26 06:54 UTC146
  if (!raw) return null;
235ce394f90datagen2026-10-26 06:54 UTC147
  try {
235ce394f90datagen2026-10-26 06:54 UTC148
    const value = record(JSON.parse(raw));
235ce394f90datagen2026-10-26 06:54 UTC149
    const endpoint = text(value['endpoint']);
235ce394f90datagen2026-10-26 06:54 UTC150
    return endpoint ? { endpoint, pushkey: text(value['pushkey']) } : null;
235ce394f90datagen2026-10-26 06:54 UTC151
  } catch {
235ce394f90datagen2026-10-26 06:54 UTC152
    return null;
235ce394f90datagen2026-10-26 06:54 UTC153
  }
235ce394f90datagen2026-10-26 06:54 UTC154
}
235ce394f90datagen2026-10-26 06:54 UTC155
235ce394f90datagen2026-10-26 06:54 UTC156
export function devicePusherRequest(keys: PushSubscriptionKeys, origin: string, lang: string, browser: string) {
235ce394f90datagen2026-10-26 06:54 UTC157
  return {
235ce394f90datagen2026-10-26 06:54 UTC158
    op: 'set_pusher' as const,
235ce394f90datagen2026-10-26 06:54 UTC159
    app_id: DEVICE_PUSH_APP_ID,
235ce394f90datagen2026-10-26 06:54 UTC160
    pushkey: keys.p256dh,
235ce394f90datagen2026-10-26 06:54 UTC161
    app_display_name: 'Malice',
235ce394f90datagen2026-10-26 06:54 UTC162
    device_display_name: 'Malice web',
235ce394f90datagen2026-10-26 06:54 UTC163
    lang,
235ce394f90datagen2026-10-26 06:54 UTC164
    url: `${origin}${PUSH_NOTIFY_PATH}`,
235ce394f90datagen2026-10-26 06:54 UTC165
    format: 'Full' as const,
235ce394f90datagen2026-10-26 06:54 UTC166
    data: { endpoint: keys.endpoint, auth: keys.auth, browser }
235ce394f90datagen2026-10-26 06:54 UTC167
  };
235ce394f90datagen2026-10-26 06:54 UTC168
}
235ce394f90datagen2026-10-26 06:54 UTC169
235ce394f90datagen2026-10-26 06:54 UTC170
/** 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. */
235ce394f90datagen2026-10-26 06:54 UTC171
export function stalePushers(reply: unknown, browser: string, endpoint: string): string[] {
235ce394f90datagen2026-10-26 06:54 UTC172
  const list = record(reply)['pushers'];
235ce394f90datagen2026-10-26 06:54 UTC173
  if (!Array.isArray(list)) return [];
235ce394f90datagen2026-10-26 06:54 UTC174
  const stale: string[] = [];
235ce394f90datagen2026-10-26 06:54 UTC175
  for (const entry of list) {
235ce394f90datagen2026-10-26 06:54 UTC176
    const pusher = record(entry);
235ce394f90datagen2026-10-26 06:54 UTC177
    const data = record(pusher['data']);
235ce394f90datagen2026-10-26 06:54 UTC178
    const pushkey = text(pusher['pushkey']);
235ce394f90datagen2026-10-26 06:54 UTC179
    if (pusher['app_id'] === DEVICE_PUSH_APP_ID && data['browser'] === browser && data['endpoint'] !== endpoint && pushkey) stale.push(pushkey);
235ce394f90datagen2026-10-26 06:54 UTC180
  }
235ce394f90datagen2026-10-26 06:54 UTC181
  return stale;
235ce394f90datagen2026-10-26 06:54 UTC182
}