From ab4f72c345e4b5194144806fdd5770d44fc2ad41 Mon Sep 17 00:00:00 2001 From: SirStendec Date: Wed, 24 Feb 2021 14:38:25 -0500 Subject: [PATCH] 4.20.70 * Fixed: Popout Chat from the dashboard and mod view not working correctly. Please note there is still a race condition on the dashboard popout chat. It may require several refreshes or not work at all depending on your Internet connection. * Fixed: Only load the chat types from Twitch once. Ignore any future module loads. * Fixed: Hide the empty bar at the bottom of Twitch pages due to incorrect styles being applied to the new snackbar container element. * Fixed: Apollo should only clear the query cache if it makes changes to a query. Likewise, Apollo should only fetch the `gql-printer` module upon demand. * Fixed: Remove debug logging from `utilities/dom::createElement` * Changed: Slightly delay tool-tip repositioning when rich content is loaded, hopefully reducing flicker events. * Changed: Refactor WebMunch, adding compatibility for a future webpack update and reducing the number of modules checked when scanning for modules. * Changed: Allow Switchboard to keep trying to load routes if the one it tries fails to actually populate `require()`. * API Added: `EventEmitter::hasListeners(event)` method for determining if there are any listeners for a specific event. * API Added: `localStorage.ffzLogLevel` can be set to override the global log level. * API Added: `log.verbose(...)` as an even weaker logging level than `debug(...)` * API Changed: Allow Tooltip instances to add tool-tips to the DOM under a different element than the parent element used for events. --- package.json | 2 +- src/modules/chat/tokenizers.jsx | 4 +- src/modules/tooltips.js | 9 +- src/sites/twitch-twilight/index.js | 122 ++++++-- src/sites/twitch-twilight/modules/channel.jsx | 7 +- .../twitch-twilight/modules/chat/index.js | 35 ++- src/sites/twitch-twilight/styles/fixes.scss | 4 + src/sites/twitch-twilight/switchboard.js | 36 ++- src/utilities/compat/apollo.js | 96 ++++-- src/utilities/compat/webmunch.js | 275 ++++++++++++++++-- src/utilities/dom.js | 1 - src/utilities/events.js | 5 + src/utilities/logging.js | 52 +++- src/utilities/path-parser.js | 130 +++++++++ src/utilities/tooltip.js | 9 +- src/utilities/twitch-data.js | 6 +- 16 files changed, 666 insertions(+), 127 deletions(-) create mode 100644 src/utilities/path-parser.js diff --git a/package.json b/package.json index ea92705b..0f57a17b 100755 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "frankerfacez", "author": "Dan Salvato LLC", - "version": "4.20.69", + "version": "4.20.70", "description": "FrankerFaceZ is a Twitch enhancement suite.", "license": "Apache-2.0", "scripts": { diff --git a/src/modules/chat/tokenizers.jsx b/src/modules/chat/tokenizers.jsx index b8133b8b..91e0c46a 100644 --- a/src/modules/chat/tokenizers.jsx +++ b/src/modules/chat/tokenizers.jsx @@ -70,7 +70,7 @@ export const Links = { i18n: this.i18n, allow_media: show_images, allow_unsafe: show_unsafe, - onload: tip.update + onload: () => requestAnimationFrame(() => tip.update()) }; let content; @@ -214,7 +214,7 @@ Links.tooltip.interactive = function(target) { }; Links.tooltip.delayHide = function(target) { - if ( ! this.context.get('tooltip.rich-links') || ! this.context.get('tooltip.link-interaction') || target.dataset.isMail === 'true' ) + if ( ! this.context.get('tooltip.rich-links') || target.dataset.isMail === 'true' ) return 0; return 64; diff --git a/src/modules/tooltips.js b/src/modules/tooltips.js index bf2cfd14..aa9d9cef 100644 --- a/src/modules/tooltips.js +++ b/src/modules/tooltips.js @@ -112,7 +112,7 @@ export default class TooltipProvider extends Module { onEnable() { - const container = document.querySelector('.sunlight-root') || document.querySelector('#root>div') || document.querySelector('#root') || document.querySelector('.clips-root') || document.body; + const container = this.getRoot(); window.addEventListener('fullscreenchange', this.onFSChange); @@ -125,12 +125,17 @@ export default class TooltipProvider extends Module { this.on(':cleanup', this.cleanup); } - _createInstance(container, klass = 'ffz-tooltip', default_type = 'text') { + getRoot() { // eslint-disable-line class-methods-use-this + return document.querySelector('.sunlight-root') || document.querySelector('#root>div') || document.querySelector('#root') || document.querySelector('.clips-root') || document.body; + } + + _createInstance(container, klass = 'ffz-tooltip', default_type = 'text', tip_container) { return new Tooltip(container, klass, { html: true, i18n: this.i18n, live: true, check_modifiers: true, + container: tip_container || container, delayHide: this.checkDelayHide.bind(this, default_type), delayShow: this.checkDelayShow.bind(this, default_type), diff --git a/src/sites/twitch-twilight/index.js b/src/sites/twitch-twilight/index.js index 02dff046..a69717c1 100644 --- a/src/sites/twitch-twilight/index.js +++ b/src/sites/twitch-twilight/index.js @@ -174,43 +174,111 @@ export default class Twilight extends BaseSite { } getCore() { - if ( this._core ) - return this._core; + if ( ! this._core ) + this._core = this.web_munch.getModule('core'); - let core = this.web_munch.getModule('core-1'); - if ( core ) - return this._core = core.o; - - core = this.web_munch.getModule('core-2'); - if ( core ) - return this._core = core.p; - - core = this.web_munch.getModule('core-3'); - if ( core ) - return this._core = core.q; + return this._core; } } +const CALCULATE_BITS = '_calculateChangedBits'; + Twilight.KNOWN_MODULES = { simplebar: n => n.globalObserver && n.initDOMLoadedElements, react: n => n.Component && n.createElement, - 'core-1': n => n.o && n.o.experiments, - 'core-2': n => n.p && n.p.experiments, - 'core-3': n => n.q && n.q.experiments, + core: n => { + if ( n['$6']?.experiments ) + return n['$6']; + if ( n.p?.experiments ) + return n.p; + if ( n.o?.experiments ) + return n.o; + if ( n.q?.experiments ) + return n.q; + }, cookie: n => n && n.set && n.get && n.getJSON && n.withConverter, 'extension-service': n => n.extensionService, - 'chat-types': n => n.b && has(n.b, 'Message') && has(n.b, 'RoomMods'), - 'gql-printer': n => n !== window && n.print, + 'chat-types': n => { + if ( has(n.b, 'Message') && has(n.b, 'RoomMods') ) + return { + automod: n.a, + chat: n.b, + message: n.c, + mod: n.e + }; + + if ( has(n.SJ, 'Message') && has(n.SJ, 'RoomMods') ) + return { + automod: n.mT, + chat: n.SJ, + message: n.Ay, + mod: n.Aw + }; + }, + 'gql-printer': n => { + if ( n === window ) + return; + + if ( n.print && n.print.toString().includes('.visit') ) + return n.print; + + if ( n.S && n.S.toString().includes('.visit') ) + return n.S; + }, mousetrap: n => n.bindGlobal && n.unbind && n.handleKey, - 'algolia-search': n => n.a && n.a.prototype && n.a.prototype.queryTopResults && n.a.prototype.queryForType, - highlightstack: n => n.b && has(n.b, '_calculateChangedBits') && n.c && has(n.c, '_calculateChangedBits') + 'algolia-search': n => { + if ( n.a?.prototype?.queryTopResults && n.a.prototype.queryForType ) + return n.a; + if ( n.w9?.prototype?.queryTopResults && n.w9.prototype.queryForType ) + return n.w9; + }, + highlightstack: n => { + if ( has(n.b, CALCULATE_BITS) && has(n.c, CALCULATE_BITS) ) + return { + stack: n.b, + dispatch: n.c + }; + + if ( has(n.fQ, CALCULATE_BITS) && has(n.vJ, CALCULATE_BITS) ) + return { + stack: n.fQ, + dispatch: n.vJ + }; + } } +const VEND_CHUNK = n => n && n.includes('vendor'); + +Twilight.KNOWN_MODULES.core.use_result = true; +//Twilight.KNOWN_MODULES.core.chunks = 'core'; + +Twilight.KNOWN_MODULES.simplebar.chunks = VEND_CHUNK; +Twilight.KNOWN_MODULES.react.chunks = VEND_CHUNK; +Twilight.KNOWN_MODULES.cookie.chunks = VEND_CHUNK; + +Twilight.KNOWN_MODULES['gql-printer'].use_result = true; +Twilight.KNOWN_MODULES['gql-printer'].chunks = VEND_CHUNK; + +Twilight.KNOWN_MODULES.mousetrap.chunks = VEND_CHUNK; + +const CHAT_CHUNK = n => n && n.includes('chat'); + +Twilight.KNOWN_MODULES['chat-types'].use_result = true; +Twilight.KNOWN_MODULES['chat-types'].chunks = CHAT_CHUNK; +Twilight.KNOWN_MODULES['highlightstack'].use_result = true; +Twilight.KNOWN_MODULES['highlightstack'].chunks = CHAT_CHUNK; + +Twilight.KNOWN_MODULES['algolia-search'].use_result = true; +Twilight.KNOWN_MODULES['algolia-search'].chunks = 'core'; + + Twilight.POPOUT_ROUTES = [ 'embed-chat', - 'popout' + 'popout', + 'dash-popout-chat', + 'mod-popout-chat' ]; @@ -233,7 +301,9 @@ Twilight.CHAT_ROUTES = [ 'squad', 'command-center', 'dash-stream-manager', - 'mod-view' + 'dash-popout-chat', + 'mod-view', + 'mod-popout-chat' ]; @@ -243,7 +313,7 @@ Twilight.ROUTE_NAMES = { 'dir-all': 'Browse Live Channels', 'dash': 'Dashboard', 'popout': 'Popout Chat', - 'dash-chat': 'Dashboard Popout Chat', + 'dash-popout-chat': 'Dashboard Popout Chat', 'user-video': 'Channel Video', 'popout-player': 'Popout/Embed Player' }; @@ -293,6 +363,7 @@ Twilight.DASH_ROUTES = { 'dash-settings-revenue': '/u/:userName/settings/revenue', 'dash-extensions': '/u/:userName/extensions', 'dash-streaming-tools': '/u/:userName/broadcast', + 'dash-popout-chat': '/popout/u/:userName/stream-manager/chat', }; Twilight.ROUTES = { @@ -313,7 +384,7 @@ Twilight.ROUTES = { //'dash-automod': '/:userName/dashboard/settings/automod', 'event': '/event/:eventName', 'popout': '/popout/:userName/chat', - 'dash-chat': '/popout/:userName/dashboard/live/chat', + //'dash-chat': '/popout/:userName/dashboard/live/chat', 'video': '/videos/:videoID', 'user-video': '/:userName/video/:videoID', 'user-videos': '/:userName/videos/:filter?', @@ -331,7 +402,8 @@ Twilight.ROUTES = { 'squad': '/:userName/squad', 'command-center': '/:userName/commandcenter', 'embed-chat': '/embed/:userName/chat', - 'mod-view': '/moderator/:userName' + 'mod-view': '/moderator/:userName', + 'mod-popout-chat': '/popout/moderator/:userName/chat' }; diff --git a/src/sites/twitch-twilight/modules/channel.jsx b/src/sites/twitch-twilight/modules/channel.jsx index 8483ca3e..a2e03b90 100644 --- a/src/sites/twitch-twilight/modules/channel.jsx +++ b/src/sites/twitch-twilight/modules/channel.jsx @@ -193,8 +193,11 @@ export default class Channel extends Module { this.removePanelTips(inst); if ( ! inst._ffz_tips ) { - inst._ffz_tips = this.resolve('tooltips')._createInstance(el, 'tw-link', 'link'); - inst._ffz_tip_el = el; + const tt = this.resolve('tooltips'); + if ( tt ) { + inst._ffz_tips = tt._createInstance(el, 'tw-link', 'link', tt.getRoot()); + inst._ffz_tip_el = el; + } } } diff --git a/src/sites/twitch-twilight/modules/chat/index.js b/src/sites/twitch-twilight/modules/chat/index.js index bae77ab4..4942033b 100644 --- a/src/sites/twitch-twilight/modules/chat/index.js +++ b/src/sites/twitch-twilight/modules/chat/index.js @@ -735,27 +735,32 @@ export default class ChatHook extends Module { async grabTypes() { - const ct = await this.web_munch.findModule('chat-types'), - changes = []; + if ( this.types_loaded ) + return; - this.automod_types = ct && ct.a || AUTOMOD_TYPES; - this.chat_types = ct && ct.b || CHAT_TYPES; - this.message_types = ct && ct.c || MESSAGE_TYPES; - this.mod_types = ct && ct.e || MOD_TYPES; + const ct = await this.web_munch.findModule('chat-types'); + + this.automod_types = ct?.automod || AUTOMOD_TYPES; + this.chat_types = ct?.chat || CHAT_TYPES; + this.message_types = ct?.message || MESSAGE_TYPES; + this.mod_types = ct?.mod || MOD_TYPES; if ( ! ct ) return; - if ( ct.a && ! shallow_object_equals(ct.a, AUTOMOD_TYPES) ) + this.types_loaded = true; + const changes = []; + + if ( ! shallow_object_equals(this.automod_types, AUTOMOD_TYPES) ) changes.push('AUTOMOD_TYPES'); - if ( ct.b && ! shallow_object_equals(ct.b, CHAT_TYPES) ) + if ( ! shallow_object_equals(this.chat_types, CHAT_TYPES) ) changes.push('CHAT_TYPES'); - if ( ct.c && ! shallow_object_equals(ct.c, MESSAGE_TYPES) ) + if ( ! shallow_object_equals(this.message_types, MESSAGE_TYPES) ) changes.push('MESSAGE_TYPES'); - if ( ct.e && ! shallow_object_equals(ct.e, MOD_TYPES) ) + if ( ! shallow_object_equals(this.mod_types, MOD_TYPES) ) changes.push('MOD_TYPES'); if ( changes.length ) @@ -1296,15 +1301,15 @@ export default class ChatHook extends Module { const t = this, React = this.web_munch.getModule('react'), - Stack = this.web_munch.getModule('highlightstack'), - createElement = React && React.createElement; + createElement = React && React.createElement, + StackMod = this.web_munch.getModule('highlightstack'); - if ( ! createElement || ! Stack || ! Stack.b ) + if ( ! createElement || ! StackMod ) return false; this.CommunityStackHandler = function() { - const stack = React.useContext(Stack.b), - dispatch = React.useContext(Stack.c); + const stack = React.useContext(StackMod.stack), + dispatch = React.useContext(StackMod.dispatch); t.community_stack = stack; t.community_dispatch = dispatch; diff --git a/src/sites/twitch-twilight/styles/fixes.scss b/src/sites/twitch-twilight/styles/fixes.scss index 0bc7180e..f517585f 100644 --- a/src/sites/twitch-twilight/styles/fixes.scss +++ b/src/sites/twitch-twilight/styles/fixes.scss @@ -5,4 +5,8 @@ .top-nav__menu > div:empty { display: none +} + +.twilight-main > .tw-relative.tw-z-above.tw-bottom-0 { + position: absolute !important; } \ No newline at end of file diff --git a/src/sites/twitch-twilight/switchboard.js b/src/sites/twitch-twilight/switchboard.js index e8ce9e75..6c6fad33 100644 --- a/src/sites/twitch-twilight/switchboard.js +++ b/src/sites/twitch-twilight/switchboard.js @@ -16,6 +16,8 @@ export default class Switchboard extends Module { this.inject('site.web_munch'); this.inject('site.fine'); this.inject('site.router'); + + this.tried = new Set; } @@ -64,29 +66,49 @@ export default class Switchboard extends Module { const router = await this.awaitRouter(); this.log.info(`Found Route and Switch with ${da_switch.props.children.length} routes.`); - const location = router.props.location.pathname; + this.da_switch = da_switch; + this.location = router.props.location.pathname; + //const location = router.props.location.pathname; - if ( ! this.loadRoute(da_switch, location, false) ) - this.loadRoute(da_switch, location, true); + this.loadOne(); } - loadRoute(da_switch, location, with_params) { - for(const route of da_switch.props.children) { + loadOne() { + if ( ! this.loadRoute(false) ) + this.loadRoute(true); + } + + waitAndSee() { + requestAnimationFrame(() => { + if ( this.web_munch._require ) + return; + + this.log.info('We still need require(). Trying again.'); + this.loadOne(); + }); + } + + loadRoute(with_params) { + for(const route of this.da_switch.props.children) { if ( ! route.props || ! route.props.component ) continue; if ( with_params !== null && with_params !== route.props.path.includes(':') ) continue; + if ( this.tried.has(route.props.path) ) + continue; + try { const reg = pathToRegexp(route.props.path); - if ( ! reg.exec || reg.exec(location) ) + if ( ! reg.exec || reg.exec(this.location) ) continue; } catch(err) { continue; } + this.tried.add(route.props.path); this.log.info('Found Non-Matching Route', route.props.path); const component_class = route.props.component; @@ -107,6 +129,7 @@ export default class Switchboard extends Module { try { component.props.loader().then(() => { this.log.info('Successfully forced a chunk to load using route', route.props.path) + this.waitAndSee(); }); } catch(err) { this.log.warn('Unexpected result trying to use component pre-loader to force loading of another chunk.'); @@ -126,6 +149,7 @@ export default class Switchboard extends Module { try { component.props.children.props.loader().then(() => { this.log.info('Successfully forced a chunk to load using route', route.props.path) + this.waitAndSee(); }); } catch(err) { this.log.warn('Unexpected result trying to use component loader to force loading of another chunk.'); diff --git a/src/utilities/compat/apollo.js b/src/utilities/compat/apollo.js index 63f48345..6ae6e545 100644 --- a/src/utilities/compat/apollo.js +++ b/src/utilities/compat/apollo.js @@ -8,6 +8,7 @@ import Module from 'utilities/module'; import {get} from 'utilities/object'; import merge from 'utilities/graphql'; +import { FFZEvent } from 'utilities/events'; /*const BAD_ERRORS = [ @@ -35,6 +36,20 @@ function skip_error(err) { }*/ +export class ApolloEvent extends FFZEvent { + constructor(data) { + super(data); + + this._changed = false; + } + + markChanged() { + this._changed = true; + return this; + } +} + + export class GQLError extends Error { constructor(err) { super(`${err.message}; Location: ${err.locations}`); @@ -49,11 +64,20 @@ export default class Apollo extends Module { this.modifiers = {}; this.post_modifiers = {}; - this.inject('..web_munch'); this.inject('..fine'); } - async onEnable() { + get gqlPrint() { + if ( this._gql_print ) + return this._gql_print; + + const web_munch = this.resolve('site.web_munch'), + printer = this._gql_print = web_munch?.getModule?.('gql-printer'); + + return printer; + } + + onEnable() { // TODO: Come up with a better way to await something existing. let client = this.client; @@ -69,9 +93,6 @@ export default class Apollo extends Module { if ( ! client ) return new Promise(() => this.onEnable(), 50); - this.printer = await this.web_munch.findModule('gql-printer'); - this.gql_print = this.printer && this.printer.print; - // Register middleware so that we can intercept requests. if ( ! this.client.link || ! this.client.queryManager || ! this.client.queryManager.link ) { this.log.error('Apollo does not have a Link. We are unable to manipulate queries.'); @@ -216,19 +237,25 @@ export default class Apollo extends Module { onDisable() { // Remove our references to things. - this.client = this.printer = this.gql_print = this.old_link = this.old_qm_dedup = this.old_qm_link = null; + this.client = this.printer = this._gql_print = this.old_link = this.old_qm_dedup = this.old_qm_link = null; } apolloPreFlight(request) { const operation = request.operationName, - qm = this.client.queryManager, + modifiers = this.modifiers[operation], + event = `:request.${operation}`, + has_listeners = this.hasListeners(event); + + if ( ! modifiers && ! has_listeners ) + return; + + const qm = this.client.queryManager, id_map = qm && qm.queryIdsByName, query_map = qm && qm.queries, raw_id = id_map && id_map[operation], id = Array.isArray(raw_id) ? raw_id[0] : raw_id, - query = query_map && query_map.get(id), - modifiers = this.modifiers[operation]; + query = query_map && query_map.get(id); if ( modifiers ) { for(const mod of modifiers) { @@ -239,30 +266,43 @@ export default class Apollo extends Module { } } - this.emit(`:request.${operation}`, request.query, request.variables); + let modified = !! modifiers; - // Wipe the old query data. This is obviously not optimal, but Apollo will - // raise an exception otherwise because the query string doesn't match. + if ( has_listeners ) { + const e = new ApolloEvent({ + operation, + request + }); - const q = this.client.queryManager.queryStore.store[id], - qs = this.gql_print && this.gql_print(request.query); + this.emit(event, e); + if ( e._changed ) + modified = true; + } - if ( q ) - if ( qs ) { - q.queryString = qs; - request.query.loc.source.body = qs; - request.query.loc.end = qs.length; + if ( modified ) { + // Wipe the old query data. This is obviously not optimal, but Apollo will + // raise an exception otherwise because the query string doesn't match. - if ( query ) { - query.document = request.query; - if ( query.observableQuery && query.observableQuery.options ) - query.observableQuery.options.query = request.query; + const q = this.client.queryManager.queryStore.store[id], + qs = this.gqlPrint && this.gqlPrint(request.query); + + if ( q ) + if ( qs ) { + q.queryString = qs; + request.query.loc.source.body = qs; + request.query.loc.end = qs.length; + + if ( query ) { + query.document = request.query; + if ( query.observableQuery && query.observableQuery.options ) + query.observableQuery.options.query = request.query; + } + + } else { + this.log.info('Unable to find GQL Print. Clearing store for query:', operation); + this.client.queryManager.queryStore.store[id] = null; } - - } else { - this.log.info('Unable to find GQL Print. Clearing store for query:', operation); - this.client.queryManager.queryStore.store[id] = null; - } + } } apolloPostFlight(response) { diff --git a/src/utilities/compat/webmunch.js b/src/utilities/compat/webmunch.js index 6805b48a..cf61476d 100644 --- a/src/utilities/compat/webmunch.js +++ b/src/utilities/compat/webmunch.js @@ -7,7 +7,17 @@ import Module from 'utilities/module'; import {has} from 'utilities/object'; +import { DEBUG } from '../constants'; +const NAMES = [ + 'webpackJsonp', + 'webpackChunktwitch_twilight' +]; + +const HARD_MODULES = [ + [0, 'vendor'], + [1, 'core'] +]; let last_muncher = 0; @@ -20,9 +30,11 @@ export default class WebMunch extends Module { this._original_loader = null; this._known_rules = {}; this._require = null; - this._module_names = {}; + this._chunk_names = {}; this._mod_cache = {}; + this._known_ids = new Set; + this.v4 = null; this.hookLoader(); @@ -38,37 +50,47 @@ export default class WebMunch extends Module { if ( this._original_loader ) return this.log.warn('Attempted to call hookLoader twice.'); - if ( ! window.webpackJsonp ) { + let name; + for(const n of NAMES) + if ( window[n] ) { + name = n; + break; + } + + if ( ! name ) { if ( attempts > 500 ) return this.log.error("Unable to find webpack's loader after two minutes."); return setTimeout(this.hookLoader.bind(this, attempts + 1), 250); } - if ( typeof window.webpackJsonp === 'function' ) { + const thing = window[name]; + + if ( typeof thing === 'function' ) { // v3 this.v4 = false; - this._original_loader = window.webpackJsonp; + this._original_loader = thing; try { - window.webpackJsonp = this.webpackJsonpv3.bind(this); + window[name] = this.webpackJsonpv3.bind(this); } catch(err) { this.log.warn('Unable to wrap webpackJsonp due to write protection.'); return; } - } else if ( Array.isArray(window.webpackJsonp) ) { + } else if ( Array.isArray(thing) ) { // v4 this.v4 = true; - this._original_loader = window.webpackJsonp.push; + this._original_store = thing; + this._original_loader = thing.push; // Wrap all existing modules in case any of them haven't been required yet. - for(const chunk of window.webpackJsonp) + for(const chunk of thing) if ( chunk && chunk[1] ) this.processModulesV4(chunk[1]); try { - window.webpackJsonp.push = this.webpackJsonpv4.bind(this); + thing.push = this.webpackJsonpv4.bind(this); } catch(err) { this.log.warn('Unable to wrap webpackJsonp (v4) due to write protection.'); return; @@ -84,9 +106,9 @@ export default class WebMunch extends Module { webpackJsonpv3(chunk_ids, modules) { - const names = chunk_ids.map(x => this._module_names[x] || x).join(', '); - this.log.debug(`Twitch Chunk Loaded: ${chunk_ids} (${names})`); - this.log.debug(`Modules: ${Object.keys(modules)}`); + const names = chunk_ids.map(x => this._chunk_names[x] || x).join(', '); + this.log.verbose(`Twitch Chunk Loaded: ${chunk_ids} (${names})`); + this.log.verbose(`Modules: ${Object.keys(modules)}`); const res = this._original_loader.apply(window, arguments); // eslint-disable-line prefer-rest-params @@ -101,6 +123,7 @@ export default class WebMunch extends Module { for(const mod_id in modules) if ( has(modules, mod_id) ) { + this._known_ids.add(mod_id); const original_module = modules[mod_id]; modules[mod_id] = function(module, exports, require, ...args) { if ( ! t._require && typeof require === 'function' ) { @@ -127,15 +150,15 @@ export default class WebMunch extends Module { webpackJsonpv4(data) { const chunk_ids = data[0], modules = data[1], - names = Array.isArray(chunk_ids) && chunk_ids.map(x => this._module_names[x] || x).join(', '); + names = Array.isArray(chunk_ids) && chunk_ids.map(x => this._chunk_names[x] || x).join(', '); - this.log.debug(`Twitch Chunk Loaded: ${chunk_ids} (${names})`); - this.log.debug(`Modules: ${Object.keys(modules)}`); + this.log.verbose(`Twitch Chunk Loaded: ${chunk_ids} (${names})`); + this.log.verbose(`Modules: ${Object.keys(modules)}`); if ( modules ) this.processModulesV4(modules); - const res = this._original_loader.apply(window.webpackJsonp, arguments); // eslint-disable-line prefer-rest-params + const res = this._original_loader.apply(this._original_store, arguments); // eslint-disable-line prefer-rest-params this.emit(':loaded', chunk_ids, names, modules); return res; } @@ -166,6 +189,54 @@ export default class WebMunch extends Module { } + findDeep(chunks, predicate, multi = true) { + if ( chunks && ! Array.isArray(chunks) ) + chunks = [chunks]; + + if ( ! this._require || ! this.v4 || ! this._original_store ) + return new Error('We do not have webpack'); + + const out = [], + names = this._chunk_names; + for(const [cs, modules] of this._original_store) { + if ( chunks ) { + let matched = false; + for(const c of cs) { + if ( chunks.includes(c) || chunks.includes(`${c}`) || (names[c] && chunks.includes(names[c])) ) { + matched = true; + break; + } + } + + if ( ! matched ) + continue; + } + + for(const id of Object.keys(modules)) { + try { + const mod = this._require(id); + for(const key in mod) + if ( mod[key] && predicate(mod[key]) ) { + this.log.info(`Found in key "${key}" of module "${id}" (${this.chunkNameForModule(id)})`); + if ( ! multi ) + return mod; + out.push(mod); + break; + } + } catch(err) { + this.log.warn('Exception while deep scanning webpack.', err); + } + } + } + + if ( out.length ) + return out; + + this.log.info('Unable to find deep scan target.'); + return null; + } + + getModule(key, predicate) { if ( typeof key === 'function' ) { predicate = key; @@ -176,7 +247,7 @@ export default class WebMunch extends Module { return this._mod_cache[key]; const require = this._require; - if ( ! require || ! require.c ) + if ( ! require ) return null; if ( ! predicate ) @@ -185,17 +256,147 @@ export default class WebMunch extends Module { if ( ! predicate ) throw new Error(`no known predicate for locating ${key}`); - for(const k in require.c) + if ( require.c ) + return this._oldGetModule(key, predicate, require); + + if ( require.m ) + return this._newGetModule(key, predicate, require); + } + + _chunksForModule(id) { + if ( ! this.v4 ) + return null; + + if ( ! this._original_store ) + return null; + + for(const [chunks, modules] of this._original_store) { + if ( modules[id] ) + return chunks; + } + } + + chunkNameForModule(id) { + const chunks = this._chunksForModule(id); + if ( ! chunks ) + return null; + + for(const chunk of chunks) { + const name = this._chunk_names[chunk]; + if ( name ) + return name; + } + + return null; + } + + + _oldGetModule(key, predicate, require) { + if ( ! require || ! require.c ) + return null; + + let ids; + if ( this._original_store && predicate.chunks ) { + const chunk_pred = typeof predicate.chunks === 'function'; + if ( ! chunk_pred && ! Array.isArray(predicate.chunks) ) + predicate.chunks = [predicate.chunks]; + + const chunks = predicate.chunks, + names = this._chunk_names; + + ids = []; + for(const [cs, modules] of this._original_store) { + let matched = false; + for(const c of cs) { + if ( chunk_pred ? chunks(names[c], c) : (chunks.includes(c) || chunks.includes(String(c)) || (names[c] && chunks.includes(names[c]))) ) { + matched = true; + break; + } + } + + if ( matched ) + ids = [...ids, ...Object.keys(modules)]; + } + + ids = new Set(ids); + } else + ids = Object.keys(require.c); + + let checked = 0; + for(const k of ids) if ( has(require.c, k) ) { + checked++; const module = require.c[k], mod = module && module.exports; - if ( mod && predicate(mod) ) { - if ( key ) - this._mod_cache[key] = mod; - return mod; + if ( mod ) { + const ret = predicate(mod); + if ( ret ) { + this.log.debug(`Located module "${key}" in module ${k}${DEBUG ? ` (${this.chunkNameForModule(k)})` : ''} after ${checked} tries`); + const out = predicate.use_result ? ret : mod; + if ( key ) + this._mod_cache[key] = out; + return out; + } } } + + this.log.debug(`Unable to locate module "${key}"`); + return null; + } + + _newGetModule(key, predicate, require) { + if ( ! require ) + return null; + + let ids = this._known_ids; + if ( this._original_store && predicate.chunks ) { + const chunk_pred = typeof predicate.chunks === 'function'; + if ( ! chunk_pred && ! Array.isArray(predicate.chunks) ) + predicate.chunks = [predicate.chunks]; + + const chunks = predicate.chunks, + names = this._chunk_names; + + ids = []; + for(const [cs, modules] of this._original_store) { + let matched = false; + for(const c of cs) { + if ( chunk_pred ? chunks(names[c], c) : (chunks.includes(c) || chunks.includes(String(c)) || (names[c] && chunks.includes(names[c]))) ) { + matched = true; + break; + } + } + + if ( matched ) + ids = [...ids, ...Object.keys(modules)]; + } + + ids = new Set(ids); + } + + let checked = 0; + for(const id of ids) { + try { + checked++; + const mod = require(id); + if ( mod ) { + const ret = predicate(mod); + if ( ret ) { + this.log.debug(`Located module "${key}" in module ${id}${DEBUG ? ` (${this.chunkNameForModule(id)})` : ''} after ${checked} tries`); + const out = predicate.use_result ? ret : mod; + if ( key ) + this._mod_cache[key] = out; + return out; + } + } + } catch(err) { + this.log.warn('Unexpected error trying to find module', err); + } + } + + this.log.debug(`Unable to locate module "${key}"`); + return null; } @@ -256,10 +457,38 @@ export default class WebMunch extends Module { try { modules = JSON.parse(data[1].replace(/(\d+):/g, '"$1":')) } catch(err) { } // eslint-disable-line no-empty + + } else if ( require.u ) { + const builder = require.u.toString(), + match = /assets\/"\+({\d+:.*?})/.exec(builder), + data = match ? match[1].replace(/([\de]+):/g, (_, m) => { + if ( /^\d+e\d+$/.test(m) ) { + const bits = m.split('e'); + m = parseInt(bits[0], 10) * (10 ** parseInt(bits[1], 10)); + } + + return `"${m}":`; + }) : null; + + if ( data ) + try { + modules = JSON.parse(data); + } catch(err) { console.log(data); console.log(err) /* no-op */ } } if ( modules ) { - this._module_names = modules; + // Ensure that vendor and core have names. + if ( this._original_store ) { + for(const [pos, name] of HARD_MODULES) { + const mods = this._original_store[pos]?.[0]; + if ( Array.isArray(mods) ) + for(const id of mods) + if ( typeof id !== 'object' && ! modules[id] ) + modules[id] = name; + } + } + + this._chunk_names = modules; this.log.info(`Loaded names for ${Object.keys(modules).length} chunks from require().`) } else this.log.warn(`Unable to find chunk names in require().`); diff --git a/src/utilities/dom.js b/src/utilities/dom.js index 183055cf..4c0b18aa 100644 --- a/src/utilities/dom.js +++ b/src/utilities/dom.js @@ -127,7 +127,6 @@ export function createElement(tag, props, ...children) { else if ( BOOLEAN_ATTRS.includes(lk) ) { if ( prop && prop !== 'false' ) el.setAttribute(key, prop); - console.log('bool-attr', key, prop); } else if ( lk.startsWith('aria-') || ATTRS.includes(lk) ) el.setAttribute(key, prop); diff --git a/src/utilities/events.js b/src/utilities/events.js index 7a62ff10..337f71c4 100644 --- a/src/utilities/events.js +++ b/src/utilities/events.js @@ -124,6 +124,10 @@ export class EventEmitter { return list ? Array.from(list) : []; } + hasListeners(event) { + return !! this.__listeners[event] + } + emitUnsafe(event, ...args) { let list = this.__listeners[event]; if ( ! list ) @@ -446,6 +450,7 @@ export class HierarchicalEventEmitter extends EventEmitter { waitFor(event) { return super.waitFor(this.abs_path(event)) } off(event, fn, ctx) { return super.off(this.abs_path(event), fn, ctx) } listeners(event) { return super.listeners(this.abs_path(event)) } + hasListeners(event) { return super.hasListeners(this.abs_path(event)) } emit(event, ...args) { return super.emit(this.abs_path(event), ...args) } emitUnsafe(event, ...args) { return super.emitUnsafe(this.abs_path(event), ...args) } diff --git a/src/utilities/logging.js b/src/utilities/logging.js index 96ecf227..e647ef9a 100644 --- a/src/utilities/logging.js +++ b/src/utilities/logging.js @@ -8,6 +8,22 @@ const RAVEN_LEVELS = { }; +function readLSLevel() { + const level = localStorage.ffzLogLevel; + if ( ! level ) + return null; + + const upper = level.toUpperCase(); + if ( Logger[upper] ) + return Logger[upper]; + + if ( /^\d+$/.test(level) ) + return parseInt(level, 10); + + return null; +} + + export class Logger { constructor(parent, name, level, raven) { this.root = parent ? parent.root : this; @@ -21,7 +37,7 @@ export class Logger { this.init = false; this.enabled = true; - this.level = level || (parent && parent.level) || Logger.DEFAULT_LEVEL; + this.level = level ?? (parent && parent.level) ?? readLSLevel() ?? Logger.DEFAULT_LEVEL; this.raven = raven || (parent && parent.raven); this.children = {}; @@ -34,6 +50,10 @@ export class Logger { return this.children[name]; } + verbose(...args) { + return this.invoke(Logger.VERBOSE, args); + } + debug(...args) { return this.invoke(Logger.DEBUG, args); } @@ -79,26 +99,28 @@ export class Logger { const message = Array.prototype.slice.call(args); - if ( this.root.init ) - this.root.captured_init.push({ - time: Date.now(), - category: this.name, + if ( level !== Logger.VERBOSE ) { + if ( this.root.init ) + this.root.captured_init.push({ + time: Date.now(), + category: this.name, + message: message.join(' '), + level: RAVEN_LEVELS[level] || level + }); + + this.crumb({ message: message.join(' '), + category: this.name, level: RAVEN_LEVELS[level] || level }); - - this.crumb({ - message: message.join(' '), - category: this.name, - level: RAVEN_LEVELS[level] || level - }); + } if ( this.name ) message.unshift(`%c${this.root.label} [%c${this.name}%c]:%c`, 'color:#755000; font-weight:bold', '', 'color:#755000; font-weight:bold', ''); else message.unshift(`%c${this.root.label}:%c`, 'color:#755000; font-weight:bold', ''); - if ( level === Logger.DEBUG ) + if ( level === Logger.DEBUG || level === Logger.VERBOSE ) console.debug(...message); else if ( level === Logger.INFO ) @@ -115,9 +137,7 @@ export class Logger { } } - -Logger.DEFAULT_LEVEL = 2; - +Logger.VERBOSE = 0; Logger.DEBUG = 1; Logger.INFO = 2; Logger.WARN = 4; @@ -125,4 +145,6 @@ Logger.WARNING = 4; Logger.ERROR = 8; Logger.OFF = 99; +Logger.DEFAULT_LEVEL = Logger.INFO; + export default Logger; \ No newline at end of file diff --git a/src/utilities/path-parser.js b/src/utilities/path-parser.js new file mode 100644 index 00000000..ee523b94 --- /dev/null +++ b/src/utilities/path-parser.js @@ -0,0 +1,130 @@ +'use strict'; + +export function parse(path) { + return parseAST({ + path, + i: 0 + }); +} + +function parseAST(ctx) { + const path = ctx.path, + length = path.length, + out = []; + + let token, raw; + let old_tab = false, + old_page = false; + + while ( ctx.i < length ) { + const start = ctx.i, + char = path[start], + next = path[start + 1]; + + if ( ! token ) { + raw = []; + token = {}; + } + + // JSON + if ( char === '@' && next === '{') { + ctx.i++; + const tag = parseJSON(ctx); + if ( tag ) + Object.assign(token, tag); + + continue; + } + + // Segment End? + const tab = char === `~` && next === '>', + page = char === '>' && next === '>', + segment = ! page && char === '>'; + + if ( ! segment && ! page && ! tab ) { + raw.push(char); + ctx.i++; + continue; + } + + // We're at the end of a segment, so push + // the token out. + if ( tab || page ) + ctx.i++; + + token.title = raw.join('').trim(); + token.key = token.title.toSnakeCase(); + + token.page = old_page; + token.tab = old_tab; + + old_page = page; + old_tab = tab; + + out.push(token); + token = raw = null; + ctx.i++; + } + + if ( token ) { + token.title = raw.join('').trim(); + token.key = token.title.toSnakeCase(); + token.page = old_page; + token.tab = old_tab; + out.push(token); + } + + return out; +} + +function parseJSON(ctx) { + const path = ctx.path, + length = path.length, + + start = ctx.i; + + ctx.i++; + + const stack = ['{']; + let string = false; + + while ( ctx.i < length && stack.length ) { + const start = ctx.i, + char = path[start]; + + if ( string ) { + if ( char === '\\' ) { + ctx.i++; + continue; + } + + if ( (char === '"' || char === "'") && char === string ) { + stack.pop(); + string = false; + } + + } else { + if ( char === '"' || char === "'" ) { + string = char; + stack.push(char); + } + + if ( char === '{' || char === '[' ) + stack.push(char); + + if ( char === ']' ) { + if ( stack.pop() !== '[' ) + throw new SyntaxError('Invalid JSON'); + } + + if ( char === '}' ) { + if ( stack.pop() !== '{' ) + throw new SyntaxError('Invalid JSON'); + } + } + + ctx.i++; + } + + return JSON.parse(path.slice(start, ctx.i)); +} \ No newline at end of file diff --git a/src/utilities/tooltip.js b/src/utilities/tooltip.js index 77e3f0a2..a429fda2 100644 --- a/src/utilities/tooltip.js +++ b/src/utilities/tooltip.js @@ -45,6 +45,7 @@ export class Tooltip { this.check_modifiers = this.options.check_modifiers; this.parent = parent; + this.container = this.options.container || this.parent; this.cls = cls; if ( this.check_modifiers ) @@ -134,6 +135,7 @@ export class Tooltip { this.elements = null; this._onMouseOut = this._onMouseOver = null; + this.container = null; this.parent = null; } @@ -367,8 +369,9 @@ export class Tooltip { tip._update = () => { if ( tip.popper ) { - tip.popper.destroy(); - tip.popper = new Popper(popper_target, el, pop_opts); + tip.popper.update(); + /*tip.popper.destroy(); + tip.popper = new Popper(popper_target, el, pop_opts);*/ } } @@ -412,7 +415,7 @@ export class Tooltip { // Add everything to the DOM and create the Popper instance. tip.popper = new Popper(popper_target, el, pop_opts); - this.parent.appendChild(el); + this.container.appendChild(el); tip.visible = true; if ( opts.onShow ) diff --git a/src/utilities/twitch-data.js b/src/utilities/twitch-data.js index ad44a7c2..cb8702ef 100644 --- a/src/utilities/twitch-data.js +++ b/src/utilities/twitch-data.js @@ -138,11 +138,9 @@ export default class TwitchData extends Module { return this._search; const apollo = this.apollo.client, - core = this.site.getCore(), - - search_module = this.web_munch.getModule('algolia-search'), - SearchClient = search_module && search_module.a; + core = this.site.getCore(); + const SearchClient = this.web_munch.getModule('algolia-search'); if ( ! SearchClient || ! apollo || ! core ) return null;