| 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 | } |