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