1
0
Fork 0
mirror of https://github.com/FrankerFaceZ/FrankerFaceZ.git synced 2025-06-27 21:05:53 +00:00
FrankerFaceZ/src/utilities/object.js
SirStendec e433aa3340 4.41.0
I'm almost finished implementing a replacement for emote cards, but they aren't quite ready yet. Please wait just a bit longer.

* Added: Support for modifier emote effects, as well as settings to disable them.
* Changed: Update the chat types enum to match changes to Twitch's internals.
* Changed: Implement a new data structure for more efficiently storing bulk user to emote set mappings.
* Changed: Implement support for loading data from staging.

* Experiments: Push the new chat line rendering experiment to 20%. Let's see if it works properly.
2023-03-03 15:24:20 -05:00

805 lines
No EOL
17 KiB
JavaScript

'use strict';
import {BAD_HOTKEYS, TWITCH_EMOTE_V2, WORD_SEPARATORS} from 'utilities/constants';
const HOP = Object.prototype.hasOwnProperty;
export function getTwitchEmoteURL(id, scale, animated = false, dark = true) {
return `${TWITCH_EMOTE_V2}/${id}/${animated ? 'default' : 'static'}/${dark ? 'dark' : 'light'}/${scale == 4 ? 3 : scale}.0`
}
export function getTwitchEmoteSrcSet(id, animated = false, dark = true, big = false) {
if ( big )
return `${getTwitchEmoteURL(id, 2, animated, dark)} 1x, ${getTwitchEmoteURL(id, 4, animated, dark)} 2x`;
return `${getTwitchEmoteURL(id, 1, animated, dark)} 1x, ${getTwitchEmoteURL(id, 2, animated, dark)} 2x, ${getTwitchEmoteURL(id, 4, animated, dark)} 4x`;
}
export function isValidShortcut(key) {
if ( ! key )
return false;
key = key.toLowerCase().trim();
return ! BAD_HOTKEYS.includes(key);
}
// Source: https://gist.github.com/jed/982883 (WTFPL)
export function generateUUID(input) {
return input // if the placeholder was passed, return
? ( // a random number from 0 to 15
input ^ // unless b is 8,
Math.random() // in which case
* 16 // a random number from
>> input/4 // 8 to 11
).toString(16) // in hexadecimal
: ( // or otherwise a concatenated string:
[1e7] + // 10000000 +
-1e3 + // -1000 +
-4e3 + // -4000 +
-8e3 + // -80000000 +
-1e11 // -100000000000,
).replace( // replacing
/[018]/g, // zeroes, ones, and eights with
generateUUID // random hex digits
);
}
export function has(object, key) {
return object ? HOP.call(object, key) : false;
}
export function sleep(delay) {
return new Promise(s => setTimeout(s, delay));
}
export function make_enum(...array) {
const out = {};
for(let i=0; i < array.length; i++) {
const word = array[i];
out[word] = i;
out[i] = word;
}
return out;
}
export function make_enum_flags(...array) {
const out = {};
out.None = 0;
out[0] = 'None';
for(let i = 0; i < array.length; i++) {
const word = array[i],
value = Math.pow(2, i);
out[word] = value;
out[value] = word;
}
return out;
}
export function timeout(promise, delay) {
return new Promise((resolve, reject) => {
let resolved = false;
const timer = setTimeout(() => {
if ( ! resolved ) {
resolved = true;
reject(new Error('timeout'));
}
}, delay);
promise.then(result => {
if ( ! resolved ) {
resolved = true;
clearTimeout(timer);
resolve(result);
}
}).catch(err => {
if ( ! resolved ) {
resolved = true;
clearTimeout(timer);
reject(err);
}
});
});
}
export class Mutex {
constructor(limit = 1) {
this.limit = limit;
this._active = 0;
this._waiting = [];
this._done = this._done.bind(this);
}
get available() { return this._active < this.limit }
_done() {
this._active--;
while(this._active < this.limit && this._waiting.length > 0) {
this._active++;
const waiter = this._waiting.shift();
waiter(this._done);
}
}
wait() {
if ( this._active < this.limit) {
this._active++;
return Promise.resolve(this._done);
}
return new Promise(s => this._waiting.push(s));
}
}
/**
* Return a wrapper for a function that will only execute the function
* a period of time after it has stopped being called.
* @param {Function} fn The function to wrap.
* @param {Integer} delay The time to wait, in milliseconds
* @param {Boolean} immediate If immediate is true, trigger the function immediately rather than eventually.
* @returns {Function} wrapped function
*/
export function debounce(fn, delay, immediate) {
let timer;
if ( immediate ) {
const later = () => timer = null;
if ( immediate === 2 )
// Special Mode! Run immediately OR later.
return function(...args) {
if ( timer ) {
clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
fn.apply(this, args); // eslint-disable-line no-invalid-this
}, delay);
} else {
fn.apply(this, args); // eslint-disable-line no-invalid-this
timer = setTimeout(later, delay);
}
}
return function(...args) {
if ( ! timer )
fn.apply(this, args); // eslint-disable-line no-invalid-this
else
clearTimeout(timer);
timer = setTimeout(later, delay);
}
}
return function(...args) {
if ( timer )
clearTimeout(timer);
timer = setTimeout(fn.bind(this, ...args), delay); // eslint-disable-line no-invalid-this
}
}
/**
* Make sure that a given asynchronous function is only called once
* at a time.
*/
export function once(fn) {
let waiters;
return function(...args) {
return new Promise(async (s,f) => {
if ( waiters )
return waiters.push([s,f]);
waiters = [[s,f]];
let result;
try {
result = await fn.call(this, ...args); // eslint-disable-line no-invalid-this
} catch(err) {
for(const w of waiters)
w[1](err);
waiters = null;
return;
}
for(const w of waiters)
w[0](result);
waiters = null;
})
}
}
/**
* Check that two arrays are the same length and that each array has the same
* items in the same indices.
* @param {Array} a The first array
* @param {Array} b The second array
* @returns {boolean} Whether or not they match
*/
export function array_equals(a, b) {
if ( ! Array.isArray(a) || ! Array.isArray(b) || a.length !== b.length )
return false;
let i = a.length;
while(i--)
if ( a[i] !== b[i] )
return false;
return true;
}
export function deep_equals(object, other, ignore_undefined = false, seen, other_seen) {
if ( object === other )
return true;
if ( typeof object !== typeof other )
return false;
if ( typeof object !== 'object' )
return false;
if ( (object === null) !== (other === null) )
return false;
if ( ! seen )
seen = new Set;
if ( ! other_seen )
other_seen = new Set;
if ( seen.has(object) || other_seen.has(other) )
throw new Error('recursive structure detected');
seen.add(object);
other_seen.add(other);
const source_keys = Object.keys(object),
dest_keys = Object.keys(other);
if ( ! ignore_undefined && ! set_equals(new Set(source_keys), new Set(dest_keys)) )
return false;
for(const key of source_keys)
if ( ! deep_equals(object[key], other[key], ignore_undefined, new Set(seen), new Set(other_seen)) )
return false;
if ( ignore_undefined )
for(const key of dest_keys)
if ( ! source_keys.includes(key) ) {
if ( ! deep_equals(object[key], other[key], ignore_undefined, new Set(seen), new Set(other_seen)) )
return false;
}
return true;
}
export function shallow_object_equals(a, b) {
if ( typeof a !== 'object' || typeof b !== 'object' )
return false;
const keys = Object.keys(a);
if ( ! set_equals(new Set(keys), new Set(Object.keys(b))) )
return false;
for(const key of keys)
if ( a[key] !== b[key] )
return false;
return true;
}
export function map_equals(a, b) {
if ( !(a instanceof Map) || !(b instanceof Map) || a.size !== b.size )
return false;
for(const [key, val] of a)
if ( ! b.has(key) || b.get(key) !== val )
return false;
return true;
}
export function set_equals(a,b) {
if ( !(a instanceof Set) || !(b instanceof Set) || a.size !== b.size )
return false;
for(const v of a)
if ( ! b.has(v) )
return false;
return true;
}
/**
* Special logic to ensure that a target object is matched by a filter.
* @param {object} filter The filter object
* @param {object} target The object to check it against
* @returns {boolean} Whether or not it matches
*/
export function filter_match(filter, target) {
for(const key in filter) {
if ( HOP.call(filter, key) ) {
const filter_value = filter[key],
target_value = target[key],
type = typeof filter_value;
if ( type === 'function' ) {
if ( ! filter_value(target_value) )
return false;
} else if ( Array.isArray(filter_value) ) {
if ( Array.isArray(target_value) ) {
for(const val of filter_value)
if ( ! target_value.includes(val) )
return false;
} else if ( ! filter_value.include(target_value) )
return false;
} else if ( typeof target_value !== type )
return false;
else if ( type === 'object' ) {
if ( ! filter_match(filter_value, target_value) )
return false;
} else if ( filter_value !== target_value )
return false;
}
}
return true;
}
export function substr_count(str, needle) {
let i = 0, idx = 0;
while( idx < str.length ) {
const x = str.indexOf(needle, idx);
if ( x === -1 )
break;
i++;
idx = x + 1;
}
return i;
}
/**
* Get a value from an object at a path.
* @param {string|Array} path The path to follow, using periods to go down a level.
* @param {object|Array} object The starting object.
* @returns {*} The value at that point in the path, or undefined if part of the path doesn't exist.
*/
export function get(path, object) {
if ( HOP.call(object, path) )
return object[path];
if ( typeof path === 'string' )
path = path.split('.');
for(let i=0, l = path.length; i < l; i++) {
const part = path[i];
if ( part === '@each' ) {
const p = path.slice(i + 1);
if ( p.length ) {
if ( Array.isArray )
object = object.map(x => get(p, x));
else {
const new_object = {};
for(const key in object)
if ( HOP.call(object, key) )
new_object[key] = get(p, object[key]);
object = new_object;
}
}
break;
} else if ( part === '@last' )
object = object[object.length - 1];
else
object = object[path[i]];
if ( ! object )
break;
}
return object;
}
/**
* Copy an object so that it can be safely serialized. If an object
* is not serializable, such as a promise, returns null.
*
* @export
* @param {*} object The thing to copy.
* @param {Number} [depth=2] The maximum depth to explore the object.
* @param {Set} [seen=null] A Set of seen objects. Internal use only.
* @returns {Object} The copy to safely store or use.
*/
export function shallow_copy(object, depth = 2, seen = null) {
if ( object == null )
return object;
if ( object instanceof Promise || typeof object === 'function' )
return null;
if ( typeof object !== 'object' )
return object;
if ( depth === 0 )
return null;
if ( ! seen )
seen = new Set;
seen.add(object);
if ( Array.isArray(object) ) {
const out = [];
for(const val of object) {
if ( seen.has(val) )
continue;
out.push(shallow_copy(val, depth - 1, new Set(seen)));
}
return out;
}
const out = {};
for(const [key, val] of Object.entries(object) ) {
if ( seen.has(val) )
continue;
out[key] = shallow_copy(val, depth - 1, new Set(seen));
}
return out;
}
export function deep_copy(object, seen) {
if ( object === null )
return null;
else if ( object === undefined )
return undefined;
if ( object instanceof Promise )
return new Promise((s,f) => object.then(s).catch(f));
if ( typeof object === 'function' )
return function(...args) { return object.apply(this, args); } // eslint-disable-line no-invalid-this
if ( typeof object !== 'object' )
return object;
if ( ! seen )
seen = new Set;
if ( seen.has(object) )
throw new Error('recursive structure detected');
seen.add(object);
if ( Array.isArray(object) )
return object.map(x => deep_copy(x, new Set(seen)));
const out = {};
for(const key in object)
if ( HOP.call(object, key) ) {
const val = object[key];
if ( typeof val === 'object' )
out[key] = deep_copy(val, new Set(seen));
else
out[key] = val;
}
return out;
}
export function maybe_call(fn, ctx, ...args) {
if ( typeof fn === 'function' ) {
if ( ctx )
return fn.call(ctx, ...args);
return fn(...args);
}
return fn;
}
const SPLIT_REGEX = /[^\uD800-\uDFFF]|[\uD800-\uDBFF][\uDC00-\uDFFF]|[\uD800-\uDFFF]/g;
export function split_chars(str) {
if ( str === '' )
return [];
return str.match(SPLIT_REGEX);
}
export function pick_random(obj) {
if ( ! obj )
return null;
if ( ! Array.isArray(obj) )
return obj[pick_random(Object.keys(obj))]
return obj[Math.floor(Math.random() * obj.length)];
}
export const escape_regex = RegExp.escape || function escape_regex(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
export function addWordSeparators(str) {
return `(^|.*?${WORD_SEPARATORS})(?:${str})(?=$|${WORD_SEPARATORS})`
}
const CONTROL_CHARS = '/$^+.()=!|';
export function glob_to_regex(input) {
if ( typeof input !== 'string' )
throw new TypeError('input must be a string');
let output = '',
groups = 0;
for(let i=0, l=input.length; i<l; i++) {
const char = input[i];
if ( CONTROL_CHARS.includes(char) )
output += `\\${char}`;
else if ( char === '\\' ) {
i++;
const next = input[i];
if ( next ) {
if ( CONTROL_CHARS.includes(next) )
output += `\\${next}`;
else
output += next;
}
} else if ( char === '?' )
output += '.';
else if ( char === '[' ) {
output += char;
const next = input[i + 1];
if ( next === '!' ) {
i++;
output += '^';
}
} else if ( char === ']' )
output += char;
else if ( char === '{' ) {
output += '(?:';
groups++;
} else if ( char === '}' ) {
if ( groups > 0 ) {
output += ')';
groups--;
}
} else if ( char === ',' && groups > 0 )
output += '|';
else if ( char === '*' ) {
let count = 1;
while(input[i+1] === '*') {
count++;
i++;
}
if ( count > 1 )
output += '.*?';
else
output += '[^\\s]*?';
} else
output += char;
}
/*while(groups > 0) {
output += ')';
groups--;
}*/
return output;
}
/**
* Truncate a string. Tries to intelligently break the string in white-space
* if possible, without back-tracking. The returned string can be up to
* `ellipsis.length + target + overage` characters long.
* @param {String} str The string to truncate.
* @param {Number} target The target length for the result
* @param {Number} overage Accept up to this many additional characters for a better result
* @param {String} [ellipsis='…'] The string to append when truncating
* @param {Boolean} [break_line=true] If true, attempt to break at the first LF
* @param {Boolean} [trim=true] If true, runs trim() on the string before truncating
* @returns {String} The truncated string
*/
export function truncate(str, target = 100, overage = 15, ellipsis = '…', break_line = true, trim = true) {
if ( ! str || ! str.length )
return str;
if ( trim )
str = str.trim();
let idx = break_line ? str.indexOf('\n') : -1;
if ( idx === -1 || idx > target )
idx = target;
if ( str.length <= idx )
return str;
let out = str.slice(0, idx).trimRight();
if ( overage > 0 && out.length >= idx ) {
let next_space = str.slice(idx).search(/\s+/);
if ( next_space === -1 && overage + idx > str.length )
next_space = str.length - idx;
if ( next_space !== -1 && next_space <= overage ) {
if ( str.length <= (idx + next_space) )
return str;
out = str.slice(0, idx + next_space);
}
}
return out + ellipsis;
}
function decimalToHex(number) {
return number.toString(16).padStart(2, '0')
}
export function generateHex(length = 40) {
const arr = new Uint8Array(length / 2);
window.crypto.getRandomValues(arr);
return Array.from(arr, decimalToHex).join('')
}
export class SourcedSet {
constructor(use_set = false) {
this._use_set = use_set;
this._cache = use_set ? new Set : [];
}
_rebuild() {
if ( ! this._sources )
return;
const use_set = this._use_set,
cache = this._cache = use_set ? new Set : [];
for(const items of this._sources.values())
for(const i of items)
if ( use_set )
cache.add(i);
else if ( ! cache.includes(i) )
this._cache.push(i);
}
get(key) { return this._sources && this._sources.get(key) }
has(key) { return this._sources ? this._sources.has(key) : false }
sourceIncludes(key, val) {
const src = this._sources && this._sources.get(key);
return src && src.includes(val);
}
includes(val) {
return this._use_set ? this._cache.has(val) : this._cache.includes(val);
}
delete(key) {
if ( this._sources && this._sources.has(key) ) {
this._sources.delete(key);
this._rebuild();
}
}
extend(key, ...items) {
if ( ! this._sources )
this._sources = new Map;
const had = this.has(key);
if ( had )
items = [...this._sources.get(key), ...items];
this._sources.set(key, items);
if ( had )
this._rebuild();
else
for(const i of items)
if ( this._use_set )
this._cache.add(i);
else if ( ! this._cache.includes(i) )
this._cache.push(i);
}
set(key, val) {
if ( ! this._sources )
this._sources = new Map;
const had = this.has(key);
if ( ! Array.isArray(val) )
val = [val];
this._sources.set(key, val);
if ( had )
this._rebuild();
else
for(const i of val)
if ( this._use_set )
this._cache.add(i);
else if ( ! this._cache.includes(i) )
this._cache.push(i);
}
push(key, val) {
if ( ! this._sources )
return this.set(key, val);
const old_val = this._sources.get(key);
if ( old_val === undefined )
return this.set(key, val);
else if ( old_val.includes(val) )
return;
old_val.push(val);
if ( this._use_set )
this._cache.add(val);
else if ( ! this._cache.includes(val) )
this._cache.push(val);
}
remove(key, val) {
if ( ! this.has(key) )
return;
const old_val = this._sources.get(key),
idx = old_val.indexOf(val);
if ( idx === -1 )
return;
old_val.splice(idx, 1);
this._rebuild();
}
}