atagen

malice

public
Star0
Watch0
976126a62a3c99e6b4bf5c28590eb74706059dc3
2
ssh://brewery@pls.lobotomise.me:33/atagen/malice.git
web/src/lib/link-previews.tsTypescript78 lines2.8 KB
1
import { get, writable } from 'svelte/store';
2
import { ApiError, getAccountData, getUrlPreview, setAccountData } from './api';
3
import { parseLinkPreview } from './types';
4
import type { LinkPreview } from './types';
5
6
const EVENT_TYPE = 'dev.malice.url_previews';
7
const BACKOFF_MS = 30_000;
8
const BATCH_MS = 150;
9
10
export interface UrlPreviewSettings {
11
  encrypted_rooms: boolean;
12
}
13
14
const DEFAULT_SETTINGS: UrlPreviewSettings = { encrypted_rooms: false };
15
16
export const linkPreviews = writable<Record<string, LinkPreview | null>>({});
17
export const urlPreviewSettings = writable<UrlPreviewSettings>(DEFAULT_SETTINGS);
18
19
const inFlight = new Set<string>();
20
const landed: Record<string, LinkPreview | null> = {};
21
let flushTimer: ReturnType<typeof setTimeout> | null = null;
22
23
/** Previews resolve one by one after a room opens, and each one landing on its own shifted the timeline again. */
24
function land(url: string, preview: LinkPreview | null): void {
25
  landed[url] = preview;
26
  flushTimer ??= setTimeout(() => {
27
    flushTimer = null;
28
    const batch = { ...landed };
29
    for (const key of Object.keys(landed)) delete landed[key];
30
    linkPreviews.update((all) => ({ ...all, ...batch }));
31
  }, BATCH_MS);
32
}
33
let backoffUntil = 0;
34
let refusalLogged = false;
35
36
export function requestLinkPreview(roomId: string, url: string): void {
37
  if (inFlight.has(url) || url in landed || url in get(linkPreviews) || Date.now() < backoffUntil) return;
38
  inFlight.add(url);
39
  getUrlPreview(roomId, url)
40
    .then((raw) => land(url, parseLinkPreview(raw)))
41
    .catch((e) => {
42
      if (e instanceof ApiError && e.status === 429) backoffUntil = Date.now() + BACKOFF_MS;
43
      else if (e instanceof ApiError && e.status === 403) {
44
        land(url, null);
45
        if (!refusalLogged) console.warn('The homeserver refused a link preview', e.message);
46
        refusalLogged = true;
47
      }
48
    })
49
    .finally(() => inFlight.delete(url));
50
}
51
52
function parseSettings(raw: unknown): UrlPreviewSettings {
53
  if (typeof raw !== 'object' || raw === null) return DEFAULT_SETTINGS;
54
  return { encrypted_rooms: (raw as Record<string, unknown>)['encrypted_rooms'] === true };
55
}
56
57
export async function loadUrlPreviewSettings(): Promise<void> {
58
  try {
59
    urlPreviewSettings.set(parseSettings(await getAccountData(EVENT_TYPE)));
60
  } catch {
61
    urlPreviewSettings.set(DEFAULT_SETTINGS);
62
  }
63
}
64
65
export async function saveUrlPreviewSettings(settings: UrlPreviewSettings): Promise<void> {
66
  await setAccountData(EVENT_TYPE, { encrypted_rooms: settings.encrypted_rooms });
67
  urlPreviewSettings.set(settings);
68
}
69
70
export function resetLinkPreviews(): void {
71
  linkPreviews.set({});
72
  urlPreviewSettings.set(DEFAULT_SETTINGS);
73
  inFlight.clear();
74
  if (flushTimer) clearTimeout(flushTimer);
75
  flushTimer = null;
76
  for (const key of Object.keys(landed)) delete landed[key];
77
  backoffUntil = 0;
78
}