| 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 | let scratch: HTMLElement | null = null; |
| 66 | |
| 67 | /** User pills already in a sent message, so editing it keeps them linked. */ |
| 68 | export function mentionsFromHtml(html: string | null): Mention[] { |
| 69 | if (!html) return []; |
| 70 | scratch ??= document.implementation.createHTMLDocument('').body; |
| 71 | scratch.innerHTML = html; |
| 72 | try { |
| 73 | const out: Mention[] = []; |
| 74 | for (const a of scratch.querySelectorAll('a[href^="https://matrix.to/#/@"]')) { |
| 75 | const userId = decodeURIComponent(a.getAttribute('href')!.slice('https://matrix.to/#/'.length).split('?')[0]); |
| 76 | const text = a.textContent ?? ''; |
| 77 | if (text) out.push({ user_id: userId, text }); |
| 78 | } |
| 79 | return out; |
| 80 | } finally { |
| 81 | scratch.replaceChildren(); |
| 82 | } |
| 83 | } |
| 84 | |
| 85 | const MARKDOWN_PILL = /\[([^\]\n]+)\]\(https:\/\/matrix\.to\/#\/(@[^)\s?]+)[^)\s]*\)/g; |
| 86 | |
| 87 | /** Some clients send `[name](https://matrix.to/#/@user)` as the plain body instead of just the name. */ |
| 88 | export function stripMarkdownPills(body: string): { text: string; mentions: Mention[] } { |
| 89 | const mentions: Mention[] = []; |
| 90 | const text = body.replace(MARKDOWN_PILL, (whole: string, name: string, id: string) => { |
| 91 | try { |
| 92 | mentions.push({ user_id: decodeURIComponent(id), text: name }); |
| 93 | return name; |
| 94 | } catch { |
| 95 | return whole; |
| 96 | } |
| 97 | }); |
| 98 | return { text, mentions }; |
| 99 | } |
| 100 | |
| 101 | export function mentionedUserIds(mentions: Mention[]): string[] { |
| 102 | return [...new Set(mentions.map((m) => m.user_id))]; |
| 103 | } |