atagen

malice

public
Star0
Watch0
c04b1e46303fc98620efa4a4e18798510df98208
2
ssh://brewery@pls.lobotomise.me:33/atagen/malice.git
web/src/lib/mentions.tsTypescript96 lines3.4 KB
1
import type { Person } from './types';
2
3
export interface Mention {
4
  user_id: string;
5
  text: string;
6
}
7
8
export interface MentionQuery {
9
  start: number;
10
  query: string;
11
}
12
13
const MXID = /@[a-z0-9._=\-/+]+:[a-zA-Z0-9.\-]+(?::\d+)?/g;
14
const MAX_CANDIDATES = 8;
15
16
/** The `@word` the caret sits in, when it starts at a word boundary. */
17
export function mentionQueryAt(text: string, caret: number): MentionQuery | null {
18
  const before = text.slice(0, caret);
19
  const at = before.lastIndexOf('@');
20
  if (at < 0) return null;
21
  if (at > 0 && !/\s/.test(before[at - 1])) return null;
22
  const query = before.slice(at + 1);
23
  if (query.length > 64 || /\s/.test(query)) return null;
24
  return { start: at, query };
25
}
26
27
export function rankPeople(people: Person[], query: string, exclude: string | null): Person[] {
28
  const q = query.toLowerCase();
29
  const scored: { person: Person; score: number }[] = [];
30
  for (const person of people) {
31
    if (person.user_id === exclude) continue;
32
    const name = person.name.toLowerCase();
33
    const id = person.user_id.toLowerCase();
34
    let score: number;
35
    if (!q) score = 3;
36
    else if (name.startsWith(q) || id.startsWith(`@${q}`) || id.startsWith(q)) score = 0;
37
    else if (name.split(/\s+/).some((word) => word.startsWith(q))) score = 1;
38
    else if (name.includes(q) || id.includes(q)) score = 2;
39
    else continue;
40
    scored.push({ person, score });
41
  }
42
  scored.sort((a, b) => a.score - b.score || a.person.name.localeCompare(b.person.name));
43
  return scored.slice(0, MAX_CANDIDATES).map((s) => s.person);
44
}
45
46
/** Picked names still present in the body, plus any full matrix ids typed by hand. */
47
export function resolveMentions(body: string, picked: Mention[]): Mention[] {
48
  const out: Mention[] = [];
49
  const seen = new Set<string>();
50
  for (const m of picked) {
51
    const key = `${m.user_id}\n${m.text}`;
52
    if (seen.has(key) || !m.text || !body.includes(m.text)) continue;
53
    seen.add(key);
54
    out.push(m);
55
  }
56
  for (const match of body.matchAll(MXID)) {
57
    const key = `${match[0]}\n${match[0]}`;
58
    if (seen.has(key)) continue;
59
    seen.add(key);
60
    out.push({ user_id: match[0], text: match[0] });
61
  }
62
  return out;
63
}
64
65
/** User pills already in a sent message, so editing it keeps them linked. */
66
export function mentionsFromHtml(html: string | null): Mention[] {
67
  if (!html) return [];
68
  const doc = new DOMParser().parseFromString(html, 'text/html');
69
  const out: Mention[] = [];
70
  for (const a of doc.querySelectorAll('a[href^="https://matrix.to/#/@"]')) {
71
    const userId = decodeURIComponent(a.getAttribute('href')!.slice('https://matrix.to/#/'.length).split('?')[0]);
72
    const text = a.textContent ?? '';
73
    if (text) out.push({ user_id: userId, text });
74
  }
75
  return out;
76
}
77
78
const MARKDOWN_PILL = /\[([^\]\n]+)\]\(https:\/\/matrix\.to\/#\/(@[^)\s?]+)[^)\s]*\)/g;
79
80
/** Some clients send `[name](https://matrix.to/#/@user)` as the plain body instead of just the name. */
81
export function stripMarkdownPills(body: string): { text: string; mentions: Mention[] } {
82
  const mentions: Mention[] = [];
83
  const text = body.replace(MARKDOWN_PILL, (whole: string, name: string, id: string) => {
84
    try {
85
      mentions.push({ user_id: decodeURIComponent(id), text: name });
86
      return name;
87
    } catch {
88
      return whole;
89
    }
90
  });
91
  return { text, mentions };
92
}
93
94
export function mentionedUserIds(mentions: Mention[]): string[] {
95
  return [...new Set(mentions.map((m) => m.user_id))];
96
}