1
0
Fork 0
mirror of https://github.com/FrankerFaceZ/FrankerFaceZ.git synced 2025-06-28 15:27:43 +00:00
* Fixed: Case-sensitive blocked terms not functioning correctly.
* Fixed: Settings in the FFZ Control Center not reverting to a default appearance when reset.
* Fixed: Current Channel and Channel Color not being properly detected in the mod view, channel pages, and dashboard.
* Fixed: The channel points reward queue not functioning correctly.
* Changed: Allow highlighting and blocking by add-on badge, not just Twitch badge.
* Changed: Don't allocate `user.badges` and `user.emote_sets` until they're actually used to save on memory.
* Changed: Don't default the `Chat > Bits and Cheering >> Display animated cheers.` setting to the `Animated Emotes` setting.
* API Added: `badges.setBulk`, `badges.deleteBulk`, and `badges.extendBulk` for setting badges on users in bulk using an optimized data structure.
* API Added: Tokenizers can set `msg.ffz_halt_tokens = true` to prevent further tokenizers running. Useful when just discarding a message.
This commit is contained in:
SirStendec 2021-03-22 18:19:09 -04:00
parent a8b28b2d27
commit 1cdff0ec67
31 changed files with 533 additions and 1158 deletions

View file

@ -1,7 +1,7 @@
{ {
"name": "frankerfacez", "name": "frankerfacez",
"author": "Dan Salvato LLC", "author": "Dan Salvato LLC",
"version": "4.20.83", "version": "4.20.84",
"description": "FrankerFaceZ is a Twitch enhancement suite.", "description": "FrankerFaceZ is a Twitch enhancement suite.",
"private": true, "private": true,
"license": "Apache-2.0", "license": "Apache-2.0",

View file

@ -7,7 +7,7 @@
import {NEW_API, SERVER, API_SERVER, IS_WEBKIT, IS_FIREFOX, WEBKIT_CSS as WEBKIT} from 'utilities/constants'; import {NEW_API, SERVER, API_SERVER, IS_WEBKIT, IS_FIREFOX, WEBKIT_CSS as WEBKIT} from 'utilities/constants';
import {createElement, ManagedStyle} from 'utilities/dom'; import {createElement, ManagedStyle} from 'utilities/dom';
import {has, maybe_call} from 'utilities/object'; import {has, maybe_call, SourcedSet} from 'utilities/object';
import Module from 'utilities/module'; import Module from 'utilities/module';
import { ColorAdjuster } from 'src/utilities/color'; import { ColorAdjuster } from 'src/utilities/color';
@ -181,11 +181,16 @@ export default class Badges extends Module {
this.style = new ManagedStyle('badges'); this.style = new ManagedStyle('badges');
// Bulk data structure for badges 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;
// Special data structure for supporters to greatly reduce // Special data structure for supporters to greatly reduce
// memory usage and speed things up for people who only have // memory usage and speed things up for people who only have
// a supporter badge. // a supporter badge.
this.supporter_id = null; //this.supporter_id = null;
this.supporters = new Set; //this.supporters = new Set;
this.badges = {}; this.badges = {};
this.twitch_badges = {}; this.twitch_badges = {};
@ -340,17 +345,23 @@ export default class Badges extends Module {
if ( include_addons ) if ( include_addons )
for(const key in this.badges) for(const key in this.badges)
if ( has(this.badges, key) ) { if ( has(this.badges, key) ) {
const badge = this.badges[key], const badge = this.badges[key];
image = badge.urls ? (badge.urls[2] || badge.urls[1]) : badge.image;
if ( badge.no_visibility ) if ( badge.no_visibility )
continue; continue;
let image = badge.urls ? (badge.urls[2] || badge.urls[1]) : badge.image,
color = badge.color || 'transparent';
if ( ! badge.addon ) {
image = `//cdn.frankerfacez.com/badge/${badge.id}/2/rounded`;
color = 'transparent';
}
(badge.addon ? addon : ffz).push({ (badge.addon ? addon : ffz).push({
id: key, id: key,
provider: 'ffz', provider: 'ffz',
name: badge.title, name: badge.title,
color: badge.color || 'transparent', color,
image, image,
styleImage: `url("${image}")` styleImage: `url("${image}")`
}); });
@ -539,6 +550,11 @@ export default class Badges extends Module {
render(msg, createElement, skip_hide = false) { // eslint-disable-line class-methods-use-this render(msg, createElement, skip_hide = false) { // eslint-disable-line class-methods-use-this
if ( ! msg.badges && ! msg.ffz_badges )
return null;
// TODO: A lot of this can be cached
const hidden_badges = skip_hide ? {} : (this.parent.context.get('chat.badges.hidden') || {}), const hidden_badges = skip_hide ? {} : (this.parent.context.get('chat.badges.hidden') || {}),
badge_style = this.parent.context.get('chat.badges.style'), badge_style = this.parent.context.get('chat.badges.style'),
custom_mod = this.parent.context.get('chat.badges.custom-mod'), custom_mod = this.parent.context.get('chat.badges.custom-mod'),
@ -556,14 +572,14 @@ export default class Badges extends Module {
twitch_badges = msg.badges || {}, twitch_badges = msg.badges || {},
dynamic_data = msg.badgeDynamicData || {}, dynamic_data = msg.badgeDynamicData || {},
user = msg.user || {}, //user = msg.user || {},
user_id = user.id, //user_id = user.id,
user_login = user.login, //user_login = user.login,
room_id = msg.roomID, room_id = msg.roomID,
room_login = msg.roomLogin, room_login = msg.roomLogin,
room = this.parent.getRoom(room_id, room_login, true), room = this.parent.getRoom(room_id, room_login, true),
badges = this.getBadges(user_id, user_login, room_id, room_login); badges = msg.ffz_badges; // this.getBadges(user_id, user_login, room_id, room_login);
let last_slot = 50, slot; let last_slot = 50, slot;
@ -616,105 +632,107 @@ export default class Badges extends Module {
}; };
} }
const handled_ids = new Set; if ( Array.isArray(badges) ) {
const handled_ids = new Set;
for(const badge of badges) for(const badge of badges)
if ( badge && badge.id != null ) { if ( badge && badge.id != null ) {
if ( handled_ids.has(badge.id) ) if ( handled_ids.has(badge.id) )
continue;
handled_ids.add(badge.id);
const full_badge = this.badges[badge.id] || {},
is_hidden = hidden_badges[badge.id];
if ( is_hidden || (is_hidden == null && (full_badge.addon ? addon_hidden : ffz_hidden)) )
continue;
const slot = has(badge, 'slot') ? badge.slot : full_badge.slot,
old_badge = slotted[slot],
urls = badge.urls || (badge.image ? {1: badge.image} : null),
color = badge.color || full_badge.color || 'transparent',
no_invert = badge.no_invert,
masked = color !== 'transparent' && is_mask,
bu = (urls || full_badge.urls || {1: full_badge.image}),
bd = {
provider: 'ffz',
image: bu[4] || bu[2] || bu[1],
color: badge.color || full_badge.color,
title: badge.title || full_badge.title,
};
// Hacky nonsense.
if ( ! full_badge.addon ) {
bd.image = `//cdn.frankerfacez.com/badge/${badge.id}/4/rounded`;
bd.color = null;
}
let style;
if ( old_badge ) {
old_badge.badges.push(bd);
const replaces = has(badge, 'replaces') ? badge.replaces : full_badge.replaces,
replaces_type = badge.replaces_type || full_badge.replaces_type;
if ( replaces && (!replaces_type || replaces_type === old_badge.id) ) {
old_badge.replaced = badge.id;
old_badge.content = badge.content || full_badge.content || old_badge.content;
} else
continue; continue;
style = old_badge.props.style; handled_ids.add(badge.id);
} else if ( slot == null ) const full_badge = this.badges[badge.id] || {},
continue; is_hidden = hidden_badges[badge.id];
else { if ( is_hidden || (is_hidden == null && (full_badge.addon ? addon_hidden : ffz_hidden)) )
style = {}; continue;
const props = {
className: 'ffz-tooltip ffz-badge',
'data-tooltip-type': 'badge',
'data-provider': 'ffz',
'data-badge': badge.id,
style
};
slotted[slot] = { const slot = has(badge, 'slot') ? badge.slot : full_badge.slot,
id: badge.id, old_badge = slotted[slot],
props, urls = badge.urls || (badge.image ? {1: badge.image} : null),
badges: [bd], color = badge.color || full_badge.color || 'transparent',
content: badge.content || full_badge.content no_invert = badge.no_invert,
masked = color !== 'transparent' && is_mask,
bu = (urls || full_badge.urls || {1: full_badge.image}),
bd = {
provider: 'ffz',
image: bu[4] || bu[2] || bu[1],
color: badge.color || full_badge.color,
title: badge.title || full_badge.title,
};
// Hacky nonsense.
if ( ! full_badge.addon ) {
bd.image = `//cdn.frankerfacez.com/badge/${badge.id}/4/rounded`;
bd.color = null;
}
let style;
if ( old_badge ) {
old_badge.badges.push(bd);
const replaces = has(badge, 'replaces') ? badge.replaces : full_badge.replaces,
replaces_type = badge.replaces_type || full_badge.replaces_type;
if ( replaces && (!replaces_type || replaces_type === old_badge.id) ) {
old_badge.replaced = badge.id;
old_badge.content = badge.content || full_badge.content || old_badge.content;
} else
continue;
style = old_badge.props.style;
} else if ( slot == null )
continue;
else {
style = {};
const props = {
className: 'ffz-tooltip ffz-badge',
'data-tooltip-type': 'badge',
'data-provider': 'ffz',
'data-badge': badge.id,
style
};
slotted[slot] = {
id: badge.id,
props,
badges: [bd],
content: badge.content || full_badge.content
}
}
if (no_invert) {
slotted[slot].full_size = true;
slotted[slot].no_invert = true;
style.background = 'unset';
style.backgroundSize = 'unset';
style[CSS_MASK_IMAGE] = 'unset';
}
if ( (has_image || color === 'transparent') && urls ) {
const image = `url("${urls[1]}")`;
let image_set;
if ( urls[2] || urls[4] )
image_set = `${WEBKIT}image-set(${image} 1x${urls[2] ? `, url("${urls[2]}") 2x` : ''}${urls[4] ? `, url("${urls[4]}") 4x` : ''})`;
style[masked && !no_invert ? CSS_MASK_IMAGE : 'backgroundImage'] = image;
if ( image_set )
style[masked && !no_invert ? CSS_MASK_IMAGE : 'backgroundImage'] = image_set;
}
if ( is_colored && badge.color ) {
if ( masked && !no_invert )
style.backgroundImage = `linear-gradient(${badge.color},${badge.color})`;
else
style.backgroundColor = badge.color;
} }
} }
}
if (no_invert) {
slotted[slot].full_size = true;
slotted[slot].no_invert = true;
style.background = 'unset';
style.backgroundSize = 'unset';
style[CSS_MASK_IMAGE] = 'unset';
}
if ( (has_image || color === 'transparent') && urls ) {
const image = `url("${urls[1]}")`;
let image_set;
if ( urls[2] || urls[4] )
image_set = `${WEBKIT}image-set(${image} 1x${urls[2] ? `, url("${urls[2]}") 2x` : ''}${urls[4] ? `, url("${urls[4]}") 4x` : ''})`;
style[masked && !no_invert ? CSS_MASK_IMAGE : 'backgroundImage'] = image;
if ( image_set )
style[masked && !no_invert ? CSS_MASK_IMAGE : 'backgroundImage'] = image_set;
}
if ( is_colored && badge.color ) {
if ( masked && !no_invert )
style.backgroundImage = `linear-gradient(${badge.color},${badge.color})`;
else
style.backgroundColor = badge.color;
}
}
for(const slot in slotted) for(const slot in slotted)
if ( has(slotted, slot) ) { if ( has(slotted, slot) ) {
@ -785,19 +803,56 @@ export default class Badges extends Module {
getBadges(user_id, user_login, room_id, room_login) { getBadges(user_id, user_login, room_id, room_login) {
const room = this.parent.getRoom(room_id, room_login, true), const room = this.parent.getRoom(room_id, room_login, true),
global_user = this.parent.getUser(user_id, user_login, true), global_user = this.parent.getUser(user_id, user_login, true);
room_user = room && room.getUser(user_id, user_login, true);
const out = (global_user ? global_user.badges._cache : []).concat( if ( global_user ) {
room_user ? room_user.badges._cache : []); user_id = user_id ?? global_user.id;
user_login = user_login ?? global_user.login;
}
if ( this.supporter_id && this.supporters.has(`${user_id}`) ) const room_user = room && room.getUser(user_id, user_login, true);
out.push({id: this.supporter_id});
const out = (global_user?.badges ? global_user.badges._cache : []).concat(
room_user?.badges ? room_user.badges._cache : []);
if ( this.bulk.size ) {
const str_user = String(user_id);
for(const [badge_id, users] of this.bulk) {
if ( users?._cache.has(str_user) )
out.push({id: badge_id});
}
}
return out; return out;
} }
setBulk(source, badge_id, entries) {
let set = this.bulk.get(badge_id);
if ( ! set )
this.bulk.set(badge_id, set = new SourcedSet(true));
set.set(source, entries);
}
deleteBulk(source, badge_id) {
const set = this.bulk.get(badge_id);
if ( set )
set.delete(source);
}
extendBulk(source, badge_id, entries) {
let set = this.bulk.get(badge_id);
if ( ! set )
this.bulk.set(badge_id, set = new SourcedSet(true));
if ( ! Array.isArray(entries) )
entries = [entries];
set.extend(source, ...entries);
}
async loadGlobalBadges(tries = 0) { async loadGlobalBadges(tries = 0) {
let response, data; let response, data;
@ -839,15 +894,17 @@ export default class Badges extends Module {
if ( data.users ) if ( data.users )
for(const badge_id in data.users) for(const badge_id in data.users)
if ( has(data.users, badge_id) ) { if ( has(data.users, badge_id) ) {
const badge = this.badges[badge_id]; const badge = this.badges[badge_id],
name = badge?.name;
let c = 0; let c = 0;
if ( badge?.name === 'supporter' ) { if ( name === 'supporter' || name === 'bot' ) {
this.supporter_id = badge_id; this.setBulk('ffz-global', badge_id, data.users[badge_id].map(x => String(x)));
/*this.supporter_id = badge_id;
for(const user_id of data.users[badge_id]) for(const user_id of data.users[badge_id])
this.supporters.add(`${user_id}`); this.supporters.add(`${user_id}`);*/
c = this.supporters.size; c = data.users[badge_id].length; // this.supporters.size;
} else } else
for(const user_id of data.users[badge_id]) { for(const user_id of data.users[badge_id]) {
const user = this.parent.getUser(user_id, undefined); const user = this.parent.getUser(user_id, undefined);

View file

@ -506,9 +506,9 @@ export default class Emotes extends Module {
room_user = room && room.getUser(user_id, user_login, true), room_user = room && room.getUser(user_id, user_login, true),
user = this.parent.getUser(user_id, user_login, true); user = this.parent.getUser(user_id, user_login, true);
return (user ? user.emote_sets._cache : []).concat( return (user?.emote_sets ? user.emote_sets._cache : []).concat(
room_user ? room_user.emote_sets._cache : [], room_user?.emote_sets ? room_user.emote_sets._cache : [],
room ? room.emote_sets._cache : [], room?.emote_sets ? room.emote_sets._cache : [],
this.default_sets._cache this.default_sets._cache
); );
} }
@ -519,7 +519,7 @@ export default class Emotes extends Module {
} }
_withSources(out, seen, emote_sets) { // eslint-disable-line class-methods-use-this _withSources(out, seen, emote_sets) { // eslint-disable-line class-methods-use-this
if ( ! emote_sets._sources ) if ( ! emote_sets?._sources )
return; return;
for(const [provider, data] of emote_sets._sources) for(const [provider, data] of emote_sets._sources)
@ -560,8 +560,11 @@ export default class Emotes extends Module {
if ( ! room ) if ( ! room )
return []; return [];
if ( ! room_user ) if ( ! room_user?.emote_sets )
return room.emote_sets._cache; 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); return room_user.emote_sets._cache.concat(room.emote_sets._cache);
} }
@ -589,7 +592,7 @@ export default class Emotes extends Module {
getGlobalSetIDs(user_id, user_login) { getGlobalSetIDs(user_id, user_login) {
const user = this.parent.getUser(user_id, user_login, true); const user = this.parent.getUser(user_id, user_login, true);
if ( ! user ) if ( ! user?.emote_sets )
return this.default_sets._cache; return this.default_sets._cache;
return user.emote_sets._cache.concat(this.default_sets._cache); return user.emote_sets._cache.concat(this.default_sets._cache);

View file

@ -504,7 +504,7 @@ export default class Chat extends Module {
path: 'Chat > Filtering > Highlight >> Badges', path: 'Chat > Filtering > Highlight >> Badges',
component: 'badge-highlighting', component: 'badge-highlighting',
colored: true, colored: true,
data: () => this.badges.getSettingsBadges() data: () => this.badges.getSettingsBadges(true)
} }
}); });
@ -540,7 +540,7 @@ export default class Chat extends Module {
path: 'Chat > Filtering > Block >> Badges @{"description": "**Note:** This section is for filtering messages out of chat from users with specific badges. If you wish to hide a badge, go to [Chat > Badges >> Visibility](~chat.badges.tabs.visibility)."}', path: 'Chat > Filtering > Block >> Badges @{"description": "**Note:** This section is for filtering messages out of chat from users with specific badges. If you wish to hide a badge, go to [Chat > Badges >> Visibility](~chat.badges.tabs.visibility)."}',
component: 'badge-highlighting', component: 'badge-highlighting',
removable: true, removable: true,
data: () => this.badges.getSettingsBadges() data: () => this.badges.getSettingsBadges(true)
} }
}); });
@ -687,13 +687,31 @@ export default class Chat extends Module {
if ( ! val || ! val.length ) if ( ! val || ! val.length )
return null; return null;
const out = [ const data = [
[[], []], [ // no-remove
[[], []] [ // sensitive
[], [] // word
],
[ // intensitive
[], []
]
],
[ // remove
[ // sensitive
[], [] // word
],
[ // intensiitve
[], []
]
]
]; ];
let had_remove = false,
had_non = false;
for(const item of val) { for(const item of val) {
const t = item.t, const t = item.t,
sensitive = item.s,
word = has(item, 'w') ? item.w : t !== 'raw'; word = has(item, 'w') ? item.w : t !== 'raw';
let v = item.v; let v = item.v;
@ -706,15 +724,21 @@ export default class Chat extends Module {
if ( ! v || ! v.length ) if ( ! v || ! v.length )
continue; continue;
out[item.remove ? 1 : 0][word ? 0 : 1].push(v); if ( item.remove )
had_remove = true;
else
had_non = true;
data[item.remove ? 1 : 0][sensitive ? 0 : 1][word ? 0 : 1].push(v);
} }
return out.map(data => { if ( ! had_remove && ! had_non )
if ( data[0].length ) return null;
data[1].push(`(^|.*?${SEPARATORS})(?:${data[0].join('|')})(?=$|${SEPARATORS})`);
return data[1].length ? new RegExp(data[1].join('|'), 'gi') : null; return {
}); remove: had_remove ? formatTerms(data[1]) : null,
non: had_non ? formatTerms(data[0]) : null
};
} }
}); });
@ -936,6 +960,13 @@ export default class Chat extends Module {
path: 'Chat > Appearance >> Emotes', path: 'Chat > Appearance >> Emotes',
title: 'Animated Emotes', title: 'Animated Emotes',
default(ctx) {
const temp = ctx.get('ffzap.betterttv.gif_emoticons_mode');
if ( temp == null )
return ctx.get('context.bttv.gifs') ? 1 : 0;
return temp === 2 ? 1 : 0;
},
getExtraTerms: () => GIF_TERMS, getExtraTerms: () => GIF_TERMS,
description: 'This controls whether or not animated emotes are allowed to play in chat. When this is `Disabled`, emotes will appear as static images. Setting this to `Enable on Hover` may cause performance issues.', description: 'This controls whether or not animated emotes are allowed to play in chat. When this is `Disabled`, emotes will appear as static images. Setting this to `Enable on Hover` may cause performance issues.',
@ -966,13 +997,7 @@ export default class Chat extends Module {
}); });
this.settings.add('chat.bits.animated', { this.settings.add('chat.bits.animated', {
requires: ['chat.emotes.animated'], default: true,
default: null,
process(ctx, val) {
if ( val == null )
val = ctx.get('chat.emotes.animated') ? true : false
},
ui: { ui: {
path: 'Chat > Bits and Cheering >> Appearance', path: 'Chat > Bits and Cheering >> Appearance',
title: 'Display animated cheers.', title: 'Display animated cheers.',
@ -1395,6 +1420,9 @@ export default class Chat extends Module {
if ( msg.deletedAt !== undefined ) if ( msg.deletedAt !== undefined )
msg.deleted = !!msg.deletedAt; msg.deleted = !!msg.deletedAt;
// Addon Badges
msg.ffz_badges = this.badges.getBadges(user.id, user.login, msg.roomID, msg.roomLogin);
return msg; return msg;
} }
@ -1634,7 +1662,7 @@ export default class Chat extends Module {
} }
tokenizeMessage(msg, user) { tokenizeMessage(msg, user, haltable = false) {
if ( msg.content && ! msg.message ) if ( msg.content && ! msg.message )
msg.message = msg.content.text; msg.message = msg.content.text;
@ -1646,8 +1674,13 @@ export default class Chat extends Module {
let tokens = [{type: 'text', text: msg.message}]; let tokens = [{type: 'text', text: msg.message}];
for(const tokenizer of this.__tokenizers) for(const tokenizer of this.__tokenizers) {
tokens = tokenizer.process.call(this, tokens, msg, user); tokens = tokenizer.process.call(this, tokens, msg, user, haltable);
if ( haltable && msg.ffz_halt_tokens ) {
msg.ffz_halt_tokens = undefined;
break;
}
}
return tokens; return tokens;
} }

View file

@ -20,7 +20,7 @@ export default class Room {
this.refs = new Set; this.refs = new Set;
this.style = new ManagedStyle(`room--${login}`); this.style = new ManagedStyle(`room--${login}`);
this.emote_sets = new SourcedSet; this.emote_sets = null; // new SourcedSet;
this.badges = null; this.badges = null;
this.users = {}; this.users = {};
this.user_ids = {}; this.user_ids = {};
@ -305,9 +305,11 @@ export default class Room {
this.data = d; this.data = d;
if ( d.set ) if ( d.set ) {
if ( ! this.emote_sets )
this.emote_sets = new SourcedSet;
this.emote_sets.set('main', d.set); this.emote_sets.set('main', d.set);
else } else if ( this.emote_sets )
this.emote_sets.delete('main'); this.emote_sets.delete('main');
@ -342,6 +344,9 @@ export default class Room {
if ( this.destroyed ) if ( this.destroyed )
return; return;
if ( ! this.emote_sets )
this.emote_sets = new SourcedSet;
let changed = false; let changed = false;
if ( ! this.emote_sets.sourceIncludes(provider, set_id) ) { if ( ! this.emote_sets.sourceIncludes(provider, set_id) ) {
this.emote_sets.push(provider, set_id); this.emote_sets.push(provider, set_id);
@ -357,7 +362,7 @@ export default class Room {
} }
removeSet(provider, set_id) { removeSet(provider, set_id) {
if ( this.destroyed ) if ( this.destroyed || ! this.emote_sets )
return; return;
if ( this.emote_sets.sourceIncludes(provider, set_id) ) { if ( this.emote_sets.sourceIncludes(provider, set_id) ) {

View file

@ -464,7 +464,7 @@ export const BlockedUsers = {
type: 'user_block', type: 'user_block',
priority: 100, priority: 100,
process(tokens, msg, user) { process(tokens, msg, user, haltable) {
if ( user && user.login && user.login == msg.user.login && ! this.context.get('chat.filtering.process-own') ) if ( user && user.login && user.login == msg.user.login && ! this.context.get('chat.filtering.process-own') )
return tokens; return tokens;
@ -476,39 +476,69 @@ export const BlockedUsers = {
if ( regexes[1] && (regexes[1].test(u.login) || regexes[1].test(u.displayName)) ) { if ( regexes[1] && (regexes[1].test(u.login) || regexes[1].test(u.displayName)) ) {
msg.deleted = true; msg.deleted = true;
msg.ffz_removed = true; msg.ffz_removed = true;
} if ( haltable )
msg.ffz_halt_tokens = true;
if ( ! msg.deleted && regexes[0] && (regexes[0].test(u.login) || regexes[0].test(u.displayName)) ) } else if ( ! msg.deleted && regexes[0] && (regexes[0].test(u.login) || regexes[0].test(u.displayName)) )
msg.deleted = true; msg.deleted = true;
return tokens; return tokens;
} }
} }
export const BadgeHighlights = { function getBadgeIDs(msg) {
type: 'badge_highlight', let keys = msg.badges ? Object.keys(msg.badges) : null;
if ( ! msg.ffz_badges )
return keys;
if ( ! keys )
keys = [];
for(const badge of msg.ffz_badges)
if ( badge?.id )
keys.push(badge.id);
return keys;
}
export const BadgeStuff = {
type: 'badge_stuff',
priority: 80, priority: 80,
process(tokens, msg, user) { process(tokens, msg, user, haltable) {
if ( user && user.login && user.login == msg.user.login && ! this.context.get('chat.filtering.process-own') ) if ( user && user.login && user.login == msg.user.login && ! this.context.get('chat.filtering.process-own') )
return tokens; return tokens;
const badges = msg.badges; const colors = this.context.get('chat.filtering.highlight-basic-badges--colors'),
if ( ! badges ) list = this.context.get('chat.filtering.highlight-basic-badges-blocked--list');
if ( ! colors && ! list )
return tokens; return tokens;
const colors = this.context.get('chat.filtering.highlight-basic-badges--colors'); const keys = getBadgeIDs(msg);
if ( ! colors || ! colors.size ) if ( ! keys || ! keys.length )
return tokens; return tokens;
for(const badge of Object.keys(badges)) { for(const badge of keys) {
if ( colors.has(badge) ) { if ( list && list[1].includes(badge) ) {
msg.deleted = true;
msg.ffz_removed = true;
if ( haltable )
msg.ffz_halt_tokens = true;
return tokens;
}
if ( list && ! msg.deleted && list[0].includes(badge) )
msg.deleted = true;
if ( colors && colors.has(badge) ) {
const color = colors.get(badge); const color = colors.get(badge);
(msg.highlights = (msg.highlights || new Set())).add('badge'); (msg.highlights = (msg.highlights || new Set())).add('badge');
msg.mentioned = true; msg.mentioned = true;
if ( color ) { if ( color ) {
msg.mention_color = color; msg.mention_color = color;
return tokens; if ( ! list )
return tokens;
} }
} }
} }
@ -517,25 +547,27 @@ export const BadgeHighlights = {
} }
} }
export const BlockedBadges = { /*export const BlockedBadges = {
type: 'badge_block', type: 'badge_block',
priority: 100, priority: 100,
process(tokens, msg, user) { process(tokens, msg, user, haltable) {
if ( user && user.login && user.login == msg.user.login && ! this.context.get('chat.filtering.process-own') ) if ( user && user.login && user.login == msg.user.login && ! this.context.get('chat.filtering.process-own') )
return tokens; return tokens;
const badges = msg.badges;
if ( ! badges )
return tokens;
const list = this.context.get('chat.filtering.highlight-basic-badges-blocked--list'); const list = this.context.get('chat.filtering.highlight-basic-badges-blocked--list');
if ( ! list || (! list[0].length && ! list[1].length) ) if ( ! list || (! list[0].length && ! list[1].length) )
return tokens; return tokens;
for(const badge of Object.keys(badges)) { const keys = getBadgeIDs(msg);
if ( ! keys || ! keys.length )
return tokens;
for(const badge of keys) {
if ( list[1].includes(badge) ) { if ( list[1].includes(badge) ) {
msg.deleted = true; msg.deleted = true;
msg.ffz_removed = true; msg.ffz_removed = true;
if ( haltable )
msg.ffz_halt_tokens = true;
return tokens; return tokens;
} }
@ -545,7 +577,7 @@ export const BlockedBadges = {
return tokens; return tokens;
} }
} }*/
export const CustomHighlights = { export const CustomHighlights = {
type: 'highlight', type: 'highlight',
@ -649,7 +681,7 @@ export const CustomHighlights = {
} }
function blocked_process(tokens, msg, regex, do_remove) { function blocked_process(tokens, msg, regexes, do_remove, haltable) {
const out = []; const out = [];
for(const token of tokens) { for(const token of tokens) {
if ( token.type !== 'text' ) { if ( token.type !== 'text' ) {
@ -657,11 +689,23 @@ function blocked_process(tokens, msg, regex, do_remove) {
continue; continue;
} }
regex.lastIndex = 0;
const text = token.text; const text = token.text;
let idx = 0, match; let idx = 0, match;
while((match = regex.exec(text))) { while(idx < text.length) {
if ( regexes[0] )
regexes[0].lastIndex = idx;
if ( regexes[1] )
regexes[1].lastIndex = idx;
match = regexes[0] ? regexes[0].exec(text) : null;
const second = regexes[1] ? regexes[1].exec(text) : null;
if ( second && (! match || match.index > second.index) )
match = second;
if ( ! match )
break;
const raw_nix = match.index, const raw_nix = match.index,
offset = match[1] ? match[1].length : 0, offset = match[1] ? match[1].length : 0,
nix = raw_nix + offset; nix = raw_nix + offset;
@ -669,15 +713,18 @@ function blocked_process(tokens, msg, regex, do_remove) {
if ( idx !== nix ) if ( idx !== nix )
out.push({type: 'text', text: text.slice(idx, nix)}); out.push({type: 'text', text: text.slice(idx, nix)});
if ( do_remove ) {
msg.ffz_removed = true;
if ( haltable )
return tokens;
}
out.push({ out.push({
type: 'blocked', type: 'blocked',
text: match[0].slice(offset) text: match[0].slice(offset)
}); });
if ( do_remove ) idx = raw_nix + match[0].length
msg.ffz_removed = true;
idx = raw_nix + match[0].length;
} }
if ( idx < text.length ) if ( idx < text.length )
@ -715,7 +762,7 @@ export const BlockedTerms = {
] ]
}, },
process(tokens, msg, user) { process(tokens, msg, user, haltable) {
if ( ! tokens || ! tokens.length ) if ( ! tokens || ! tokens.length )
return tokens; return tokens;
@ -726,11 +773,16 @@ export const BlockedTerms = {
if ( ! regexes ) if ( ! regexes )
return tokens; return tokens;
if ( regexes[0] ) if ( regexes.remove ) {
tokens = blocked_process(tokens, msg, regexes[0], false); tokens = blocked_process(tokens, msg, regexes.remove, true, haltable);
if ( haltable && msg.ffz_removed ) {
msg.ffz_halt_tokens = true;
return tokens;
}
}
if ( regexes[1] ) if ( regexes.non )
tokens = blocked_process(tokens, msg, regexes[1], true); tokens = blocked_process(tokens, msg, regexes.non, false, haltable);
return tokens; return tokens;
} }
@ -792,7 +844,7 @@ export const AutomoddedTerms = {
]; ];
}, },
process(tokens, msg) { process(tokens, msg, user, haltable) {
if ( ! tokens || ! tokens.length || ! msg.flags || ! Array.isArray(msg.flags.list) ) if ( ! tokens || ! tokens.length || ! msg.flags || ! Array.isArray(msg.flags.list) )
return tokens; return tokens;
@ -827,6 +879,8 @@ export const AutomoddedTerms = {
if ( remove ) { if ( remove ) {
msg.ffz_removed = true; msg.ffz_removed = true;
if ( haltable )
msg.ffz_halt_tokens = true;
return tokens; return tokens;
} }

View file

@ -11,8 +11,8 @@ export default class User {
this.manager = manager; this.manager = manager;
this.room = room; this.room = room;
this.emote_sets = new SourcedSet; this.emote_sets = null; //new SourcedSet;
this.badges = new SourcedSet; this.badges = null; // new SourcedSet;
this._id = id; this._id = id;
this.login = login; this.login = login;
@ -31,6 +31,9 @@ export default class User {
this.emote_sets = null; this.emote_sets = null;
} }
if ( this.badges )
this.badges = null;
const parent = this.room || this.manager; const parent = this.room || this.manager;
if ( parent ) { if ( parent ) {
@ -100,11 +103,17 @@ export default class User {
// ======================================================================== // ========================================================================
addBadge(provider, badge_id, data) { addBadge(provider, badge_id, data) {
if ( this.destroyed )
return;
if ( data ) if ( data )
data.id = badge_id; data.id = badge_id;
else else
data = {id: badge_id}; data = {id: badge_id};
if ( ! this.badges )
this.badges = new SourcedSet;
if ( this.badges.has(provider) ) if ( this.badges.has(provider) )
for(const old_b of this.badges.get(provider)) for(const old_b of this.badges.get(provider))
if ( old_b.id == badge_id ) { if ( old_b.id == badge_id ) {
@ -119,6 +128,9 @@ export default class User {
getBadge(badge_id) { getBadge(badge_id) {
if ( ! this.badges )
return null;
for(const badge of this.badges._cache) for(const badge of this.badges._cache)
if ( badge.id == badge_id ) if ( badge.id == badge_id )
return badge; return badge;
@ -126,7 +138,7 @@ export default class User {
removeBadge(provider, badge_id) { removeBadge(provider, badge_id) {
if ( ! this.badges.has(provider) ) if ( ! this.badges || ! this.badges.has(provider) )
return false; return false;
for(const old_b of this.badges.get(provider)) for(const old_b of this.badges.get(provider))
@ -147,6 +159,9 @@ export default class User {
if ( this.destroyed ) if ( this.destroyed )
return; return;
if ( ! this.emote_sets )
this.emote_sets = new SourcedSet;
if ( ! this.emote_sets.sourceIncludes(provider, set_id) ) { if ( ! this.emote_sets.sourceIncludes(provider, set_id) ) {
this.emote_sets.push(provider, set_id); this.emote_sets.push(provider, set_id);
this.manager.emotes.refSet(set_id); this.manager.emotes.refSet(set_id);
@ -156,7 +171,7 @@ export default class User {
} }
removeSet(provider, set_id) { removeSet(provider, set_id) {
if ( this.destroyed ) if ( this.destroyed || ! this.emote_sets )
return; return;
if ( this.emote_sets.sourceIncludes(provider, set_id) ) { if ( this.emote_sets.sourceIncludes(provider, set_id) ) {

View file

@ -3,8 +3,9 @@
<div class="tw-align-items-center tw-flex tw-flex-nowrap tw-flex-row tw-full-width"> <div class="tw-align-items-center tw-flex tw-flex-nowrap tw-flex-row tw-full-width">
<div class="tw-mg-r-1"> <div class="tw-mg-r-1">
<img <img
v-if="current" v-if="current && current.image"
:src="current.image" :src="current.image"
:style="{backgroundColor: current.color || null}"
class="ffz--badge-term-image" class="ffz--badge-term-image"
> >
<div <div
@ -50,7 +51,34 @@
</figure> </figure>
</div> </div>
</div> </div>
<div v-if="removable" class="tw-flex-shrink-0 tw-mg-r-05 tw-relative tw-tooltip__container"> <div
v-if="removable && (editing || display.remove)"
class="tw-flex-shrink-0 tw-mg-r-05 tw-mg-y-05 tw-flex tw-align-items-center ffz-checkbox tw-relative tw-tooltip__container"
>
<input
v-if="editing"
:id="'remove$' + id"
v-model="edit_data.remove"
type="checkbox"
class="ffz-min-width-unset ffz-checkbox__input"
>
<label
v-if="editing"
:for="'remove$' + id"
class="ffz-min-width-unset ffz-checkbox__label"
>
<span class="tw-mg-l-05 ffz-i-trash" />
</label>
<span
v-else-if="term.remove"
class="ffz-i-trash tw-pd-x-1"
/>
<div class="tw-tooltip tw-tooltip--down tw-tooltip--align-right">
{{ t('setting.terms.remove.on', 'Remove matching messages from chat.') }}
</div>
</div>
<!--div v-if="removable" class="tw-flex-shrink-0 tw-mg-r-05 tw-relative tw-tooltip__container">
<button <button
v-if="editing" v-if="editing"
:class="{active: edit_data.remove}" :class="{active: edit_data.remove}"
@ -74,7 +102,7 @@
{{ t('setting.terms.remove.off', 'Do not remove matching messages from chat.') }} {{ t('setting.terms.remove.off', 'Do not remove matching messages from chat.') }}
</span> </span>
</div> </div>
</div> </div-->
<div v-if="adding" class="tw-flex-shrink-0"> <div v-if="adding" class="tw-flex-shrink-0">
<button <button
class="tw-button" class="tw-button"
@ -165,14 +193,14 @@ export default {
data() { data() {
if ( this.adding ) if ( this.adding )
return { return {
editor_id: id++, id: id++,
deleting: false, deleting: false,
editing: true, editing: true,
edit_data: deep_copy(this.term) edit_data: deep_copy(this.term)
}; };
return { return {
editor_id: id++, id: id++,
deleting: false, deleting: false,
editing: false, editing: false,
edit_data: null edit_data: null

View file

@ -166,24 +166,33 @@ export default {
}, },
_uses_changed(uses) { _uses_changed(uses) {
if ( this.source ) if ( this._uses_cb )
this.source.off('changed', this._source_setting_changed, this); clearTimeout(this._uses_cb);
// We primarily only care about the main source. // We don't do this immediately because this code will be
uses = uses ? uses[0] : null; // running inside a "changed" event handler and unregistering
// our listener will throw a concurrent modification exception.
this._uses_cb = setTimeout(() => {
if ( this.source )
this.source.off('changed', this._source_setting_changed, this);
const source = this.source = this.context.profile_keys[uses], // We primarily only care about the main source.
setting = this.item.setting; uses = uses ? uses[0] : null;
if ( source ) { const source = this.source = uses == null ? null : this.context.profile_keys[uses],
source.on('changed', this._source_setting_changed, this); setting = this.item.setting;
this.source_value = deep_copy(source.get(setting));
} else if ( source ) {
this.source_value = undefined; source.on('changed', this._source_setting_changed, this);
this.source_value = deep_copy(source.get(setting));
if ( ! this.has_value ) } else
this.value = this.isInherited ? this.source_value : this.default_value; this.source_value = undefined;
this.has_value = this.profile.has(this.item.setting);
if ( ! this.has_value )
this.value = this.isInherited ? this.source_value : this.default_value;
}, 0);
}, },
set(value) { set(value) {

View file

@ -175,6 +175,7 @@ export default class Line extends Module {
roomLogin: room && room.login, roomLogin: room && room.login,
roomID: room && room.id, roomID: room && room.id,
badges, badges,
ffz_badges: this.chat.badges.getBadges(author.id, author.login, room?.id, room?.login),
messageParts: msg.message.fragments messageParts: msg.message.fragments
}; };

View file

@ -1,100 +0,0 @@
'use strict';
// ============================================================================
// Site Support: Twitch Clips
// ============================================================================
import BaseSite from '../base';
import WebMunch from 'utilities/compat/webmunch';
import Fine from 'utilities/compat/fine';
import Apollo from 'utilities/compat/apollo';
import {createElement} from 'utilities/dom';
import MAIN_URL from 'site/styles/main.scss';
//import Switchboard from './switchboard';
// ============================================================================
// The Site
// ============================================================================
export default class Clippy extends BaseSite {
constructor(...args) {
super(...args);
this.inject(WebMunch);
this.inject(Fine);
this.inject(Apollo, false);
//this.inject(Switchboard);
}
async populateModules() {
const ctx = await require.context('site/modules', true, /(?:^(?:\.\/)?[^/]+|index)\.jsx?$/);
const modules = await this.populate(ctx, this.log);
this.log.info(`Loaded descriptions of ${Object.keys(modules).length} modules.`);
}
async onLoad() {
await this.populateModules();
}
onEnable() {
const thing = this.fine.searchTree(null, n => n.props && n.props.store),
store = this.store = thing && thing.props && thing.props.store;
if ( ! store )
return new Promise(r => setTimeout(r, 50)).then(() => this.onEnable());
// Share Context
store.subscribe(() => this.updateContext());
this.updateContext();
this.settings.updateContext({
clips: true
});
document.head.appendChild(createElement('link', {
href: MAIN_URL,
rel: 'stylesheet',
type: 'text/css',
crossOrigin: 'anonymouse'
}));
}
updateContext() {
try {
const state = this.store.getState(),
history = this.router && this.router.history;
this.settings.updateContext({
location: history && history.location,
ui: state && state.ui,
session: state && state.session
});
} catch(err) {
this.log.error('Error updating context.', err);
}
}
getSession() {
const state = this.store && this.store.getState();
return state && state.session;
}
getUser() {
if ( this._user )
return this._user;
const session = this.getSession();
return this._user = session && session.user;
}
}
Clippy.DIALOG_EXCLUSIVE = '.clips-root';
Clippy.DIALOG_MAXIMIZED = '.clips-root>.tw-full-height .scrollable-area';
Clippy.DIALOG_SELECTOR = '.clips-root>.tw-full-height .scrollable-area';

View file

@ -1,13 +0,0 @@
query FFZ_GetBadges {
badges {
id
setID
version
title
clickAction
clickURL
image1x: imageURL(size: NORMAL)
image2x: imageURL(size: DOUBLE)
image4x: imageURL(size: QUADRUPLE)
}
}

View file

@ -1,191 +0,0 @@
'use strict';
// ============================================================================
// Chat Hooks
// ============================================================================
import {get} from 'utilities/object';
import {ColorAdjuster} from 'utilities/color';
import Module from 'utilities/module';
import Line from './line';
import BADGE_QUERY from './get_badges.gql';
export default class Chat extends Module {
constructor(...args) {
super(...args);
this.should_enable = true;
this.colors = new ColorAdjuster;
this.inverse_colors = new ColorAdjuster;
this.inject('settings');
this.inject('i18n');
this.inject('chat');
this.inject('site');
this.inject('site.fine');
this.inject('site.css_tweaks');
this.inject(Line);
this.ChatController = this.fine.define(
'clip-chat-controller',
n => n.filterChatLines
);
}
onEnable() {
this.chat.context.on('changed:chat.font-size', this.updateChatCSS, this);
this.chat.context.on('changed:chat.font-family', this.updateChatCSS, this);
this.chat.context.on('changed:chat.lines.emote-alignment', this.updateChatCSS, this);
this.chat.context.on('changed:chat.adjustment-mode', this.updateColors, this);
this.chat.context.on('changed:chat.adjustment-contrast', this.updateColors, this);
this.chat.context.on('changed:theme.is-dark', this.updateColors, this);
this.ChatController.on('mount', this.chatMounted, this);
this.ChatController.on('unmount', this.chatMounted, this);
this.ChatController.on('update', this.chatUpdated, this);
this.ChatController.on('receive-props', this.chatUpdated, this);
this.ChatController.ready((cls, instances) => {
for(const inst of instances)
this.chatMounted(inst);
});
this.loadBadges();
this.updateChatCSS();
this.updateColors();
}
updateChatCSS() {
const size = this.chat.context.get('chat.font-size'),
emote_alignment = this.chat.context.get('chat.lines.emote-alignment'),
lh = Math.round((20/12) * size);
let font = this.chat.context.get('chat.font-family') || 'inherit';
if ( font.indexOf(' ') !== -1 && font.indexOf(',') === -1 && font.indexOf('"') === -1 && font.indexOf("'") === -1 )
font = `"${font}"`;
this.css_tweaks.setVariable('chat-font-size', `${size/10}rem`);
this.css_tweaks.setVariable('chat-line-height', `${lh/10}rem`);
this.css_tweaks.setVariable('chat-font-family', font);
this.css_tweaks.toggle('chat-font', size !== 12 || font);
this.css_tweaks.toggle('emote-alignment-padded', emote_alignment === 1);
this.css_tweaks.toggle('emote-alignment-baseline', emote_alignment === 2);
}
updateColors() {
const is_dark = this.chat.context.get('theme.is-dark'),
mode = this.chat.context.get('chat.adjustment-mode'),
contrast = this.chat.context.get('chat.adjustment-contrast'),
c = this.colors,
ic = this.inverse_colors;
// TODO: Get the background color from the theme system.
// Updated: Use the lightest/darkest colors from alternating rows for better readibility.
c._base = is_dark ? '#191919' : '#e0e0e0'; //#0e0c13' : '#faf9fa';
c.mode = mode;
c.contrast = contrast;
ic._base = is_dark ? '#dad8de' : '#19171c';
ic.mode = mode;
ic.contrast = contrast;
this.line.updateLines();
}
async loadBadges() {
let data;
try {
data = await this.resolve('site.apollo').client.query({
query: BADGE_QUERY
});
} catch(err) {
this.log.warn('Error loading badge data.', err);
return;
}
if ( data && data.data && data.data.badges )
this.chat.badges.updateTwitchBadges(data.data.badges);
}
// ========================================================================
// Room Handling
// ========================================================================
addRoom(thing, props) {
if ( ! props )
props = thing.props;
const channel_id = get('data.clip.broadcaster.id', props);
if ( ! channel_id )
return null;
const room = thing._ffz_room = this.chat.getRoom(channel_id, null, false, true);
room.ref(thing);
return room;
}
removeRoom(thing) { // eslint-disable-line class-methods-use-this
if ( ! thing._ffz_room )
return;
thing._ffz_room.unref(thing);
thing._ffz_room = null;
}
// ========================================================================
// Chat Controller
// ========================================================================
chatMounted(chat, props) {
if ( ! props )
props = chat.props;
if ( ! this.addRoom(chat, props) )
return;
this.updateRoomBadges(chat, get('data.clip.video.owner.broadcastBadges', props));
}
chatUmounted(chat) {
this.removeRoom(chat);
}
chatUpdated(chat, props) {
if ( ! chat._ffz_room || props?.data?.clip?.broadcaster?.id !== chat._ffz_room.id ) {
this.chatUmounted(chat);
this.chatMounted(chat, props);
return;
}
const new_room_badges = get('data.clip.video.owner.broadcastBadges', props),
old_room_badges = get('data.clip.video.owner.broadcastBadges', chat.props);
if ( new_room_badges !== old_room_badges )
this.updateRoomBadges(chat, new_room_badges);
}
updateRoomBadges(chat, badges) { // eslint-disable-line class-methods-use-this
const room = chat._ffz_room;
if ( ! room )
return;
room.updateBadges(badges);
}
}

View file

@ -1,180 +0,0 @@
'use strict';
// ============================================================================
// Chat Line
// ============================================================================
import Module from 'utilities/module';
import {createElement} from 'react';
import { split_chars } from 'utilities/object';
export default class Line extends Module {
constructor(...args) {
super(...args);
this.inject('settings');
this.inject('i18n');
this.inject('chat');
this.inject('site');
this.inject('site.fine');
this.ChatLine = this.fine.define(
'clip-chat-line',
n => n.renderFragments && n.renderUserBadges
);
}
onEnable() {
this.chat.context.on('changed:chat.emoji.style', this.updateLines, this);
this.chat.context.on('changed:chat.bits.stack', this.updateLines, this);
this.chat.context.on('changed:chat.badges.style', this.updateLines, this);
this.chat.context.on('changed:chat.badges.hidden', this.updateLines, this);
this.chat.context.on('changed:chat.badges.custom-mod', this.updateLines, this);
this.chat.context.on('changed:chat.rich.enabled', this.updateLines, this);
this.chat.context.on('changed:chat.rich.hide-tokens', this.updateLines, this);
this.chat.context.on('changed:chat.rich.all-links', this.updateLines, this);
this.chat.context.on('changed:chat.rich.minimum-level', this.updateLines, this);
this.chat.context.on('changed:tooltip.link-images', this.maybeUpdateLines, this);
this.chat.context.on('changed:tooltip.link-nsfw-images', this.maybeUpdateLines, this);
this.ChatLine.ready(cls => {
const t = this,
old_render = cls.prototype.render;
cls.prototype.render = function() {
try {
this._ffz_no_scan = true;
const msg = t.standardizeMessage(this.props.node, this.props.video),
is_action = msg.is_action,
user = msg.user,
color = t.parent.colors.process(user.color),
u = t.site.getUser();
const tokens = msg.ffz_tokens = msg.ffz_tokens || t.chat.tokenizeMessage(msg, u);
return (<div class="tw-mg-b-1 tw-font-size-5 tw-c-text-alt clip-chat__message">
<div class="tw-animation tw-animation--animate tw-animation--duration-short tw-animation--fill-mode-both tw-animation--slide-in-bottom tw-animation--timing-ease" data-room-id={msg.roomID} data-room={msg.roomLogin} data-user-id={user.id} data-user={user.login}>
<span class="chat-line__message--badges">{
t.chat.badges.render(msg, createElement)
}</span>
<a
class="tw-font-size-5 tw-strong clip-chat__message-author notranslate"
href={`https://www.twitch.tv/${user.login}/clips`}
style={{color}}
>
<span class="chat-author__display-name">{ user.displayName }</span>
{user.isIntl && <span class="chat-author__intl-login"> ({user.login})</span>}
</a>
<span>{is_action ? ' ' : ': '}</span>
<span class="message" style={{color: is_action ? color : null}}>{
t.chat.renderTokens(tokens, createElement)
}</span>
</div>
</div>)
} catch(err) {
t.log.error(err);
t.log.capture(err, {extra:{props: this.props}});
}
return old_render.call(this);
}
this.ChatLine.forceUpdate();
});
}
maybeUpdateLines() {
if ( this.chat.context.get('chat.rich.all-links') )
this.updateLines();
}
updateLines() {
for(const inst of this.ChatLine.instances) {
const msg = inst.props.node;
if ( msg )
msg._ffz_message = null;
}
this.ChatLine.forceUpdate();
}
standardizeMessage(msg, video) {
if ( ! msg || ! msg.message )
return msg;
if ( msg._ffz_message )
return msg._ffz_message;
const room = this.chat.getRoom(video.owner.id, null, true, true),
author = msg.commenter || {},
badges = {};
if ( msg.message.userBadges )
for(const badge of msg.message.userBadges)
if ( badge )
badges[badge.setID] = badge.version;
const out = msg._ffz_message = {
user: {
color: author.chatColor,
id: author.id,
login: author.login,
displayName: author.displayName,
isIntl: author.login && author.displayName && author.displayName.trim().toLowerCase() !== author.login,
type: 'user'
},
roomLogin: room && room.login,
roomID: room && room.id,
badges,
messageParts: msg.message.fragments
};
this.detokenizeMessage(out, msg);
return out;
}
detokenizeMessage(msg) { // eslint-disable-line class-methods-use-this
const out = [],
parts = msg.messageParts,
l = parts.length,
emotes = {};
let idx = 0;
for(let i=0; i < l; i++) {
const part = parts[i],
text = part && part.text;
if ( ! text || ! text.length )
continue;
const len = split_chars(text).length;
if ( part.emote ) {
const id = part.emote.emoteID,
em = emotes[id] = emotes[id] || [];
em.push({startIndex: idx, endIndex: idx + len - 1});
}
out.push(text);
idx += len;
}
msg.message = out.join('');
msg.ffz_emotes = emotes;
return msg;
}
}

View file

@ -1,87 +0,0 @@
'use strict';
// ============================================================================
// CSS Tweaks for Twitch Clips
// ============================================================================
import Module from 'utilities/module';
import {ManagedStyle} from 'utilities/dom';
import {has} from 'utilities/object';
const CLASSES = {
};
export default class CSSTweaks extends Module {
constructor(...args) {
super(...args);
this.should_enable = true;
this.inject('settings');
this.inject('site.theme');
this.style = new ManagedStyle;
this.chunks = {};
this.chunks_loaded = false;
}
toggleHide(key, val) {
const k = `hide--${key}`;
if ( ! val ) {
this.style.delete(k);
return;
}
if ( ! has(CLASSES, key) )
throw new Error(`cannot find class for "${key}"`);
this.style.set(k, `${CLASSES[key]} { display: none !important }`);
}
async toggle(key, val) {
if ( ! val ) {
this.style.delete(key);
return;
}
if ( ! this.chunks_loaded )
await this.populate();
if ( ! has(this.chunks, key) )
throw new Error(`cannot find chunk "${key}"`);
this.style.set(key, this.chunks[key]);
}
set(key, val) { return this.style.set(key, val) }
delete(key) { return this.style.delete(key) }
setVariable(key, val, scope = 'body') {
this.style.set(`var--${key}`, `${scope} { --ffz-${key}: ${val}; }`);
}
deleteVariable(key) { this.style.delete(`var--${key}`) }
populate() {
if ( this.chunks_loaded )
return;
return new Promise(async r => {
const raw = (await import(/* webpackChunkName: "site-css-tweaks" */ './styles.js')).default;
for(const key of raw.keys()) {
const k = key.slice(2, key.length - (key.endsWith('.scss') ? 5 : 4));
this.chunks[k] = raw(key).default;
}
this.chunks_loaded = true;
r();
})
}
}

View file

@ -1,3 +0,0 @@
'use strict';
export default require.context('!raw-loader!sass-loader!./styles', false, /\.s?css$/);

View file

@ -1,9 +0,0 @@
.clips-chat-replay {
a.clip-chat__message-author,
.message {
font-size: var(--ffz-chat-font-size) !important;
}
line-height: var(--ffz-chat-line-height);
font-family: var(--ffz-chat-font-family);
}

View file

@ -1,8 +0,0 @@
.message > .chat-line__message--emote {
vertical-align: baseline;
padding-top: 5px;
}
.message > .chat-line__message--emote.ffz-emoji {
padding-top: 0px;
}

View file

@ -1,3 +0,0 @@
.message > .chat-line__message--emote {
margin: -1px 0 0;
}

View file

@ -1,104 +0,0 @@
'use strict';
// ============================================================================
// Settings Sync
// ============================================================================
import Module from 'utilities/module';
import {createElement} from 'utilities/dom';
const VALID_KEYS = [
'client-id',
'profiles'
];
export default class SettingsSync extends Module {
constructor(...args) {
super(...args);
this.should_enable = true;
this.inject('settings');
}
onEnable() {
const frame = this.frame = createElement('iframe');
frame.src = '//www.twitch.tv/p/ffz_bridge/';
frame.id = 'ffz-settings-bridge';
frame.style.width = 0;
frame.style.height = 0;
window.addEventListener('message', this.onMessage.bind(this));
document.body.appendChild(frame);
}
send(msg) {
try {
this.frame.contentWindow.postMessage(msg, '*');
} catch(err) { this.log.error('send error', err); /* no-op */ }
}
onMessage(event) {
const msg = event.data;
if ( ! msg || ! msg.ffz_type )
return;
if ( msg.ffz_type === 'ready' )
this.send({ffz_type: 'load'});
else if ( msg.ffz_type === 'loaded' )
this.onLoad(msg.data);
else if ( msg.ffz_type === 'change' )
this.onChange(msg);
else
this.log.info('Unknown Message', msg.ffz_type, msg);
}
onLoad(data) {
if ( ! data )
return;
const provider = this.settings.provider,
old_keys = new Set(provider.keys());
for(const [key, value] of Object.entries(data)) {
old_keys.delete(key);
if ( ! this.isValidSetting(key) || provider.get(key) === value )
continue;
provider.set(key, value);
provider.emit('changed', key, value, false);
}
for(const key of old_keys) {
provider.delete(key);
provider.emit('changed', key, undefined, true);
}
}
onChange(msg) {
const key = msg.key,
value = msg.value,
deleted = msg.deleted;
if ( ! this.isValidSetting(key) )
return;
if ( deleted )
this.settings.provider.delete(key);
else
this.settings.provider.set(key, value);
this.settings.provider.emit('changed', key, value, deleted);
}
isValidSetting(key) {
if ( ! key.startsWith('p:') )
return VALID_KEYS.includes(key);
const idx = key.indexOf(':', 2);
if ( idx === -1 )
return false;
return this.settings.definitions.has(key.slice(idx + 1));
}
}

View file

@ -1,102 +0,0 @@
'use strict';
// ============================================================================
// Menu Module
// ============================================================================
import Module from 'utilities/module';
import {createElement} from 'utilities/dom';
//import THEME_CSS_URL from 'site/styles/theme.scss';
export default class ThemeEngine extends Module {
constructor(...args) {
super(...args);
this.inject('settings');
this.inject('site');
this.should_enable = true;
this.settings.add('theme.dark', {
requires: ['theme.is-dark'],
default: false,
process(ctx, val) {
return ctx.get('theme.is-dark') ? val : false
},
ui: {
path: 'Appearance @{"description": "Personalize the appearance of Twitch. Change the color scheme and fonts and tune the layout to optimize your experience."} > Theme >> General',
title: 'Gray (no Purple)',
description: '<em>Requires Dark Theme to be Enabled.</em><br>I see my website and I want it painted black...<br>This is a very early feature and will change as there is time.',
component: 'setting-check-box'
},
changed: val => this.updateSetting(val)
});
this.settings.add('theme.can-dark', {
requires: ['context.route.name'],
process() {
return true;
}
});
this.settings.add('theme.is-dark', {
requires: ['theme.can-dark', 'context.ui.theme'],
process(ctx) {
return ctx.get('theme.can-dark') && ctx.get('context.ui.theme') === 1;
},
changed: () => this.updateCSS()
});
this.settings.add('theme.tooltips-dark', {
requires: ['theme.is-dark'],
process(ctx) {
return ! ctx.get('theme.is-dark')
}
});
this._style = null;
}
updateCSS() {
const dark = this.settings.get('theme.is-dark'),
gray = this.settings.get('theme.dark');
document.body.classList.toggle('tw-root--theme-dark', dark);
document.body.classList.toggle('tw-root--theme-ffz', gray);
}
toggleStyle(enable) {
if ( ! this._style ) {
if ( ! enable )
return;
this._style = createElement('link', {
rel: 'stylesheet',
type: 'text/css',
//href: THEME_CSS_URL
});
} else if ( ! enable ) {
this._style.remove();
return;
}
document.head.appendChild(this._style);
}
updateSetting(enable) {
this.toggleStyle(enable);
this.updateCSS();
}
onEnable() {
this.updateSetting(this.settings.get('theme.dark'));
}
}

View file

@ -1,25 +0,0 @@
.chat-line__message--emote {
vertical-align: middle;
margin: -.5rem 0;
}
.chat-author__display-name,
.chat-author__intl-login {
cursor: pointer;
}
.ffz-emoji {
width: calc(var(--ffz-chat-font-size) * 1.5);
height: calc(var(--ffz-chat-font-size) * 1.5);
&.preview-image {
width: 7.2rem;
height: 7.2rem;
}
&.emote-autocomplete-provider__image {
width: 1.8rem;
height: 1.8rem;
margin: .5rem;
}
}

View file

@ -1,3 +0,0 @@
@import 'styles/main.scss';
@import 'chat.scss';

View file

@ -1,80 +0,0 @@
'use strict';
// ============================================================================
// Switchboard
// A hack for React Router to make it load a module.
// ============================================================================
import Module from 'utilities/module';
import pathToRegexp from 'path-to-regexp';
export default class Switchboard extends Module {
constructor(...args) {
super(...args);
this.inject('site.web_munch');
this.inject('site.fine');
}
async onEnable() {
await this.parent.awaitElement('.clips-root');
if ( this.web_munch._require || this.web_munch.v4 === false )
return;
const da_switch = this.fine.searchTree(null, n =>
n.context && n.context.router &&
n.props && n.props.children &&
n.componentWillMount && n.componentWillMount.toString().includes('Switch')
);
if ( ! da_switch )
return new Promise(r => setTimeout(r, 50)).then(() => this.onEnable());
// Identify Router
this.log.info(`Found Switch with ${da_switch.props.children.length} routes.`);
const location = da_switch.context.router.route.location.pathname;
for(const route of da_switch.props.children) {
if ( ! route.props || ! route.props.component )
continue;
try {
const reg = pathToRegexp(route.props.path);
if ( ! reg.exec || reg.exec(location) )
continue;
} catch(err) {
continue;
}
this.log.info('Found Non-Matching Route', route.props.path);
let component;
try {
component = new route.props.component;
} catch(err) {
this.log.error('Error instantiating component for forced chunk loading.', err);
component = null;
}
if ( ! component || ! component.props || ! component.props.children || ! component.props.children.props || ! component.props.children.props.loader )
continue;
try {
component.props.children.props.loader().then(() => {
this.log.info('Successfully forced a chunk to load using route', route.props.path)
});
} catch(err) {
this.log.warn('Unexpected result trying to use component loader to force loading of another chunk.');
}
return;
}
this.log.warn('Unable to use any of the available routes.');
}
}

View file

@ -123,6 +123,10 @@ export default class Channel extends Module {
const user = resp?.data?.user; const user = resp?.data?.user;
if ( user ) if ( user )
user.hosting = null; user.hosting = null;
const userOrError = resp?.data?.userOrError;
if ( userOrError )
userOrError.hosting = null;
}; };
this.apollo.registerModifier('UseHosting', strip_host, false); this.apollo.registerModifier('UseHosting', strip_host, false);
@ -505,11 +509,17 @@ export default class Channel extends Module {
updateRoot(el) { updateRoot(el) {
const root = this.fine.getReactInstance(el); const root = this.fine.getReactInstance(el);
let channel = null, state = root?.return?.return?.return?.memoizedState, i = 0; let channel = null, node = root, j=0, i=0;
while(state != null && channel == null && i < 50 ) { while(node != null && channel == null && j < 10) {
state = state?.next; let state = node?.memoizedState;
channel = state?.memoizedState?.current?.previousData?.result?.data?.user; i=0;
i++; while(state != null && channel == null && i < 25) {
state = state?.next;
channel = state?.memoizedState?.current?.previousData?.result?.data?.userOrError;
i++;
}
node = node?.return;
j++;
} }
if ( channel && channel.id ) { if ( channel && channel.id ) {

View file

@ -1238,7 +1238,7 @@ export default class ChatHook extends Module {
service.postMessageToCurrentChannel({}, msg); service.postMessageToCurrentChannel({}, msg);
} }
event.preventDefault(); //event.preventDefault();
}); });
} }
@ -1445,6 +1445,9 @@ export default class ChatHook extends Module {
if ( blocked_types.has(types[msg.type]) ) if ( blocked_types.has(types[msg.type]) )
return; return;
if ( msg.type === types.ChannelPointsReward )
return;
if ( msg.type === types.RewardGift && ! t.chat.context.get('chat.bits.show-rewards') ) if ( msg.type === types.RewardGift && ! t.chat.context.get('chat.bits.show-rewards') )
return; return;
@ -1470,6 +1473,8 @@ export default class ChatHook extends Module {
} }
m.ffz_tokens = m.ffz_tokens || t.chat.tokenizeMessage(m, u, r); m.ffz_tokens = m.ffz_tokens || t.chat.tokenizeMessage(m, u, r);
if ( m.ffz_removed )
return;
const event = new FFZEvent({ const event = new FFZEvent({
message: m, message: m,
@ -2487,6 +2492,11 @@ export default class ChatHook extends Module {
// Chat Containers // Chat Containers
// ======================================================================== // ========================================================================
get shouldUpdateChannel() {
const route = this.router.current_name;
return Twilight.POPOUT_ROUTES.includes(route) || Twilight.SUNLIGHT_ROUTES.includes(route);
}
containerMounted(cont, props) { containerMounted(cont, props) {
if ( ! props ) if ( ! props )
props = cont.props; props = cont.props;
@ -2497,7 +2507,7 @@ export default class ChatHook extends Module {
this.updateRoomBitsConfig(cont, props.bitsConfig); this.updateRoomBitsConfig(cont, props.bitsConfig);
if ( props.data ) { if ( props.data ) {
if ( Twilight.POPOUT_ROUTES.includes(this.router.current_name) ) { if ( this.shouldUpdateChannel ){
const color = props.data.user?.primaryColorHex; const color = props.data.user?.primaryColorHex;
this.resolve('site.channel').updateChannelColor(color); this.resolve('site.channel').updateChannelColor(color);
@ -2516,7 +2526,7 @@ export default class ChatHook extends Module {
containerUnmounted(cont) { containerUnmounted(cont) {
if ( Twilight.POPOUT_ROUTES.includes(this.router.current_name) ) { if ( this.shouldUpdateChannel ) {
this.resolve('site.channel').updateChannelColor(); this.resolve('site.channel').updateChannelColor();
this.settings.updateContext({ this.settings.updateContext({

View file

@ -1005,6 +1005,7 @@ other {# messages were deleted by a moderator.}
user = msg?.user; user = msg?.user;
if ( user && ((id && id == user.id) || (login && login == user.login)) ) { if ( user && ((id && id == user.id) || (login && login == user.login)) ) {
msg.ffz_tokens = null; msg.ffz_tokens = null;
msg.ffz_badges = null;
msg.highlights = msg.mentioned = msg.mention_color = null; msg.highlights = msg.mentioned = msg.mention_color = null;
inst.forceUpdate(); inst.forceUpdate();
} }
@ -1031,6 +1032,7 @@ other {# messages were deleted by a moderator.}
const msg = inst.props.message; const msg = inst.props.message;
if ( msg ) { if ( msg ) {
msg.ffz_tokens = null; msg.ffz_tokens = null;
msg.ffz_badges = null;
msg.highlights = msg.mentioned = msg.mention_color = null; msg.highlights = msg.mentioned = msg.mention_color = null;
} }
} }
@ -1039,6 +1041,7 @@ other {# messages were deleted by a moderator.}
const msg = inst.props.message; const msg = inst.props.message;
if ( msg ) { if ( msg ) {
msg.ffz_tokens = null; msg.ffz_tokens = null;
msg.ffz_badges = null;
msg.highlights = msg.mentioned = msg.mention_color = null; msg.highlights = msg.mentioned = msg.mention_color = null;
} }
} }

View file

@ -27,7 +27,7 @@ export default class Dashboard extends Module {
this.SunlightManager = this.fine.define( this.SunlightManager = this.fine.define(
'sunlight-manager', 'sunlight-manager',
n => n.props?.channelID && n.handleChange && has(n, 'hasVisitedStreamManager'), n => n.props?.channelID && n.props.channelLogin && has(n.props, 'hostedChannel'),
Twilight.SUNLIGHT_ROUTES Twilight.SUNLIGHT_ROUTES
); );
} }

View file

@ -25,6 +25,7 @@ export default class ModView extends Module {
this.should_enable = true; this.should_enable = true;
this._cached_color = null;
this._cached_channel = null; this._cached_channel = null;
this._cached_id = null; this._cached_id = null;
@ -76,7 +77,7 @@ export default class ModView extends Module {
checkNavigation() { checkNavigation() {
if ( this.router.current_name === 'mod-view' ) { if ( this.router.current_name === 'mod-view' ) {
this.channel.updateChannelColor(); this.channel.updateChannelColor(this._cached_color);
this.checkRoot(); this.checkRoot();
} }
} }
@ -92,35 +93,52 @@ export default class ModView extends Module {
updateRoot(el) { updateRoot(el) {
const root = this.fine.getReactInstance(el); const root = this.fine.getReactInstance(el);
let channel = null, state = root?.child?.memoizedState, i = 0; let channel = null, node = root, j = 0, i;
while(state != null && channel == null && i < 50 ) { while(node != null && channel == null && j < 10) {
state = state?.next; let state = node.memoizedState;
channel = state?.memoizedState?.current?.previousData?.result?.data?.user; i = 0;
i++; while(state != null && channel == null && i < 50) {
state = state?.next;
channel = state?.memoizedState?.current?.previousData?.result?.data?.user;
i++;
}
node = node?.child;
j++;
} }
if ( channel?.id && this._cached_id != channel.id ) { if ( channel?.id ) {
this._cached_id = channel.id; if ( this._cached_id != channel.id ) {
this._cached_channel = channel; this._cached_id = channel.id;
this.updateSubscription(channel.login); this._cached_channel = channel;
this._cached_color = null;
this.updateSubscription(channel.login);
this.getChannelColor(el, channel.id).then(color => { this.getChannelColor(el, channel.id).then(color => {
this.channel.updateChannelColor(color); if ( this._cached_id != channel.id )
this.settings.updateContext({ return;
channelColor: color
this._cached_color = color;
this.channel.updateChannelColor(color);
this.settings.updateContext({
channelColor: color
});
}).catch(() => {
if ( this._cached_id != channel.id )
return;
this._cached_color = null;
this.channel.updateChannelColor();
this.settings.updateContext({
channelColor: null
});
}); });
}).catch(() => {
this.channel.updateChannelColor();
this.settings.updateContext({ this.settings.updateContext({
channelColor: null channel: channel.login,
channelID: channel.id
}); });
}); }
this.settings.updateContext({
channel: channel.login,
channelID: channel.id
});
} else } else
this.removeRoot(); this.removeRoot();
@ -178,6 +196,7 @@ export default class ModView extends Module {
removeRoot() { removeRoot() {
this._cached_id = null; this._cached_id = null;
this._cached_channel = null; this._cached_channel = null;
this._cached_color = null;
this.updateSubscription(); this.updateSubscription();
this.channel.updateChannelColor(); this.channel.updateChannelColor();
this.settings.updateContext({ this.settings.updateContext({
@ -202,8 +221,9 @@ export default class ModView extends Module {
title = bcast?.title, title = bcast?.title,
game = bcast?.game; game = bcast?.game;
if ( channel?.id && channel.id != this._cached_id ) // This doesn't work because hosting in mod view.
this.checkRoot(); //if ( channel?.id && channel.id != this._cached_id )
// this.checkRoot();
if ( title != el._cached_title || game?.id != el._cached_game ) { if ( title != el._cached_title || game?.id != el._cached_game ) {
el._cached_title = title; el._cached_title = title;

View file

@ -74,7 +74,10 @@ export default class VideoChatHook extends Module {
async onEnable() { async onEnable() {
this.chat.context.on('changed:chat.video-chat.enabled', this.updateLines, this); this.chat.context.on('changed:chat.video-chat.enabled', this.updateLines, this);
this.chat.context.on('changed:chat.video-chat.timestamps', this.updateLines, this); this.chat.context.on('changed:chat.video-chat.timestamps', this.updateLines, this);
this.on('chat:updated-lines', this.updateLines, this); this.on('chat.overrides:changed', id => this.updateLinesByUser(id), this);
this.on('chat:update-lines', this.updateLines, this);
this.on('chat:update-lines-by-user', this.updateLinesByUser, this);
this.on('i18n:update', this.updateLines, this);
this.VideoChatController.on('mount', this.chatMounted, this); this.VideoChatController.on('mount', this.chatMounted, this);
this.VideoChatController.on('unmount', this.chatUnmounted, this); this.VideoChatController.on('unmount', this.chatUnmounted, this);
@ -365,6 +368,21 @@ export default class VideoChatHook extends Module {
} }
updateLinesByUser(id, login) {
for(const inst of this.VideoChatLine.instances) {
const context = inst.props.messageContext;
if ( ! context.comment )
continue;
const author = context.author;
if ( author && ((id && id == author.id) || (login && login == author.name))) {
context.comment._ffz_message = null;
inst.forceUpdate();
}
}
}
// ======================================================================== // ========================================================================
// Message Standardization // Message Standardization
// ======================================================================== // ========================================================================
@ -388,6 +406,7 @@ export default class VideoChatHook extends Module {
}, },
roomLogin: room && room.login, roomLogin: room && room.login,
roomID: room && room.id, roomID: room && room.id,
ffz_badges: this.chat.badges.getBadges(author.id, author.login, room?.id, room?.login),
badges: comment.userBadges, badges: comment.userBadges,
messageParts: comment.message.tokens, messageParts: comment.message.tokens,
is_action: comment.message.isAction, is_action: comment.message.isAction,

View file

@ -620,18 +620,22 @@ export function generateHex(length = 40) {
export class SourcedSet { export class SourcedSet {
constructor() { constructor(use_set = false) {
this._cache = []; this._use_set = use_set;
this._cache = use_set ? new Set : [];
} }
_rebuild() { _rebuild() {
if ( ! this._sources ) if ( ! this._sources )
return; return;
this._cache = []; const use_set = this._use_set,
cache = this._cache = use_set ? new Set : [];
for(const items of this._sources.values()) for(const items of this._sources.values())
for(const i of items) for(const i of items)
if ( ! this._cache.includes(i) ) if ( use_set )
cache.add(i);
else if ( ! cache.includes(i) )
this._cache.push(i); this._cache.push(i);
} }
@ -644,7 +648,7 @@ export class SourcedSet {
} }
includes(val) { includes(val) {
return this._cache.includes(val); return this._use_set ? this._cache.has(val) : this._cache.includes(val);
} }
delete(key) { delete(key) {
@ -659,12 +663,17 @@ export class SourcedSet {
this._sources = new Map; this._sources = new Map;
const had = this.has(key); const had = this.has(key);
this._sources.set(key, [false, items]); if ( had )
items = [...this._sources.get(key), ...items];
this._sources.set(key, items);
if ( had ) if ( had )
this._rebuild(); this._rebuild();
else else
for(const i of items) for(const i of items)
if ( ! this._cache.includes(i) ) if ( this._use_set )
this._cache.add(i);
else if ( ! this._cache.includes(i) )
this._cache.push(i); this._cache.push(i);
} }
@ -673,13 +682,18 @@ export class SourcedSet {
this._sources = new Map; this._sources = new Map;
const had = this.has(key); const had = this.has(key);
this._sources.set(key, [val]); if ( ! Array.isArray(val) )
val = [val];
this._sources.set(key, val);
if ( had ) if ( had )
this._rebuild(); this._rebuild();
else
else if ( ! this._cache.includes(val) ) for(const i of val)
this._cache.push(val); if ( this._use_set )
this._cache.add(i);
else if ( ! this._cache.includes(i) )
this._cache.push(i);
} }
push(key, val) { push(key, val) {
@ -694,7 +708,9 @@ export class SourcedSet {
return; return;
old_val.push(val); old_val.push(val);
if ( ! this._cache.includes(val) ) if ( this._use_set )
this._cache.add(val);
else if ( ! this._cache.includes(val) )
this._cache.push(val); this._cache.push(val);
} }