atagen

malice

public
Star0
Watch0
850d5a52e9497c4bff4a5ca7532b304405795f36
2
ssh://brewery@pls.lobotomise.me:33/atagen/malice.git
web/src/lib/emotes.tsTypescript83 lines3.2 KB
1
import type { EmojiEntry } from './emoji';
2
import type { ImagePack } from './types';
3
4
export interface Emote {
5
  shortcode: string;
6
  mxc: string;
7
}
8
9
export type EmoteCandidate =
10
  | { kind: 'emoji'; emoji: string; shortcode: string }
11
  | { kind: 'custom'; shortcode: string; mxc: string };
12
13
export interface EmoteQuery {
14
  start: number;
15
  query: string;
16
}
17
18
const SHORTCODE = /^[\w+-]+$/;
19
20
export function emoteQueryAt(text: string, caret: number): EmoteQuery | null {
21
  const before = text.slice(0, caret);
22
  const colon = before.lastIndexOf(':');
23
  if (colon < 0 || (colon > 0 && !/\s/.test(before[colon - 1]))) return null;
24
  const query = before.slice(colon + 1);
25
  if (query.length < 2 || query.length > 40 || !SHORTCODE.test(query)) return null;
26
  return { start: colon, query };
27
}
28
29
export function customEmotes(packs: ImagePack[]): Emote[] {
30
  const seen = new Map<string, Emote>();
31
  for (const pack of packs) {
32
    for (const image of pack.images) {
33
      if (image.usage.length && !image.usage.includes('Emoticon')) continue;
34
      if (!seen.has(image.shortcode)) seen.set(image.shortcode, { shortcode: image.shortcode, mxc: image.media_id });
35
    }
36
  }
37
  return [...seen.values()];
38
}
39
40
function score(code: string, q: string): number {
41
  const c = code.toLowerCase();
42
  if (c === q) return 0;
43
  if (c.startsWith(q)) return 1;
44
  if (c.split(/[_-]/).some((part) => part.startsWith(q))) return 2;
45
  return c.includes(q) ? 3 : -1;
46
}
47
48
export function rankEmotes(query: string, customs: Emote[], emoji: EmojiEntry[], limit = 8): EmoteCandidate[] {
49
  const q = query.toLowerCase();
50
  const scored: { candidate: EmoteCandidate; score: number; length: number }[] = [];
51
  for (const emote of customs) {
52
    const s = score(emote.shortcode, q);
53
    if (s >= 0) scored.push({ candidate: { kind: 'custom', ...emote }, score: s, length: emote.shortcode.length });
54
  }
55
  for (const entry of emoji) {
56
    let best: { code: string; s: number } | null = null;
57
    for (const code of entry.shortcodes) {
58
      const s = score(code, q);
59
      if (s >= 0 && (!best || s < best.s || (s === best.s && code.length < best.code.length))) best = { code, s };
60
    }
61
    if (best) scored.push({ candidate: { kind: 'emoji', emoji: entry.emoji, shortcode: best.code }, score: best.s, length: best.code.length });
62
  }
63
  return scored
64
    .sort((a, b) => a.score - b.score || a.length - b.length)
65
    .slice(0, limit)
66
    .map((s) => s.candidate);
67
}
68
69
/** When the caret sits just after a closing colon of a known emoji shortcode, swap the shortcode for the emoji. */
70
export function completeShortcode(text: string, caret: number, emoji: EmojiEntry[]): { text: string; caret: number } | null {
71
  const match = /(^|\s):([\w+-]+):$/.exec(text.slice(0, caret));
72
  if (!match) return null;
73
  const code = match[2].toLowerCase();
74
  const entry = emoji.find((e) => e.shortcodes.includes(code));
75
  if (!entry) return null;
76
  const start = caret - code.length - 2;
77
  return { text: text.slice(0, start) + entry.emoji + text.slice(caret), caret: start + entry.emoji.length };
78
}
79
80
export function resolveEmotes(body: string, customs: Emote[]): Emote[] {
81
  const used = new Set([...body.matchAll(/:([\w+-]+):/g)].map((m) => m[1]));
82
  return customs.filter((e) => used.has(e.shortcode));
83
}