mirror of
https://github.com/FrankerFaceZ/FrankerFaceZ.git
synced 2025-08-03 16:38:31 +00:00
1005 lines
28 KiB
JavaScript
1005 lines
28 KiB
JavaScript
'use strict';
|
|
|
|
// ============================================================================
|
|
// RichContent Component
|
|
// ============================================================================
|
|
|
|
import Module from 'utilities/module';
|
|
import { findReactFragment } from 'utilities/dom';
|
|
import { FFZEvent } from 'utilities/events';
|
|
import { getTwitchEmoteSrcSet, has, getTwitchEmoteURL } from 'utilities/object';
|
|
import { TWITCH_POINTS_SETS, TWITCH_GLOBAL_SETS, TWITCH_PRIME_SETS, KNOWN_CODES, REPLACEMENTS, REPLACEMENT_BASE, KEYS } from 'utilities/constants';
|
|
|
|
import Twilight from 'site';
|
|
|
|
// Prefer using these statically-allocated collators to String.localeCompare
|
|
const locale = Intl.Collator();
|
|
const localeCaseInsensitive = Intl.Collator(undefined, {sensitivity: 'accent'});
|
|
|
|
// Describes how an emote matches against a given input
|
|
// Higher values represent a more exact match
|
|
const NO_MATCH = 0;
|
|
const NON_PREFIX_MATCH = 1;
|
|
const CASE_INSENSITIVE_PREFIX_MATCH = 2;
|
|
const EXACT_PREFIX_MATCH = 3;
|
|
|
|
function getNodeText(node) {
|
|
if ( ! node )
|
|
return '';
|
|
|
|
if ( node.type === 'emote' )
|
|
return node.emoteName;
|
|
|
|
if ( node.type === 'text' )
|
|
return node.text;
|
|
|
|
if ( Array.isArray(node.children) )
|
|
return node.children.map(getNodeText).join('');
|
|
|
|
return '';
|
|
}
|
|
|
|
function getNodeOffset(nodes, path) {
|
|
let offset = 0, pidx = 0, n = nodes;
|
|
|
|
while(pidx < path.length) {
|
|
const p = path[pidx];
|
|
|
|
for(let i = 0; i < p; i++)
|
|
offset += getNodeText(n[i]).length;
|
|
|
|
n = Array.isArray(n[p]) ? n[p] : n[p]?.children;
|
|
pidx++;
|
|
}
|
|
|
|
return offset;
|
|
}
|
|
|
|
|
|
export default class Input extends Module {
|
|
constructor(...args) {
|
|
super(...args);
|
|
|
|
this.inject('chat');
|
|
this.inject('chat.actions');
|
|
this.inject('chat.emotes');
|
|
this.inject('chat.emoji');
|
|
this.inject('i18n');
|
|
this.inject('settings');
|
|
|
|
this.inject('site.fine');
|
|
this.inject('site.web_munch');
|
|
|
|
|
|
// Settings
|
|
|
|
this.settings.add('chat.mru.enabled', {
|
|
default: true,
|
|
ui: {
|
|
path: 'Chat > Input >> Recent Messages',
|
|
title: 'Allow pressing up and down to recall previously sent chat messages.',
|
|
component: 'setting-check-box'
|
|
}
|
|
});
|
|
|
|
this.settings.add('chat.tab-complete.ffz-emotes', {
|
|
default: true,
|
|
ui: {
|
|
path: 'Chat > Input >> Tab Completion',
|
|
title: 'Allow tab-completion of FrankerFaceZ emotes.',
|
|
component: 'setting-check-box'
|
|
}
|
|
});
|
|
|
|
this.settings.add('chat.tab-complete.emoji', {
|
|
default: true,
|
|
ui: {
|
|
path: 'Chat > Input >> Tab Completion',
|
|
title: 'Allow tab-completion of emoji.',
|
|
component: 'setting-check-box'
|
|
}
|
|
});
|
|
|
|
this.settings.add('chat.tab-complete.emotes-without-colon', {
|
|
default: false,
|
|
ui: {
|
|
path: 'Chat > Input >> Tab Completion',
|
|
title: 'Allow tab-completion of emotes without typing a colon. (:)',
|
|
description: 'This will prevent the tab-completion of usernames without the @ prefix.',
|
|
component: 'setting-check-box'
|
|
}
|
|
});
|
|
|
|
this.settings.add('chat.tab-complete.limit-results', {
|
|
default: true,
|
|
ui: {
|
|
path: 'Chat > Input >> Tab Completion',
|
|
title: 'Limit tab-completion results to 25.',
|
|
component: 'setting-check-box'
|
|
}
|
|
});
|
|
|
|
this.settings.add('chat.tab-complete.prioritize-favorites', {
|
|
default: false,
|
|
ui: {
|
|
path: 'Chat > Input >> Tab Completion',
|
|
title: 'Prioritize favorite emotes at the top.',
|
|
component: 'setting-check-box'
|
|
}
|
|
});
|
|
|
|
|
|
// Components
|
|
|
|
this.ChatInput = this.fine.define(
|
|
'chat-input',
|
|
n => n && n.setLocalChatInputRef && n.setLocalAutocompleteInputRef,
|
|
Twilight.CHAT_ROUTES
|
|
);
|
|
|
|
this.EmoteSuggestions = this.fine.define(
|
|
'tab-emote-suggestions',
|
|
n => n && n.getMatchedEmotes,
|
|
Twilight.CHAT_ROUTES
|
|
);
|
|
|
|
|
|
this.MentionSuggestions = this.fine.define(
|
|
'tab-mention-suggestions',
|
|
n => n && n.getMentions && n.renderMention,
|
|
Twilight.CHAT_ROUTES
|
|
);
|
|
|
|
this.CommandSuggestions = this.fine.define(
|
|
'tab-cmd-suggestions',
|
|
n => n && n.getMatches && n.doesCommandMatchTerm,
|
|
Twilight.CHAT_ROUTES
|
|
);
|
|
|
|
// Implement Twitch's unfinished emote usage object for prioritizing sorting
|
|
this.EmoteUsageCount = {
|
|
TriHard: 196568036,
|
|
Kappa: 192158118,
|
|
'4Head': 155758710,
|
|
PogChamp: 151485090,
|
|
cmonBruh: 146352878,
|
|
BibleThump: 56472964,
|
|
WutFace: 45069031,
|
|
Kreygasm: 41387580,
|
|
DansGame: 38097659,
|
|
SMOrc: 34734484,
|
|
KappaPride: 34262839,
|
|
VoHiYo: 27886434,
|
|
SwiftRage: 24561900,
|
|
ResidentSleeper: 24438298,
|
|
EleGiggle: 19891526,
|
|
FailFish: 19118343,
|
|
NotLikeThis: 18802905,
|
|
Keepo: 18351415,
|
|
BabyRage: 18220906,
|
|
MingLee: 18026207,
|
|
HeyGuys: 14851569,
|
|
ANELE: 14648986,
|
|
PJSalt: 14438861
|
|
};
|
|
}
|
|
|
|
async onEnable() {
|
|
this.chat.context.on('changed:chat.actions.room', () => this.ChatInput.forceUpdate());
|
|
this.chat.context.on('changed:chat.actions.room-above', () => this.ChatInput.forceUpdate());
|
|
this.chat.context.on('changed:chat.tab-complete.emotes-without-colon', enabled => {
|
|
for (const inst of this.EmoteSuggestions.instances)
|
|
inst.canBeTriggeredByTab = enabled;
|
|
|
|
for (const inst of this.MentionSuggestions.instances)
|
|
inst.canBeTriggeredByTab = !enabled;
|
|
});
|
|
|
|
const React = await this.web_munch.findModule('react'),
|
|
createElement = React && React.createElement;
|
|
|
|
if ( ! createElement )
|
|
return this.log.warn('Unable to get React.');
|
|
|
|
const t = this;
|
|
|
|
this.ChatInput.ready((cls, instances) => {
|
|
const old_render = cls.prototype.render;
|
|
|
|
cls.prototype.render = function() {
|
|
const out = old_render.call(this);
|
|
try {
|
|
const above = t.chat.context.get('chat.actions.room-above'),
|
|
state = t.chat.context.get('context.chat_state') || {},
|
|
container = above ? out : findReactFragment(out, n => n.props && n.props.className === 'chat-input__buttons-container');
|
|
if ( ! container || ! container.props || ! container.props.children )
|
|
return out;
|
|
|
|
const props = this.props;
|
|
if ( ! props || ! props.channelID )
|
|
return out;
|
|
|
|
const u = props.sessionUser ? {
|
|
id: props.sessionUser.id,
|
|
login: props.sessionUser.login,
|
|
displayName: props.sessionUser.displayName,
|
|
mod: props.isCurrentUserModerator,
|
|
staff: props.isStaff
|
|
} : null,
|
|
r = {
|
|
id: props.channelID,
|
|
login: props.channelLogin,
|
|
displayName: props.channelDisplayName,
|
|
emoteOnly: props.emoteOnlyMode,
|
|
slowMode: props.slowMode,
|
|
slowDuration: props.slowModeDuration,
|
|
subsMode: props.subsOnlyMode,
|
|
r9kMode: state.r9k,
|
|
followersOnly: state.followersOnly,
|
|
followersDuration: state.followersOnlyRequirement
|
|
}
|
|
|
|
const actions = t.actions.renderRoom(t.chat.context.get('context.chat.showModIcons'), u, r, above, createElement);
|
|
if ( above )
|
|
container.props.children.unshift(actions || null);
|
|
else
|
|
container.props.children.splice(1, 0, actions || null);
|
|
|
|
} catch(err) {
|
|
t.log.error(err);
|
|
t.log.capture(err);
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
for(const inst of instances) {
|
|
inst.forceUpdate();
|
|
this.emit('site:dom-update', 'chat-input', inst);
|
|
this.updateEmoteCompletion(inst);
|
|
this.overrideChatInput(inst);
|
|
}
|
|
});
|
|
|
|
this.EmoteSuggestions.ready((cls, instances) => {
|
|
for(const inst of instances)
|
|
this.overrideEmoteMatcher(inst);
|
|
});
|
|
|
|
this.MentionSuggestions.ready((cls, instances) => {
|
|
for(const inst of instances)
|
|
this.overrideMentionMatcher(inst);
|
|
});
|
|
|
|
this.CommandSuggestions.ready((cls, instances) => {
|
|
for(const inst of instances)
|
|
this.overrideCommandMatcher(inst);
|
|
});
|
|
|
|
this.ChatInput.on('update', this.updateEmoteCompletion, this);
|
|
this.ChatInput.on('mount', this.overrideChatInput, this);
|
|
this.EmoteSuggestions.on('mount', this.overrideEmoteMatcher, this);
|
|
this.MentionSuggestions.on('mount', this.overrideMentionMatcher, this);
|
|
this.CommandSuggestions.on('mount', this.overrideCommandMatcher, this);
|
|
|
|
this.chat.context.on('changed:chat.emotes.animated', this.uncacheTabCompletion, this);
|
|
this.chat.context.on('changed:chat.emotes.enabled', this.uncacheTabCompletion, this);
|
|
this.on('chat.emotes:change-hidden', this.uncacheTabCompletion, this);
|
|
this.on('chat.emotes:change-set-hidden', this.uncacheTabCompletion, this);
|
|
this.on('chat.emotes:change-favorite', this.uncacheTabCompletion, this);
|
|
this.on('chat.emotes:update-default-sets', this.uncacheTabCompletion, this);
|
|
this.on('chat.emotes:update-user-sets', this.uncacheTabCompletion, this);
|
|
this.on('chat.emotes:update-room-sets', this.uncacheTabCompletion, this);
|
|
|
|
this.on('site.css_tweaks:update-chat-css', this.resizeInput, this);
|
|
}
|
|
|
|
uncacheTabCompletion() {
|
|
for(const inst of this.EmoteSuggestions.instances) {
|
|
inst.ffz_ffz_cache = null;
|
|
inst.ffz_twitch_cache = null;
|
|
}
|
|
}
|
|
|
|
updateInput() {
|
|
for(const inst of this.ChatInput.instances) {
|
|
if ( inst ) {
|
|
inst.forceUpdate();
|
|
this.emit('site:dom-update', 'chat-input', inst);
|
|
}
|
|
}
|
|
}
|
|
|
|
resizeInput() {
|
|
if ( this._resize_waiter )
|
|
cancelAnimationFrame(this._resize_waiter);
|
|
|
|
this._resize_waiter = requestAnimationFrame(() => this._resizeInput())
|
|
}
|
|
|
|
_resizeInput() {
|
|
this._resize_waiter = null;
|
|
for (const chat_input of this.ChatInput.instances)
|
|
chat_input.resizeInput();
|
|
}
|
|
|
|
|
|
updateEmoteCompletion(inst, child) {
|
|
if ( ! child )
|
|
child = this.fine.searchTree(inst, 'tab-emote-suggestions', 50);
|
|
if ( ! child )
|
|
return;
|
|
|
|
child._ffz_user = inst.props.sessionUser;
|
|
child._ffz_channel_id = inst.props.channelID;
|
|
child._ffz_channel_login = inst.props.channelLogin;
|
|
}
|
|
|
|
overrideChatInput(inst) {
|
|
if ( inst._ffz_override )
|
|
return;
|
|
|
|
const t = this;
|
|
|
|
const originalOnKeyDown = inst.onKeyDown,
|
|
originalOnMessageSend = inst.onMessageSend,
|
|
old_resize = inst.resizeInput;
|
|
|
|
inst.resizeInput = function(msg, ...args) {
|
|
try {
|
|
if ( msg ) {
|
|
if ( inst.chatInputRef instanceof Element ) {
|
|
const style = getComputedStyle(inst.chatInputRef),
|
|
height = style && parseFloat(style.lineHeight || 18) || 18,
|
|
t = height * 1 + 20,
|
|
i = Math.ceil((inst.chatInputRef.scrollHeight - t) / height),
|
|
a = Math.min(1 + i, 4);
|
|
|
|
inst.setState({
|
|
numInputRows: a
|
|
});
|
|
}
|
|
} else
|
|
inst.setState({
|
|
numInputRows: 1
|
|
});
|
|
} catch (err) {
|
|
t.log.error('Error in resizeInput', err);
|
|
return old_resize.call(this, msg, ...args);
|
|
}
|
|
}
|
|
|
|
inst.messageHistory = [];
|
|
inst.tempInput = '';
|
|
inst.messageHistoryPos = -1;
|
|
|
|
inst.ffzGetValue = function() {
|
|
if ( inst.chatInputRef && typeof inst.chatInputRef.value === 'string' )
|
|
return inst.chatInputRef.value;
|
|
|
|
if ( inst.state.value && typeof inst.state.value === 'string' )
|
|
return inst.state.value;
|
|
|
|
return '';
|
|
}
|
|
|
|
inst.ffzGetSelection = function() {
|
|
if ( typeof inst.chatInputRef?.selectionEnd === 'number' ) {
|
|
return [inst.chatInputRef.selectionStart, inst.chatInputRef.selectionEnd]
|
|
}
|
|
|
|
if ( inst.chatInputRef?.state?.slateEditor ) {
|
|
const editor = inst.chatInputRef.state.slateEditor,
|
|
sel = editor.selection,
|
|
nodes = editor.children;
|
|
|
|
if ( ! sel?.anchor?.path || ! sel?.focus?.path )
|
|
return [0,0];
|
|
|
|
const first = getNodeOffset(nodes, sel.anchor.path) + sel.anchor.offset,
|
|
second = getNodeOffset(nodes, sel.focus.path) + sel.focus.offset;
|
|
|
|
if ( first < second )
|
|
return [first, second];
|
|
else
|
|
return [second, first];
|
|
}
|
|
|
|
return [0,0];
|
|
}
|
|
|
|
inst.ffzSetSelection = function(start, end) {
|
|
if ( inst.chatInputRef?.setSelectionRange )
|
|
inst.chatInputRef.setSelectionRange(start, end);
|
|
}
|
|
|
|
inst.onKeyDown = function(event) {
|
|
try {
|
|
const code = event.charCode || event.keyCode;
|
|
|
|
if ( inst.onEmotePickerToggle && t.chat.context.get('chat.emote-menu.shortcut') && event.key === 'e' && event.ctrlKey && ! event.altKey && ! event.shiftKey ) {
|
|
inst.onEmotePickerToggle();
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
|
|
const val = inst.ffzGetValue();
|
|
|
|
if ( inst.autocompleteInputRef && inst.chatInputRef && t.chat.context.get('chat.mru.enabled') && ! event.shiftKey && ! event.ctrlKey && ! event.altKey ) {
|
|
const sel = inst.ffzGetSelection();
|
|
|
|
// Arrow Up
|
|
if ( code === 38 && sel[0] === 0 && sel[1] === 0 ) {
|
|
if ( ! inst.messageHistory.length )
|
|
return;
|
|
|
|
if ( val && inst.messageHistoryPos === -1 )
|
|
inst.tempInput = val;
|
|
|
|
if ( inst.messageHistoryPos < inst.messageHistory.length - 1 ) {
|
|
inst.messageHistoryPos++;
|
|
inst.autocompleteInputRef.setValue(inst.messageHistory[inst.messageHistoryPos]);
|
|
inst.ffzSetSelection(0);
|
|
}
|
|
|
|
return;
|
|
|
|
// Arrow Down
|
|
} else if ( code === 40 && sel[0] >= val.length && sel[1] === sel[0] ) {
|
|
if ( ! inst.messageHistory.length )
|
|
return;
|
|
|
|
if ( inst.messageHistoryPos > 0 ) {
|
|
inst.messageHistoryPos--;
|
|
inst.autocompleteInputRef.setValue(inst.messageHistory[inst.messageHistoryPos]);
|
|
inst.ffzSetSelection(inst.messageHistory[inst.messageHistoryPos].length);
|
|
|
|
} else if ( inst.messageHistoryPos === 0 ) {
|
|
inst.autocompleteInputRef.setValue(inst.tempInput);
|
|
inst.ffzSetSelection(inst.tempInput.length);
|
|
inst.messageHistoryPos = -1;
|
|
}
|
|
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Let users close stuff with Escape.
|
|
if ( code === KEYS.Escape && ! event.shiftKey && ! event.ctrlKey && ! event.altKey ) {
|
|
if ( inst.props.isShowingEmotePicker )
|
|
inst.props.closeEmotePicker();
|
|
else if ( inst.props.tray && (! inst.state.value || ! inst.state.value.length) )
|
|
inst.closeTray();
|
|
}
|
|
|
|
} catch(err) {
|
|
t.log.capture(err);
|
|
t.log.error(err);
|
|
}
|
|
|
|
originalOnKeyDown.call(this, event);
|
|
}
|
|
|
|
inst.onMessageSend = function(event) {
|
|
try {
|
|
if ( t.chat.context.get('chat.mru.enabled') ) {
|
|
const val = inst.ffzGetValue();
|
|
if (val && val.length) {
|
|
if (! inst.messageHistory.length || inst.messageHistory[0] !== val) {
|
|
inst.messageHistory.unshift(val);
|
|
inst.messageHistory = inst.messageHistory.slice(0, 20);
|
|
}
|
|
}
|
|
inst.messageHistoryPos = -1;
|
|
inst.tempInput = '';
|
|
}
|
|
|
|
} catch(err) {
|
|
t.log.capture(err);
|
|
t.log.error(err);
|
|
}
|
|
|
|
originalOnMessageSend.call(this, event);
|
|
}
|
|
}
|
|
|
|
|
|
overrideMentionMatcher(inst) {
|
|
inst.canBeTriggeredByTab = !this.chat.context.get('chat.tab-complete.emotes-without-colon');
|
|
}
|
|
|
|
|
|
overrideCommandMatcher(inst) {
|
|
if ( inst._ffz_override )
|
|
return;
|
|
|
|
inst._ffz_override = true;
|
|
inst.oldCommands = inst.getCommands;
|
|
|
|
const t = this;
|
|
|
|
inst.getCommands = function(input) { try {
|
|
const commands = inst.props.getCommands(inst.props.permissionLevel, {
|
|
isEditor: inst.props.isCurrentUserEditor
|
|
});
|
|
|
|
const event = new FFZEvent({
|
|
input,
|
|
permissionLevel: inst.props.permissionLevel,
|
|
isEditor: inst.props.isCurrentUserEditor,
|
|
commands
|
|
});
|
|
|
|
t.emit('chat:get-tab-commands', event);
|
|
|
|
if ( ! commands || ! commands.length )
|
|
return null;
|
|
|
|
// Trim off the starting /
|
|
const i = input.slice(1);
|
|
|
|
const sorted = commands.filter(cmd => inst.doesCommandMatchTerm(cmd, i)).sort(inst.sortCommands);
|
|
const out = [];
|
|
for(const cmd of sorted) {
|
|
const arg = cmd.commandArgs?.[0];
|
|
let selection;
|
|
if ( arg?.isRequired )
|
|
selection = `[${arg.name}]`;
|
|
|
|
out.push({
|
|
current: input,
|
|
replacement: inst.determineReplacement(cmd),
|
|
element: inst.renderCommandSuggestion(cmd, i),
|
|
group: cmd.ffz_group ?
|
|
(Array.isArray(cmd.ffz_group) ? t.i18n.t(...cmd.ffz_group) : cmd.ffz_group)
|
|
: inst.determineGroup(cmd),
|
|
selection
|
|
});
|
|
}
|
|
|
|
return out;
|
|
|
|
} catch(err) {
|
|
console.error(err);
|
|
return inst.oldCommands(input);
|
|
}}
|
|
}
|
|
|
|
|
|
overrideEmoteMatcher(inst) {
|
|
if ( inst._ffz_override )
|
|
return;
|
|
|
|
const t = this;
|
|
|
|
inst.canBeTriggeredByTab = this.chat.context.get('chat.tab-complete.emotes-without-colon');
|
|
|
|
inst.getMatches = function(input, pressedTab) {
|
|
return pressedTab
|
|
? input.length < 2 ? null : inst.getMatchedEmotes(input)
|
|
: input.startsWith(':') ? input.length < 3 ? null : inst.getMatchedEmotes(input) : null;
|
|
}
|
|
|
|
inst.doesEmoteMatchTerm = function(emote, term) {
|
|
const emote_name = emote.name || emote.token;
|
|
if ( ! emote_name )
|
|
return NO_MATCH;
|
|
|
|
if (emote_name.startsWith(term))
|
|
return EXACT_PREFIX_MATCH;
|
|
|
|
let emote_lower = emote.tokenLower;
|
|
if ( ! emote_lower )
|
|
emote_lower = emote_name.toLowerCase();
|
|
|
|
const term_lower = term.toLowerCase();
|
|
if (emote_lower.startsWith(term_lower))
|
|
return CASE_INSENSITIVE_PREFIX_MATCH;
|
|
|
|
const idx = emote_name.indexOf(term.charAt(0).toUpperCase());
|
|
if (idx !== -1 && emote_lower.slice(idx + 1).startsWith(term_lower.slice(1)))
|
|
return NON_PREFIX_MATCH;
|
|
|
|
return NO_MATCH;
|
|
}
|
|
|
|
inst.getMatchedEmotes = function(input) {
|
|
const setting = t.chat.context.get('chat.emotes.enabled');
|
|
const limitResults = t.chat.context.get('chat.tab-complete.limit-results');
|
|
let results = setting ? t.getTwitchEmoteSuggestions(input, this) : [];
|
|
|
|
if ( setting > 1 && t.chat.context.get('chat.tab-complete.ffz-emotes') ) {
|
|
const ffz_emotes = t.getEmoteSuggestions(input, this);
|
|
if ( Array.isArray(ffz_emotes) && ffz_emotes.length )
|
|
results = results.concat(ffz_emotes);
|
|
}
|
|
|
|
if ( t.chat.context.get('chat.tab-complete.emoji') ) {
|
|
const emoji = t.getEmojiSuggestions(input, this);
|
|
if ( Array.isArray(emoji) && emoji.length )
|
|
results = Array.isArray(results) ? results.concat(emoji) : emoji;
|
|
}
|
|
|
|
results = t.sortEmotes(results);
|
|
return limitResults && results.length > 25 ? results.slice(0, 25) : results;
|
|
}
|
|
|
|
const React = this.web_munch.getModule('react'),
|
|
createElement = React && React.createElement;
|
|
|
|
inst.renderFFZEmojiSuggestion = function(data) {
|
|
return (<React.Fragment>
|
|
<div class="tw-relative tw-flex-shrink-0 tw-pd-05" title={data.token} favorite={data.favorite}>
|
|
<img
|
|
class="emote-autocomplete-provider__image ffz-emoji"
|
|
src={data.src}
|
|
srcSet={data.srcset}
|
|
/>
|
|
{data.favorite && <figure class="ffz--favorite ffz-i-star" />}
|
|
</div>
|
|
<div class="tw-ellipsis" title={data.token}>
|
|
{data.token}
|
|
</div>
|
|
</React.Fragment>);
|
|
}
|
|
|
|
inst.renderEmoteSuggestion = function(emote) {
|
|
return (<React.Fragment>
|
|
<div class="tw-relative tw-flex-shrink-0 tw-pd-05" title={emote.token} favorite={emote.favorite}>
|
|
<img
|
|
class="emote-autocomplete-provider__image"
|
|
srcSet={emote.srcSet}
|
|
/>
|
|
{emote.favorite && <figure class="ffz--favorite ffz-i-star" />}
|
|
</div>
|
|
<div class="tw-ellipsis" title={emote.token}>
|
|
{emote.token}
|
|
</div>
|
|
</React.Fragment>);
|
|
}
|
|
}
|
|
|
|
|
|
// eslint-disable-next-line class-methods-use-this
|
|
sortEmotes(emotes) {
|
|
const preferFavorites = this.chat.context.get('chat.tab-complete.prioritize-favorites');
|
|
|
|
return emotes.sort((a, b) => {
|
|
const aStr = a.replacement;
|
|
const bStr = b.replacement;
|
|
|
|
// Prefer favorites over non-favorites, if enabled
|
|
if (preferFavorites && (a.favorite ^ b.favorite))
|
|
return 0 - a.favorite + b.favorite;
|
|
|
|
// Prefer case-sensitive prefix matches
|
|
const aStartsWithInput = (a.match_type === EXACT_PREFIX_MATCH);
|
|
const bStartsWithInput = (b.match_type === EXACT_PREFIX_MATCH);
|
|
if (aStartsWithInput && bStartsWithInput)
|
|
return locale.compare(aStr, bStr);
|
|
else if (aStartsWithInput) return -1;
|
|
else if (bStartsWithInput) return 1;
|
|
|
|
// Else prefer case-insensitive prefix matches
|
|
const aStartsWithInputCI = (a.match_type === CASE_INSENSITIVE_PREFIX_MATCH);
|
|
const bStartsWithInputCI = (b.match_type === CASE_INSENSITIVE_PREFIX_MATCH);
|
|
if (aStartsWithInputCI && bStartsWithInputCI)
|
|
return localeCaseInsensitive.compare(aStr, bStr);
|
|
else if (aStartsWithInputCI) return -1;
|
|
else if (bStartsWithInputCI) return 1;
|
|
|
|
// Else alphabetize
|
|
return locale.compare(aStr, bStr);
|
|
});
|
|
}
|
|
|
|
|
|
buildTwitchCache(emotes) {
|
|
if ( ! Array.isArray(emotes) )
|
|
return {emotes: [], length: 0};
|
|
|
|
const out = [],
|
|
anim = this.chat.context.get('chat.emotes.animated') > 0,
|
|
hidden_sets = this.settings.provider.get('emote-menu.hidden-sets'),
|
|
has_hidden = Array.isArray(hidden_sets) && hidden_sets.length > 0,
|
|
hidden_emotes = this.emotes.getHidden('twitch'),
|
|
favorites = this.emotes.getFavorites('twitch');
|
|
|
|
for(const set of emotes) {
|
|
const int_id = parseInt(set.id, 10),
|
|
owner = set.owner,
|
|
is_points = TWITCH_POINTS_SETS.includes(int_id) || owner?.login === 'channel_points',
|
|
channel = is_points ? null : owner;
|
|
|
|
let key = `twitch-set-${set.id}`;
|
|
let extra = null;
|
|
|
|
if ( channel?.login ) {
|
|
key = `twitch-${channel.id}`;
|
|
extra = channel.displayName || channel.login;
|
|
|
|
} else if ( is_points )
|
|
key = 'twitch-points';
|
|
else if ( TWITCH_GLOBAL_SETS.includes(int_id) )
|
|
key = 'twitch-global';
|
|
else if ( TWITCH_PRIME_SETS.includes(int_id) )
|
|
key = 'twitch-prime';
|
|
else
|
|
key = 'twitch-misc';
|
|
|
|
if ( has_hidden && hidden_sets.includes(key) )
|
|
continue;
|
|
|
|
for(const emote of set.emotes) {
|
|
if ( ! emote || ! emote.id || hidden_emotes.includes(emote.id) )
|
|
continue;
|
|
|
|
const id = emote.id,
|
|
token = KNOWN_CODES[emote.token] || emote.token;
|
|
|
|
if ( ! token )
|
|
continue;
|
|
|
|
const replacement = REPLACEMENTS[id];
|
|
let srcSet;
|
|
|
|
if ( replacement && this.chat.context.get('chat.fix-bad-emotes') ) {
|
|
srcSet = `${REPLACEMENT_BASE}${replacement} 1x`;
|
|
} else
|
|
srcSet = getTwitchEmoteSrcSet(id, anim);
|
|
|
|
out.push({
|
|
id,
|
|
source: key,
|
|
extra,
|
|
setID: set.id,
|
|
token,
|
|
tokenLower: token.toLowerCase(),
|
|
srcSet,
|
|
favorite: favorites.includes(id)
|
|
});
|
|
}
|
|
}
|
|
|
|
return {
|
|
emotes: out,
|
|
length: emotes.length
|
|
}
|
|
}
|
|
|
|
|
|
getTwitchEmoteSuggestions(input, inst) {
|
|
if ( inst.ffz_twitch_cache?.length !== inst.props.emotes?.length )
|
|
inst.ffz_twitch_cache = this.buildTwitchCache(inst.props.emotes);
|
|
|
|
const emotes = inst.ffz_twitch_cache.emotes;
|
|
|
|
if ( ! emotes.length )
|
|
return [];
|
|
|
|
const results_usage = [],
|
|
results_starting = [],
|
|
results_other = [],
|
|
|
|
search = input.startsWith(':') ? input.slice(1) : input;
|
|
|
|
for(const emote of emotes) {
|
|
const match_type = inst.doesEmoteMatchTerm(emote, search);
|
|
if ( match_type !== NO_MATCH ) {
|
|
const element = {
|
|
current: input,
|
|
emote,
|
|
replacement: emote.token,
|
|
element: inst.renderEmoteSuggestion(emote),
|
|
favorite: emote.favorite,
|
|
count: this.EmoteUsageCount[emote.token] || 0,
|
|
match_type
|
|
};
|
|
|
|
if ( element.count > 0 )
|
|
results_usage.push(element);
|
|
else if ( match_type > NON_PREFIX_MATCH )
|
|
results_starting.push(element);
|
|
else
|
|
results_other.push(element);
|
|
}
|
|
}
|
|
|
|
results_usage.sort((a,b) => b.count - a.count);
|
|
results_starting.sort((a,b) => locale.compare(a.replacement, b.replacement));
|
|
results_other.sort((a,b) => locale.compare(a.replacement, b.replacement));
|
|
|
|
return results_usage.concat(results_starting).concat(results_other);
|
|
}
|
|
|
|
|
|
getEmojiSuggestions(input, inst) {
|
|
if (!input.startsWith(':')) {
|
|
return [];
|
|
}
|
|
|
|
let search = input.slice(1).toLowerCase();
|
|
const style = this.chat.context.get('chat.emoji.style'),
|
|
tone = this.settings.provider.get('emoji-tone', null),
|
|
favorites = this.emotes.getFavorites('emoji'),
|
|
results = [],
|
|
has_colon = search.endsWith(':');
|
|
|
|
if ( has_colon )
|
|
search = search.slice(0,-1);
|
|
|
|
const included = new Set;
|
|
|
|
for(const name in this.emoji.names)
|
|
if ( has_colon ? name === search : name.startsWith(search) ) {
|
|
const emoji = this.emoji.emoji[this.emoji.names[name]],
|
|
toned = emoji.variants && emoji.variants[tone],
|
|
source = toned || emoji;
|
|
|
|
if ( emoji && (style === 0 || source.has[style]) && ! included.has(source.raw) ) {
|
|
included.add(source.raw);
|
|
|
|
const srcSet = this.emoji.getFullImageSet(source.image, style);
|
|
const matched = `:${name}:`;
|
|
|
|
const favorite = favorites.includes(emoji.code);
|
|
results.push({
|
|
current: input,
|
|
emoji: source,
|
|
matched,
|
|
srcSet,
|
|
replacement: source.raw,
|
|
element: inst.renderFFZEmojiSuggestion({
|
|
token: matched,
|
|
id: `emoji-${emoji.code}`,
|
|
src: this.emoji.getFullImage(source.image, style),
|
|
srcSet,
|
|
favorite
|
|
}),
|
|
favorite
|
|
});
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
|
|
buildFFZCache(user_id, user_login, channel_id, channel_login) {
|
|
const sets = this.emotes.getSets(user_id, user_login, channel_id, channel_login);
|
|
if ( ! sets || ! sets.length )
|
|
return {emotes: [], length: 0, user_id, user_login, channel_id, channel_login};
|
|
|
|
const out = [],
|
|
anim = this.chat.context.get('chat.emotes.animated') > 0,
|
|
hidden_sets = this.settings.provider.get('emote-menu.hidden-sets'),
|
|
has_hidden = Array.isArray(hidden_sets) && hidden_sets.length > 0,
|
|
added_emotes = new Set;
|
|
|
|
for(const set of sets) {
|
|
if ( ! set || ! set.emotes )
|
|
continue;
|
|
|
|
const source = set.source || 'ffz',
|
|
source_line = set.source_line || (`${set.source || 'FFZ'} ${set.title || 'Global'}`),
|
|
key = `${set.merge_source || source}-${set.merge_id || set.id}`;
|
|
|
|
if ( has_hidden && hidden_sets.includes(key) )
|
|
continue;
|
|
|
|
const hidden_emotes = this.emotes.getHidden(source),
|
|
favorites = this.emotes.getFavorites(source);
|
|
|
|
for(const emote of Object.values(set.emotes)) {
|
|
if ( ! emote || ! emote.id || emote.hidden || hidden_emotes.includes(emote.id) || added_emotes.has(emote.name) )
|
|
continue;
|
|
|
|
if ( ! emote.name )
|
|
continue;
|
|
|
|
added_emotes.add(emote.name);
|
|
|
|
out.push({
|
|
id: `${source}-${emote.id}`,
|
|
source,
|
|
extra: source_line,
|
|
token: emote.name,
|
|
tokenLower: emote.name.toLowerCase(),
|
|
srcSet: anim && emote.animSrcSet || emote.srcSet,
|
|
favorite: favorites.includes(emote.id)
|
|
});
|
|
}
|
|
}
|
|
|
|
return {
|
|
emotes: out,
|
|
length: sets.length
|
|
}
|
|
}
|
|
|
|
|
|
getEmoteSuggestions(input, inst) {
|
|
const user = inst._ffz_user,
|
|
channel_id = inst._ffz_channel_id,
|
|
channel_login = inst._ffz_channel_login;
|
|
|
|
if ( ! channel_login ) {
|
|
const parent = this.fine.searchParent(inst, 'chat-input', 50);
|
|
if ( parent )
|
|
this.updateEmoteCompletion(parent, inst);
|
|
|
|
if ( ! channel_login )
|
|
return [];
|
|
}
|
|
|
|
let cache = inst.ffz_ffz_cache;
|
|
if ( ! cache || cache.user_id !== user?.id || cache.user_login !== user?.login || cache.channel_id !== channel_id || cache.channel_login !== channel_login )
|
|
cache = inst.ffz_ffz_cache = this.buildFFZCache(user?.id, user?.login, channel_id, channel_login);
|
|
|
|
const emotes = cache.emotes;
|
|
if ( ! emotes.length )
|
|
return [];
|
|
|
|
const search = input.startsWith(':') ? input.slice(1) : input,
|
|
results = [];
|
|
|
|
for(const emote of emotes) {
|
|
const match_type = inst.doesEmoteMatchTerm(emote, search)
|
|
if ( match_type !== NO_MATCH )
|
|
results.push({
|
|
current: input,
|
|
emote,
|
|
replacement: emote.token,
|
|
element: inst.renderEmoteSuggestion(emote),
|
|
favorite: emote.favorite,
|
|
count: 0, // TODO: Count stuff?
|
|
match_type
|
|
});
|
|
}
|
|
|
|
return results;
|
|
|
|
/*for(const set of sets) {
|
|
if ( ! set || ! set.emotes )
|
|
continue;
|
|
|
|
const
|
|
|
|
if ( set && set.emotes )
|
|
for(const emote of Object.values(set.emotes))
|
|
if ( inst.doesEmoteMatchTerm(emote, search) && !added_emotes.has(emote.name) && ! this.emotes.isHidden(set.source || 'ffz', emote.id) ) {
|
|
const favorite = this.emotes.isFavorite(set.source || 'ffz', emote.id);
|
|
results.push({
|
|
current: input,
|
|
replacement: emote.name,
|
|
element: inst.renderEmoteSuggestion({
|
|
token: emote.name,
|
|
id: `${set.source}-${emote.id}`,
|
|
srcSet: emote.srcSet,
|
|
favorite
|
|
}),
|
|
favorite
|
|
});
|
|
added_emotes.add(emote.name);
|
|
}
|
|
}
|
|
|
|
return results;*/
|
|
}
|
|
|
|
pasteMessage(room, message) {
|
|
for(const inst of this.ChatInput.instances) {
|
|
if ( inst?.props?.channelLogin !== room )
|
|
continue;
|
|
|
|
if ( ! inst.autocompleteInputRef || ! inst.state )
|
|
return;
|
|
|
|
if ( inst.state.value )
|
|
message = `${inst.state.value} ${message}`;
|
|
|
|
inst.autocompleteInputRef.setValue(message);
|
|
inst.autocompleteInputRef.componentRef?.focus?.();
|
|
}
|
|
}
|
|
}
|