From 4e5ec874caf67e47080c6800fb7418f7824fe6c3 Mon Sep 17 00:00:00 2001 From: Adam Bodnar Date: Wed, 14 Oct 2026 21:00:25 -0500 Subject: [PATCH 1/8] Add Chat Shortcuts add-on Shortcuts like ;gg expand into the chat input on Space/Tab, or via the emote autocomplete menu. Pure matcher with node:test coverage. Co-Authored-By: Claude Opus 5.5 --- src/chat-shortcuts/index.js | 241 ++++++++++++++++++++ src/chat-shortcuts/manifest.json | 13 ++ src/chat-shortcuts/matcher.js | 63 +++++ src/chat-shortcuts/matcher.test.js | 96 ++++++++ src/chat-shortcuts/shortcut-list-editor.vue | 90 ++++++++ 5 files changed, 503 insertions(+) create mode 100644 src/chat-shortcuts/index.js create mode 100644 src/chat-shortcuts/manifest.json create mode 100644 src/chat-shortcuts/matcher.js create mode 100644 src/chat-shortcuts/matcher.test.js create mode 100644 src/chat-shortcuts/shortcut-list-editor.vue diff --git a/src/chat-shortcuts/index.js b/src/chat-shortcuts/index.js new file mode 100644 index 0000000..a4ea413 --- /dev/null +++ b/src/chat-shortcuts/index.js @@ -0,0 +1,241 @@ +'use strict'; + +import { parseShortcuts, findExact, findMatches } from './matcher.js'; + +const KeyHandler = Symbol('ChatShortcutsKeyHandler'); +const OriginalMatches = Symbol('ChatShortcutsOriginalMatches'); + +const DEFAULT_PREFIX = ';'; +const PREVIEW_LENGTH = 60; + +class ChatShortcuts extends Addon { + constructor(...args) { + super(...args); + + this.inject('settings'); + this.inject('site'); + this.inject('site.fine'); + this.inject('site.chat.input'); + + 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' + } + }); + + this.settings.add('addon.chat-shortcuts.list', { + default: [], + changed: () => this.loadShortcuts() + }); + + 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.loadShortcuts(); + + 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); + } + + loadShortcuts() { + this.shortcuts = parseShortcuts(this.settings.get('addon.chat-shortcuts.list')); + } + + 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; + } + + 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); + 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 { + const is_space = event.key === ' ', + is_tab = event.key === 'Tab'; + + if ( ! (is_space || is_tab) || event.ctrlKey || event.altKey || event.metaKey || (is_tab && event.shiftKey) ) + return; + + const value = inst.ffzGetValue?.(); + const [start, end] = inst.ffzGetSelection?.() ?? []; + if ( typeof value !== 'string' || start !== end || start == null ) + return; + + const before = value.slice(0, start), + after = value.slice(start), + word = /\S+$/.exec(before); + + if ( ! word ) + return; + + const prefix = this.prefix; + if ( ! word[0].startsWith(prefix) ) + return; + + const text = findExact(this.shortcuts, word[0].slice(prefix.length)); + if ( text == null ) + return; + + const pad = is_space && ! /^\s/.test(after) ? ' ' : '', + head = before.slice(0, word.index) + text + pad; + + event.preventDefault(); + event.stopPropagation(); + + inst.autocompleteInputRef.setValue(head + after); + inst.ffzSetSelection?.(head.length, head.length); + + } catch(err) { + this.log.error('Error expanding shortcut.', err); + } + } + + // Autocomplete menu + + getSuggestions(input) { + 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; + + return matches.map(match => ({ + current: input, + replacement: match.text, + element: this.renderSuggestion(prefix + match.name, match.text) + })); + } + + renderSuggestion(token, text) { + const createElement = this.site.getReact()?.createElement; + if ( ! createElement ) + return token; + + const preview = text.length > PREVIEW_LENGTH + ? `${text.slice(0, PREVIEW_LENGTH - 1)}…` + : 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' }, preview); + + 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/manifest.json b/src/chat-shortcuts/manifest.json new file mode 100644 index 0000000..9877451 --- /dev/null +++ b/src/chat-shortcuts/manifest.json @@ -0,0 +1,13 @@ +{ + "enabled": false, + "requires": [], + + "version": "0.1.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" +} diff --git a/src/chat-shortcuts/matcher.js b/src/chat-shortcuts/matcher.js new file mode 100644 index 0000000..6e50994 --- /dev/null +++ b/src/chat-shortcuts/matcher.js @@ -0,0 +1,63 @@ +// 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 })); +} diff --git a/src/chat-shortcuts/matcher.test.js b/src/chat-shortcuts/matcher.test.js new file mode 100644 index 0000000..a709314 --- /dev/null +++ b/src/chat-shortcuts/matcher.test.js @@ -0,0 +1,96 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { parseShortcuts, findExact, findMatches } 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, ''), []); +}); diff --git a/src/chat-shortcuts/shortcut-list-editor.vue b/src/chat-shortcuts/shortcut-list-editor.vue new file mode 100644 index 0000000..7fe90c1 --- /dev/null +++ b/src/chat-shortcuts/shortcut-list-editor.vue @@ -0,0 +1,90 @@ + + + + From 9ac7d8f121906272cd1da43fba7a16fcc3dc965a Mon Sep 17 00:00:00 2001 From: Adam Bodnar Date: Wed, 14 Oct 2026 21:33:14 -0500 Subject: [PATCH 2/8] Chat Shortcuts: read shortcut list from the settings provider The editor writes the list directly to the provider, but the add-on read it via settings.get(), which only sees profile-prefixed keys, so the list was always empty and nothing expanded. Co-Authored-By: Claude Opus 5.5 --- src/chat-shortcuts/index.js | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/src/chat-shortcuts/index.js b/src/chat-shortcuts/index.js index a4ea413..dae687c 100644 --- a/src/chat-shortcuts/index.js +++ b/src/chat-shortcuts/index.js @@ -17,7 +17,8 @@ class ChatShortcuts extends Addon { this.inject('site.fine'); this.inject('site.chat.input'); - this.shortcuts = []; + this._raw_list = undefined; + this._shortcuts = []; this.settings.add('addon.chat-shortcuts.prefix', { default: DEFAULT_PREFIX, @@ -29,9 +30,10 @@ class ChatShortcuts extends Addon { } }); + // 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: [], - changed: () => this.loadShortcuts() + default: [] }); this.settings.addUI('addon.chat-shortcuts.list-editor', { @@ -41,8 +43,6 @@ class ChatShortcuts extends Addon { } onEnable() { - this.loadShortcuts(); - this.input.ChatInput.on('mount', this.install, this); this.input.ChatInput.on('update', this.install, this); this.input.ChatInput.ready((cls, instances) => { @@ -59,8 +59,16 @@ class ChatShortcuts extends Addon { this.uninstall(inst); } - loadShortcuts() { - this.shortcuts = parseShortcuts(this.settings.get('addon.chat-shortcuts.list')); + // 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() { From f3aa26a312befcf749af70cf140fd0dcce9e531d Mon Sep 17 00:00:00 2001 From: Adam Bodnar Date: Wed, 14 Oct 2026 21:38:39 -0500 Subject: [PATCH 3/8] Chat Shortcuts: render emotes in autocomplete previews Previews resolve emote names against the Twitch and FFZ/BTTV/7TV tab-completion caches FFZ keeps on the emote provider and render them as inline images. Pure buildPreview() with node:test coverage. Co-Authored-By: Claude Opus 5.5 --- src/chat-shortcuts/index.js | 75 ++++++++++++++++++++++++++---- src/chat-shortcuts/preview.js | 46 ++++++++++++++++++ src/chat-shortcuts/preview.test.js | 59 +++++++++++++++++++++++ 3 files changed, 171 insertions(+), 9 deletions(-) create mode 100644 src/chat-shortcuts/preview.js create mode 100644 src/chat-shortcuts/preview.test.js diff --git a/src/chat-shortcuts/index.js b/src/chat-shortcuts/index.js index dae687c..38c2ff8 100644 --- a/src/chat-shortcuts/index.js +++ b/src/chat-shortcuts/index.js @@ -1,12 +1,13 @@ 'use strict'; import { parseShortcuts, findExact, findMatches } from './matcher.js'; +import { buildPreview } from './preview.js'; const KeyHandler = Symbol('ChatShortcutsKeyHandler'); const OriginalMatches = Symbol('ChatShortcutsOriginalMatches'); +const FFZCacheKey = Symbol('ChatShortcutsFFZCacheKey'); const DEFAULT_PREFIX = ';'; -const PREVIEW_LENGTH = 60; class ChatShortcuts extends Addon { constructor(...args) { @@ -154,7 +155,7 @@ class ChatShortcuts extends Addon { provider.getMatches = function(input, ...rest) { try { - const items = t.getSuggestions(input); + const items = t.getSuggestions(input, this); if ( items ) return items; } catch(err) { @@ -211,7 +212,7 @@ class ChatShortcuts extends Addon { // Autocomplete menu - getSuggestions(input) { + getSuggestions(input, provider) { const prefix = this.prefix; if ( typeof input !== 'string' || ! input.startsWith(prefix) || input.length <= prefix.length ) return null; @@ -220,24 +221,80 @@ class ChatShortcuts extends Addon { if ( ! matches.length ) return null; + let lookup = null; + try { + lookup = this.getEmoteLookup(provider); + } 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) + element: this.renderSuggestion(prefix + match.name, match.text, lookup) })); } - renderSuggestion(token, text) { + // Map of emote name -> srcSet 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. + getEmoteLookup(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._lookup && this._lookup_twitch === twitch && this._lookup_ffz === ffz ) + return this._lookup; + + const lookup = new Map; + for(const cache of [twitch, ffz]) + for(const emote of cache?.emotes ?? []) + if ( emote?.token && emote.srcSet && ! lookup.has(emote.token) ) + lookup.set(emote.token, emote.srcSet); + + this._lookup = lookup; + this._lookup_twitch = twitch; + this._lookup_ffz = ffz; + return lookup; + } + + renderSuggestion(token, text, lookup) { const createElement = this.site.getReact()?.createElement; if ( ! createElement ) return token; - const preview = text.length > PREVIEW_LENGTH - ? `${text.slice(0, PREVIEW_LENGTH - 1)}…` - : text; + 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' }, preview); + 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', 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' }]); +}); From 8a7022f5eb8297acbc6e131fadefdecbd5786c10 Mon Sep 17 00:00:00 2001 From: Adam Bodnar Date: Wed, 14 Oct 2026 21:48:12 -0500 Subject: [PATCH 4/8] Chat Shortcuts: editor validation and previews, Backspace undo - validateShortcuts() flags rows parseShortcuts() would skip, plus names that include the prefix; shown inline in the editor. - Editor rows show the emote-rendered preview using the open chat's emotes. - Expansion logic moved to pure expandAt(); Backspace immediately after an expansion restores the original text. Co-Authored-By: Claude Opus 5.5 --- src/chat-shortcuts/index.js | 78 ++++++++++++----- src/chat-shortcuts/matcher.js | 61 +++++++++++++ src/chat-shortcuts/matcher.test.js | 86 ++++++++++++++++++- src/chat-shortcuts/shortcut-list-editor.vue | 95 +++++++++++++++------ 4 files changed, 271 insertions(+), 49 deletions(-) diff --git a/src/chat-shortcuts/index.js b/src/chat-shortcuts/index.js index 38c2ff8..ab94436 100644 --- a/src/chat-shortcuts/index.js +++ b/src/chat-shortcuts/index.js @@ -1,11 +1,14 @@ 'use strict'; -import { parseShortcuts, findExact, findMatches } from './matcher.js'; +import { parseShortcuts, findMatches, expandAt } from './matcher.js'; import { buildPreview } from './preview.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 = ';'; @@ -105,6 +108,8 @@ class ChatShortcuts extends Addon { inst[KeyHandler] = null; } + inst[LastExpansion] = null; + const provider = this.getEmoteProvider(inst); if ( provider?.[OriginalMatches] ) { provider.getMatches = provider[OriginalMatches]; @@ -170,10 +175,22 @@ class ChatShortcuts extends Addon { 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'; + is_tab = event.key === 'Tab' && ! event.shiftKey, + is_backspace = event.key === 'Backspace'; - if ( ! (is_space || is_tab) || event.ctrlKey || event.altKey || event.metaKey || (is_tab && event.shiftKey) ) + if ( ! (is_space || is_tab || is_backspace) ) return; const value = inst.ffzGetValue?.(); @@ -181,35 +198,34 @@ class ChatShortcuts extends Addon { if ( typeof value !== 'string' || start !== end || start == null ) return; - const before = value.slice(0, start), - after = value.slice(start), - word = /\S+$/.exec(before); - - if ( ! word ) - return; - - const prefix = this.prefix; - if ( ! word[0].startsWith(prefix) ) + 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 text = findExact(this.shortcuts, word[0].slice(prefix.length)); - if ( text == null ) + const result = expandAt(value, start, this.prefix, this.shortcuts, is_space); + if ( ! result ) return; - const pad = is_space && ! /^\s/.test(after) ? ' ' : '', - head = before.slice(0, word.index) + text + pad; - - event.preventDefault(); - event.stopPropagation(); - - inst.autocompleteInputRef.setValue(head + after); - inst.ffzSetSelection?.(head.length, head.length); + this.applyValue(inst, event, result.value, result.caret); + inst[LastExpansion] = { from: value, from_caret: start, to: result.value }; } catch(err) { - this.log.error('Error expanding shortcut.', 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) { @@ -235,6 +251,22 @@ class ChatShortcuts extends Addon { })); } + // Emote lookup for the settings editor's previews, from whichever chat input + // is currently mounted. Null when no chat is open. + getPreviewLookup() { + try { + for(const inst of this.input.ChatInput.instances) { + const provider = this.getEmoteProvider(inst); + if ( provider ) + return this.getEmoteLookup(provider); + } + } catch(err) { + this.log.error('Unable to build emote lookup for the editor.', err); + } + + return null; + } + // Map of emote name -> srcSet 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 diff --git a/src/chat-shortcuts/matcher.js b/src/chat-shortcuts/matcher.js index 6e50994..29fa5c6 100644 --- a/src/chat-shortcuts/matcher.js +++ b/src/chat-shortcuts/matcher.js @@ -61,3 +61,64 @@ export function findMatches(shortcuts, partial, limit = DEFAULT_LIMIT) { .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 index a709314..51d6921 100644 --- a/src/chat-shortcuts/matcher.test.js +++ b/src/chat-shortcuts/matcher.test.js @@ -1,7 +1,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { parseShortcuts, findExact, findMatches } from './matcher.js'; +import { parseShortcuts, findExact, findMatches, validateShortcuts, expandAt } from './matcher.js'; const LIST = parseShortcuts([ { name: 'gg', text: 'good game everyone' }, @@ -94,3 +94,87 @@ test('findMatches respects the limit', () => { 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/shortcut-list-editor.vue b/src/chat-shortcuts/shortcut-list-editor.vue index 7fe90c1..48b93a4 100644 --- a/src/chat-shortcuts/shortcut-list-editor.vue +++ b/src/chat-shortcuts/shortcut-list-editor.vue @@ -7,38 +7,65 @@