Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 132 additions & 0 deletions src/chat-shortcuts/emotes.js
Original file line number Diff line number Diff line change
@@ -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;
}
131 changes: 131 additions & 0 deletions src/chat-shortcuts/emotes.test.js
Original file line number Diff line number Diff line change
@@ -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');
});
Loading