mirror of
https://github.com/FrankerFaceZ/FrankerFaceZ.git
synced 2025-06-27 12:55:55 +00:00
2465 lines
No EOL
63 KiB
JavaScript
2465 lines
No EOL
63 KiB
JavaScript
'use strict';
|
|
|
|
// ============================================================================
|
|
// Emote Handling and Default Provider
|
|
// ============================================================================
|
|
|
|
import Module, { buildAddonProxy } from 'utilities/module';
|
|
import {ManagedStyle} from 'utilities/dom';
|
|
|
|
import {get, has, timeout, SourcedSet, make_enum_flags, makeAddonIdChecker} from 'utilities/object';
|
|
import {NEW_API, IS_OSX, EmoteTypes, TWITCH_GLOBAL_SETS, TWITCH_POINTS_SETS, TWITCH_PRIME_SETS, DEBUG} from 'utilities/constants';
|
|
|
|
import GET_EMOTE from './emote_info.gql';
|
|
import GET_EMOTE_SET from './emote_set_info.gql';
|
|
|
|
const HoverRAF = Symbol('FFZ:Hover:RAF');
|
|
const HoverState = Symbol('FFZ:Hover:State');
|
|
|
|
const MOD_KEY = IS_OSX ? 'metaKey' : 'ctrlKey';
|
|
|
|
const Flags = make_enum_flags(
|
|
'Hidden',
|
|
'FlipX',
|
|
'FlipY',
|
|
'GrowX',
|
|
'Slide',
|
|
'Appear',
|
|
'Leave',
|
|
'Rotate',
|
|
'Rotate90',
|
|
'Greyscale',
|
|
'Sepia',
|
|
'Rainbow',
|
|
'HyperRed',
|
|
'Shake',
|
|
'Cursed',
|
|
'Jam',
|
|
'Bounce',
|
|
'NoSpace'
|
|
);
|
|
|
|
export const MODIFIER_FLAGS = Flags;
|
|
|
|
export const MODIFIER_KEYS = Object.values(MODIFIER_FLAGS).filter(x => typeof x === 'number');
|
|
|
|
const APPEAR_FRAMES = [
|
|
[0, -18, 0, 0],
|
|
[19.99, -18, 0, 0],
|
|
[20, -18, 0.1, 0],
|
|
[25, -16, 0.2, 0.6],
|
|
[30, -14, 0.3, -4],
|
|
[35, -12, 0.4, 0.6],
|
|
[40, -10, 0.5, -4],
|
|
[45, -8, 0.6, 2],
|
|
[50, -6, 0.7, -3],
|
|
[55, -4, 0.8, 2],
|
|
[60, -2, 0.9, -3],
|
|
[65, 0, 1, 0],
|
|
[100, 0, 1, 0]
|
|
];
|
|
|
|
const LEAVE_FRAMES = [
|
|
[0, 0, 1, 0],
|
|
[39.99, 0, 1, 0],
|
|
[40, 0, -.9, .9, -3],
|
|
[45, -2, -.8, .8, 2],
|
|
[50, -4, -.7, .7, -3],
|
|
[55, -6, -.6, .6, 2],
|
|
[60, -8, -.5, .5, -4],
|
|
[65, -10, -.4, .4, .6],
|
|
[70, -12, -.3, .3, -4],
|
|
[75, -14, -.2, .2, .6],
|
|
[80, -16, -.1, .1, 0],
|
|
[85, -18, -0.01, 0, 0],
|
|
[100, -18, 0, 0, 0]
|
|
];
|
|
|
|
|
|
function appearLeaveToKeyframes(source, multi = 1, offset = 0, has_var = false) {
|
|
const out = [];
|
|
|
|
for(const line of source) {
|
|
const pct = (line[0] * multi) + offset;
|
|
|
|
let vr, tx, scale, ty;
|
|
vr = has_var ? `var(--ffz-effect-transforms) ` : '';
|
|
tx = line[1] === 0 ? '' : `translateX(${line[1]}px) `;
|
|
|
|
if ( line.length === 4 ) {
|
|
scale = `scale(${line[2]})`;
|
|
ty = line[3] === 0 ? '' : ` translateY(${line[3]}px)`;
|
|
|
|
} else {
|
|
const sx = line[2],
|
|
sy = line[3];
|
|
|
|
scale = `scale(${sx}, ${sy})`;
|
|
|
|
ty = line[4] === 0 ? '' : ` translateY(${line[4]}px)`;
|
|
}
|
|
|
|
out.push(`\t${pct}% { transform:${vr}${tx}${scale}${ty}; }`);
|
|
}
|
|
|
|
return out.join('\n');
|
|
}
|
|
|
|
|
|
|
|
const EFFECT_STYLES = [
|
|
{
|
|
setting: 'FlipX',
|
|
flags: Flags.FlipX,
|
|
title: 'Flip Horizontal',
|
|
transform: 'scaleX(-1)'
|
|
},
|
|
{
|
|
setting: 'FlipY',
|
|
flags: Flags.FlipY,
|
|
title: 'Flip Vertical',
|
|
transform: 'scaleY(-1)'
|
|
},
|
|
{
|
|
setting: 'ShrinkX',
|
|
flags: Flags.ShrinkX,
|
|
title: 'Squish Horizontal'
|
|
},
|
|
{
|
|
setting: 'GrowX',
|
|
flags: Flags.GrowX,
|
|
title: 'Stretch Horizontal'
|
|
},
|
|
{
|
|
setting: 'Slide',
|
|
flags: Flags.Slide,
|
|
//not_flags: Flags.Rotate,
|
|
title: 'Slide Animation',
|
|
as_background: true,
|
|
animation: 'ffz-effect-slide var(--ffz-speed-x) linear infinite',
|
|
raw: `@keyframes ffz-effect-slide {
|
|
0% { background-position-x: 0; }
|
|
100% { background-position-x: calc(-1 * var(--ffz-width)); }
|
|
}`
|
|
},
|
|
{
|
|
setting: 'Appear',
|
|
flags: Flags.Appear,
|
|
not_flags: Flags.Leave,
|
|
title: 'Appear Animation',
|
|
animation: 'ffz-effect-appear 3s infinite linear',
|
|
animationTransform: 'ffz-effect-appear-transform 3s linear infinite',
|
|
raw: `@keyframes ffz-effect-appear {
|
|
${appearLeaveToKeyframes(APPEAR_FRAMES)}
|
|
}
|
|
@keyframes ffz-effect-appear-transform {
|
|
${appearLeaveToKeyframes(APPEAR_FRAMES, 1, 0, true)}
|
|
}`
|
|
},
|
|
{
|
|
setting: 'Leave',
|
|
flags: Flags.Leave,
|
|
not_flags: Flags.Appear,
|
|
title: 'Leave Animation',
|
|
animation: 'ffz-effect-leave 3s infinite linear',
|
|
animationTransform: 'ffz-effect-leave-transform 3s infinite linear',
|
|
raw: `@keyframes ffz-effect-leave {
|
|
${appearLeaveToKeyframes(LEAVE_FRAMES)}
|
|
}
|
|
@keyframes ffz-effect-leave-transform {
|
|
${appearLeaveToKeyframes(LEAVE_FRAMES, 1, 0, true)}
|
|
}`
|
|
},
|
|
{
|
|
setting: [
|
|
'Appear',
|
|
'Leave'
|
|
],
|
|
flags: Flags.Appear | Flags.Leave,
|
|
animation: 'ffz-effect-in-out 6s infinite linear',
|
|
animationTransform: 'ffz-effect-in-out-transform 6s linear infinite',
|
|
raw: `@keyframes ffz-effect-in-out {
|
|
${appearLeaveToKeyframes(APPEAR_FRAMES, 0.5, 0)}
|
|
${appearLeaveToKeyframes(LEAVE_FRAMES, 0.5, 50)}
|
|
}
|
|
@keyframes ffz-effect-in-out-transform {
|
|
${appearLeaveToKeyframes(APPEAR_FRAMES, 0.5, 0, true)}
|
|
${appearLeaveToKeyframes(LEAVE_FRAMES, 0.5, 50, true)}
|
|
}`
|
|
},
|
|
{
|
|
setting: 'Rotate',
|
|
flags: Flags.Rotate,
|
|
not_flags: Flags.Slide,
|
|
title: 'Rotate Animation',
|
|
no_wide: true,
|
|
animation: 'ffz-effect-rotate 1.5s infinite linear',
|
|
animationTransform: 'ffz-effect-rotate-transform 1.5s infinite linear',
|
|
raw: `@keyframes ffz-effect-rotate {
|
|
0% { transform: rotate(0deg); }
|
|
100% { transform: rotate(360deg); }
|
|
}
|
|
@keyframes ffz-effect-rotate-transform {
|
|
0% { transform: var(--ffz-effect-transforms) rotate(0deg); }
|
|
100% { transform: var(--ffz-effect-transforms) rotate(360deg); }
|
|
}`
|
|
},
|
|
/*{
|
|
setting: [
|
|
'Slide',
|
|
'Rotate'
|
|
],
|
|
flags: Flags.Rotate | Flags.Slide,
|
|
// Sync up the speed for slide and rotate if both are applied.
|
|
animation: 'ffz-effect-slide calc(1.5 * var(--ffz-speed-x)) linear infinite'
|
|
},
|
|
{
|
|
setting: 'Greyscale',
|
|
flags: Flags.Greyscale,
|
|
filter: 'grayscale(1)'
|
|
},
|
|
{
|
|
setting: 'Sepia',
|
|
flags: Flags.Sepia,
|
|
filter: 'sepia(1)'
|
|
},*/
|
|
{
|
|
setting: 'Rainbow',
|
|
flags: Flags.Rainbow,
|
|
title: 'Rainbow Animation',
|
|
animation: 'ffz-effect-rainbow 2s linear infinite',
|
|
animationFilter: 'ffz-effect-rainbow-filter 2s linear infinite',
|
|
raw: `@keyframes ffz-effect-rainbow {
|
|
0% { filter: hue-rotate(0deg) }
|
|
100% { filter: hue-rotate(360deg) }
|
|
}
|
|
@keyframes ffz-effect-rainbow-filter {
|
|
0% { filter: var(--ffz-effect-filters) hue-rotate(0deg) }
|
|
100% { filter: var(--ffz-effect-filters) hue-rotate(360deg) }
|
|
}`
|
|
},
|
|
{
|
|
setting: 'HyperRed',
|
|
flags: Flags.HyperRed,
|
|
title: 'Hyper Red',
|
|
filter: 'brightness(0.2) sepia(1) brightness(2.2) contrast(3) saturate(8)'
|
|
},
|
|
{
|
|
setting: 'Shake',
|
|
flags: Flags.Shake,
|
|
title: 'Hyper Shake Animation',
|
|
animation: 'ffz-effect-shake 0.1s linear infinite',
|
|
animationTransform: 'ffz-effect-shake-transform 0.1s linear infinite',
|
|
raw: `@keyframes ffz-effect-shake-transform {
|
|
0% { transform: var(--ffz-effect-transforms) translate(1px, 1px); }
|
|
10% { transform: var(--ffz-effect-transforms) translate(-1px, -2px); }
|
|
20% { transform: var(--ffz-effect-transforms) translate(-3px, 0px); }
|
|
30% { transform: var(--ffz-effect-transforms) translate(3px, 2px); }
|
|
40% { transform: var(--ffz-effect-transforms) translate(1px, -1px); }
|
|
50% { transform: var(--ffz-effect-transforms) translate(-1px, 2px); }
|
|
60% { transform: var(--ffz-effect-transforms) translate(-3px, 1px); }
|
|
70% { transform: var(--ffz-effect-transforms) translate(3px, 1px); }
|
|
80% { transform: var(--ffz-effect-transforms) translate(-1px, -1px); }
|
|
90% { transform: var(--ffz-effect-transforms) translate(1px, 2px); }
|
|
100% { transform: var(--ffz-effect-transforms) translate(1px, -2px); }
|
|
}
|
|
@keyframes ffz-effect-shake {
|
|
0% { transform: translate(1px, 1px); }
|
|
10% { transform: translate(-1px, -2px); }
|
|
20% { transform: translate(-3px, 0px); }
|
|
30% { transform: translate(3px, 2px); }
|
|
40% { transform: translate(1px, -1px); }
|
|
50% { transform: translate(-1px, 2px); }
|
|
60% { transform: translate(-3px, 1px); }
|
|
70% { transform: translate(3px, 1px); }
|
|
80% { transform: translate(-1px, -1px); }
|
|
90% { transform: translate(1px, 2px); }
|
|
100% { transform: translate(1px, -2px); }
|
|
}`
|
|
},
|
|
{
|
|
setting: 'Photocopy',
|
|
flags: Flags.Cursed,
|
|
title: 'Cursed',
|
|
filter: 'grayscale(1) brightness(0.7) contrast(2.5)'
|
|
},
|
|
{
|
|
setting: 'Jam',
|
|
flags: Flags.Jam,
|
|
title: 'Jam Animation',
|
|
animation: 'ffz-effect-jam 0.6s linear infinite',
|
|
animationTransform: 'ffz-effect-jam-transform 0.6s linear infinite',
|
|
raw: `@keyframes ffz-effect-jam {
|
|
0% { transform: translate(-2px, -2px) rotate(-6deg); }
|
|
10% { transform: translate(-1.5px, -2px) rotate(-8deg); }
|
|
20% { transform: translate(1px, -1.5px) rotate(-8deg); }
|
|
30% { transform: translate(3px, 2.5px) rotate(-6deg); }
|
|
40% { transform: translate(3px, 4px) rotate(-2deg); }
|
|
50% { transform: translate(2px, 4px) rotate(3deg); }
|
|
60% { transform: translate(1px, 4px) rotate(3deg); }
|
|
70% { transform: translate(-0.5px, 3px) rotate(2deg); }
|
|
80% { transform: translate(-1.25px, 1px) rotate(0deg); }
|
|
90% { transform: translate(-1.75px, -0.5px) rotate(-2deg); }
|
|
100% { transform: translate(-2px, -2px) rotate(-5deg); }
|
|
}
|
|
@keyframes ffz-effect-jam-transform {
|
|
0% { transform: var(--ffz-effect-transforms) translate(-2px, -2px) rotate(-6deg); }
|
|
10% { transform: var(--ffz-effect-transforms) translate(-1.5px, -2px) rotate(-8deg); }
|
|
20% { transform: var(--ffz-effect-transforms) translate(1px, -1.5px) rotate(-8deg); }
|
|
30% { transform: var(--ffz-effect-transforms) translate(3px, 2.5px) rotate(-6deg); }
|
|
40% { transform: var(--ffz-effect-transforms) translate(3px, 4px) rotate(-2deg); }
|
|
50% { transform: var(--ffz-effect-transforms) translate(2px, 4px) rotate(3deg); }
|
|
60% { transform: var(--ffz-effect-transforms) translate(1px, 4px) rotate(3deg); }
|
|
70% { transform: var(--ffz-effect-transforms) translate(-0.5px, 3px) rotate(2deg); }
|
|
80% { transform: var(--ffz-effect-transforms) translate(-1.25px, 1px) rotate(0deg); }
|
|
90% { transform: var(--ffz-effect-transforms) translate(-1.75px, -0.5px) rotate(-2deg); }
|
|
100% { transform: var(--ffz-effect-transforms) translate(-2px, -2px) rotate(-5deg); }
|
|
}`
|
|
},
|
|
{
|
|
setting: 'Bounce',
|
|
flags: Flags.Bounce,
|
|
animation: 'ffz-effect-bounce 0.5s linear infinite',
|
|
animationTransform: 'ffz-effect-bounce-transform 0.5s linear infinite',
|
|
transformOrigin: 'bottom center',
|
|
raw: `@keyframes ffz-effect-bounce {
|
|
0% { transform: scale(0.8, 1); }
|
|
10% { transform: scale(0.9, 0.8); }
|
|
20% { transform: scale(1, 0.4); }
|
|
25% { transform: scale(1.2, 0.3); }
|
|
25.001% { transform: scale(-1.2, 0.3); }
|
|
30% { transform: scale(-1, 0.4); }
|
|
40% { transform: scale(-0.9, 0.8); }
|
|
50% { transform: scale(-0.8, 1); }
|
|
60% { transform: scale(-0.9, 0.8); }
|
|
70% { transform: scale(-1, 0.4); }
|
|
75% { transform: scale(-1.2, 0.3); }
|
|
75.001% { transform: scale(1.2, 0.3); }
|
|
80% { transform: scale(1, 0.4); }
|
|
90% { transform: scale(0.9, 0.8); }
|
|
100% { transform: scale(0.8, 1); }
|
|
}
|
|
@keyframes ffz-effect-bounce-transform {
|
|
0% { transform: scale(0.8, 1) var(--ffz-effect-transforms); }
|
|
10% { transform: scale(0.9, 0.8) var(--ffz-effect-transforms); }
|
|
20% { transform: scale(1, 0.4) var(--ffz-effect-transforms); }
|
|
25% { transform: scale(1.2, 0.3) var(--ffz-effect-transforms); }
|
|
25.001% { transform: scale(-1.2, 0.3) var(--ffz-effect-transforms); }
|
|
30% { transform: scale(-1, 0.4) var(--ffz-effect-transforms); }
|
|
40% { transform: scale(-0.9, 0.8) var(--ffz-effect-transforms); }
|
|
50% { transform: scale(-0.8, 1) var(--ffz-effect-transforms); }
|
|
60% { transform: scale(-0.9, 0.8) var(--ffz-effect-transforms); }
|
|
70% { transform: scale(-1, 0.4) var(--ffz-effect-transforms); }
|
|
75% { transform: scale(-1.2, 0.3) var(--ffz-effect-transforms); }
|
|
75.001% { transform: scale(1.2, 0.3) var(--ffz-effect-transforms); }
|
|
80% { transform: scale(1, 0.4) var(--ffz-effect-transforms); }
|
|
90% { transform: scale(0.9, 0.8) var(--ffz-effect-transforms); }
|
|
100% { transform: scale(0.8, 1) var(--ffz-effect-transforms); }
|
|
}`
|
|
},
|
|
{
|
|
setting: [
|
|
'Bounce',
|
|
'FlipY'
|
|
],
|
|
flags: Flags.Bounce | Flags.FlipY,
|
|
transform: 'translateY(100%)',
|
|
},
|
|
];
|
|
|
|
|
|
function generateBaseFilterCss() {
|
|
const out = [
|
|
`.modified-emote[data-effects] > .chat-line__message--emote {
|
|
--ffz-effect-filters: none;
|
|
--ffz-effect-transforms: initial;
|
|
--ffz-effect-animations: initial;
|
|
}`
|
|
];
|
|
|
|
//for(const [key, val] of Object.entries(MODIFIER_FLAG_CSS)) {
|
|
for(const val of EFFECT_STYLES) {
|
|
if ( val.raw )
|
|
out.push(val.raw);
|
|
}
|
|
|
|
return out.join('\n');
|
|
}
|
|
|
|
|
|
const MODIFIERS = {
|
|
59847: {
|
|
modifier_offset: '0 15px 15px 0',
|
|
modifier: true
|
|
},
|
|
|
|
70852: {
|
|
modifier: true,
|
|
modifier_offset: '0 5px 20px 0',
|
|
extra_width: 5,
|
|
shrink_to_fit: true
|
|
},
|
|
|
|
70854: {
|
|
modifier: true,
|
|
modifier_offset: '30px 0 0'
|
|
},
|
|
|
|
147049: {
|
|
modifier: true,
|
|
modifier_offset: '4px 1px 0 3px'
|
|
},
|
|
|
|
147011: {
|
|
modifier: true,
|
|
modifier_offset: '0'
|
|
},
|
|
|
|
70864: {
|
|
modifier: true,
|
|
modifier_offset: '0'
|
|
},
|
|
|
|
147038: {
|
|
modifier: true,
|
|
modifier_offset: '0'
|
|
}
|
|
};
|
|
|
|
|
|
export default class Emotes extends Module {
|
|
constructor(...args) {
|
|
super(...args);
|
|
|
|
this.EmoteTypes = EmoteTypes;
|
|
this.ModifierFlags = MODIFIER_FLAGS;
|
|
|
|
this.inject('i18n');
|
|
this.inject('settings');
|
|
this.inject('experiments');
|
|
this.inject('staging');
|
|
this.inject('load_tracker');
|
|
|
|
this.twitch_inventory_sets = new Set; //(EXTRA_INVENTORY);
|
|
this.__twitch_emote_to_set = {};
|
|
this.__twitch_set_to_channel = {};
|
|
this.__twitch_emote_to_artist = {};
|
|
|
|
// Bulk data structure for collections applied to a lot of users.
|
|
// This lets us avoid allocating lots of individual user
|
|
// objects when we don't need to do so.
|
|
this.bulk = new Map;
|
|
|
|
this.effects_enabled = {};
|
|
this.pending_effects = new Set();
|
|
this.applyEffects = this.applyEffects.bind(this);
|
|
|
|
this.sub_sets = new SourcedSet;
|
|
this.default_sets = new SourcedSet;
|
|
this.global_sets = new SourcedSet;
|
|
|
|
this.providers = new Map;
|
|
|
|
this.providers.set('featured', {
|
|
name: 'Featured',
|
|
i18n_key: 'emote-menu.featured',
|
|
sort_key: 75
|
|
})
|
|
|
|
this.emote_sets = {};
|
|
this._set_refs = {};
|
|
this._set_timers = {};
|
|
|
|
this.settings.add('chat.emotes.enabled', {
|
|
default: 2,
|
|
ui: {
|
|
path: 'Chat > Appearance >> Emotes',
|
|
title: 'Display Emotes',
|
|
sort: -100,
|
|
force_seen: true,
|
|
description: 'If you do not wish to see emotes, you can disable them here.',
|
|
component: 'setting-select-box',
|
|
data: [
|
|
{value: 0, title: 'Disabled'},
|
|
{value: 1, title: 'Twitch Only'},
|
|
{value: 2, title: 'Enabled'}
|
|
]
|
|
}
|
|
});
|
|
|
|
this.settings.add('chat.emotes.2x', {
|
|
default: 0,
|
|
process(ctx, val) {
|
|
if ( val === true ) return 1;
|
|
else if ( val === false ) return 0;
|
|
return val;
|
|
},
|
|
ui: {
|
|
path: 'Chat > Appearance >> Emotes',
|
|
title: 'Larger Emotes',
|
|
description: 'This setting will make emotes appear twice as large in chat. It\'s good for use with larger fonts or just if you really like emotes.',
|
|
component: 'setting-select-box',
|
|
data: [
|
|
{value: 0, title: 'Disabled'},
|
|
{value: 1, title: 'Emotes'},
|
|
{value: 2, title: 'Emotes and Emoji'}
|
|
]
|
|
}
|
|
});
|
|
|
|
this.settings.add('chat.emotes.limit-size', {
|
|
default: true,
|
|
ui: {
|
|
path: 'Chat > Appearance >> Emotes',
|
|
title: 'Limit Native Emote Size',
|
|
description: 'Sometimes, really obnoxiously large emotes slip through the cracks and wind up on Twitch. This limits the size of Twitch emotes to mitigate the issue.',
|
|
component: 'setting-check-box'
|
|
}
|
|
});
|
|
|
|
this.settings.add('chat.fix-bad-emotes', {
|
|
default: true,
|
|
ui: {
|
|
path: 'Chat > Appearance >> Emotes',
|
|
title: 'Fix Bad Twitch Global Emotes',
|
|
description: 'Clean up the images for bad Twitch global emotes, removing white borders and solid backgrounds.',
|
|
component: 'setting-check-box'
|
|
}
|
|
});
|
|
|
|
this.settings.add('chat.click-emotes', {
|
|
default: true,
|
|
|
|
ui: {
|
|
path: 'Chat > Behavior >> General',
|
|
title: 'Open emote information pages by Shift-Clicking them.',
|
|
component: 'setting-check-box'
|
|
}
|
|
});
|
|
|
|
this.settings.add('chat.sub-emotes', {
|
|
default: true,
|
|
ui: {
|
|
path: 'Chat > Behavior >> General',
|
|
title: 'Open Twitch subscription pages by Shift-Clicking emotes when relevant.',
|
|
component: 'setting-check-box'
|
|
}
|
|
});
|
|
|
|
this.settings.add('chat.emote-dialogs', {
|
|
default: true,
|
|
ui: {
|
|
path: 'Chat > Behavior >> General',
|
|
title: 'Open emote information cards for Twitch emotes by clicking them.',
|
|
component: 'setting-check-box'
|
|
}
|
|
});
|
|
|
|
this.settings.add('chat.effects.enable', {
|
|
default: true,
|
|
ui: {
|
|
path: 'Chat > Emote Effects >> General',
|
|
title: 'Enable the use of emote effects.',
|
|
description: 'Emote Effects are special effects that can be applied to some emotes using special modifiers.',
|
|
component: 'setting-check-box'
|
|
}
|
|
});
|
|
|
|
for(const val of EFFECT_STYLES) {
|
|
if ( ! val.setting || Array.isArray(val.setting) )
|
|
continue;
|
|
|
|
const setting = {
|
|
default: val.animation
|
|
? null
|
|
: true,
|
|
ui: {
|
|
path: 'Chat > Emote Effects >> Specific Effect @{"description": "**Note:** Animated effects are, by default, only enabled when [Animated Emotes](~chat.appearance.emotes) are enabled."}',
|
|
title: `Enable the effect "${val.title ?? val.setting}".`,
|
|
component: 'setting-check-box',
|
|
force_seen: true
|
|
}
|
|
};
|
|
|
|
if ( val.animation ) {
|
|
setting.default = null;
|
|
setting.requires = ['chat.emotes.animated'];
|
|
setting.process = function(ctx, val) {
|
|
if ( val == null )
|
|
return ctx.get('chat.emotes.animated') === 1;
|
|
return val;
|
|
};
|
|
}
|
|
|
|
this.settings.add(`chat.effects.${val.setting}`, setting);
|
|
}
|
|
|
|
// Because this may be used elsewhere.
|
|
this.handleClick = this.handleClick.bind(this);
|
|
this.animHover = this.animHover.bind(this);
|
|
this.animLeave = this.animLeave.bind(this);
|
|
}
|
|
|
|
|
|
getAddonProxy(addon_id, addon, module) {
|
|
if ( ! addon_id )
|
|
return this;
|
|
|
|
const is_dev = DEBUG || addon?.dev,
|
|
id_checker = makeAddonIdChecker(addon_id);
|
|
|
|
const overrides = {},
|
|
warnings = {};
|
|
|
|
overrides.addDefaultSet = (provider, set_id, data) => {
|
|
if ( is_dev && ! id_checker.test(provider) )
|
|
module.log.warn('[DEV-CHECK] Call to emotes.addDefaultSet did not include addon ID in provider:', provider);
|
|
|
|
if ( data ) {
|
|
if ( is_dev && ! id_checker.test(set_id) )
|
|
module.log.warn('[DEV-CHECK] Call to emotes.addDefaultSet loaded set data but did not include addon ID in set ID:', set_id);
|
|
|
|
data.__source = addon_id;
|
|
}
|
|
|
|
return this.addDefaultSet(provider, set_id, data);
|
|
}
|
|
|
|
overrides.addSubSet = (provider, set_id, data) => {
|
|
if ( is_dev && ! id_checker.test(provider) )
|
|
module.log.warn('[DEV-CHECK] Call to emotes.addSubSet did not include addon ID in provider:', provider);
|
|
|
|
if ( data ) {
|
|
if ( is_dev && ! id_checker.test(set_id) )
|
|
module.log.warn('[DEV-CHECK] Call to emotes.addSubSet loaded set data but did not include addon ID in set ID:', set_id);
|
|
|
|
data.__source = addon_id;
|
|
}
|
|
|
|
return this.addSubSet(provider, set_id, data);
|
|
}
|
|
|
|
overrides.loadSetData = (set_id, data, ...args) => {
|
|
if ( is_dev && ! id_checker.test(set_id) )
|
|
module.log.warn('[DEV-CHECK] Call to emotes.loadSetData did not include addon ID in set ID:', set_id);
|
|
|
|
if ( data )
|
|
data.__source = addon_id;
|
|
|
|
return this.loadSetData(set_id, data, ...args);
|
|
}
|
|
|
|
if ( is_dev ) {
|
|
overrides.removeDefaultSet = (provider, ...args) => {
|
|
if ( ! id_checker.test(provider) )
|
|
module.log.warn('[DEV-CHECK] Call to emotes.removeDefaultSet did not include addon ID in provider:', provider);
|
|
|
|
return this.removeDefaultSet(provider, ...args);
|
|
}
|
|
|
|
overrides.removeSubSet = (provider, ...args) => {
|
|
if ( ! id_checker.test(provider) )
|
|
module.log.warn('[DEV-CHECK] Call to emotes.removeSubSet did not include addon ID in provider:', provider);
|
|
|
|
return this.removeSubSet(provider, ...args);
|
|
}
|
|
|
|
warnings.style = true;
|
|
warnings.effect_style = true;
|
|
warnings.emote_sets = true;
|
|
warnings.loadSetUserIds = warnings.loadSetUsers = 'This method is meant for internal use.';
|
|
}
|
|
|
|
return buildAddonProxy(module, this, 'emotes', overrides, warnings);
|
|
}
|
|
|
|
|
|
onEnable() {
|
|
this.style = new ManagedStyle('emotes');
|
|
this.effect_style = new ManagedStyle('effects');
|
|
|
|
// Generate the base filter CSS.
|
|
this.base_effect_css = generateBaseFilterCss();
|
|
|
|
this.parent.context.on('changed:chat.effects.enable', this.updateEffects, this);
|
|
for(const input of EFFECT_STYLES)
|
|
if ( input.setting && ! Array.isArray(input.setting) )
|
|
this.parent.context.on(`changed:chat.effects.${input.setting}`, this.updateEffects, this);
|
|
|
|
this.updateEffects();
|
|
|
|
// Fix numeric Twitch favorite IDs.
|
|
const favs = this.getFavorites('twitch');
|
|
let changed = false;
|
|
for(let i=0; i < favs.length; i++) {
|
|
if ( typeof favs[i] === 'number' ) {
|
|
changed = true;
|
|
favs[i] = `${favs[i]}`;
|
|
}
|
|
}
|
|
|
|
if ( changed )
|
|
this.setFavorites('twitch', favs);
|
|
|
|
if ( Object.keys(this.emote_sets).length ) {
|
|
this.log.info('Generating CSS for existing emote sets.');
|
|
for(const set_id in this.emote_sets)
|
|
if ( has(this.emote_sets, set_id) ) {
|
|
const emote_set = this.emote_sets[set_id];
|
|
if ( emote_set && (emote_set.pending_css || emote_set.css) ) {
|
|
this.style.set(`es--${set_id}`, (emote_set.pending_css || '') + (emote_set.css || ''));
|
|
emote_set.pending_css = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
this.on('pubsub:command:follow_sets', this.updateFollowSets, this);
|
|
|
|
this.on('pubsub:command:add_emote', msg => {
|
|
const set_id = msg.set_id,
|
|
emote = msg.emote,
|
|
|
|
emote_set = this.emote_sets[set_id];
|
|
|
|
if ( ! emote_set )
|
|
return;
|
|
|
|
const has_old = !! emote_set.emotes?.[emote.id];
|
|
const processed = this.addEmoteToSet(set_id, emote);
|
|
|
|
this.maybeNotifyChange(
|
|
has_old ? 'modified' : 'added',
|
|
set_id,
|
|
processed
|
|
);
|
|
});
|
|
|
|
this.on('pubsub:command:remove_emote', msg => {
|
|
const set_id = msg.set_id,
|
|
emote_id = msg.emote_id;
|
|
|
|
if ( ! this.emote_sets[set_id] )
|
|
return;
|
|
|
|
// If removing it returns nothing, there was no
|
|
// emote to remove with that ID.
|
|
const removed = this.removeEmoteFromSet(set_id, emote_id);
|
|
if ( ! removed )
|
|
return;
|
|
|
|
this.maybeNotifyChange(
|
|
'removed',
|
|
set_id,
|
|
removed
|
|
);
|
|
});
|
|
|
|
this.on('chat:reload-data', flags => {
|
|
if ( ! flags || flags.emotes )
|
|
this.loadGlobalSets();
|
|
});
|
|
|
|
this.on('addon:fully-unload', addon_id => {
|
|
let removed = 0;
|
|
for(const [key, set] of Object.entries(this.emote_sets)) {
|
|
if ( set?.__source === addon_id ) {
|
|
removed++;
|
|
this.loadSetData(key, null, true);
|
|
}
|
|
}
|
|
|
|
if ( removed ) {
|
|
this.log.debug(`Cleaned up ${removed} entries when unloading addon:`, addon_id);
|
|
// TODO: Debounced retokenize all chat messages.
|
|
}
|
|
})
|
|
|
|
this.loadGlobalSets();
|
|
}
|
|
|
|
|
|
// ========================================================================
|
|
// Chat Notices
|
|
// ========================================================================
|
|
|
|
maybeNotifyChange(action, set_id, emote) {
|
|
if ( ! this._pending_notifications )
|
|
this._pending_notifications = [];
|
|
|
|
this._pending_notifications.push({action, set_id, emote});
|
|
|
|
if ( ! this._pending_timer )
|
|
this._pending_timer = setTimeout(() => this._handleNotifyChange(), 1000);
|
|
}
|
|
|
|
_handleNotifyChange() {
|
|
clearTimeout(this._pending_timer);
|
|
this._pending_timer = null;
|
|
|
|
const notices = this._pending_notifications;
|
|
this._pending_notifications = null;
|
|
|
|
// Make sure we are equipped to send notices.
|
|
const chat = this.resolve('site.chat');
|
|
if ( ! chat?.addNotice )
|
|
return;
|
|
|
|
// Get the current user.
|
|
const me = this.resolve('site').getUser();
|
|
if ( ! me?.id )
|
|
return;
|
|
|
|
// Get the current channel.
|
|
let room_id = this.parent.context.get('context.channelID'),
|
|
room_login = this.parent.context.get('context.channel');
|
|
|
|
// And now get the current user's available emote sets.
|
|
const sets = this.getSetIDs(me.id, me.login, room_id, room_login);
|
|
const set_changes = {};
|
|
|
|
// Build a data structure for reducing the needed number of notices.
|
|
for(const notice of notices) {
|
|
// Make sure the set ID is a string.
|
|
const set_id = `${notice.set_id}`,
|
|
action = notice.action;
|
|
|
|
if ( sets.includes(set_id) ) {
|
|
const changes = set_changes[set_id] = set_changes[set_id] || {},
|
|
list = changes[action] = changes[action] || [];
|
|
|
|
// Deduplicate while we're at it.
|
|
if ( list.find(em => em.id === notice.emote.id) )
|
|
continue;
|
|
|
|
list.push(notice.emote);
|
|
}
|
|
}
|
|
|
|
// Iterate over everything, sending chat notices.
|
|
for(const [set_id, notices] of Object.entries(set_changes)) {
|
|
const emote_set = this.emote_sets[set_id];
|
|
if ( ! emote_set )
|
|
continue;
|
|
|
|
for(const [action, emotes] of Object.entries(notices)) {
|
|
const emote_list = emotes
|
|
.map(emote => emote.name)
|
|
.join(', ');
|
|
|
|
let msg;
|
|
if ( action === 'added' )
|
|
msg = this.i18n.t('emote-updates.added', 'The {count, plural, one {emote {emotes} has} other {emotes {emotes} have}} been added to {set}.', {
|
|
count: emotes.length,
|
|
emotes: emote_list,
|
|
set: emote_set.title
|
|
});
|
|
|
|
else if ( action === 'modified' )
|
|
msg = this.i18n.t('emote-updates.modified', 'The {count, plural, one {emote {emotes} has} other {emotes {emotes} have}} been updated in {set}.', {
|
|
count: emotes.length,
|
|
emotes: emote_list,
|
|
set: emote_set.title
|
|
});
|
|
|
|
else if ( action === 'removed' )
|
|
msg = this.i18n.t('emote-updates.removed', 'The {count, plural, one {emote {emotes} has} other {emotes {emotes} have}} been removed from {set}.', {
|
|
count: emotes.length,
|
|
emotes: emote_list,
|
|
set: emote_set.title
|
|
});
|
|
|
|
if ( msg )
|
|
chat.addNotice('*', `[FFZ] ${msg}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
// ========================================================================
|
|
// Load Modifier Effects
|
|
// ========================================================================
|
|
|
|
ensureEffect(flags) {
|
|
if ( ! this.effect_style.has(`${flags}`) ) {
|
|
this.pending_effects.add(flags);
|
|
if ( ! this._effect_timer )
|
|
this._effect_timer = requestAnimationFrame(this.applyEffects);
|
|
}
|
|
}
|
|
|
|
applyEffects() {
|
|
this._effect_timer = null;
|
|
const effects = this.pending_effects;
|
|
this.pending_effects = new Set;
|
|
|
|
for(const flags of effects) {
|
|
const result = this.generateFilterCss(flags);
|
|
this.effect_style.set(`${flags}`, result ?? '');
|
|
}
|
|
}
|
|
|
|
generateFilterCss(flags) {
|
|
if ( ! this.parent.context.get('chat.effects.enable') )
|
|
return null;
|
|
|
|
let filter, transformOrigin, transform, animation, animations = [];
|
|
|
|
for(const input of this.activeEffectStyles) {
|
|
if ( (flags & input.flags) !== input.flags )
|
|
continue;
|
|
|
|
if ( input.not_flags && (flags & input.not_flags) === input.not_flags )
|
|
continue;
|
|
|
|
if ( input.animation )
|
|
animations.push(input);
|
|
|
|
if ( input.filter )
|
|
filter = filter
|
|
? `${filter} ${input.filter}`
|
|
: input.filter;
|
|
|
|
if ( input.transformOrigin )
|
|
transformOrigin = input.transformOrigin;
|
|
|
|
if ( input.transform )
|
|
transform = transform
|
|
? `${transform} ${input.transform}`
|
|
: input.transform;
|
|
}
|
|
|
|
if ( animations.length )
|
|
for(const input of animations) {
|
|
if ( filter && input.animationFilter )
|
|
animation = animation
|
|
? `${animation}, ${input.animationFilter}`
|
|
: input.animationFilter;
|
|
else if ( transform && input.animationTransform )
|
|
animation = animation
|
|
? `${animation}, ${input.animationTransform}`
|
|
: input.animationTransform;
|
|
else
|
|
animation = animation
|
|
? `${animation}, ${input.animation}`
|
|
: input.animation;
|
|
}
|
|
|
|
if ( ! filter && ! transform && ! animation )
|
|
return null;
|
|
|
|
return `.modified-emote[data-effects="${flags}"] > .chat-line__message--emote {${filter ? `
|
|
--ffz-effect-filters: ${filter};
|
|
filter: var(--ffz-effect-filters);` : ''}${transformOrigin ? `
|
|
transform-origin: ${transformOrigin};` : ''}${transform ? `
|
|
--ffz-effect-transforms: ${transform};
|
|
transform: var(--ffz-effect-transforms);` : ''}${animation ? `
|
|
--ffz-effect-animations: ${animation};
|
|
animation: var(--ffz-effect-animations);` : ''}
|
|
}`;
|
|
}
|
|
|
|
updateEffects() {
|
|
// TODO: Smarter logic so it does less work.
|
|
const enabled = this.parent.context.get('chat.effects.enable');
|
|
|
|
this.effects_enabled = {};
|
|
this.activeEffectStyles = [];
|
|
|
|
this.activeAsBackgroundMask = 0;
|
|
this.activeNoWideMask = 0;
|
|
|
|
for(const input of EFFECT_STYLES) {
|
|
if ( input.setting && ! Array.isArray(input.setting) )
|
|
this.effects_enabled[input.setting] = this.parent.context.get(`chat.effects.${input.setting}`);
|
|
}
|
|
|
|
for(const input of EFFECT_STYLES) {
|
|
let enabled = true;
|
|
if ( Array.isArray(input.setting) ) {
|
|
for(const setting of input.setting)
|
|
if ( ! this.effects_enabled[setting] ) {
|
|
enabled = false;
|
|
break;
|
|
}
|
|
|
|
} else if ( input.setting )
|
|
enabled = this.effects_enabled[input.setting];
|
|
|
|
if ( enabled ) {
|
|
this.activeEffectStyles.push(input);
|
|
|
|
if ( input.as_background )
|
|
this.activeAsBackgroundMask = this.activeAsBackgroundMask | input.flags;
|
|
if ( input.no_wide )
|
|
this.activeNoWideMask = this.activeNoWideMask | input.flags;
|
|
}
|
|
}
|
|
|
|
this.effect_style.clear();
|
|
if ( ! enabled || ! this.activeEffectStyles.length )
|
|
return;
|
|
|
|
this.effect_style.set('base', this.base_effect_css);
|
|
this.emit(':update-effects');
|
|
}
|
|
|
|
|
|
// ========================================================================
|
|
// Featured Sets
|
|
// ========================================================================
|
|
|
|
updateFollowSets(data) {
|
|
for(const room_login in data)
|
|
if ( has(data, room_login) ) {
|
|
const room = this.parent.getRoom(null, room_login, true);
|
|
if ( ! room || room.destroyed )
|
|
continue;
|
|
|
|
const new_sets = data[room_login] || [],
|
|
emote_sets = room.emote_sets,
|
|
providers = emote_sets && emote_sets._sources;
|
|
|
|
if ( providers && providers.has('featured') )
|
|
for(const item of providers.get('featured')) {
|
|
const idx = new_sets.indexOf(item);
|
|
if ( idx === -1 )
|
|
room.removeSet('featured', item);
|
|
else
|
|
new_sets.splice(idx, 1);
|
|
}
|
|
|
|
for(const set_id of new_sets) {
|
|
room.addSet('featured', set_id);
|
|
|
|
if ( ! this.emote_sets[set_id] )
|
|
this.loadSet(set_id);
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
// ========================================================================
|
|
// Hidden Checking
|
|
// ========================================================================
|
|
|
|
toggleHidden(source, id, value = null) {
|
|
const key = `hidden-emotes.${source}`,
|
|
p = this.settings.provider,
|
|
hidden = p.get(key, []),
|
|
|
|
idx = hidden.indexOf(id);
|
|
|
|
if ( value === null )
|
|
value = idx === -1;
|
|
|
|
if ( value && idx === -1 )
|
|
hidden.push(id);
|
|
else if ( ! value && idx !== -1 )
|
|
hidden.splice(idx, 1);
|
|
else
|
|
return;
|
|
|
|
if ( hidden.length )
|
|
p.set(key, hidden);
|
|
else
|
|
p.delete(key);
|
|
|
|
this.emit(':change-hidden', source, id, value);
|
|
}
|
|
|
|
isHidden(source, id) {
|
|
return this.getHidden(source).includes(id);
|
|
}
|
|
|
|
getHidden(source) {
|
|
return this.settings.provider.get(`hidden-emotes.${source}`) || [];
|
|
}
|
|
|
|
setHidden(source, list) {
|
|
const key = `hidden-emotes.${source}`;
|
|
if ( ! Array.isArray(list) || ! list.length )
|
|
this.settings.provider.delete(key);
|
|
else
|
|
this.settings.provider.set(key, list);
|
|
}
|
|
|
|
|
|
// ========================================================================
|
|
// Animation Hover
|
|
// ========================================================================
|
|
|
|
animHover(event) { // eslint-disable-line class-methods-use-this
|
|
const target = event.currentTarget;
|
|
if ( target[HoverState] )
|
|
return;
|
|
|
|
if ( target[HoverRAF] )
|
|
cancelAnimationFrame(target[HoverRAF]);
|
|
|
|
target[HoverRAF] = requestAnimationFrame(() => {
|
|
target[HoverRAF] = null;
|
|
if ( target[HoverState] )
|
|
return;
|
|
|
|
if ( ! target.matches(':hover') )
|
|
return;
|
|
|
|
target[HoverState] = true;
|
|
const emotes = target.querySelectorAll('.ffz-hover-emote');
|
|
for(const em of emotes) {
|
|
const ds = em.dataset;
|
|
if ( ds.normalSrc && ds.hoverSrc ) {
|
|
em.src = ds.hoverSrc;
|
|
em.srcset = ds.hoverSrcSet;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
|
|
animLeave(event) { // eslint-disable-line class-methods-use-this
|
|
const target = event.currentTarget;
|
|
if ( ! target[HoverState] )
|
|
return;
|
|
|
|
if ( target[HoverRAF] )
|
|
cancelAnimationFrame(target[HoverRAF]);
|
|
|
|
target[HoverRAF] = requestAnimationFrame(() => {
|
|
target[HoverRAF] = null;
|
|
if ( ! target[HoverState] )
|
|
return;
|
|
|
|
if ( target.matches(':hover') )
|
|
return;
|
|
|
|
target[HoverState] = false;
|
|
const emotes = target.querySelectorAll('.ffz-hover-emote');
|
|
for(const em of emotes) {
|
|
const ds = em.dataset;
|
|
if ( ds.normalSrc ) {
|
|
em.src = ds.normalSrc;
|
|
em.srcset = ds.normalSrcSet;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
|
|
// ========================================================================
|
|
// Favorite Checking
|
|
// ========================================================================
|
|
|
|
toggleFavorite(source, id, value = null) {
|
|
const key = `favorite-emotes.${source}`,
|
|
p = this.settings.provider,
|
|
favorites = p.get(key) || [],
|
|
|
|
idx = favorites.indexOf(id);
|
|
|
|
if ( value === null )
|
|
value = idx === -1;
|
|
|
|
if ( value && idx === -1 )
|
|
favorites.push(id);
|
|
else if ( ! value && idx !== -1 )
|
|
favorites.splice(idx, 1);
|
|
else
|
|
return value;
|
|
|
|
if ( favorites.length )
|
|
p.set(key, favorites);
|
|
else
|
|
p.delete(key);
|
|
|
|
this.emit(':change-favorite', source, id, value);
|
|
return value;
|
|
}
|
|
|
|
isFavorite(source, id) {
|
|
const favorites = this.settings.provider.get(`favorite-emotes.${source}`);
|
|
return favorites && favorites.includes(id);
|
|
}
|
|
|
|
getFavorites(source) {
|
|
return this.settings.provider.get(`favorite-emotes.${source}`) || [];
|
|
}
|
|
|
|
setFavorites(source, favs) {
|
|
const key = `favorite-emotes.${source}`;
|
|
if ( ! Array.isArray(favs) || ! favs.length )
|
|
this.settings.provider.delete(key);
|
|
else
|
|
this.settings.provider.set(key, favs);
|
|
}
|
|
|
|
handleClick(event, favorite_only = false) {
|
|
const target = event.target,
|
|
ds = target && target.dataset;
|
|
|
|
/*const modified = target.closest('.modified-emote');
|
|
if ( modified && modified !== target )
|
|
return;*/
|
|
|
|
if ( ! ds )
|
|
return;
|
|
|
|
const provider = ds.provider,
|
|
click_emote = this.parent.context.get('chat.click-emotes'),
|
|
click_sub = this.parent.context.get('chat.sub-emotes');
|
|
|
|
if ( event.shiftKey && (click_emote || click_sub) ) {
|
|
let url;
|
|
|
|
if ( provider === 'twitch' ) {
|
|
url = null; // = `https://twitchemotes.com/emotes/${ds.id}`;
|
|
|
|
if ( click_sub ) {
|
|
const apollo = this.resolve('site.apollo');
|
|
if ( apollo ) {
|
|
apollo.client.query({
|
|
query: GET_EMOTE,
|
|
variables: {
|
|
id: ds.id
|
|
}
|
|
}).then(result => {
|
|
const prod = get('data.emote.subscriptionProduct', result);
|
|
if ( prod && prod.state === 'ACTIVE' && prod.owner && prod.owner.login )
|
|
url = `https://www.twitch.tv/subs/${prod.owner.login}`;
|
|
else if ( ! click_emote )
|
|
return false;
|
|
|
|
if ( url ) {
|
|
const win = window.open();
|
|
if ( win ) {
|
|
win.opener = null;
|
|
win.location = url;
|
|
}
|
|
}
|
|
});
|
|
|
|
return true;
|
|
}
|
|
}
|
|
|
|
} else if ( provider === 'ffz' ) {
|
|
const emote_set = this.emote_sets[ds.set],
|
|
emote = emote_set && emote_set.emotes[ds.id];
|
|
|
|
if ( ! emote )
|
|
return;
|
|
|
|
if ( emote.click_url )
|
|
url = emote.click_url;
|
|
|
|
else if ( ! emote_set.source )
|
|
url = `https://www.frankerfacez.com/emoticons/${emote.id}`;
|
|
}
|
|
|
|
if ( ! click_emote )
|
|
return false;
|
|
|
|
if ( url ) {
|
|
const win = window.open();
|
|
if ( win ) {
|
|
win.opener = null;
|
|
win.location = url;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
if ( event[MOD_KEY] ) {
|
|
// Favoriting Emotes
|
|
let source, id;
|
|
|
|
if ( provider === 'twitch' ) {
|
|
source = 'twitch';
|
|
id = ds.id;
|
|
|
|
} else if ( provider === 'ffz' ) {
|
|
const emote_set = this.emote_sets[ds.set],
|
|
emote = emote_set && emote_set.emotes[ds.id];
|
|
|
|
if ( ! emote )
|
|
return;
|
|
|
|
source = emote_set.source || 'ffz';
|
|
id = emote.id;
|
|
|
|
} else if ( provider === 'emoji' ) {
|
|
source = 'emoji';
|
|
id = ds.code;
|
|
|
|
} else
|
|
return;
|
|
|
|
this.toggleFavorite(source, id);
|
|
const tt = target._ffz_tooltip;
|
|
if ( tt && tt.visible ) {
|
|
tt.hide();
|
|
setTimeout(() => document.contains(target) && tt.show(), 0);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
if ( favorite_only )
|
|
return false;
|
|
|
|
let modifiers;
|
|
try {
|
|
modifiers = JSON.parse(ds.modifierInfo);
|
|
} catch(err) {
|
|
/* no-op */
|
|
}
|
|
|
|
const evt = this.makeEvent({
|
|
provider,
|
|
id: ds.id,
|
|
set: ds.set,
|
|
code: ds.code,
|
|
variant: ds.variant,
|
|
name: ds.name || target.alt,
|
|
modifiers,
|
|
source: event
|
|
});
|
|
|
|
this.emit('chat.emotes:click', evt);
|
|
if ( evt.defaultPrevented )
|
|
return true;
|
|
|
|
if ( provider === 'twitch' && this.parent.context.get('chat.emote-dialogs') ) {
|
|
const fine = this.resolve('site.fine');
|
|
if ( ! fine )
|
|
return;
|
|
|
|
const line = fine.searchParent(target, n => n.props && n.props.message),
|
|
opener = fine.searchParent(target, n => n.onShowEmoteCard, 500);
|
|
|
|
if ( ! line || ! opener )
|
|
return;
|
|
|
|
const rect = target.getBoundingClientRect();
|
|
|
|
opener.onShowEmoteCard({
|
|
channelID: line.props.channelID || '',
|
|
channelLogin: line.props.channelLogin || '',
|
|
emoteID: ds.id,
|
|
emoteCode: target.alt,
|
|
sourceID: 'chat',
|
|
referrerID: '',
|
|
initialTopOffset: rect.bottom,
|
|
initialBottomOffset: rect.top
|
|
});
|
|
|
|
return true;
|
|
}
|
|
}
|
|
|
|
|
|
// ========================================================================
|
|
// Access
|
|
// ========================================================================
|
|
|
|
getTargetEmote() {
|
|
this.target_emote = null;
|
|
|
|
const me = this.resolve('site').getUser(),
|
|
Input = me ? this.resolve('site.chat.input') : null,
|
|
entered = Input ? Input.getInput() : null;
|
|
|
|
const menu = this.resolve('site.chat.emote_menu')?.MenuWrapper?.first,
|
|
emote_sets = menu?.getAllSets?.(),
|
|
emotes = emote_sets
|
|
? emote_sets.map(x => x.emotes).flat().filter(x => ! x.effects && ! x.locked)
|
|
: null;
|
|
|
|
if ( entered && emotes ) {
|
|
// Okay this is gonna be oof.
|
|
const name_map = {};
|
|
for(let i = 0; i < emotes.length; i++)
|
|
if ( ! name_map[emotes[i].name] )
|
|
name_map[emotes[i].name] = i;
|
|
|
|
const words = entered.split(' ');
|
|
let i = words.length;
|
|
while(i--) {
|
|
const word = words[i];
|
|
if ( name_map[word] != null )
|
|
return emotes[name_map[word]];
|
|
}
|
|
}
|
|
|
|
// Random emote
|
|
if ( emotes && emotes.length ) {
|
|
const idx = Math.floor(Math.random() * emotes.length),
|
|
emote = emotes[idx];
|
|
|
|
this.target_emote = emote;
|
|
return emote;
|
|
}
|
|
|
|
// Return LaterSooner
|
|
return this.target_emote = {
|
|
provider: 'ffz',
|
|
set_id: 3,
|
|
id: 149346,
|
|
name: 'LaterSooner',
|
|
src: 'https://cdn.frankerfacez.com/emote/149346/1',
|
|
srcSet: 'https://cdn.frankerfacez.com/emote/149346/1 1x, https://cdn.frankerfacez.com/emote/149346/2 2x, https://cdn.frankerfacez.com/emote/149346/4 4x',
|
|
width: 25,
|
|
height: 32
|
|
}
|
|
}
|
|
|
|
|
|
getSetIDs(user_id, user_login, room_id, room_login) {
|
|
const room = this.parent.getRoom(room_id, room_login, true),
|
|
room_user = room && room.getUser(user_id, user_login, true),
|
|
user = this.parent.getUser(user_id, user_login, true);
|
|
|
|
const out = (user?.emote_sets ? user.emote_sets._cache : []).concat(
|
|
room_user?.emote_sets ? room_user.emote_sets._cache : [],
|
|
room?.emote_sets ? room.emote_sets._cache : [],
|
|
this.default_sets._cache
|
|
);
|
|
|
|
if ( this.bulk.size ) {
|
|
const str_user = String(user_id);
|
|
for(const [set_id, users] of this.bulk) {
|
|
if ( users?._cache.has(str_user) )
|
|
out.push(set_id);
|
|
}
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
getSets(user_id, user_login, room_id, room_login) {
|
|
return this.getSetIDs(user_id, user_login, room_id, room_login)
|
|
.map(set_id => this.emote_sets[set_id]);
|
|
}
|
|
|
|
_withSources(out, seen, emote_sets) { // eslint-disable-line class-methods-use-this
|
|
if ( ! emote_sets?._sources )
|
|
return;
|
|
|
|
for(const [provider, data] of emote_sets._sources)
|
|
for(const item of data)
|
|
if ( ! seen.has(item) ) {
|
|
out.push([item, provider]);
|
|
seen.add(item);
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
getRoomSetIDsWithSources(user_id, user_login, room_id, room_login) {
|
|
const room = this.parent.getRoom(room_id, room_login, true),
|
|
room_user = room && room.getUser(user_id, user_login, true);
|
|
|
|
if ( ! room )
|
|
return [];
|
|
|
|
const out = [], seen = new Set;
|
|
|
|
this._withSources(out, seen, room.emote_sets);
|
|
if ( room_user )
|
|
this._withSources(out, seen, room_user);
|
|
|
|
return out;
|
|
}
|
|
|
|
getRoomSetsWithSources(user_id, user_login, room_id, room_login) {
|
|
return this.getRoomSetIDsWithSources(user_id, user_login, room_id, room_login)
|
|
.map(([set_id, source]) => [this.emote_sets[set_id], source]);
|
|
}
|
|
|
|
getRoomSetIDs(user_id, user_login, room_id, room_login) {
|
|
const room = this.parent.getRoom(room_id, room_login, true),
|
|
room_user = room && room.getUser(user_id, user_login, true);
|
|
|
|
if ( ! room )
|
|
return [];
|
|
|
|
if ( ! room_user?.emote_sets )
|
|
return room.emote_sets ? room.emote_sets._cache : [];
|
|
|
|
else if ( ! room.emote_sets )
|
|
return room_user.emote_sets._cache;
|
|
|
|
return room_user.emote_sets._cache.concat(room.emote_sets._cache);
|
|
}
|
|
|
|
getRoomSets(user_id, user_login, room_id, room_login) {
|
|
return this.getRoomSetIDs(user_id, user_login, room_id, room_login)
|
|
.map(set_id => this.emote_sets[set_id]);
|
|
}
|
|
|
|
getGlobalSetIDsWithSources(user_id, user_login) {
|
|
const user = this.parent.getUser(user_id, user_login, true),
|
|
out = [], seen = new Set;
|
|
|
|
this._withSources(out, seen, this.default_sets);
|
|
if ( user )
|
|
this._withSources(out, seen, user.emote_sets);
|
|
|
|
if ( this.bulk.size ) {
|
|
const str_user = String(user_id);
|
|
for(const [set_id, users] of this.bulk) {
|
|
if ( ! seen.has(set_id) && users?._cache.has(str_user) ) {
|
|
for(const [provider, data] of users._sources) {
|
|
if ( data && data.includes(str_user) ) {
|
|
out.push([set_id, provider]);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
getGlobalSetsWithSources(user_id, user_login) {
|
|
return this.getGlobalSetIDsWithSources(user_id, user_login)
|
|
.map(([set_id, source]) => [this.emote_sets[set_id], source]);
|
|
}
|
|
|
|
|
|
getSubSetIDsWithSources() {
|
|
const out = [], seen = new Set;
|
|
|
|
this._withSources(out, seen, this.sub_sets);
|
|
|
|
return out;
|
|
}
|
|
|
|
getSubSetsWithSources() {
|
|
return this.getSubSetIDsWithSources()
|
|
.map(([set_id, source]) => [this.emote_sets[set_id], source]);
|
|
}
|
|
|
|
|
|
getGlobalSetIDs(user_id, user_login) {
|
|
const user = this.parent.getUser(user_id, user_login, true);
|
|
|
|
const out = (user?.emote_sets ? user.emote_sets._cache : []).concat(
|
|
this.default_sets._cache
|
|
);
|
|
|
|
if ( this.bulk.size ) {
|
|
const str_user = String(user_id);
|
|
for(const [set_id, users] of this.bulk) {
|
|
if ( users?._cache.has(str_user) )
|
|
out.push(set_id);
|
|
}
|
|
}
|
|
|
|
return out;
|
|
|
|
}
|
|
|
|
getGlobalSets(user_id, user_login) {
|
|
return this.getGlobalSetIDs(user_id, user_login)
|
|
.map(set_id => this.emote_sets[set_id]);
|
|
}
|
|
|
|
getEmotes(user_id, user_login, room_id, room_login) {
|
|
const emotes = {};
|
|
for(const emote_set of this.getSets(user_id, user_login, room_id, room_login))
|
|
if ( emote_set && emote_set.emotes )
|
|
for(const emote of Object.values(emote_set.emotes) )
|
|
if ( emote && ! has(emotes, emote.name) )
|
|
emotes[emote.name] = emote;
|
|
|
|
return emotes;
|
|
}
|
|
|
|
// ========================================================================
|
|
// Bulk Management
|
|
// ========================================================================
|
|
|
|
setBulk(source, set_id, entries) {
|
|
let set = this.bulk.get(set_id);
|
|
if ( ! set )
|
|
this.bulk.set(set_id, set = new SourcedSet(true));
|
|
|
|
const size = set._cache.size;
|
|
set.set(source, entries);
|
|
const new_size = set._cache.size;
|
|
|
|
if ( ! size && new_size )
|
|
this.refSet(set_id);
|
|
}
|
|
|
|
deleteBulk(source, set_id) {
|
|
const set = this.bulk.get(set_id);
|
|
if ( ! set )
|
|
return;
|
|
|
|
const size = set._cache.size;
|
|
set.delete(source);
|
|
const new_size = set._cache.size;
|
|
|
|
if ( size && ! new_size )
|
|
this.unrefSet(set_id);
|
|
}
|
|
|
|
extendBulk(source, set_id, entries) {
|
|
let set = this.bulk.get(set_id);
|
|
if ( ! set )
|
|
this.bulk.set(set_id, set = new SourcedSet(true));
|
|
|
|
if ( ! Array.isArray(entries) )
|
|
entries = [entries];
|
|
|
|
const size = set._cache.size;
|
|
set.extend(source, ...entries);
|
|
const new_size = set._cache.size;
|
|
|
|
if ( ! size && new_size )
|
|
this.refSet(set_id);
|
|
}
|
|
|
|
// ========================================================================
|
|
// Emote Set Ref Counting
|
|
// ========================================================================
|
|
|
|
addDefaultSet(provider, set_id, data) {
|
|
if ( typeof set_id === 'number' )
|
|
set_id = `${set_id}`;
|
|
|
|
let changed = false, added = false;
|
|
if ( ! this.default_sets.sourceIncludes(provider, set_id) ) {
|
|
changed = ! this.default_sets.includes(set_id);
|
|
this.default_sets.push(provider, set_id);
|
|
added = true;
|
|
}
|
|
|
|
if ( data )
|
|
this.loadSetData(set_id, data);
|
|
|
|
if ( changed ) {
|
|
this.refSet(set_id);
|
|
this.emit(':update-default-sets', provider, set_id, true);
|
|
}
|
|
|
|
return added;
|
|
}
|
|
|
|
removeDefaultSet(provider, set_id) {
|
|
if ( ! set_id ) {
|
|
const sets = this.default_sets.get(provider);
|
|
if ( sets )
|
|
for(const set_id of Array.from(sets))
|
|
this.removeDefaultSet(provider, set_id);
|
|
return;
|
|
}
|
|
|
|
if ( typeof set_id === 'number' )
|
|
set_id = `${set_id}`;
|
|
|
|
if ( this.default_sets.sourceIncludes(provider, set_id) ) {
|
|
this.default_sets.remove(provider, set_id);
|
|
if ( ! this.default_sets.includes(set_id) ) {
|
|
this.unrefSet(set_id);
|
|
this.emit(':update-default-sets', provider, set_id, false);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
addSubSet(provider, set_id, data) {
|
|
if ( typeof set_id === 'number' )
|
|
set_id = `${set_id}`;
|
|
|
|
let changed = false, added = false;
|
|
if ( ! this.sub_sets.sourceIncludes(provider, set_id) ) {
|
|
changed = ! this.sub_sets.includes(set_id);
|
|
this.sub_sets.push(provider, set_id);
|
|
added = true;
|
|
}
|
|
|
|
if ( data )
|
|
this.loadSetData(set_id, data);
|
|
|
|
if ( changed ) {
|
|
this.refSet(set_id);
|
|
this.emit(':update-sub-sets', provider, set_id, true);
|
|
}
|
|
|
|
return added;
|
|
}
|
|
|
|
removeSubSet(provider, set_id) {
|
|
if ( typeof set_id === 'number' )
|
|
set_id = `${set_id}`;
|
|
|
|
if ( this.sub_sets.sourceIncludes(provider, set_id) ) {
|
|
this.sub_sets.remove(provider, set_id);
|
|
if ( ! this.sub_sets.includes(set_id) ) {
|
|
this.unrefSet(set_id);
|
|
this.emit(':update-sub-sets', provider, set_id, false);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
refSet(set_id) {
|
|
this._set_refs[set_id] = (this._set_refs[set_id] || 0) + 1;
|
|
if ( this._set_timers[set_id] ) {
|
|
clearTimeout(this._set_timers[set_id]);
|
|
this._set_timers[set_id] = null;
|
|
}
|
|
}
|
|
|
|
unrefSet(set_id) {
|
|
const c = this._set_refs[set_id] = (this._set_refs[set_id] || 1) - 1;
|
|
if ( c <= 0 && ! this._set_timers[set_id] )
|
|
this._set_timers[set_id] = setTimeout(() => this.unloadSet(set_id), 5000);
|
|
}
|
|
|
|
|
|
// ========================================================================
|
|
// Emote Set Loading
|
|
// ========================================================================
|
|
|
|
async loadGlobalSets(tries = 0) {
|
|
this.load_tracker.schedule('chat-data', 'ffz-global');
|
|
|
|
let response, data;
|
|
|
|
if ( this.experiments.getAssignment('api_load') && tries < 1 )
|
|
try {
|
|
fetch(`${NEW_API}/v1/set/global`).catch(() => {});
|
|
} catch(err) { /* do nothing */ }
|
|
|
|
try {
|
|
response = await fetch(`${this.staging.api}/v1/set/global/ids`)
|
|
} catch(err) {
|
|
tries++;
|
|
if ( tries < 10 )
|
|
return setTimeout(() => this.loadGlobalSets(tries), 500 * tries);
|
|
|
|
this.log.error('Error loading global emote sets.', err);
|
|
this.load_tracker.notify('chat-data', 'ffz-global', false);
|
|
return false;
|
|
}
|
|
|
|
if ( ! response.ok ) {
|
|
this.load_tracker.notify('chat-data', 'ffz-global', false);
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
data = await response.json();
|
|
} catch(err) {
|
|
this.log.error('Error parsing global emote data.', err);
|
|
this.load_tracker.notify('chat-data', 'ffz-global', false);
|
|
return false;
|
|
}
|
|
|
|
const sets = data.sets || {};
|
|
|
|
// Remove existing global sets, in case we have any.
|
|
this.removeDefaultSet('ffz-global');
|
|
|
|
for(const set_id of data.default_sets)
|
|
this.addDefaultSet('ffz-global', set_id);
|
|
|
|
for(const set_id in sets)
|
|
if ( has(sets, set_id) ) {
|
|
const id = sets[set_id]?.id;
|
|
this.loadSetData(set_id, sets[set_id]);
|
|
if ( id && ! data.default_sets.includes(id) )
|
|
this.addSubSet('ffz-global', set_id);
|
|
}
|
|
|
|
if ( data.user_ids )
|
|
this.loadSetUserIds(data.user_ids);
|
|
else if ( data.users )
|
|
this.loadSetUsers(data.users);
|
|
|
|
this.load_tracker.notify('chat-data', 'ffz-global');
|
|
return true;
|
|
}
|
|
|
|
|
|
async loadSet(set_id, suppress_log = false, tries = 0) {
|
|
const load_key = `ffz-${set_id}`;
|
|
this.load_tracker.schedule('chat-data', load_key);
|
|
let response, data;
|
|
|
|
if ( this.experiments.getAssignment('api_load') )
|
|
try {
|
|
fetch(`${NEW_API}/v1/set/${set_id}`).catch(() => {});
|
|
} catch(err) { /* do nothing */ }
|
|
|
|
try {
|
|
response = await fetch(`${this.staging.api}/v1/set/${set_id}${this.staging.active ? '/ids' : ''}`)
|
|
} catch(err) {
|
|
tries++;
|
|
if ( tries < 10 )
|
|
return setTimeout(() => this.loadGlobalSets(tries), 500 * tries);
|
|
|
|
this.log.error(`Error loading data for set "${set_id}".`, err);
|
|
this.load_tracker.notify('chat-data', load_key, false);
|
|
return false;
|
|
}
|
|
|
|
if ( ! response.ok ) {
|
|
this.load_tracker.notify('chat-data', load_key, false);
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
data = await response.json();
|
|
} catch(err) {
|
|
this.log.error(`Error parsing data for set "${set_id}".`, err);
|
|
this.load_tracker.notify('chat-data', load_key, false);
|
|
return false;
|
|
}
|
|
|
|
const set = data.set;
|
|
if ( set )
|
|
this.loadSetData(set.id, set, suppress_log);
|
|
|
|
if ( data.user_ids )
|
|
this.loadSetUserIds(data.user_ids);
|
|
else if ( data.users )
|
|
this.loadSetUsers(data.users);
|
|
|
|
this.load_tracker.notify('chat-data', load_key, true);
|
|
return true;
|
|
}
|
|
|
|
|
|
loadSetUserIds(data, suppress_log = false) {
|
|
for(const set_id in data)
|
|
if ( has(data, set_id) ) {
|
|
const emote_set = this.emote_sets[set_id],
|
|
users = data[set_id];
|
|
|
|
this.setBulk('ffz-global', set_id, users.map(x => String(x)));
|
|
if ( ! suppress_log )
|
|
this.log.info(`Added "${emote_set ? emote_set.title : set_id}" emote set to ${users.length} users.`);
|
|
}
|
|
}
|
|
|
|
|
|
loadSetUsers(data, suppress_log = false) {
|
|
for(const set_id in data)
|
|
if ( has(data, set_id) ) {
|
|
const emote_set = this.emote_sets[set_id],
|
|
users = data[set_id];
|
|
|
|
for(const login of users)
|
|
this.parent.getUser(undefined, login)
|
|
.addSet('ffz-global', set_id);
|
|
|
|
if ( ! suppress_log )
|
|
this.log.info(`Added "${emote_set ? emote_set.title : set_id}" emote set to ${users.length} users.`);
|
|
}
|
|
}
|
|
|
|
|
|
processEmote(emote, set_id) {
|
|
if ( ! emote.id || ! emote.name || ! emote.urls )
|
|
return null;
|
|
|
|
emote.set_id = set_id;
|
|
emote.src = emote.urls[1];
|
|
emote.srcSet = `${emote.urls[1]} 1x`;
|
|
if ( emote.urls[2] )
|
|
emote.srcSet += `, ${emote.urls[2]} 2x`;
|
|
if ( emote.urls[4] )
|
|
emote.srcSet += `, ${emote.urls[4]} 4x`;
|
|
|
|
if ( emote.urls[2] ) {
|
|
emote.can_big = true;
|
|
emote.src2 = emote.urls[2];
|
|
emote.srcSet2 = `${emote.urls[2]} 1x`;
|
|
if ( emote.urls[4] )
|
|
emote.srcSet2 += `, ${emote.urls[4]} 2x`;
|
|
}
|
|
|
|
if ( emote.animated?.[1] ) {
|
|
emote.animSrc = emote.animated[1];
|
|
emote.animSrcSet = `${emote.animated[1]} 1x`;
|
|
if ( emote.animated[2] ) {
|
|
emote.animSrcSet += `, ${emote.animated[2]} 2x`;
|
|
emote.animSrc2 = emote.animated[2];
|
|
emote.animSrcSet2 = `${emote.animated[2]} 1x`;
|
|
|
|
if ( emote.animated[4] ) {
|
|
emote.animSrcSet += `, ${emote.animated[4]} 4x`;
|
|
emote.animSrcSet2 += `, ${emote.animated[4]} 2x`;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check to see if this emote applies any effects with as_background.
|
|
/*let as_background = false;
|
|
if ( emote.modifier_flags ) {
|
|
for(const input of EFFECT_STYLES)
|
|
if ( (emote.modifier_flags & input.flags) === input.flags ) {
|
|
if ( input.as_background ) {
|
|
as_background = true;
|
|
break;
|
|
}
|
|
}
|
|
}*/
|
|
|
|
emote.token = {
|
|
type: 'emote',
|
|
id: emote.id,
|
|
set: set_id,
|
|
provider: 'ffz',
|
|
src: emote.src,
|
|
srcSet: emote.srcSet,
|
|
can_big: !! emote.urls[2],
|
|
src2: emote.src2,
|
|
srcSet2: emote.srcSet2,
|
|
animSrc: emote.animSrc,
|
|
animSrcSet: emote.animSrcSet,
|
|
animSrc2: emote.animSrc2,
|
|
animSrcSet2: emote.animSrcSet2,
|
|
masked: !! emote.mask,
|
|
mod: emote.modifier,
|
|
mod_prefix: emote.modifier_prefix,
|
|
mod_hidden: (emote.modifier_flags & 1) === 1,
|
|
text: emote.hidden ? '???' : emote.name,
|
|
length: emote.name.length,
|
|
height: emote.height,
|
|
width: emote.width,
|
|
source_modifier_flags: emote.modifier_flags ?? 0,
|
|
//effect_bg: as_background
|
|
};
|
|
|
|
if ( has(MODIFIERS, emote.id) )
|
|
Object.assign(emote, MODIFIERS[emote.id]);
|
|
|
|
return emote;
|
|
}
|
|
|
|
|
|
addEmoteToSet(set_id, emote) {
|
|
const set = this.emote_sets[set_id];
|
|
if ( ! set )
|
|
throw new Error(`Invalid emote set "${set_id}"`);
|
|
|
|
let processed = this.processEmote(emote, set_id);
|
|
if ( ! processed )
|
|
throw new Error("Invalid emote data object.");
|
|
|
|
// Are we removing an existing emote?
|
|
const old_emote = set.emotes[processed.id],
|
|
old_css = old_emote && this.generateEmoteCSS(old_emote);
|
|
|
|
// Store the emote.
|
|
set.emotes[processed.id] = processed;
|
|
if ( ! old_emote )
|
|
set.count++;
|
|
|
|
// Now we need to update the CSS. If we had old emote CSS, then we
|
|
// will need to totally rebuild the CSS.
|
|
const style_key = `es--${set_id}`;
|
|
|
|
if ( old_css && old_css.length ) {
|
|
const css = [];
|
|
for(const em of Object.values(set.emotes)) {
|
|
const emote_css = this.generateEmoteCSS(em);
|
|
if ( emote_css && emote_css.length )
|
|
css.push(emote_css);
|
|
}
|
|
|
|
if ( this.style && (css.length || set.css) )
|
|
this.style.set(style_key, css.join('') + (set.css || ''));
|
|
else if ( css.length )
|
|
set.pending_css = css.join('');
|
|
|
|
} else {
|
|
const emote_css = this.generateEmoteCSS(processed);
|
|
if ( emote_css && emote_css.length ) {
|
|
if ( this.style )
|
|
this.style.set(style_key, (this.style.get(style_key) || '') + emote_css);
|
|
else
|
|
set.pending_css = (set.pending_css || '') + emote_css;
|
|
}
|
|
}
|
|
|
|
// Send a loaded event because this emote set changed.
|
|
this.emit(':loaded', set_id, set);
|
|
|
|
// Return the processed emote object.
|
|
return processed;
|
|
}
|
|
|
|
|
|
removeEmoteFromSet(set_id, emote_id) {
|
|
const set = this.emote_sets[set_id];
|
|
if ( ! set )
|
|
throw new Error(`Invalid emote set "${set_id}"`);
|
|
|
|
if ( emote_id && emote_id.id )
|
|
emote_id = emote_id.id;
|
|
|
|
const emote = set.emotes[emote_id];
|
|
if ( ! emote )
|
|
return;
|
|
|
|
const emote_css = this.generateEmoteCSS(emote);
|
|
const css = (emote_css && emote_css.length) ? [] : null;
|
|
|
|
// Rebuild the emotes object to avoid gaps.
|
|
const new_emotes = {};
|
|
let count = 0;
|
|
|
|
for(const em of Object.values(set.emotes)) {
|
|
if ( em.id == emote_id )
|
|
continue;
|
|
|
|
new_emotes[em.id] = em;
|
|
count++;
|
|
|
|
if ( css != null) {
|
|
const em_css = this.generateEmoteCSS(em);
|
|
if ( em_css && em_css.length )
|
|
css.push(em_css);
|
|
}
|
|
}
|
|
|
|
set.emotes = new_emotes;
|
|
set.count = count;
|
|
|
|
if ( css != null ) {
|
|
const style_key = `es--${set_id}`;
|
|
if ( this.style && (css.length || set.css) )
|
|
this.style.set(style_key, css.join('') + (set.css || ''));
|
|
else if ( css.length )
|
|
set.pending_css = css.join('');
|
|
}
|
|
|
|
// Send a loaded event because this emote set changed.
|
|
this.emit(':loaded', set_id, set);
|
|
|
|
// Return the removed emote.
|
|
return emote;
|
|
}
|
|
|
|
|
|
loadSetData(set_id, data, suppress_log = false) {
|
|
const old_set = this.emote_sets[set_id];
|
|
if ( ! data ) {
|
|
if ( old_set ) {
|
|
if ( this.style )
|
|
this.style.delete(`es--${set_id}`);
|
|
this.emote_sets[set_id] = null;
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
this.emote_sets[set_id] = data;
|
|
|
|
let count = 0;
|
|
const ems = data.emotes || data.emoticons,
|
|
new_ems = data.emotes = {},
|
|
css = [];
|
|
|
|
data.id = set_id;
|
|
data.emoticons = undefined;
|
|
|
|
const bad_emotes = [];
|
|
|
|
for(const emote of ems) {
|
|
let processed = this.processEmote(emote, set_id);
|
|
if ( ! processed ) {
|
|
bad_emotes.push(emote);
|
|
continue;
|
|
}
|
|
|
|
const emote_css = this.generateEmoteCSS(processed);
|
|
if ( emote_css )
|
|
css.push(emote_css);
|
|
|
|
count++;
|
|
new_ems[processed.id] = processed;
|
|
}
|
|
|
|
if ( bad_emotes.length )
|
|
this.log.warn(`Bad Emote Data for Set #${set_id}`, bad_emotes);
|
|
|
|
data.count = count;
|
|
|
|
if ( this.style && (css.length || data.css) )
|
|
this.style.set(`es--${set_id}`, css.join('') + (data.css || ''));
|
|
else if ( css.length )
|
|
data.pending_css = css.join('');
|
|
|
|
if ( ! suppress_log )
|
|
this.log.info(`Loaded emote set #${set_id}: ${data.title} (${count} emotes)`);
|
|
|
|
this.emit(':loaded', set_id, data);
|
|
|
|
// Don't let people endlessly load unused sets.
|
|
const refs = this._set_refs[set_id] || 0;
|
|
if ( refs <= 0 && ! this._set_timers[set_id] )
|
|
this._set_timers[set_id] = setTimeout(() => this.unloadSet(set_id), 5000);
|
|
}
|
|
|
|
|
|
unloadSet(set_id, force = false, suppress_log = false) {
|
|
const old_set = this.emote_sets[set_id],
|
|
count = this._set_refs[set_id] || 0;
|
|
|
|
if ( ! old_set )
|
|
return;
|
|
|
|
if ( count > 0 ) {
|
|
if ( ! force )
|
|
return this.log.warn(`Attempted to unload emote set #${set_id} with ${count} users.`);
|
|
this.log.warn(`Unloading emote set ${set_id} with ${count} users.`);
|
|
}
|
|
|
|
if ( ! suppress_log )
|
|
this.log.info(`Unloaded emote set #${set_id}: ${old_set.title}`);
|
|
|
|
if ( this._set_timers[set_id] ) {
|
|
clearTimeout(this._set_timers[set_id]);
|
|
this._set_timers[set_id] = null;
|
|
}
|
|
|
|
this.emit(':unloaded', set_id, old_set);
|
|
this.emote_sets[set_id] = null;
|
|
}
|
|
|
|
|
|
// ========================================================================
|
|
// Emote CSS
|
|
// ========================================================================
|
|
|
|
generateEmoteCSS(emote) { // eslint-disable-line class-methods-use-this
|
|
if ( ! emote.mask && ! emote.margins && ( ! emote.modifier || ( ! emote.modifier_offset && ! emote.extra_width && ! emote.shrink_to_fit ) ) && ! emote.css )
|
|
return '';
|
|
|
|
let output = '';
|
|
if ( emote.modifier && (emote.modifier_offset || emote.margins || emote.extra_width || emote.shrink_to_fit) ) {
|
|
let margins = emote.modifier_offset || emote.margins || '0';
|
|
margins = margins.split(/\s+/).map(x => parseInt(x, 10));
|
|
if ( margins.length === 3 )
|
|
margins.push(margins[1]);
|
|
|
|
const l = margins.length,
|
|
m_top = margins[0 % l],
|
|
m_right = margins[1 % l],
|
|
m_bottom = margins[2 % l],
|
|
m_left = margins[3 % l];
|
|
|
|
output = `.modified-emote span .ffz-emote[data-id="${emote.id}"] {
|
|
padding: ${m_top}px ${m_right}px ${m_bottom}px ${m_left}px;
|
|
${emote.shrink_to_fit ? `max-width: calc(100% - ${40 - m_left - m_right - (emote.extra_width||0)}px);` : ''}
|
|
margin: 0 !important;
|
|
}`;
|
|
}
|
|
|
|
if ( emote.modifier && emote.mask?.[1] ) {
|
|
output = (output || '') + `.modified-emote[data-modifiers~="${emote.id}"] > .chat-line__message--emote {
|
|
-webkit-mask-image: url("${emote.mask[1]}");
|
|
-webkit-mask-position: center center;
|
|
}`
|
|
}
|
|
|
|
return `${output}.ffz-emote[data-id="${emote.id}"] {
|
|
${(emote.margins && ! emote.modifier) ? `margin: ${emote.margins} !important;` : ''}
|
|
${emote.css||''}
|
|
}`;
|
|
}
|
|
|
|
|
|
// ========================================================================
|
|
// Twitch Data Lookup
|
|
// ========================================================================
|
|
|
|
setTwitchEmoteSet(emote_id, set_id) {
|
|
if ( typeof emote_id === 'number' ) {
|
|
if ( isNaN(emote_id) || ! isFinite(emote_id) )
|
|
return;
|
|
emote_id = `${emote_id}`;
|
|
}
|
|
|
|
if ( typeof set_id === 'number' ) {
|
|
if ( isNaN(set_id) || ! isFinite(set_id) )
|
|
return;
|
|
set_id = `${set_id}`;
|
|
}
|
|
|
|
this.__twitch_emote_to_set[emote_id] = set_id;
|
|
}
|
|
|
|
setTwitchSetChannel(set_id, channel) {
|
|
if ( typeof set_id === 'number' ) {
|
|
if ( isNaN(set_id) || ! isFinite(set_id) )
|
|
return;
|
|
|
|
set_id = `${set_id}`;
|
|
}
|
|
|
|
this.__twitch_set_to_channel[set_id] = channel;
|
|
}
|
|
|
|
_getTwitchEmoteSet(emote_id, need_artist = false) {
|
|
const tes = this.__twitch_emote_to_set,
|
|
tsc = this.__twitch_set_to_channel,
|
|
tsa = this.__twitch_emote_to_artist;
|
|
|
|
if ( typeof emote_id === 'number' ) {
|
|
if ( isNaN(emote_id) || ! isFinite(emote_id) )
|
|
return Promise.resolve(null);
|
|
|
|
emote_id = `${emote_id}`;
|
|
}
|
|
|
|
if ( has(tes, emote_id) && (! need_artist || has(tsa, emote_id)) ) {
|
|
const val = tes[emote_id];
|
|
if ( Array.isArray(val) )
|
|
return new Promise(s => val.push(s));
|
|
else
|
|
return Promise.resolve(val);
|
|
}
|
|
|
|
const apollo = this.resolve('site.apollo');
|
|
if ( ! apollo?.client )
|
|
return Promise.resolve(null);
|
|
|
|
return new Promise(s => {
|
|
const promises = [s];
|
|
tes[emote_id] = promises;
|
|
|
|
timeout(apollo.client.query({
|
|
query: GET_EMOTE,
|
|
variables: {
|
|
id: `${emote_id}`
|
|
}
|
|
}), 2000).then(data => {
|
|
const emote = data?.data?.emote;
|
|
let set_id = null;
|
|
|
|
if ( emote ) {
|
|
set_id = emote.setID;
|
|
|
|
if ( emote.id && ! has(tsa, emote.id) ) {
|
|
tsa[emote.id] = emote.artist;
|
|
}
|
|
|
|
if ( set_id && ! has(tsc, set_id) ) {
|
|
const type = determineEmoteType(emote);
|
|
|
|
tsc[set_id] = {
|
|
id: set_id,
|
|
type,
|
|
owner: emote?.subscriptionProduct?.owner || emote?.owner
|
|
};
|
|
}
|
|
}
|
|
|
|
tes[emote_id] = set_id;
|
|
for(const fn of promises)
|
|
fn(set_id);
|
|
|
|
}).catch(() => {
|
|
tes[emote_id] = null;
|
|
for(const fn of promises)
|
|
fn(null);
|
|
});
|
|
});
|
|
}
|
|
|
|
getTwitchEmoteSet(emote_id, callback) {
|
|
const promise = this._getTwitchEmoteSet(emote_id);
|
|
if ( callback )
|
|
promise.then(callback);
|
|
else
|
|
return promise;
|
|
}
|
|
|
|
_getTwitchEmoteArtist(emote_id) {
|
|
const tsa = this.__twitch_emote_to_artist;
|
|
|
|
if ( has(tsa, emote_id) )
|
|
return Promise.resolve(tsa[emote_id]);
|
|
|
|
return this._getTwitchEmoteSet(emote_id, true)
|
|
.then(() => tsa[emote_id])
|
|
.catch(() => {
|
|
tsa[emote_id] = null;
|
|
return null;
|
|
});
|
|
}
|
|
|
|
getTwitchEmoteArtist(emote_id, callback) {
|
|
const promise = this._getTwitchEmoteArtist(emote_id);
|
|
if ( callback )
|
|
promise.then(callback);
|
|
else
|
|
return promise;
|
|
}
|
|
|
|
|
|
_getTwitchSetChannel(set_id) {
|
|
const tsc = this.__twitch_set_to_channel;
|
|
|
|
if ( typeof set_id === 'number' ) {
|
|
if ( isNaN(set_id) || ! isFinite(set_id) )
|
|
return Promise.resolve(null);
|
|
|
|
set_id = `${set_id}`;
|
|
}
|
|
|
|
if ( has(tsc, set_id) ) {
|
|
const val = tsc[set_id];
|
|
if ( Array.isArray(val) )
|
|
return new Promise(s => val.push(s));
|
|
else
|
|
return Promise.resolve(val);
|
|
}
|
|
|
|
const apollo = this.resolve('site.apollo');
|
|
if ( ! apollo?.client )
|
|
return Promise.resolve(null);
|
|
|
|
return new Promise(s => {
|
|
const promises = [s];
|
|
tsc[set_id] = promises;
|
|
|
|
timeout(apollo.client.query({
|
|
query: GET_EMOTE_SET,
|
|
variables: {
|
|
id: `${set_id}`
|
|
}
|
|
}), 2000).then(data => {
|
|
const set = data?.data?.emoteSet;
|
|
let result = null;
|
|
|
|
if ( set ) {
|
|
result = {
|
|
id: set_id,
|
|
type: determineSetType(set),
|
|
owner: set.owner ? {
|
|
id: set.owner.id,
|
|
login: set.owner.login,
|
|
displayName: set.owner.displayName
|
|
} : null
|
|
};
|
|
}
|
|
|
|
tsc[set_id] = result;
|
|
for(const fn of promises)
|
|
fn(result);
|
|
|
|
}).catch(() => {
|
|
tsc[set_id] = null;
|
|
for(const fn of promises)
|
|
fn(null);
|
|
});
|
|
});
|
|
}
|
|
|
|
|
|
getTwitchSetChannel(set_id, callback) {
|
|
const promise = this._getTwitchSetChannel(set_id);
|
|
if ( callback )
|
|
promise.then(callback);
|
|
else
|
|
return promise;
|
|
}
|
|
}
|
|
|
|
|
|
function determineEmoteType(emote) {
|
|
const product = emote.subscriptionProduct;
|
|
if ( product ) {
|
|
if ( product.id == 12658 )
|
|
return EmoteTypes.Prime;
|
|
else if ( product.id == 324 )
|
|
return EmoteTypes.Turbo;
|
|
|
|
// TODO: Care about Overwatch League
|
|
|
|
const owner = product.owner;
|
|
if ( owner ) {
|
|
if ( owner.id == 139075904 || product.state === 'INACTIVE' )
|
|
return EmoteTypes.LimitedTime;
|
|
|
|
return EmoteTypes.Subscription;
|
|
}
|
|
}
|
|
|
|
if ( emote.setID == 300238151 )
|
|
return EmoteTypes.ChannelPoints;
|
|
|
|
if ( emote.setID == 300374282 )
|
|
return EmoteTypes.TwoFactor;
|
|
|
|
const id = parseInt(emote.setID, 10);
|
|
if ( ! isNaN(id) && isFinite(id) && id >= 5e8 )
|
|
return EmoteTypes.BitsTier;
|
|
|
|
return EmoteTypes.Global;
|
|
}
|
|
|
|
|
|
function determineSetType(set) {
|
|
const id = /^\d+$/.test(set.id) ? parseInt(set.id, 10) : null;
|
|
|
|
if ( id && TWITCH_GLOBAL_SETS.includes(id) )
|
|
return EmoteTypes.Global;
|
|
|
|
if ( id && TWITCH_POINTS_SETS.includes(id) )
|
|
return EmoteTypes.ChannelPoints;
|
|
|
|
if ( id && TWITCH_PRIME_SETS.includes(id) )
|
|
return EmoteTypes.Prime;
|
|
|
|
if ( id == 300374282 )
|
|
return EmoteTypes.TwoFactor;
|
|
|
|
const owner = set.owner;
|
|
if ( owner ) {
|
|
if ( owner.id == 139075904 )
|
|
return EmoteTypes.LimitedTime;
|
|
|
|
let product;
|
|
if ( Array.isArray(owner.subscriptionProducts) )
|
|
for(const prod of owner.subscriptionProducts)
|
|
if ( set.id == prod.emoteSetID ) {
|
|
product = prod;
|
|
break;
|
|
}
|
|
|
|
if ( product ) {
|
|
if ( product.id == 12658 )
|
|
return EmoteTypes.Prime;
|
|
else if ( product.id == 324 )
|
|
return EmoteTypes.Turbo;
|
|
else if ( product.state === 'INACTIVE' )
|
|
return EmoteTypes.LimitedTime;
|
|
}
|
|
|
|
return EmoteTypes.Subscription;
|
|
}
|
|
|
|
if ( id >= 5e8 )
|
|
return EmoteTypes.BitsTier;
|
|
|
|
return EmoteTypes.Global;
|
|
} |