diff --git a/src/chat-shortcuts/emotes.js b/src/chat-shortcuts/emotes.js new file mode 100644 index 0000000..4a0cd70 --- /dev/null +++ b/src/chat-shortcuts/emotes.js @@ -0,0 +1,132 @@ +// Pure emote matching for the settings editor. Mirrors FFZ's tab-completion rules +// (doesEmoteMatchTerm / sortEmotes in FFZ's chat input module) without FFZ deps. + +export const MATCH = { + NONE: 0, + ANY: 1, + NON_PREFIX: 2, + CASE_INSENSITIVE_PREFIX: 3, + EXACT_PREFIX: 4 +}; + +const locale = new Intl.Collator(), + localeCaseInsensitive = new Intl.Collator(undefined, { sensitivity: 'accent' }); + +export function emoteMatchType(name, term) { + if ( ! name || ! term ) + return MATCH.NONE; + + if ( name.startsWith(term) ) + return MATCH.EXACT_PREFIX; + + const name_lower = name.toLowerCase(), + term_lower = term.toLowerCase(); + + if ( name_lower.startsWith(term_lower) ) + return MATCH.CASE_INSENSITIVE_PREFIX; + + const idx = name.indexOf(term.charAt(0).toUpperCase()); + if ( idx !== -1 && name_lower.slice(idx + 1).startsWith(term_lower.slice(1)) ) + return MATCH.NON_PREFIX; + + if ( name_lower.includes(term_lower) ) + return MATCH.ANY; + + return MATCH.NONE; +} + +// entries: [{token, srcSet, source, favorite}]. Options mirror FFZ settings: +// matching = chat.tab-complete.matching (1-3), limit = 25 when limit-results is on. +export function findEmotes(entries, term, { + matching = 1, + limit = null, + preferFavorites = false, + prioritizePrefix = false +} = {}) { + if ( ! term ) + return []; + + const results = []; + for(const entry of entries) { + const match_type = emoteMatchType(entry.token, term); + if ( match_type !== MATCH.NONE && match_type >= matching ) + results.push({ ...entry, match_type }); + } + + results.sort((a, b) => { + if ( preferFavorites && (!! a.favorite !== !! b.favorite) ) + return a.favorite ? -1 : 1; + + if ( ! prioritizePrefix ) + return 0; + + for(const type of [MATCH.EXACT_PREFIX, MATCH.CASE_INSENSITIVE_PREFIX]) { + const a_is = a.match_type === type, + b_is = b.match_type === type; + + if ( a_is && b_is ) + return (type === MATCH.EXACT_PREFIX ? locale : localeCaseInsensitive).compare(a.token, b.token); + if ( a_is ) + return -1; + if ( b_is ) + return 1; + } + + return locale.compare(a.token, b.token); + }); + + return limit ? results.slice(0, limit) : results; +} + +// Merge FFZ tab-completion caches ({emotes: [...]}) in priority order. +export function buildEmoteIndex(caches) { + const entries = [], + lookup = new Map; + + for(const cache of caches) + for(const emote of cache?.emotes ?? []) { + if ( ! emote?.token || ! emote.srcSet || lookup.has(emote.token) ) + continue; + + lookup.set(emote.token, emote.srcSet); + entries.push({ + token: emote.token, + srcSet: emote.srcSet, + source: emote.source, + extra: emote.extra, + favorite: !! emote.favorite + }); + } + + return { entries, lookup }; +} + +// The emote search term at the caret, or null. `:term` needs 2+ characters; a bare +// word only counts when no-colon tab completion is on and Tab was pressed. +export function emoteQuery(value, caret, { withoutColon = false, pressedTab = false } = {}) { + const word = /\S+$/.exec(value.slice(0, caret)); + if ( ! word ) + return null; + + if ( word[0].startsWith(':') ) + return word[0].length >= 3 ? { start: word.index, term: word[0].slice(1) } : null; + + if ( withoutColon && pressedTab && word[0].length >= 2 ) + return { start: word.index, term: word[0] }; + + return null; +} + +// Label for an emote's origin, following FFZ's chat.tab-complete.show-source +// setting: 0 = none, 1 = short provider name, 2 = channel / set name. +export function sourceLabel(entry, mode) { + if ( ! mode ) + return null; + + const source = entry?.source, + short = typeof source === 'string' && source.startsWith('twitch') + ? 'Twitch' + : source === 'ffz' ? 'FFZ' : (source || null); + + return mode === 2 ? (entry?.extra || short) : short; +} diff --git a/src/chat-shortcuts/emotes.test.js b/src/chat-shortcuts/emotes.test.js new file mode 100644 index 0000000..8ff8556 --- /dev/null +++ b/src/chat-shortcuts/emotes.test.js @@ -0,0 +1,131 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { emoteMatchType, findEmotes, buildEmoteIndex, emoteQuery, sourceLabel, MATCH } from './emotes.js'; + +const e = (token, extra = {}) => ({ token, srcSet: `${token}.png 1x`, source: 'ffz', ...extra }); + +// emoteMatchType — mirrors FFZ's doesEmoteMatchTerm + +test('emoteMatchType ranks exact-case prefix highest', () => { + assert.equal(emoteMatchType('Kappa', 'Kap'), MATCH.EXACT_PREFIX); +}); + +test('emoteMatchType detects case-insensitive prefix', () => { + assert.equal(emoteMatchType('Kappa', 'kap'), MATCH.CASE_INSENSITIVE_PREFIX); +}); + +test('emoteMatchType detects non-prefix (capitalised word) matches', () => { + assert.equal(emoteMatchType('cohhSip', 'sip'), MATCH.NON_PREFIX); +}); + +test('emoteMatchType detects plain substring matches', () => { + assert.equal(emoteMatchType('Gossip', 'sip'), MATCH.ANY); +}); + +test('emoteMatchType returns NONE when unrelated', () => { + assert.equal(emoteMatchType('Kappa', 'xyz'), MATCH.NONE); +}); + +// findEmotes + +const ENTRIES = [e('Gossip'), e('cohhSip'), e('sipTea'), e('SipBig'), e('Kappa')]; + +test('findEmotes with matching 1 includes substring matches', () => { + assert.deepEqual(findEmotes(ENTRIES, 'sip').map(x => x.token).sort(), ['Gossip', 'SipBig', 'cohhSip', 'sipTea'].sort()); +}); + +test('findEmotes with matching 2 excludes plain substring matches', () => { + const names = findEmotes(ENTRIES, 'sip', { matching: 2 }).map(x => x.token); + assert.ok(! names.includes('Gossip')); + assert.ok(names.includes('cohhSip')); +}); + +test('findEmotes with matching 3 keeps only prefix matches', () => { + assert.deepEqual(findEmotes(ENTRIES, 'sip', { matching: 3 }).map(x => x.token).sort(), ['SipBig', 'sipTea']); +}); + +test('findEmotes prioritizePrefix orders exact prefix, then case-insensitive prefix, then alphabetical', () => { + const names = findEmotes(ENTRIES, 'sip', { prioritizePrefix: true }).map(x => x.token); + assert.deepEqual(names, ['sipTea', 'SipBig', 'cohhSip', 'Gossip']); +}); + +test('findEmotes preferFavorites puts favorites first', () => { + const list = [e('KappaA'), e('KappaB', { favorite: true })]; + assert.deepEqual(findEmotes(list, 'Kap', { preferFavorites: true }).map(x => x.token), ['KappaB', 'KappaA']); +}); + +test('findEmotes applies the limit', () => { + const list = Array.from({ length: 30 }, (_, i) => e(`Pog${i}`)); + assert.equal(findEmotes(list, 'Pog', { limit: 25 }).length, 25); + assert.equal(findEmotes(list, 'Pog').length, 30); +}); + +test('findEmotes returns nothing for an empty term', () => { + assert.deepEqual(findEmotes(ENTRIES, ''), []); +}); + +// buildEmoteIndex + +test('buildEmoteIndex merges caches with the first cache winning name clashes', () => { + const twitch = { emotes: [e('Kappa', { source: 'twitch-global' })] }, + ffz = { emotes: [e('Kappa', { source: 'ffz', srcSet: 'other.png 1x' }), e('KEKW')] }; + + const { entries, lookup } = buildEmoteIndex([twitch, ffz]); + assert.deepEqual(entries.map(x => x.token), ['Kappa', 'KEKW']); + assert.equal(entries[0].source, 'twitch-global'); + assert.equal(lookup.get('Kappa'), 'Kappa.png 1x'); + assert.equal(lookup.get('KEKW'), 'KEKW.png 1x'); +}); + +test('buildEmoteIndex skips missing caches and entries without a token or image', () => { + const { entries } = buildEmoteIndex([null, { emotes: [{ token: 'x' }, { srcSet: 'y' }, e('ok')] }]); + assert.deepEqual(entries.map(x => x.token), ['ok']); +}); + +// emoteQuery + +test('emoteQuery finds a colon term before the caret', () => { + assert.deepEqual(emoteQuery('hi :kek', 7), { start: 3, term: 'kek' }); +}); + +test('emoteQuery needs at least two characters after the colon', () => { + assert.equal(emoteQuery(':k', 2), null); + assert.deepEqual(emoteQuery(':ke', 3), { start: 0, term: 'ke' }); +}); + +test('emoteQuery ignores bare words unless no-colon tab completion applies', () => { + assert.equal(emoteQuery('hi kek', 6), null); + assert.equal(emoteQuery('hi kek', 6, { withoutColon: true }), null); + assert.deepEqual(emoteQuery('hi kek', 6, { withoutColon: true, pressedTab: true }), { start: 3, term: 'kek' }); +}); + +test('emoteQuery only considers the word ending at the caret', () => { + assert.equal(emoteQuery(':kek x', 6), null); +}); + +// sourceLabel — mirrors FFZ's chat.tab-complete.show-source (0 off, 1 short, 2 long) + +test('sourceLabel returns null when show-source is disabled', () => { + assert.equal(sourceLabel(e('x', { source: 'BTTV' }), 0), null); +}); + +test('sourceLabel short mode names the provider', () => { + assert.equal(sourceLabel(e('x', { source: 'ffz' }), 1), 'FFZ'); + assert.equal(sourceLabel(e('x', { source: 'BTTV' }), 1), 'BTTV'); + assert.equal(sourceLabel(e('x', { source: 'twitch-123' }), 1), 'Twitch'); +}); + +test('sourceLabel long mode uses the channel or set name', () => { + assert.equal(sourceLabel(e('x', { source: 'twitch-123', extra: 'nofairy' }), 2), 'nofairy'); + assert.equal(sourceLabel(e('x', { source: 'BTTV', extra: 'BetterTTV Channel Emotes' }), 2), 'BetterTTV Channel Emotes'); +}); + +test('sourceLabel long mode falls back to the short label', () => { + assert.equal(sourceLabel(e('x', { source: 'twitch-global' }), 2), 'Twitch'); +}); + +test('buildEmoteIndex keeps extra for long labels', () => { + const { entries } = buildEmoteIndex([{ emotes: [e('x', { extra: 'nofairy' })] }]); + assert.equal(entries[0].extra, 'nofairy'); +}); diff --git a/src/chat-shortcuts/index.js b/src/chat-shortcuts/index.js new file mode 100644 index 0000000..28b4bb2 --- /dev/null +++ b/src/chat-shortcuts/index.js @@ -0,0 +1,362 @@ +'use strict'; + +import { parseShortcuts, findMatches, expandAt } from './matcher.js'; +import { buildPreview } from './preview.js'; +import { buildEmoteIndex } from './emotes.js'; + +const KeyHandler = Symbol('ChatShortcutsKeyHandler'); +const OriginalMatches = Symbol('ChatShortcutsOriginalMatches'); +const FFZCacheKey = Symbol('ChatShortcutsFFZCacheKey'); +const LastExpansion = Symbol('ChatShortcutsLastExpansion'); + +const MODIFIER_KEYS = new Set(['Shift', 'Control', 'Alt', 'Meta', 'CapsLock']); + +const DEFAULT_PREFIX = ';'; + +class ChatShortcuts extends Addon { + constructor(...args) { + super(...args); + + this.inject('settings'); + this.inject('site'); + this.inject('site.fine'); + this.inject('site.chat.input'); + + this._raw_list = undefined; + this._shortcuts = []; + + this.settings.add('addon.chat-shortcuts.prefix', { + default: DEFAULT_PREFIX, + ui: { + path: 'Add-Ons > Chat Shortcuts >> General', + title: 'Prefix', + description: 'Typed before a shortcut name, e.g. `;gg`. Must not contain spaces.', + component: 'setting-text-box' + } + }); + + // Written by shortcut-list-editor.vue directly on the provider (global, not + // per-profile), so it must be read with provider.get(), not settings.get(). + this.settings.add('addon.chat-shortcuts.list', { + default: [] + }); + + this.settings.addUI('addon.chat-shortcuts.list-editor', { + path: 'Add-Ons > Chat Shortcuts >> Shortcuts', + component: () => import(/* webpackChunkName: 'chat-shortcuts' */ './shortcut-list-editor.vue') + }); + } + + onEnable() { + this.input.ChatInput.on('mount', this.install, this); + this.input.ChatInput.on('update', this.install, this); + this.input.ChatInput.ready((cls, instances) => { + for(const inst of instances) + this.install(inst); + }); + } + + onDisable() { + this.input.ChatInput.off('mount', this.install, this); + this.input.ChatInput.off('update', this.install, this); + + for(const inst of this.input.ChatInput.instances) + this.uninstall(inst); + } + + // The provider only emits 'changed' for other tabs, so read on demand and + // re-parse only when the stored array changes. + get shortcuts() { + const raw = this.settings.provider.get('addon.chat-shortcuts.list'); + if ( raw !== this._raw_list ) { + this._raw_list = raw; + this._shortcuts = parseShortcuts(raw); + } + + return this._shortcuts; + } + + get prefix() { + const value = this.settings.get('addon.chat-shortcuts.prefix'); + return typeof value === 'string' && value.trim() && ! /\s/.test(value.trim()) + ? value.trim() + : DEFAULT_PREFIX; + } + + getElement(inst) { + if ( inst.chatInputRef instanceof Element ) + return inst.chatInputRef; + + return this.fine.getChildNode(inst)?.querySelector('.chat-wysiwyg-input__editor'); + } + + // Installation + + install(inst) { + try { + this.installKeyHandler(inst); + this.installProvider(inst); + } catch(err) { + this.log.error('Unable to install chat shortcuts.', err); + } + } + + uninstall(inst) { + try { + const handler = inst[KeyHandler]; + if ( handler ) { + handler.el.removeEventListener('keydown', handler); + inst[KeyHandler] = null; + } + + inst[LastExpansion] = null; + + const provider = this.getEmoteProvider(inst); + if ( provider?.[OriginalMatches] ) { + provider.getMatches = provider[OriginalMatches]; + provider[OriginalMatches] = null; + } + } catch(err) { + this.log.error('Unable to uninstall chat shortcuts.', err); + } + } + + installKeyHandler(inst) { + const el = this.getElement(inst); + if ( ! el ) + return; + + // Twitch can swap the editor element on update; move the listener if so. + const existing = inst[KeyHandler]; + if ( existing?.el === el ) + return; + if ( existing ) + existing.el.removeEventListener('keydown', existing); + + const handler = event => this.handleKey(inst, event); + handler.el = el; + inst[KeyHandler] = handler; + el.addEventListener('keydown', handler); + } + + getEmoteProvider(inst) { + const providers = inst.autocompleteInputRef?.providers; + if ( ! providers ) + return null; + + for(const provider of providers) + if ( provider?.autocompleteType === 'emote' && typeof provider.getMatches === 'function' ) + return provider; + + return null; + } + + installProvider(inst) { + const provider = this.getEmoteProvider(inst); + if ( ! provider || provider[OriginalMatches] ) + return; + + const original = provider[OriginalMatches] = provider.getMatches; + const t = this; + + provider.getMatches = function(input, ...rest) { + try { + const items = t.getSuggestions(input, this); + if ( items ) + return items; + } catch(err) { + t.log.error('Error building shortcut suggestions.', err); + } + + return original.call(this, input, ...rest); + } + } + + // Inline expansion + + handleKey(inst, event) { + try { + if ( MODIFIER_KEYS.has(event.key) ) + return; + + // Any key other than an immediate Backspace forgets the last expansion. + const last = inst[LastExpansion]; + inst[LastExpansion] = null; + + const plain = ! event.ctrlKey && ! event.altKey && ! event.metaKey; + if ( ! plain ) + return; + + const is_space = event.key === ' ', + is_tab = event.key === 'Tab' && ! event.shiftKey, + is_backspace = event.key === 'Backspace'; + + if ( ! (is_space || is_tab || is_backspace) ) + return; + + const value = inst.ffzGetValue?.(); + const [start, end] = inst.ffzGetSelection?.() ?? []; + if ( typeof value !== 'string' || start !== end || start == null ) + return; + + if ( is_backspace ) { + // Undo: only if the input is still exactly what we produced. + if ( last && value === last.to ) { + this.applyValue(inst, event, last.from, last.from_caret); + } + return; + } + + const result = expandAt(value, start, this.prefix, this.shortcuts, is_space); + if ( ! result ) + return; + + this.applyValue(inst, event, result.value, result.caret); + inst[LastExpansion] = { from: value, from_caret: start, to: result.value }; + + } catch(err) { + this.log.error('Error handling shortcut key.', err); + } + } + + applyValue(inst, event, value, caret) { + event.preventDefault(); + event.stopPropagation(); + + inst.autocompleteInputRef.setValue(value); + inst.ffzSetSelection?.(caret, caret); + } + + // Autocomplete menu + + getSuggestions(input, provider) { + const prefix = this.prefix; + if ( typeof input !== 'string' || ! input.startsWith(prefix) || input.length <= prefix.length ) + return null; + + const matches = findMatches(this.shortcuts, input.slice(prefix.length)); + if ( ! matches.length ) + return null; + + let lookup = null; + try { + lookup = this.getEmoteIndex(provider)?.lookup ?? null; + } catch(err) { + this.log.error('Unable to build emote lookup for previews.', err); + } + + return matches.map(match => ({ + current: input, + replacement: match.text, + element: this.renderSuggestion(prefix + match.name, match.text, lookup) + })); + } + + // Emotes for the settings editor: the open chat's (Twitch + channel) if any, + // otherwise the user's global and personal FFZ/BTTV/7TV sets. Twitch emotes are + // only known while a chat is mounted. Returns {entries, lookup} or null. + getEditorEmotes() { + try { + for(const inst of this.input.ChatInput.instances) { + const provider = this.getEmoteProvider(inst); + if ( provider ) + return this.getEmoteIndex(provider); + } + + const user = this.site.getUser?.(), + key = `${user?.id}|${user?.login}`; + + if ( ! this._global_cache || this._global_key !== key ) { + this._global_cache = this.input.buildFFZCache(user?.id, user?.login, null, null); + this._global_key = key; + } + + return buildEmoteIndex([this._global_cache]); + + } catch(err) { + this.log.error('Unable to build emotes for the editor.', err); + } + + return null; + } + + // The user's FFZ tab-completion settings, for the editor's emote autocomplete. + getEmoteCompletionOptions() { + const get = key => this.settings.get(key), + enabled = get('chat.emotes.enabled'); + + return { + twitch: !! enabled, + ffz: enabled > 1 && !! get('chat.tab-complete.ffz-emotes'), + matching: get('chat.tab-complete.matching') || 1, + limit: get('chat.tab-complete.limit-results') ? 25 : null, + preferFavorites: !! get('chat.tab-complete.prioritize-favorites'), + prioritizePrefix: !! get('chat.tab-complete.prioritize-prefix-matches'), + withoutColon: !! get('chat.tab-complete.emotes-without-colon'), + showSource: get('chat.tab-complete.show-source') || 0 + }; + } + + // Emote index ({entries, lookup}) from the tab-completion caches FFZ keeps on + // the emote provider, building them with FFZ's own functions if FFZ hasn't yet. + // FFZ nulls its caches when emotes change; we additionally track the channel + // ourselves since FFZ's cache objects don't record it. Twitch emotes win name + // clashes, as in chat. + getEmoteIndex(provider) { + if ( ! provider ) + return null; + + const input = this.input; + + let twitch = provider.ffz_twitch_cache; + if ( twitch?.length !== provider.props?.emotes?.length ) + twitch = provider.ffz_twitch_cache = input.buildTwitchCache(provider.props?.emotes); + + const user = provider._ffz_user, + channel_id = provider._ffz_channel_id, + channel_login = provider._ffz_channel_login; + + const ffz_key = `${user?.id}|${user?.login}|${channel_id}|${channel_login}`; + + let ffz = provider.ffz_ffz_cache; + if ( channel_login && (! ffz || provider[FFZCacheKey] !== ffz_key) ) { + ffz = provider.ffz_ffz_cache = input.buildFFZCache(user?.id, user?.login, channel_id, channel_login); + provider[FFZCacheKey] = ffz_key; + } + + if ( this._index && this._index_twitch === twitch && this._index_ffz === ffz ) + return this._index; + + this._index = buildEmoteIndex([twitch, ffz]); + this._index_twitch = twitch; + this._index_ffz = ffz; + return this._index; + } + + renderSuggestion(token, text, lookup) { + const createElement = this.site.getReact()?.createElement; + if ( ! createElement ) + return token; + + const segments = buildPreview(text, lookup).map((seg, idx) => seg.emote + ? createElement('img', { + key: idx, + srcSet: seg.srcSet, + alt: seg.emote, + title: seg.emote, + style: { height: '2rem', verticalAlign: 'middle' } + }) + : createElement('span', { key: idx }, seg.text) + ); + + const name = createElement('span', { className: 'tw-semibold tw-flex-shrink-0 tw-mg-r-1' }, token), + body = createElement('span', { className: 'tw-c-text-alt-2 tw-ellipsis' }, ...segments); + + return createElement('div', { + className: 'tw-ellipsis tw-flex tw-align-items-center tw-pd-05', + title: text + }, name, body); + } +} + +ChatShortcuts.register(); diff --git a/src/chat-shortcuts/logo.png b/src/chat-shortcuts/logo.png new file mode 100644 index 0000000..5915824 Binary files /dev/null and b/src/chat-shortcuts/logo.png differ diff --git a/src/chat-shortcuts/manifest.json b/src/chat-shortcuts/manifest.json new file mode 100644 index 0000000..a53bbe4 --- /dev/null +++ b/src/chat-shortcuts/manifest.json @@ -0,0 +1,15 @@ +{ + "enabled": true, + "requires": [], + + "version": "1.0.0", + + "short_name": "Shortcuts", + "name": "Chat Shortcuts", + "author": "abodnar", + "description": "Define shortcuts like ;gg that expand into text in the chat input. Expand inline with Space or Tab, or pick from the autocomplete menu. Nothing is ever sent automatically.", + + "settings": "add_ons.chat_shortcuts", + "created": "2026-10-14T00:00:00.000Z", + "updated": "2026-10-14T00:00:00.000Z" +} diff --git a/src/chat-shortcuts/matcher.js b/src/chat-shortcuts/matcher.js new file mode 100644 index 0000000..29fa5c6 --- /dev/null +++ b/src/chat-shortcuts/matcher.js @@ -0,0 +1,124 @@ +// Pure shortcut matching. No FFZ / DOM dependencies so it can be unit tested with node. + +export const DEFAULT_LIMIT = 25; + +export function parseShortcuts(list) { + if ( ! Array.isArray(list) ) + return []; + + const seen = new Set, + out = []; + + for(const entry of list) { + const name = typeof entry?.name === 'string' ? entry.name.trim() : '', + text = typeof entry?.text === 'string' ? entry.text : ''; + + if ( ! name || ! text || /\s/.test(name) || seen.has(name) ) + continue; + + seen.add(name); + out.push({ name, text }); + } + + return out; +} + +export function findExact(shortcuts, name) { + if ( ! name ) + return null; + + const exact = shortcuts.find(s => s.name === name); + if ( exact ) + return exact.text; + + const lower = name.toLowerCase(), + loose = shortcuts.find(s => s.name.toLowerCase() === lower); + + return loose ? loose.text : null; +} + +export function findMatches(shortcuts, partial, limit = DEFAULT_LIMIT) { + if ( ! partial ) + return []; + + const term = partial.toLowerCase(), + groups = [[], [], []]; + + for(const s of shortcuts) { + const name = s.name.toLowerCase(); + if ( name === term ) + groups[0].push(s); + else if ( name.startsWith(term) ) + groups[1].push(s); + else if ( name.includes(term) ) + groups[2].push(s); + } + + const byName = (a, b) => a.name.localeCompare(b.name); + + return groups + .flatMap(group => group.sort(byName)) + .slice(0, limit) + .map(({ name, text }) => ({ name, text })); +} + +// Per-row problems for the settings editor: null for a valid (or fully blank) row, +// otherwise a short message. Mirrors what parseShortcuts() will skip. +export function validateShortcuts(list, prefix) { + if ( ! Array.isArray(list) ) + return []; + + const seen = new Set; + + return list.map(entry => { + const name = typeof entry?.name === 'string' ? entry.name.trim() : '', + text = typeof entry?.text === 'string' ? entry.text : ''; + + if ( ! name && ! text ) + return null; + if ( ! name ) + return 'Missing name.'; + if ( ! text ) + return 'Missing text.'; + if ( /\s/.test(name) ) + return 'Names can\'t contain spaces.'; + if ( seen.has(name) ) + return 'Duplicate name — the earlier one is used.'; + + seen.add(name); + + if ( prefix && name.startsWith(prefix) ) + return `Don't include the prefix "${prefix}" in the name — this would need ${prefix}${name}.`; + + return null; + }); +} + +// Expand the shortcut word ending at `caret`. Returns {value, caret} or null. +// With addSpace, a space follows the expansion unless one is already there, in +// which case the caret moves past it. +export function expandAt(value, caret, prefix, shortcuts, addSpace) { + if ( typeof value !== 'string' || ! prefix ) + return null; + + const before = value.slice(0, caret), + after = value.slice(caret), + word = /\S+$/.exec(before); + + if ( ! word || ! word[0].startsWith(prefix) ) + return null; + + const text = findExact(shortcuts, word[0].slice(prefix.length)); + if ( text == null ) + return null; + + const head = before.slice(0, word.index) + text; + + if ( ! addSpace ) + return { value: head + after, caret: head.length }; + + if ( /^\s/.test(after) ) + return { value: head + after, caret: head.length + 1 }; + + return { value: `${head} ${after}`, caret: head.length + 1 }; +} diff --git a/src/chat-shortcuts/matcher.test.js b/src/chat-shortcuts/matcher.test.js new file mode 100644 index 0000000..51d6921 --- /dev/null +++ b/src/chat-shortcuts/matcher.test.js @@ -0,0 +1,180 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { parseShortcuts, findExact, findMatches, validateShortcuts, expandAt } from './matcher.js'; + +const LIST = parseShortcuts([ + { name: 'gg', text: 'good game everyone' }, + { name: 'GL', text: 'good luck' }, + { name: 'ggwp', text: 'good game, well played' }, + { name: 'egg', text: 'EGG EGG EGG' }, + { name: 'hype', text: 'PogChamp PogChamp' }, +]); + +test('parseShortcuts trims names and drops entries with empty name or text', () => { + const out = parseShortcuts([ + { name: ' hi ', text: 'hello' }, + { name: '', text: 'orphan' }, + { name: 'blank', text: '' }, + { name: ' ', text: 'spaces' }, + ]); + assert.deepEqual(out, [{ name: 'hi', text: 'hello' }]); +}); + +test('parseShortcuts keeps the first occurrence of a duplicate name', () => { + const out = parseShortcuts([ + { name: 'a', text: 'first' }, + { name: 'a', text: 'second' }, + ]); + assert.deepEqual(out, [{ name: 'a', text: 'first' }]); +}); + +test('parseShortcuts tolerates non-array input', () => { + assert.deepEqual(parseShortcuts(undefined), []); + assert.deepEqual(parseShortcuts(null), []); + assert.deepEqual(parseShortcuts('nope'), []); +}); + +test('parseShortcuts rejects names containing whitespace', () => { + assert.deepEqual(parseShortcuts([{ name: 'two words', text: 'x' }]), []); +}); + +test('findExact returns text for an exact name', () => { + assert.equal(findExact(LIST, 'gg'), 'good game everyone'); +}); + +test('findExact falls back to case-insensitive match', () => { + assert.equal(findExact(LIST, 'gl'), 'good luck'); + assert.equal(findExact(LIST, 'GG'), 'good game everyone'); +}); + +test('findExact prefers a case-sensitive match over a case-insensitive one', () => { + const list = parseShortcuts([ + { name: 'Hi', text: 'upper' }, + { name: 'hi', text: 'lower' }, + ]); + assert.equal(findExact(list, 'hi'), 'lower'); + assert.equal(findExact(list, 'Hi'), 'upper'); +}); + +test('findExact returns null for unknown or partial names', () => { + assert.equal(findExact(LIST, 'g'), null); + assert.equal(findExact(LIST, ')'), null); + assert.equal(findExact(LIST, ''), null); +}); + +test('findMatches ranks exact, then prefix, then substring', () => { + const names = findMatches(LIST, 'gg').map(m => m.name); + assert.deepEqual(names, ['gg', 'ggwp', 'egg']); +}); + +test('findMatches is case-insensitive', () => { + assert.deepEqual(findMatches(LIST, 'HY').map(m => m.name), ['hype']); +}); + +test('findMatches sorts alphabetically within a group', () => { + const list = parseShortcuts([ + { name: 'gz', text: '1' }, + { name: 'ga', text: '2' }, + { name: 'gm', text: '3' }, + ]); + assert.deepEqual(findMatches(list, 'g').map(m => m.name), ['ga', 'gm', 'gz']); +}); + +test('findMatches returns name and text', () => { + assert.deepEqual(findMatches(LIST, 'hype'), [{ name: 'hype', text: 'PogChamp PogChamp' }]); +}); + +test('findMatches respects the limit', () => { + const list = parseShortcuts(Array.from({ length: 40 }, (_, i) => ({ name: `s${i}`, text: 'x' }))); + assert.equal(findMatches(list, 's').length, 25); + assert.equal(findMatches(list, 's', 3).length, 3); +}); + +test('findMatches returns nothing for an empty partial', () => { + assert.deepEqual(findMatches(LIST, ''), []); +}); + +// validateShortcuts + +test('validateShortcuts returns null for valid rows and fully blank rows', () => { + assert.deepEqual(validateShortcuts([ + { name: 'gg', text: 'good game' }, + { name: '', text: '' }, + ], ';'), [null, null]); +}); + +test('validateShortcuts flags missing name or text', () => { + const out = validateShortcuts([ + { name: '', text: 'orphan' }, + { name: 'lonely', text: '' }, + ], ';'); + assert.match(out[0], /name/i); + assert.match(out[1], /text/i); +}); + +test('validateShortcuts flags names containing spaces', () => { + assert.match(validateShortcuts([{ name: 'good game', text: 'x' }], ';')[0], /space/i); +}); + +test('validateShortcuts flags names that start with the prefix', () => { + const [msg] = validateShortcuts([{ name: ';gg', text: 'x' }], ';'); + assert.match(msg, /prefix/i); + assert.ok(msg.includes(';')); +}); + +test('validateShortcuts flags later duplicates, not the first', () => { + const out = validateShortcuts([ + { name: 'gg', text: 'first' }, + { name: ' gg ', text: 'second' }, + ], ';'); + assert.equal(out[0], null); + assert.match(out[1], /duplicate/i); +}); + +test('validateShortcuts does not count invalid rows as the first occurrence', () => { + const out = validateShortcuts([ + { name: 'gg', text: '' }, + { name: 'gg', text: 'real' }, + ], ';'); + assert.equal(out[1], null); +}); + +test('validateShortcuts tolerates non-array input', () => { + assert.deepEqual(validateShortcuts(undefined, ';'), []); +}); + +// expandAt + +const EXPAND = parseShortcuts([{ name: 'gg', text: 'good game' }]); + +test('expandAt expands the word before the caret and adds a space', () => { + assert.deepEqual(expandAt(';gg', 3, ';', EXPAND, true), { value: 'good game ', caret: 10 }); +}); + +test('expandAt without addSpace does not add a space', () => { + assert.deepEqual(expandAt(';gg', 3, ';', EXPAND, false), { value: 'good game', caret: 9 }); +}); + +test('expandAt expands mid-message and keeps the rest of the text', () => { + assert.deepEqual(expandAt('hi ;gg there', 6, ';', EXPAND, false), { value: 'hi good game there', caret: 12 }); +}); + +test('expandAt reuses existing whitespace after the caret instead of adding more', () => { + assert.deepEqual(expandAt('hi ;gg there', 6, ';', EXPAND, true), { value: 'hi good game there', caret: 13 }); +}); + +test('expandAt returns null for unknown names, bare prefix, or no prefix', () => { + assert.equal(expandAt(';nope', 5, ';', EXPAND, true), null); + assert.equal(expandAt(';', 1, ';', EXPAND, true), null); + assert.equal(expandAt('gg', 2, ';', EXPAND, true), null); + assert.equal(expandAt('', 0, ';', EXPAND, true), null); +}); + +test('expandAt only looks at the word ending at the caret', () => { + assert.equal(expandAt(';gg x', 5, ';', EXPAND, true), null); +}); + +test('expandAt supports multi-character prefixes', () => { + assert.deepEqual(expandAt('!!gg', 4, '!!', EXPAND, false), { value: 'good game', caret: 9 }); +}); diff --git a/src/chat-shortcuts/preview.js b/src/chat-shortcuts/preview.js new file mode 100644 index 0000000..f39d714 --- /dev/null +++ b/src/chat-shortcuts/preview.js @@ -0,0 +1,46 @@ +// Pure preview builder. No FFZ / DOM dependencies so it can be unit tested with node. + +export const DEFAULT_PREVIEW_LENGTH = 60; + +const ELLIPSIS = '…'; + +function truncate(text, max) { + if ( text.length <= max ) + return [text, false]; + + const cut = text.slice(0, max - 1), + boundary = cut.search(/\s\S*$/); + + return [(boundary > 0 ? cut.slice(0, boundary) : cut).trimEnd(), true]; +} + +// Split text into [{text}] and [{emote, srcSet}] segments. `lookup` is a Map of +// emote name -> srcSet; names only match whole whitespace-separated words. +export function buildPreview(text, lookup, maxLength = DEFAULT_PREVIEW_LENGTH) { + const [body, truncated] = truncate(text, maxLength), + out = []; + + const pushText = value => { + const last = out[out.length - 1]; + if ( last && last.text != null ) + last.text += value; + else + out.push({ text: value }); + }; + + for(const part of body.split(/(\s+)/)) { + if ( ! part ) + continue; + + const srcSet = lookup?.get(part); + if ( srcSet ) + out.push({ emote: part, srcSet }); + else + pushText(part); + } + + if ( truncated ) + pushText(ELLIPSIS); + + return out; +} diff --git a/src/chat-shortcuts/preview.test.js b/src/chat-shortcuts/preview.test.js new file mode 100644 index 0000000..6511649 --- /dev/null +++ b/src/chat-shortcuts/preview.test.js @@ -0,0 +1,59 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { buildPreview } from './preview.js'; + +const LOOKUP = new Map([ + ['PogChamp', 'pog.png 1x'], + ['KEKW', 'kekw.png 1x'], +]); + +test('buildPreview returns plain text as a single segment', () => { + assert.deepEqual(buildPreview('good game', LOOKUP), [{ text: 'good game' }]); +}); + +test('buildPreview turns emote names into emote segments', () => { + assert.deepEqual(buildPreview('PogChamp hype KEKW', LOOKUP), [ + { emote: 'PogChamp', srcSet: 'pog.png 1x' }, + { text: ' hype ' }, + { emote: 'KEKW', srcSet: 'kekw.png 1x' }, + ]); +}); + +test('buildPreview matches emote names case-sensitively', () => { + assert.deepEqual(buildPreview('kekw', LOOKUP), [{ text: 'kekw' }]); +}); + +test('buildPreview does not match emote names inside other words', () => { + assert.deepEqual(buildPreview('PogChamps', LOOKUP), [{ text: 'PogChamps' }]); +}); + +test('buildPreview preserves runs of whitespace', () => { + assert.deepEqual(buildPreview('KEKW KEKW', LOOKUP), [ + { emote: 'KEKW', srcSet: 'kekw.png 1x' }, + { text: ' ' }, + { emote: 'KEKW', srcSet: 'kekw.png 1x' }, + ]); +}); + +test('buildPreview truncates long text at a word boundary with an ellipsis', () => { + const out = buildPreview('alpha beta gamma delta', LOOKUP, 13); + assert.deepEqual(out, [{ text: 'alpha beta…' }]); +}); + +test('buildPreview never splits an emote when truncating', () => { + const out = buildPreview('hi PogChamp PogChamp', LOOKUP, 15); + assert.deepEqual(out, [ + { text: 'hi ' }, + { emote: 'PogChamp', srcSet: 'pog.png 1x' }, + { text: '…' }, + ]); +}); + +test('buildPreview hard-cuts a single word longer than the limit', () => { + assert.deepEqual(buildPreview('abcdefghij', LOOKUP, 5), [{ text: 'abcd…' }]); +}); + +test('buildPreview works without a lookup', () => { + assert.deepEqual(buildPreview('PogChamp', null), [{ text: 'PogChamp' }]); +}); diff --git a/src/chat-shortcuts/shortcut-list-editor.vue b/src/chat-shortcuts/shortcut-list-editor.vue new file mode 100644 index 0000000..8249ea4 --- /dev/null +++ b/src/chat-shortcuts/shortcut-list-editor.vue @@ -0,0 +1,270 @@ + + + +