diff --git a/src/addons.js b/src/addons.js index bd66b8fa..68f10b57 100644 --- a/src/addons.js +++ b/src/addons.js @@ -20,12 +20,7 @@ export default class AddonManager extends Module { constructor(...args) { super(...args); - this.should_enable = true; - - this.inject('settings'); - this.inject('i18n'); - - this.load_requires = ['settings']; + this.__module_data.addons = this.__data; this.has_dev = false; this.reload_required = false; @@ -257,7 +252,7 @@ export default class AddonManager extends Module { await this.loadAddon(id); - const module = this.resolve(`addon.${id}`); + const module = await this.resolve(`addon.${id}`, true); if ( module && ! module.enabled ) await module.enable(); } @@ -267,7 +262,7 @@ export default class AddonManager extends Module { if ( ! addon ) throw new Error(`Unknown add-on id: ${id}`); - let module = this.resolve(`addon.${id}`); + let module = await this.resolve(`addon.${id}`, true); if ( module ) { if ( ! module.loaded ) await module.load(); @@ -284,9 +279,9 @@ export default class AddonManager extends Module { })); // Error if this takes more than 5 seconds. - await timeout(this.waitFor(`addon.${id}:instanced`), 5000); + //await timeout(this.waitFor(`addon.${id}:instanced`), 5000); - module = this.resolve(`addon.${id}`); + module = await this.resolve(`addon.${id}`, true); if ( module && ! module.loaded ) await module.load(); diff --git a/src/bridge.js b/src/bridge.js index f702f0cc..4a668022 100644 --- a/src/bridge.js +++ b/src/bridge.js @@ -21,8 +21,7 @@ class FFZBridge extends Module { FFZBridge.instance = this; this.name = 'ffz_bridge'; - this.__state = 0; - this.__modules.core = this; + this.__data.state = 0; // ======================================================================== // Error Reporting and Logging diff --git a/src/i18n.js b/src/i18n.js index 847205ab..9fb20ced 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -424,11 +424,12 @@ export class TranslationManager extends Module { this.emit(':update'); } - const mod = this.resolve('translation_ui'); - if ( popout ) - mod.openPopout(); - else - mod.enable(); + this.resolve('translation_ui', true).then(mod => { + if ( popout ) + mod.openPopout(); + else + return mod.enable(); + }); } diff --git a/src/main.js b/src/main.js index d50cebc5..4d4e3dd3 100644 --- a/src/main.js +++ b/src/main.js @@ -28,8 +28,8 @@ class FrankerFaceZ extends Module { FrankerFaceZ.instance = this; this.name = 'frankerfacez'; - this.__state = 0; - this.__modules.core = this; + this.__data.state = 0; + //this.__modules.core = this; // Timing //this.inject('timing', Timing); @@ -59,7 +59,7 @@ class FrankerFaceZ extends Module { this.inject('socket', SocketClient); //this.inject('pubsub', PubSubClient); this.inject('site', Site); - this.inject('addons', AddonManager); + this.inject('addon', AddonManager); this.register('vue', Vue); @@ -134,7 +134,7 @@ ${typeof x[1] === 'string' ? x[1] : JSON.stringify(x[1], null, 4)}`).join('\n\n' async discoverModules() { // TODO: Actually do async modules. - const ctx = await require.context('src/modules', true, /(?:^(?:\.\/)?[^/]+|index)\.jsx?$/ /*, 'lazy-once' */); + const ctx = await require.context('src/modules', true, /^(?:\.\/)?([^/]+)(?:\/index)?\.jsx?$/ /*, 'lazy-once' */); const modules = this.populate(ctx, this.core_log); this.core_log.info(`Loaded descriptions of ${Object.keys(modules).length} modules.`); @@ -143,11 +143,12 @@ ${typeof x[1] === 'string' ? x[1] : JSON.stringify(x[1], null, 4)}`).join('\n\n' async enableInitialModules() { const promises = []; - /* eslint guard-for-in: off */ - for(const key in this.__modules) { - const module = this.__modules[key]; - if ( module instanceof Module && module.should_enable ) - promises.push(module.enable()); + + for(const [key, data] of Object.entries(this.__module_data)) { + if ( data.source?.should_enable ) + promises.push(this.resolve(key, true).then(module => { + return module.enable(); + })); } await Promise.all(promises); diff --git a/src/modules/chat/index.js b/src/modules/chat/index.js index 9e4efb10..072a23c7 100644 --- a/src/modules/chat/index.js +++ b/src/modules/chat/index.js @@ -28,11 +28,11 @@ const ERROR_IMAGE = 'https://static-cdn.jtvnw.net/emoticons/v1/58765/2.0'; const EMOTE_CHARS = /[ .,!]/; export default class Chat extends Module { + static should_enable = true; + constructor(...args) { super(...args); - this.should_enable = true; - this.inject('settings'); this.inject('i18n'); this.inject('tooltips'); diff --git a/src/modules/chat/overrides.js b/src/modules/chat/overrides.js index 6fe42973..9d1f1e7e 100644 --- a/src/modules/chat/overrides.js +++ b/src/modules/chat/overrides.js @@ -85,7 +85,7 @@ export default class Overrides extends Module { }, content: async (t, tip) => { - const vue = this.resolve('vue'), + const vue = await this.resolve('vue', true), _editor = import(/* webpackChunkName: "overrides" */ './override-editor.vue'); const [, editor] = await Promise.all([vue.enable(), _editor]); diff --git a/src/modules/main_menu/components/i18n-open.vue b/src/modules/main_menu/components/i18n-open.vue index 57b35d72..e6d85f3f 100644 --- a/src/modules/main_menu/components/i18n-open.vue +++ b/src/modules/main_menu/components/i18n-open.vue @@ -23,6 +23,8 @@ import I18N_MD from '../i18n.md'; export default { + props: ['item', 'context'], + data() { return { md: I18N_MD @@ -31,7 +33,7 @@ export default { methods: { open() { - window.FrankerFaceZ.get().resolve('i18n').openUI(); + this.context.getFFZ().resolve('i18n').openUI(); } } } diff --git a/src/modules/main_menu/index.js b/src/modules/main_menu/index.js index b20e574d..a3058f52 100644 --- a/src/modules/main_menu/index.js +++ b/src/modules/main_menu/index.js @@ -23,21 +23,17 @@ function format_term(term) { // separate the concept of navigation from visible pages. export default class MainMenu extends Module { + constructor(...args) { super(...args); - this.inject('settings'); - this.inject('i18n'); this.inject('site'); - this.inject('vue'); - this.load_requires = ['vue']; + this.inject('vue', true); this.Mixin = this.SettingMixin = SettingMixin; this.ProviderMixin = ProviderMixin; - //this.should_enable = true; - this.exclusive = false; this.new_seen = false; diff --git a/src/modules/metadata.jsx b/src/modules/metadata.jsx index 59e0de30..729cb25e 100644 --- a/src/modules/metadata.jsx +++ b/src/modules/metadata.jsx @@ -15,14 +15,14 @@ import Module from 'utilities/module'; const CLIP_URL = /^https:\/\/[^/]+\.(?:twitch\.tv|twitchcdn\.net)\/.+?\.mp4(?:\?.*)?$/; export default class Metadata extends Module { + + static should_enable = true; + constructor(...args) { super(...args); - this.inject('settings'); - this.inject('i18n'); this.inject('tooltips'); - this.should_enable = true; this.definitions = {}; this.settings.add('metadata.clip-download', { @@ -154,7 +154,7 @@ export default class Metadata extends Module { if ( !(created instanceof Date) ) created = new Date(created); - const now = Date.now() - socket._time_drift; + const now = Date.now() - (socket?._time_drift || 0); return { created, @@ -259,7 +259,7 @@ export default class Metadata extends Module { return; const Player = this.resolve('site.player'), - player = Player.current; + player = Player?.current; if ( ! player ) return; @@ -272,7 +272,7 @@ export default class Metadata extends Module { if ( this.settings.get('metadata.clip-download.force') ) return src; - const user = this.resolve('site').getUser?.(), + const user = this.resolve('site')?.getUser?.(), is_self = user?.id == data.channel.id; if ( is_self || data.getUserSelfImmediate(data.refresh)?.isEditor ) @@ -305,7 +305,7 @@ export default class Metadata extends Module { setup() { const Player = this.resolve('site.player'), socket = this.resolve('socket'), - player = Player.current; + player = Player?.current; let stats; @@ -408,7 +408,7 @@ export default class Metadata extends Module { click() { const Player = this.resolve('site.player'), - ui = Player.playerUI; + ui = Player?.playerUI; if ( ! ui ) return; @@ -553,13 +553,15 @@ export default class Metadata extends Module { } updateMetadata(keys) { + // TODO: Convert to an event. This is exactly + // what events were made for. const channel = this.resolve('site.channel'); - if ( channel ) + if ( channel?.InfoBar ) for(const el of channel.InfoBar.instances) channel.updateMetadata(el, keys); const player = this.resolve('site.player'); - if ( player ) + if ( player?.Player ) for(const inst of player.Player.instances) player.updateMetadata(inst, keys); } diff --git a/src/modules/tooltips.js b/src/modules/tooltips.js index aa9d9cef..b97860d6 100644 --- a/src/modules/tooltips.js +++ b/src/modules/tooltips.js @@ -15,10 +15,6 @@ export default class TooltipProvider extends Module { super(...args); this.types = {}; - this.inject('i18n'); - - this.should_enable = true; - this.types.json = target => { const title = target.dataset.title; return [ diff --git a/src/modules/translation_ui/components/translation-ui.vue b/src/modules/translation_ui/components/translation-ui.vue index f8d6ece5..0598fd20 100644 --- a/src/modules/translation_ui/components/translation-ui.vue +++ b/src/modules/translation_ui/components/translation-ui.vue @@ -230,7 +230,7 @@ import displace from 'displacejs'; import Parser from '@ffz/icu-msgparser'; import { saveAs } from 'file-saver'; -import { deep_equals, deep_copy, sleep } from 'utilities/object'; +import { deep_equals, deep_copy } from 'utilities/object'; const parser = new Parser(); const PER_PAGE = 20; diff --git a/src/modules/translation_ui/index.js b/src/modules/translation_ui/index.js index 7a326836..efcffb4f 100644 --- a/src/modules/translation_ui/index.js +++ b/src/modules/translation_ui/index.js @@ -13,12 +13,9 @@ export default class TranslationUI extends Module { constructor(...args) { super(...args); - this.inject('settings'); - this.inject('i18n'); this.inject('site'); - this.inject('vue'); - this.load_requires = ['vue']; + this.inject('vue', true); this.dialog = new Dialog(() => this.buildDialog()); } diff --git a/src/player.js b/src/player.js index f3ecab74..ee4aa979 100644 --- a/src/player.js +++ b/src/player.js @@ -25,8 +25,7 @@ class FFZPlayer extends Module { FFZPlayer.instance = this; this.name = 'ffz_player'; - this.__state = 0; - this.__modules.core = this; + this.__data.state = 0; // ======================================================================== // Error Reporting and Logging diff --git a/src/sites/player/css_tweaks/index.js b/src/sites/player/css_tweaks/index.js index 15644542..86d1ebdb 100644 --- a/src/sites/player/css_tweaks/index.js +++ b/src/sites/player/css_tweaks/index.js @@ -16,13 +16,10 @@ const CLASSES = { export default class CSSTweaks extends Module { + constructor(...args) { super(...args); - this.should_enable = true; - - this.inject('settings'); - this.style = new ManagedStyle; this.chunks = {}; this.chunks_loaded = false; diff --git a/src/sites/player/metadata.jsx b/src/sites/player/metadata.jsx index e3b08e47..75a2044e 100644 --- a/src/sites/player/metadata.jsx +++ b/src/sites/player/metadata.jsx @@ -143,7 +143,7 @@ export default class Metadata extends Module { setup() { const Player = this.resolve('site.player'), - player = Player.current; + player = Player?.current; let stats; @@ -241,7 +241,7 @@ export default class Metadata extends Module { click() { const Player = this.resolve('site.player'), - ui = Player.playerUI; + ui = Player?.playerUI; if ( ! ui ) return; @@ -400,7 +400,7 @@ export default class Metadata extends Module { updateMetadata(keys) { const player = this.resolve('site.player'); - if ( player ) + if ( player?.Player ) for(const inst of player.Player.instances) player.updateMetadata(inst, keys); } diff --git a/src/sites/player/player.jsx b/src/sites/player/player.jsx index 4f107fec..b101ec7f 100644 --- a/src/sites/player/player.jsx +++ b/src/sites/player/player.jsx @@ -23,6 +23,7 @@ export default class Player extends PlayerBase { 'player-source', n => n.setSrc && n.setInitialPlaybackSettings ); + } wantsMetadata() { return this.settings.get('player.embed-metadata'); diff --git a/src/sites/twitch-twilight/index.js b/src/sites/twitch-twilight/index.js index a69717c1..a599f0fe 100644 --- a/src/sites/twitch-twilight/index.js +++ b/src/sites/twitch-twilight/index.js @@ -30,10 +30,10 @@ export default class Twilight extends BaseSite { constructor(...args) { super(...args); - this.inject(WebMunch); + this.inject(WebMunch, true); this.inject(Fine); this.inject(Elemental); - this.inject('router', FineRouter); + this.inject('router', FineRouter, true); this.inject(Apollo, false); this.inject(TwitchData); this.inject(Switchboard); @@ -43,7 +43,7 @@ export default class Twilight extends BaseSite { } async populateModules() { - const ctx = await require.context('site/modules', true, /(?:^(?:\.\/)?[^/]+|index)\.jsx?$/); + 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.`); } @@ -64,8 +64,6 @@ export default class Twilight extends BaseSite { } onEnable() { - this.settings = this.resolve('settings'); - const thing = this.fine.searchNode(null, n => n?.pendingProps?.store?.getState), store = this.store = thing?.pendingProps?.store; @@ -134,12 +132,13 @@ export default class Twilight extends BaseSite { const params = new URL(window.location).searchParams; if ( params ) { if ( params.has('ffz-settings') ) - this.resolve('main_menu').openExclusive(); + this.resolve('main_menu', true).then(mod => mod.openExclusive()); if ( params.has('ffz-translate') ) { - const translation = this.resolve('translation_ui'); - translation.dialog.exclusive = true; - translation.enable(); + this.resolve('translation_ui', true).then(mod => { + mod.dialog.exclusive = true; + mod.enable(); + }); } } } diff --git a/src/sites/twitch-twilight/modules/bits_button.js b/src/sites/twitch-twilight/modules/bits_button.js index 58c6e315..2d6d995b 100644 --- a/src/sites/twitch-twilight/modules/bits_button.js +++ b/src/sites/twitch-twilight/modules/bits_button.js @@ -8,12 +8,11 @@ import Module from 'utilities/module'; export default class BitsButton extends Module { + static should_enable = true; + constructor(...args) { super(...args); - this.should_enable = true; - - this.inject('settings'); this.inject('site.fine'); this.settings.add('layout.display-bits-button', { @@ -35,14 +34,14 @@ export default class BitsButton extends Module { changed: () => this.BitsButton.forceUpdate() }); + } + onEnable() { this.BitsButton = this.fine.define( 'bits-button', n => n.toggleBalloon && n.toggleShowTutorial ); - } - onEnable() { this.BitsButton.ready(cls => { const t = this, old_render = cls.prototype.render; diff --git a/src/sites/twitch-twilight/modules/bttv_compat.js b/src/sites/twitch-twilight/modules/bttv_compat.js index 6dc3599d..a0135d7f 100644 --- a/src/sites/twitch-twilight/modules/bttv_compat.js +++ b/src/sites/twitch-twilight/modules/bttv_compat.js @@ -11,11 +11,7 @@ const CHAT_EVENTS = [ ]; export default class BTTVCompat extends Module { - constructor(...args) { - super(...args); - - this.should_enable = true; - } + static should_enable = true; onEnable() { this.on('core:dom-update', this.handleDomUpdate, this); diff --git a/src/sites/twitch-twilight/modules/channel.jsx b/src/sites/twitch-twilight/modules/channel.jsx index a2e03b90..5794eb0d 100644 --- a/src/sites/twitch-twilight/modules/channel.jsx +++ b/src/sites/twitch-twilight/modules/channel.jsx @@ -14,13 +14,11 @@ const USER_PAGES = ['user', 'user-home', 'user-about', 'video', 'user-video', 'u export default class Channel extends Module { + static should_enable = true; + constructor(...args) { super(...args); - this.should_enable = true; - - this.inject('i18n'); - this.inject('settings'); this.inject('site.apollo'); this.inject('site.css_tweaks'); this.inject('site.elemental'); @@ -31,7 +29,6 @@ export default class Channel extends Module { this.inject('metadata'); this.inject('socket'); - this.settings.add('channel.panel-tips', { default: false, ui: { @@ -88,15 +85,15 @@ export default class Channel extends Module { }, changed: val => ! val && this.InfoBar.each(el => this.updateBar(el)) }); + } - + onEnable() { this.ChannelPanels = this.fine.define( 'channel-panels', n => n.layoutMasonry && n.updatePanelOrder && n.onExtensionPoppedOut, USER_PAGES ); - this.ChannelTrailer = this.elemental.define( 'channel-trailer', '.channel-trailer-player__wrapper', USER_PAGES, @@ -127,9 +124,7 @@ export default class Channel extends Module { this.apollo.registerModifier('UseHosting', strip_host, false); this.apollo.registerModifier('PlayerTrackingContextQuery', strip_host, false); - } - onEnable() { this.updateChannelColor(); this.css_tweaks.toggle('panel-links', this.settings.get('channel.panel-tips')); diff --git a/src/sites/twitch-twilight/modules/chat/emote_menu.jsx b/src/sites/twitch-twilight/modules/chat/emote_menu.jsx index be933170..b4eb953c 100644 --- a/src/sites/twitch-twilight/modules/chat/emote_menu.jsx +++ b/src/sites/twitch-twilight/modules/chat/emote_menu.jsx @@ -117,8 +117,6 @@ export default class EmoteMenu extends Module { constructor(...args) { super(...args); - this.inject('settings'); - this.inject('i18n'); this.inject('chat'); this.inject('chat.badges'); this.inject('chat.emotes'); @@ -249,8 +247,9 @@ export default class EmoteMenu extends Module { component: 'setting-check-box' } }); + } - + async onEnable() { this.EmoteMenu = this.fine.define( 'chat-emote-menu', n => n.subscriptionProductHasEmotes, @@ -261,9 +260,8 @@ export default class EmoteMenu extends Module { this.MenuWrapper = this.fine.wrap('ffz-emote-menu'); //this.MenuSection = this.fine.wrap('ffz-menu-section'); //this.MenuEmote = this.fine.wrap('ffz-menu-emote'); - } - async onEnable() { + this.on('i18n:update', () => this.EmoteMenu.forceUpdate()); this.on('chat.emotes:update-default-sets', this.maybeUpdate, this); this.on('chat.emotes:update-user-sets', this.maybeUpdate, this); @@ -1147,15 +1145,16 @@ export default class EmoteMenu extends Module { win.focus(); } else { - const menu = t.resolve('main_menu'); + t.resolve('main_menu', true).then(menu => { + if ( ! menu ) + return; - if ( menu ) { menu.requestPage('chat.emote_menu'); if ( menu.showing ) return; - } - t.emit('site.menu_button:clicked'); + t.emit('site.menu_button:clicked'); + }); } } diff --git a/src/sites/twitch-twilight/modules/chat/index.js b/src/sites/twitch-twilight/modules/chat/index.js index 4942033b..8d2c35bf 100644 --- a/src/sites/twitch-twilight/modules/chat/index.js +++ b/src/sites/twitch-twilight/modules/chat/index.js @@ -155,18 +155,14 @@ const MISBEHAVING_EVENTS = [ export default class ChatHook extends Module { + static should_enable = true; + 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('experiments'); - this.inject('site'); this.inject('site.router'); this.inject('site.fine'); @@ -183,91 +179,9 @@ export default class ChatHook extends Module { this.inject(Input); this.inject(ViewerCards); - this.ChatService = this.fine.define( - 'chat-service', - n => n.join && n.connectHandlers, - Twilight.CHAT_ROUTES - ); - - this.ChatBuffer = this.fine.define( - 'chat-buffer', - n => n.updateHandlers && n.delayedMessageBuffer && n.handleMessage, - Twilight.CHAT_ROUTES - ); - - this.ChatController = this.fine.define( - 'chat-controller', - n => n.hostingHandler && n.onRoomStateUpdated, - Twilight.CHAT_ROUTES - ); - - this.ChatContainer = this.fine.define( - 'chat-container', - n => n.closeViewersList && n.onChatInputFocus, - Twilight.CHAT_ROUTES - ); - - this.ChatBufferConnector = this.fine.define( - 'chat-buffer-connector', - n => n.clearBufferHandle && n.syncBufferedMessages, - Twilight.CHAT_ROUTES - ); this.joined_raids = new Set; - this.RaidController = this.fine.define( - 'raid-controller', - n => n.handleLeaveRaid && n.handleJoinRaid, - Twilight.CHAT_ROUTES - ); - - this.InlineCallout = this.fine.define( - 'inline-callout', - n => n.showCTA && n.toggleContextMenu && n.actionClick, - Twilight.CHAT_ROUTES - ); - - this.PinnedCallout = this.fine.define( - 'pinned-callout', - n => n.getCalloutTitle && n.buildCalloutProps && n.pin, - Twilight.CHAT_ROUTES - ); - - this.CalloutSelector = this.fine.define( - 'callout-selector', - n => n.selectCalloutComponent && n.props && n.props.callouts, - Twilight.CHAT_ROUTES - ); - - this.PointsButton = this.fine.define( - 'points-button', - n => n.renderIcon && n.renderFlame && n.handleIconAnimationComplete, - Twilight.CHAT_ROUTES - ); - - this.PointsClaimButton = this.fine.define( - 'points-claim-button', - n => n.getClaim && n.onClick && n.props && n.props.claimCommunityPoints, - Twilight.CHAT_ROUTES - ); - - this.CommunityChestBanner = this.fine.define( - 'community-chest-banner', - n => n.getLastGifterText && n.getBannerText && has(n, 'finalCount'), - Twilight.CHAT_ROUTES - ); - - this.PointsInfo = this.fine.define( - 'points-info', - n => n.pointIcon !== undefined && n.pointName !== undefined, - Twilight.CHAT_ROUTES - ); - - this.GiftBanner = this.fine.define( - 'gift-banner', - n => n.getBannerText && n.onGiftMoreClick, - Twilight.CHAT_ROUTES - ); // Settings @@ -769,6 +683,90 @@ export default class ChatHook extends Module { onEnable() { + this.ChatService = this.fine.define( + 'chat-service', + n => n.join && n.connectHandlers, + Twilight.CHAT_ROUTES + ); + + this.ChatBuffer = this.fine.define( + 'chat-buffer', + n => n.updateHandlers && n.delayedMessageBuffer && n.handleMessage, + Twilight.CHAT_ROUTES + ); + + this.ChatController = this.fine.define( + 'chat-controller', + n => n.hostingHandler && n.onRoomStateUpdated, + Twilight.CHAT_ROUTES + ); + + this.ChatContainer = this.fine.define( + 'chat-container', + n => n.closeViewersList && n.onChatInputFocus, + Twilight.CHAT_ROUTES + ); + + this.ChatBufferConnector = this.fine.define( + 'chat-buffer-connector', + n => n.clearBufferHandle && n.syncBufferedMessages, + Twilight.CHAT_ROUTES + ); + + this.RaidController = this.fine.define( + 'raid-controller', + n => n.handleLeaveRaid && n.handleJoinRaid, + Twilight.CHAT_ROUTES + ); + + this.InlineCallout = this.fine.define( + 'inline-callout', + n => n.showCTA && n.toggleContextMenu && n.actionClick, + Twilight.CHAT_ROUTES + ); + + this.PinnedCallout = this.fine.define( + 'pinned-callout', + n => n.getCalloutTitle && n.buildCalloutProps && n.pin, + Twilight.CHAT_ROUTES + ); + + this.CalloutSelector = this.fine.define( + 'callout-selector', + n => n.selectCalloutComponent && n.props && n.props.callouts, + Twilight.CHAT_ROUTES + ); + + this.PointsButton = this.fine.define( + 'points-button', + n => n.renderIcon && n.renderFlame && n.handleIconAnimationComplete, + Twilight.CHAT_ROUTES + ); + + this.PointsClaimButton = this.fine.define( + 'points-claim-button', + n => n.getClaim && n.onClick && n.props && n.props.claimCommunityPoints, + Twilight.CHAT_ROUTES + ); + + this.CommunityChestBanner = this.fine.define( + 'community-chest-banner', + n => n.getLastGifterText && n.getBannerText && has(n, 'finalCount'), + Twilight.CHAT_ROUTES + ); + + this.PointsInfo = this.fine.define( + 'points-info', + n => n.pointIcon !== undefined && n.pointName !== undefined, + Twilight.CHAT_ROUTES + ); + + this.GiftBanner = this.fine.define( + 'gift-banner', + n => n.getBannerText && n.onGiftMoreClick, + Twilight.CHAT_ROUTES + ); + this.on('site.web_munch:loaded', this.grabTypes); this.on('site.web_munch:loaded', this.defineClasses); this.grabTypes(); diff --git a/src/sites/twitch-twilight/modules/chat/input.jsx b/src/sites/twitch-twilight/modules/chat/input.jsx index ce164913..17c590c5 100644 --- a/src/sites/twitch-twilight/modules/chat/input.jsx +++ b/src/sites/twitch-twilight/modules/chat/input.jsx @@ -19,8 +19,6 @@ export default class Input extends Module { this.inject('chat.actions'); this.inject('chat.emotes'); this.inject('chat.emoji'); - this.inject('i18n'); - this.inject('settings'); this.inject('site.fine'); this.inject('site.web_munch'); @@ -83,34 +81,6 @@ export default class Input extends Module { } }); - - // Components - - this.ChatInput = this.fine.define( - 'chat-input', - n => n && n.setLocalChatInputRef && n.setLocalAutocompleteInputRef, - Twilight.CHAT_ROUTES - ); - - this.EmoteSuggestions = this.fine.define( - 'tab-emote-suggestions', - n => n && n.getMatchedEmotes, - Twilight.CHAT_ROUTES - ); - - - this.MentionSuggestions = this.fine.define( - 'tab-mention-suggestions', - n => n && n.getMentions && n.renderMention, - Twilight.CHAT_ROUTES - ); - - this.CommandSuggestions = this.fine.define( - 'tab-cmd-suggestions', - n => n && n.getMatches && n.doesCommandMatchTerm, - Twilight.CHAT_ROUTES - ); - // Implement Twitch's unfinished emote usage object for prioritizing sorting this.EmoteUsageCount = { TriHard: 196568036, @@ -140,6 +110,34 @@ export default class Input extends Module { } async onEnable() { + // Components + + this.ChatInput = this.fine.define( + 'chat-input', + n => n && n.setLocalChatInputRef && n.setLocalAutocompleteInputRef, + Twilight.CHAT_ROUTES + ); + + this.EmoteSuggestions = this.fine.define( + 'tab-emote-suggestions', + n => n && n.getMatchedEmotes, + Twilight.CHAT_ROUTES + ); + + + this.MentionSuggestions = this.fine.define( + 'tab-mention-suggestions', + n => n && n.getMentions && n.renderMention, + Twilight.CHAT_ROUTES + ); + + this.CommandSuggestions = this.fine.define( + 'tab-cmd-suggestions', + n => n && n.getMatches && n.doesCommandMatchTerm, + Twilight.CHAT_ROUTES + ); + + this.chat.context.on('changed:chat.actions.room', () => this.ChatInput.forceUpdate()); this.chat.context.on('changed:chat.actions.room-above', () => this.ChatInput.forceUpdate()); this.chat.context.on('changed:chat.tab-complete.emotes-without-colon', enabled => { diff --git a/src/sites/twitch-twilight/modules/chat/line.js b/src/sites/twitch-twilight/modules/chat/line.js index a50e8ddf..5a11bf47 100644 --- a/src/sites/twitch-twilight/modules/chat/line.js +++ b/src/sites/twitch-twilight/modules/chat/line.js @@ -24,18 +24,17 @@ export default class ChatLine extends Module { constructor(...args) { super(...args); - this.inject('settings'); - this.inject('i18n'); this.inject('chat'); this.inject('site'); this.inject('site.fine'); this.inject('site.web_munch'); this.inject(RichContent); - this.inject('experiments'); this.inject('chat.actions'); this.inject('chat.overrides'); + } + async onEnable() { this.ChatLine = this.fine.define( 'chat-line', n => n.renderMessageBody && n.props && ! n.onExtensionNameClick && !has(n.props, 'hasModPermissions'), @@ -52,9 +51,7 @@ export default class ChatLine extends Module { 'whisper-line', n => n.props && n.props.message && has(n.props, 'reportOutgoingWhisperRendered') ) - } - async onEnable() { this.on('chat.overrides:changed', id => this.updateLinesByUser(id), this); this.on('chat:update-lines', this.updateLines, this); this.on('i18n:update', this.updateLines, this); diff --git a/src/sites/twitch-twilight/modules/chat/rich_content.jsx b/src/sites/twitch-twilight/modules/chat/rich_content.jsx index b48e630f..60f69a73 100644 --- a/src/sites/twitch-twilight/modules/chat/rich_content.jsx +++ b/src/sites/twitch-twilight/modules/chat/rich_content.jsx @@ -14,7 +14,6 @@ export default class RichContent extends Module { super(...args); this.inject('chat'); - this.inject('i18n'); this.inject('site.web_munch'); this.RichContent = null; diff --git a/src/sites/twitch-twilight/modules/chat/scroller.js b/src/sites/twitch-twilight/modules/chat/scroller.js index 6fe6fa0f..5ae5464f 100644 --- a/src/sites/twitch-twilight/modules/chat/scroller.js +++ b/src/sites/twitch-twilight/modules/chat/scroller.js @@ -21,18 +21,10 @@ export default class Scroller extends Module { constructor(...args) { super(...args); - this.inject('settings'); - this.inject('i18n'); this.inject('chat'); this.inject('site.fine'); this.inject('site.web_munch'); - this.ChatScroller = this.fine.define( - 'chat-scroller', - n => n.saveScrollRef && n.handleScrollEvent && ! n.renderLines && n.resume, - Twilight.CHAT_ROUTES - ); - this.settings.add('chat.scroller.freeze', { default: 0, ui: { @@ -116,6 +108,12 @@ export default class Scroller extends Module { } async onEnable() { + this.ChatScroller = this.fine.define( + 'chat-scroller', + n => n.saveScrollRef && n.handleScrollEvent && ! n.renderLines && n.resume, + Twilight.CHAT_ROUTES + ); + this.on('i18n:update', () => this.ChatScroller.forceUpdate()); this.chat.context.on('changed:chat.actions.inline', this.updateUseKeys, this); diff --git a/src/sites/twitch-twilight/modules/chat/settings_menu.jsx b/src/sites/twitch-twilight/modules/chat/settings_menu.jsx index d2581d83..3bac1696 100644 --- a/src/sites/twitch-twilight/modules/chat/settings_menu.jsx +++ b/src/sites/twitch-twilight/modules/chat/settings_menu.jsx @@ -12,13 +12,13 @@ export default class SettingsMenu extends Module { constructor(...args) { super(...args); - this.inject('settings'); - this.inject('i18n'); this.inject('chat'); this.inject('chat.badges'); this.inject('site.fine'); this.inject('site.web_munch'); + } + async onEnable() { this.SettingsMenu = this.fine.define( 'chat-settings', n => n.renderUniversalOptions && n.onBadgesChanged, @@ -30,9 +30,7 @@ export default class SettingsMenu extends Module { n => n.hideChatIdentityMenu && n.toggleBalloonRef, Twilight.CHAT_ROUTES );*/ - } - async onEnable() { this.on('i18n:update', () => this.SettingsMenu.forceUpdate()); this.chat.context.on('changed:chat.scroller.freeze', () => this.SettingsMenu.forceUpdate()); @@ -353,17 +351,19 @@ export default class SettingsMenu extends Module { } else { const target = event.currentTarget, - page = target && target.dataset && target.dataset.page, - menu = this.resolve('main_menu'); + page = target && target.dataset && target.dataset.page; + + this.resolve('main_menu', true).then(menu => { + if ( ! menu ) + return; - if ( menu ) { if ( page ) menu.requestPage(page); if ( menu.showing ) return; - } - this.emit('site.menu_button:clicked'); + this.emit('site.menu_button:clicked'); + }); } this.closeMenu(inst); diff --git a/src/sites/twitch-twilight/modules/chat/viewer_card.jsx b/src/sites/twitch-twilight/modules/chat/viewer_card.jsx index 840fe6e7..837719ba 100644 --- a/src/sites/twitch-twilight/modules/chat/viewer_card.jsx +++ b/src/sites/twitch-twilight/modules/chat/viewer_card.jsx @@ -11,7 +11,6 @@ export default class ViewerCards extends Module { super(...args); this.inject('chat'); - this.inject('settings'); this.inject('site.css_tweaks'); this.inject('site.fine'); @@ -42,14 +41,15 @@ export default class ViewerCards extends Module { return ctx.get('chat.viewer-cards.color'); } }) + } + onEnable() { this.ViewerCard = this.fine.define( 'chat-viewer-card', n => n.trackViewerCardOpen && n.onWhisperButtonClick ); - } - onEnable() { + this.chat.context.on('changed:chat.viewer-cards.highlight-chat', this.refreshStyle, this); this.chat.context.on('changed:chat.viewer-cards.color', this.refreshStyle, this); this.on('..:update-colors', this.refreshStyle, this); diff --git a/src/sites/twitch-twilight/modules/compat_emote_menu.js b/src/sites/twitch-twilight/modules/compat_emote_menu.js index 082cdbaf..148830e7 100644 --- a/src/sites/twitch-twilight/modules/compat_emote_menu.js +++ b/src/sites/twitch-twilight/modules/compat_emote_menu.js @@ -11,11 +11,12 @@ import Module from 'utilities/module'; import {has, sleep} from 'utilities/object'; export default class CompatEmoteMenu extends Module { + + static should_enable = true; + constructor(...args) { super(...args); - this.should_enable = true; - this.inject('site.chat'); this.inject('chat.emotes'); } diff --git a/src/sites/twitch-twilight/modules/css_tweaks/index.js b/src/sites/twitch-twilight/modules/css_tweaks/index.js index 0d29144c..82443a8a 100644 --- a/src/sites/twitch-twilight/modules/css_tweaks/index.js +++ b/src/sites/twitch-twilight/modules/css_tweaks/index.js @@ -47,13 +47,12 @@ const CLASSES = { export default class CSSTweaks extends Module { + + static should_enable = true; + constructor(...args) { super(...args); - this.should_enable = true; - - this.inject('settings'); - this.style = new ManagedStyle; this.chunks = {}; this.chunks_loaded = false; diff --git a/src/sites/twitch-twilight/modules/dashboard.js b/src/sites/twitch-twilight/modules/dashboard.js index 52231041..760bc2c1 100644 --- a/src/sites/twitch-twilight/modules/dashboard.js +++ b/src/sites/twitch-twilight/modules/dashboard.js @@ -10,15 +10,17 @@ import { get, has } from 'utilities/object'; import Twilight from 'site'; export default class Dashboard extends Module { + + static should_enable = true; + constructor(...args) { super(...args); - this.should_enable = true; - - this.inject('settings'); this.inject('site.fine'); this.inject('site.channel'); + } + onEnable() { this.SunlightBroadcast = this.fine.define( 'sunlight-bcast', n => n.getGame && n.getTitle && n.props?.data, @@ -30,9 +32,7 @@ export default class Dashboard extends Module { n => n.props?.channelID && n.handleChange && has(n, 'hasVisitedStreamManager'), Twilight.SUNLIGHT_ROUTES ); - } - onEnable() { this.SunlightManager.on('mount', this.updateSunlight, this); this.SunlightManager.on('update', this.updateSunlight, this); this.SunlightManager.on('unmount', this.removeSunlight, this); diff --git a/src/sites/twitch-twilight/modules/directory/following.jsx b/src/sites/twitch-twilight/modules/directory/following.jsx index 44eda4de..d11126a2 100644 --- a/src/sites/twitch-twilight/modules/directory/following.jsx +++ b/src/sites/twitch-twilight/modules/directory/following.jsx @@ -20,9 +20,6 @@ export default class Following extends SiteModule { this.inject('site.apollo'); this.inject('site.css_tweaks'); - this.inject('i18n'); - this.inject('settings'); - this.settings.add('directory.following.group-hosts', { default: true, diff --git a/src/sites/twitch-twilight/modules/directory/game.jsx b/src/sites/twitch-twilight/modules/directory/game.jsx index c93ed242..74e7fbf3 100644 --- a/src/sites/twitch-twilight/modules/directory/game.jsx +++ b/src/sites/twitch-twilight/modules/directory/game.jsx @@ -16,15 +16,6 @@ export default class Game extends SiteModule { this.inject('site.fine'); this.inject('site.apollo'); - this.inject('i18n'); - this.inject('settings'); - - this.GameHeader = this.fine.define( - 'game-header', - n => n.props && n.props.data && n.getBannerImage && n.getDirectoryCountAndTags, - ['dir-game-index', 'dir-community', 'dir-game-videos', 'dir-game-clips', 'dir-game-details'] - ); - this.settings.addUI('directory.game.blocked-games', { path: 'Directory > Categories @{"description": "Please note that due to limitations in Twitch\'s website, names here must be formatted exactly as displayed in your client. For best results, you can block or unblock categories directly from directory pages."} >> Blocked', component: 'game-list-editor', @@ -41,6 +32,12 @@ export default class Game extends SiteModule { } onEnable() { + this.GameHeader = this.fine.define( + 'game-header', + n => n.props && n.props.data && n.getBannerImage && n.getDirectoryCountAndTags, + ['dir-game-index', 'dir-community', 'dir-game-videos', 'dir-game-clips', 'dir-game-details'] + ); + this.GameHeader.on('mount', this.updateGameHeader, this); this.GameHeader.on('update', this.updateGameHeader, this); this.GameHeader.on('unmount', () => { diff --git a/src/sites/twitch-twilight/modules/directory/index.jsx b/src/sites/twitch-twilight/modules/directory/index.jsx index 1c078383..c34cc812 100644 --- a/src/sites/twitch-twilight/modules/directory/index.jsx +++ b/src/sites/twitch-twilight/modules/directory/index.jsx @@ -25,34 +25,21 @@ const DIR_ROUTES = ['front-page', 'dir', 'dir-community', 'dir-community-index', export default class Directory extends SiteModule { + + static should_enable = true; + constructor(...args) { super(...args); - this.should_enable = true; - this.inject('site.elemental'); this.inject('site.fine'); this.inject('site.router'); this.inject('site.css_tweaks'); this.inject('site.twitch_data'); - this.inject('i18n'); - this.inject('settings'); - //this.inject(Following); this.inject(Game); - this.DirectoryCard = this.elemental.define( - 'directory-card', 'article[data-a-target^="followed-vod-"],article[data-a-target^="card-"],div[data-a-target^="video-tower-card-"] article,div[data-a-target^="clips-card-"] article,.shelf-card__impression-wrapper article,.tw-tower div article', - DIR_ROUTES, null, 0, 0 - ); - - this.DirectoryShelf = this.fine.define( - 'directory-shelf', - n => n.shouldRenderNode && n.props && n.props.shelf, - DIR_ROUTES - ); - this.settings.add('directory.hidden.style', { default: 2, @@ -197,6 +184,17 @@ export default class Directory extends SiteModule { onEnable() { + this.DirectoryCard = this.elemental.define( + 'directory-card', 'article[data-a-target^="followed-vod-"],article[data-a-target^="card-"],div[data-a-target^="video-tower-card-"] article,div[data-a-target^="clips-card-"] article,.shelf-card__impression-wrapper article,.tw-tower div article', + DIR_ROUTES, null, 0, 0 + ); + + this.DirectoryShelf = this.fine.define( + 'directory-shelf', + n => n.shouldRenderNode && n.props && n.props.shelf, + DIR_ROUTES + ); + this.css_tweaks.toggleHide('profile-hover', this.settings.get('directory.show-channel-avatars') === 2); this.css_tweaks.toggleHide('dir-live-ind', this.settings.get('directory.hide-live')); this.css_tweaks.toggle('dir-reveal', this.settings.get('directory.hidden.reveal')); diff --git a/src/sites/twitch-twilight/modules/featured_follow.js b/src/sites/twitch-twilight/modules/featured_follow.js index da45abac..bea349b2 100644 --- a/src/sites/twitch-twilight/modules/featured_follow.js +++ b/src/sites/twitch-twilight/modules/featured_follow.js @@ -15,22 +15,18 @@ import FEATURED_UNFOLLOW from './featured_follow_unfollow.gql'; export default class FeaturedFollow extends Module { + + static should_enable = true; + constructor(...args) { super(...args); - this.should_enable = true; - this.inject('site'); this.inject('site.fine'); this.inject('site.apollo'); - this.inject('i18n'); this.inject('metadata'); - this.inject('settings'); - this.inject('socket'); this.inject('site.router'); - this.inject('chat'); - this.settings.add('metadata.featured-follow', { default: true, @@ -47,8 +43,10 @@ export default class FeaturedFollow extends Module { }); this.follow_data = {}; + } - this.socket.on(':command:follow_buttons', data => { + onEnable() { + this.on('socket:command:follow_buttons', data => { for(const channel_login in data) if ( has(data, channel_login) ) this.follow_data[channel_login] = data[channel_login]; @@ -58,16 +56,14 @@ export default class FeaturedFollow extends Module { this.metadata.updateMetadata('following'); }); - } - onEnable() { this.metadata.definitions.following = { order: 150, button: true, modview: true, popup: async (data, tip, refresh_fn, add_callback) => { - const vue = this.resolve('vue'), + const vue = await this.resolve('vue', true), _featured_follow_vue = import(/* webpackChunkName: "featured-follow" */ './featured-follow.vue'), _follows = this.getFollowsForLogin(data.channel.login); diff --git a/src/sites/twitch-twilight/modules/host_button/index.js b/src/sites/twitch-twilight/modules/host_button/index.js index f854509c..1d9b0250 100644 --- a/src/sites/twitch-twilight/modules/host_button/index.js +++ b/src/sites/twitch-twilight/modules/host_button/index.js @@ -20,11 +20,12 @@ const HOST_ERRORS = { }; export default class HostButton extends Module { + + static should_enable = true; + constructor(...args) { super(...args); - this.should_enable = true; - this.inject('site'); this.inject('site.fine'); this.inject('site.chat'); @@ -152,7 +153,7 @@ export default class HostButton extends Module { }, popup: async (data, tip) => { - const vue = this.resolve('vue'), + const vue = await this.resolve('vue'), _host_options_vue = import(/* webpackChunkName: "host-options" */ './host-options.vue'), _autoHosts = this.fetchAutoHosts(), _autoHostSettings = this.fetchAutoHostSettings(); diff --git a/src/sites/twitch-twilight/modules/layout.js b/src/sites/twitch-twilight/modules/layout.js index 2486ac6c..5b8829ea 100644 --- a/src/sites/twitch-twilight/modules/layout.js +++ b/src/sites/twitch-twilight/modules/layout.js @@ -12,43 +12,16 @@ const PORTRAIT_ROUTES = ['user', 'video', 'user-video', 'user-clip', 'user-video const MINIMAL_ROUTES = ['popout', 'embed-chat', 'dash-chat']; export default class Layout extends Module { + + static should_enable = true; + constructor(...args) { super(...args); - this.should_enable = true; - - this.inject('settings'); this.inject('site.fine'); this.inject('site.css_tweaks'); this.inject('site.elemental'); - /*this.TopNav = this.fine.define( - 'top-nav', - n => n.computeStyles && n.navigationLinkSize - );*/ - - /*this.RightColumn = this.fine.define( - 'tw-rightcolumn', - n => n.hideOnBreakpoint && n.handleToggleVisibility - );*/ - - this.ResizeDetector = this.fine.define( - 'resize-detector', - n => n.maybeDebounceOnScroll && n.setGrowDivRef && n.props.onResize - ); - - this.SideBar = this.elemental.define( - 'sidebar', - '.side-bar-contents', - null, - {childNodes: true, subtree: true}, 1 - ); - - /*this.SideBarChannels = this.fine.define( - 'nav-cards', - t => t.getCardSlideInContent && t.props && has(t.props, 'tooltipContent') - );*/ - this.settings.add('layout.portrait', { default: false, ui: { @@ -213,6 +186,33 @@ export default class Layout extends Module { } onEnable() { + /*this.TopNav = this.fine.define( + 'top-nav', + n => n.computeStyles && n.navigationLinkSize + );*/ + + /*this.RightColumn = this.fine.define( + 'tw-rightcolumn', + n => n.hideOnBreakpoint && n.handleToggleVisibility + );*/ + + this.ResizeDetector = this.fine.define( + 'resize-detector', + n => n.maybeDebounceOnScroll && n.setGrowDivRef && n.props.onResize + ); + + this.SideBar = this.elemental.define( + 'sidebar', + '.side-bar-contents', + null, + {childNodes: true, subtree: true}, 1 + ); + + /*this.SideBarChannels = this.fine.define( + 'nav-cards', + t => t.getCardSlideInContent && t.props && has(t.props, 'tooltipContent') + );*/ + document.body.classList.toggle('ffz--portrait-invert', this.settings.get('layout.portrait-invert')); this.on(':update-nav', this.updateNavLinks, this); diff --git a/src/sites/twitch-twilight/modules/menu_button.jsx b/src/sites/twitch-twilight/modules/menu_button.jsx index 20b8e25d..9c4fe770 100644 --- a/src/sites/twitch-twilight/modules/menu_button.jsx +++ b/src/sites/twitch-twilight/modules/menu_button.jsx @@ -12,16 +12,16 @@ import Twilight from 'site'; export default class MenuButton extends SiteModule { + + static should_enable = true; + constructor(...args) { super(...args); - this.inject('i18n'); - this.inject('settings'); this.inject('site.fine'); this.inject('site.elemental'); //this.inject('addons'); - this.should_enable = true; this._pill_content = null; this._has_update = false; this._important_update = false; @@ -38,41 +38,6 @@ export default class MenuButton extends SiteModule { }, changed: () => this.update() }); - - this.ModBar = this.fine.define( - 'mod-view-bar', - n => n.actions && n.updateRoot && n.childContext, - ['mod-view'] - ); - - /*this.SunlightDash = this.fine.define( - 'sunlight-dash', - n => n.getIsChannelEditor && n.getIsChannelModerator && n.getIsAdsEnabled && n.getIsSquadStreamsEnabled, - Twilight.SUNLIGHT_ROUTES - );*/ - - this.SunlightNav = this.elemental.define( - 'sunlight-nav', '.sunlight-top-nav > .tw-flex > .tw-flex > .tw-justify-content-end > .tw-flex', - Twilight.SUNLIGHT_ROUTES, - {attributes: true}, 1 - ); - - this.NavBar = this.fine.define( - 'nav-bar', - n => n.renderOnsiteNotifications && n.renderTwitchPrimeCrown - ); - - this.SquadBar = this.fine.define( - 'squad-nav-bar', - n => n.exitSquadMode && n.props && n.props.squadID, - ['squad'] - ); - - this.MultiController = this.fine.define( - 'multi-controller', - n => n.handleAddStream && n.handleRemoveStream && n.getInitialStreamLayout, - ['command-center'] - ); } get loading() { @@ -176,12 +141,12 @@ export default class MenuButton extends SiteModule { const addons = this.resolve('addons'); + if ( DEBUG && ! addons?.has_dev ) + return this.i18n.t('site.menu_button.main-dev', 'm-dev'); + if ( DEBUG && addons.has_dev ) return this.i18n.t('site.menu_button.dev', 'dev'); - if ( DEBUG && ! addons.has_dev ) - return this.i18n.t('site.menu_button.main-dev', 'm-dev'); - if ( ! DEBUG && addons.has_dev ) return this.i18n.t('site.menu_button.addon-dev', 'a-dev'); @@ -214,6 +179,41 @@ export default class MenuButton extends SiteModule { onEnable() { + this.ModBar = this.fine.define( + 'mod-view-bar', + n => n.actions && n.updateRoot && n.childContext, + ['mod-view'] + ); + + /*this.SunlightDash = this.fine.define( + 'sunlight-dash', + n => n.getIsChannelEditor && n.getIsChannelModerator && n.getIsAdsEnabled && n.getIsSquadStreamsEnabled, + Twilight.SUNLIGHT_ROUTES + );*/ + + this.SunlightNav = this.elemental.define( + 'sunlight-nav', '.sunlight-top-nav > .tw-flex > .tw-flex > .tw-justify-content-end > .tw-flex', + Twilight.SUNLIGHT_ROUTES, + {attributes: true}, 1 + ); + + this.NavBar = this.fine.define( + 'nav-bar', + n => n.renderOnsiteNotifications && n.renderTwitchPrimeCrown + ); + + this.SquadBar = this.fine.define( + 'squad-nav-bar', + n => n.exitSquadMode && n.props && n.props.squadID, + ['squad'] + ); + + this.MultiController = this.fine.define( + 'multi-controller', + n => n.handleAddStream && n.handleRemoveStream && n.getInitialStreamLayout, + ['command-center'] + ); + this.NavBar.ready(() => this.update()); this.NavBar.on('mount', this.updateButton, this); this.NavBar.on('update', this.updateButton, this); @@ -577,22 +577,20 @@ export default class MenuButton extends SiteModule { } - loadMenu(event, btn, page) { - const menu = this.resolve('main_menu'); - if ( ! menu ) - return; - - if ( page ) - menu.requestPage(page); - if ( menu.showing ) - return; - + async loadMenu(event, btn, page) { this.loading = true; - menu.enable(event).then(() => { - this.loading = false; + try { + const menu = await this.resolve('main_menu', true); + if ( menu ) { + if ( page ) + menu.requestPage(page); - }).catch(err => { + if ( ! menu.showing ) + await menu.enable(event); + } + + } catch(err) { this.log.capture(err); this.log.error('Error enabling main menu.', err); @@ -601,9 +599,10 @@ export default class MenuButton extends SiteModule { text: 'There was an error loading the FFZ Control Center. Please refresh and try again.' }; - this.loading = false; this.once(':clicked', this.loadMenu); - }); + } + + this.loading = false; } onDisable() { diff --git a/src/sites/twitch-twilight/modules/mod-view.jsx b/src/sites/twitch-twilight/modules/mod-view.jsx index 7f8e0862..ecce6c67 100644 --- a/src/sites/twitch-twilight/modules/mod-view.jsx +++ b/src/sites/twitch-twilight/modules/mod-view.jsx @@ -9,11 +9,12 @@ import {debounce} from 'utilities/object'; export default class ModView extends Module { + + static should_enable = true; + constructor(...args) { super(...args); - this.inject('i18n'); - this.inject('settings'); this.inject('site.channel'); this.inject('site.css_tweaks'); this.inject('site.fine'); @@ -23,11 +24,14 @@ export default class ModView extends Module { this.inject('metadata'); this.inject('socket'); - this.should_enable = true; - this._cached_channel = null; this._cached_id = null; + this.checkRoot = debounce(this.checkRoot, 250); + this.checkBar = debounce(this.checkBar, 250); + } + + onEnable() { this.Root = this.elemental.define( 'mod-view-root', '.moderation-view-page', ['mod-view'], @@ -40,11 +44,6 @@ export default class ModView extends Module { {childNodes: true, subtree: true}, 1, 30000, false ); - this.checkRoot = debounce(this.checkRoot, 250); - this.checkBar = debounce(this.checkBar, 250); - } - - onEnable() { this.Root.on('mount', this.updateRoot, this); this.Root.on('mutate', this.updateRoot, this); this.Root.on('unmount', this.removeRoot, this); diff --git a/src/sites/twitch-twilight/modules/player.jsx b/src/sites/twitch-twilight/modules/player.jsx index 6af56c26..32d3e95b 100644 --- a/src/sites/twitch-twilight/modules/player.jsx +++ b/src/sites/twitch-twilight/modules/player.jsx @@ -19,45 +19,15 @@ export const PLAYER_ROUTES = [ })();*/ export default class Player extends PlayerBase { + + static should_enable = true; + constructor(...args) { super(...args); - this.should_enable = true; - // Dependency Injection this.inject('site.router'); this.inject('metadata'); - - // React Components - - /*this.SquadStreamBar = this.fine.define( - 'squad-stream-bar', - n => n.shouldRenderSquadBanner && n.props && n.props.triggerPlayerReposition, - PLAYER_ROUTES - );*/ - - /*this.PersistentPlayer = this.fine.define( - 'persistent-player', - n => n.state && n.state.playerStyles - );*/ - - this.Player = this.fine.define( - 'highwind-player', - n => n.setPlayerActive && n.props?.playerEvents && n.props?.mediaPlayerInstance, - PLAYER_ROUTES - ); - - this.TheatreHost = this.fine.define( - 'theatre-host', - n => n.toggleTheatreMode && n.props && n.props.onTheatreModeEnabled, - ['user', 'user-home', 'video', 'user-video', 'user-clip'] - ); - - this.PlayerSource = this.fine.define( - 'player-source', - n => n.setSrc && n.setInitialPlaybackSettings, - PLAYER_ROUTES - ); } @@ -157,6 +127,37 @@ export default class Player extends PlayerBase { async onEnable() { + // React Components + + /*this.SquadStreamBar = this.fine.define( + 'squad-stream-bar', + n => n.shouldRenderSquadBanner && n.props && n.props.triggerPlayerReposition, + PLAYER_ROUTES + );*/ + + /*this.PersistentPlayer = this.fine.define( + 'persistent-player', + n => n.state && n.state.playerStyles + );*/ + + this.Player = this.fine.define( + 'highwind-player', + n => n.setPlayerActive && n.props?.playerEvents && n.props?.mediaPlayerInstance, + PLAYER_ROUTES + ); + + this.TheatreHost = this.fine.define( + 'theatre-host', + n => n.toggleTheatreMode && n.props && n.props.onTheatreModeEnabled, + ['user', 'user-home', 'video', 'user-video', 'user-clip'] + ); + + this.PlayerSource = this.fine.define( + 'player-source', + n => n.setSrc && n.setInitialPlaybackSettings, + PLAYER_ROUTES + ); + await super.onEnable(); this.css_tweaks.toggle('theatre-no-whispers', this.settings.get('player.theatre.no-whispers')); diff --git a/src/sites/twitch-twilight/modules/sub_button.jsx b/src/sites/twitch-twilight/modules/sub_button.jsx index 20ed1da5..c2e93996 100644 --- a/src/sites/twitch-twilight/modules/sub_button.jsx +++ b/src/sites/twitch-twilight/modules/sub_button.jsx @@ -8,13 +8,12 @@ import Module from 'utilities/module'; import {createElement} from 'utilities/dom'; export default class SubButton extends Module { + + static should_enable = true; + constructor(...args) { super(...args); - this.should_enable = true; - - this.inject('i18n'); - this.inject('settings'); this.inject('site.fine'); this.settings.add('sub-button.prime-notice', { @@ -29,15 +28,15 @@ export default class SubButton extends Module { changed: () => this.SubButton.forceUpdate() }); + } + onEnable() { this.SubButton = this.fine.define( 'sub-button', n => n.handleSubMenuAction && n.isUserDataReady, ['user', 'user-home', 'user-video', 'user-clip', 'video', 'user-videos', 'user-clips', 'user-collections', 'user-events', 'user-followers', 'user-following'] ); - } - onEnable() { this.settings.on(':changed:layout.swap-sidebars', () => this.SubButton.forceUpdate()) this.SubButton.ready((cls, instances) => { diff --git a/src/sites/twitch-twilight/modules/theme/index.js b/src/sites/twitch-twilight/modules/theme/index.js index 2d7c2a16..0628e3a2 100644 --- a/src/sites/twitch-twilight/modules/theme/index.js +++ b/src/sites/twitch-twilight/modules/theme/index.js @@ -33,17 +33,17 @@ const ACCENT_COLORS = { export default class ThemeEngine extends Module { + + static should_enable = true; + constructor(...args) { super(...args); - this.inject('settings'); this.inject('site'); this.inject('site.fine'); this.inject('site.css_tweaks'); this.inject('site.router'); - this.should_enable = true; - // Font this.settings.add('theme.font.size', { diff --git a/src/sites/twitch-twilight/modules/video_chat/index.jsx b/src/sites/twitch-twilight/modules/video_chat/index.jsx index 2164f33d..78b4cc54 100644 --- a/src/sites/twitch-twilight/modules/video_chat/index.jsx +++ b/src/sites/twitch-twilight/modules/video_chat/index.jsx @@ -13,14 +13,12 @@ import Module from 'utilities/module'; export default class VideoChatHook extends Module { + + static should_enable = true; + constructor(...args) { super(...args); - this.should_enable = true; - - this.inject('i18n'); - this.inject('settings'); - this.inject('site'); this.inject('site.router'); this.inject('site.fine'); @@ -30,24 +28,6 @@ export default class VideoChatHook extends Module { this.inject('site.chat', undefined, false, 'site_chat'); this.inject('site.chat.chat_line.rich_content'); - this.VideoChatController = this.fine.define( - 'video-chat-controller', - n => n.onMessageScrollAreaMount && n.createReply, - ['user-video', 'user-clip', 'video'] - ); - - this.VideoChatMenu = this.fine.define( - 'video-chat-menu', - n => n.onToggleMenu && n.getContent && n.props && has(n.props, 'isExpandedLayout'), - ['user-video', 'user-clip', 'video'] - ); - - this.VideoChatLine = this.fine.define( - 'video-chat-line', - n => n.onReplyClickHandler && n.shouldFocusMessage, - ['user-video', 'user-clip', 'video'] - ); - // Settings this.settings.add('chat.video-chat.timestamps', { @@ -72,6 +52,24 @@ export default class VideoChatHook extends Module { async onEnable() { + this.VideoChatController = this.fine.define( + 'video-chat-controller', + n => n.onMessageScrollAreaMount && n.createReply, + ['user-video', 'user-clip', 'video'] + ); + + this.VideoChatMenu = this.fine.define( + 'video-chat-menu', + n => n.onToggleMenu && n.getContent && n.props && has(n.props, 'isExpandedLayout'), + ['user-video', 'user-clip', 'video'] + ); + + this.VideoChatLine = this.fine.define( + 'video-chat-line', + n => n.onReplyClickHandler && n.shouldFocusMessage, + ['user-video', 'user-clip', 'video'] + ); + 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.on('chat:updated-lines', this.updateLines, this); diff --git a/src/utilities/addon.js b/src/utilities/addon.js index 7c1ac63b..c0d2e26b 100644 --- a/src/utilities/addon.js +++ b/src/utilities/addon.js @@ -1,13 +1,6 @@ import Module from 'utilities/module'; export class Addon extends Module { - constructor(...args) { - super(...args); - - this.inject('i18n'); - this.inject('settings'); - } - static register(id, info) { if ( typeof id === 'object' ) { info = id; @@ -30,11 +23,14 @@ export class Addon extends Module { ffz.addons.addAddon(info); } + const key = `addon.${id}`; + try { - ffz.register(`addon.${id}`, this); + ffz.register(key, this); + } catch(err) { if ( err.message && err.message.includes('Name Collision for Module') ) { - const module = ffz.resolve(`addon.${id}`); + const module = ffz.resolve(key); if ( module ) module.external = true; } diff --git a/src/utilities/compat/fine.js b/src/utilities/compat/fine.js index b1d17ee7..1c361bff 100644 --- a/src/utilities/compat/fine.js +++ b/src/utilities/compat/fine.js @@ -522,7 +522,8 @@ export default class Fine extends Module { _stopWaiting() { - this.log.info('Stopping MutationObserver.'); + if ( this._waiting_timer ) + this.log.info('Stopping MutationObserver.'); if ( this._observer ) this._observer.disconnect(); diff --git a/src/utilities/events.js b/src/utilities/events.js index 337f71c4..8ef0157a 100644 --- a/src/utilities/events.js +++ b/src/utilities/events.js @@ -21,6 +21,10 @@ String.prototype.toSnakeCase = function() { .toLowerCase(); } +export function nameFromPath(path) { + const idx = path.lastIndexOf('.'); + return idx === -1 ? path : path.slice(idx + 1); +} export class EventEmitter { constructor() { @@ -370,6 +374,9 @@ export class HierarchicalEventEmitter extends EventEmitter { super(); this.name = name || (this.constructor.name || '').toSnakeCase(); + if ( this.name.includes('.') ) + throw new Error('name cannot include path separator (.)'); + this.parent = parent; if ( parent ) { @@ -399,13 +406,21 @@ export class HierarchicalEventEmitter extends EventEmitter { // Public Methods // ======================================================================== - abs_path(path) { + abs_path(path, origin) { if ( typeof path !== 'string' || ! path.length ) throw new TypeError('path must be a non-empty string'); + let parts; + if ( origin ) { + if ( Array.isArray(origin) ) + parts = origin; + else + parts = origin.split('.'); + } else + parts = this.__path_parts; + + const depth = parts.length; let i = 0, chr; - const parts = this.__path_parts, - depth = parts.length; do { chr = path.charAt(i); @@ -419,8 +434,19 @@ export class HierarchicalEventEmitter extends EventEmitter { } while ( ++i < path.length ); const event = chr === ':'; - if ( i === 0 ) - return event && this.__path ? `${this.__path}${path}` : path; + if ( i === 0 ) { + if ( event ) { + if ( origin ) { + if ( Array.isArray(origin) ) + return `${origin.join('.')}${path}`; + return `${origin}${path}`; + + } else if ( this.__path ) + return `${this.__path}${path}`; + } + + return path; + } const prefix = parts.slice(0, depth - (i-1)).join('.'), remain = path.slice(i); diff --git a/src/utilities/module.js b/src/utilities/module.js index a0f7a556..71c2cb3f 100644 --- a/src/utilities/module.js +++ b/src/utilities/module.js @@ -5,9 +5,8 @@ // Modules are cool. // ============================================================================ -import EventEmitter from 'utilities/events'; +import EventEmitter, {nameFromPath} from 'utilities/events'; import {has} from 'utilities/object'; -import { load } from './font-awesome'; // ============================================================================ @@ -23,15 +22,59 @@ export const State = { DISABLED: 0, ENABLING: 1, ENABLED: 2, - DISABLING: 3, - - UNINJECTED: 0, - LOAD_INJECTED: 1, - FULL_INJECTED: 2 + DISABLING: 3 }; export class Module extends EventEmitter { + + /** + * Attempt to get a static requirements list from a module's class. + * If no module instance is provided, return the requirements list + * from the class this static method is attached to. + * + * @param {Module} [module] The module from which to get requirements. + * @returns {Array|Object} Requirements + */ + static getRequirements(module) { + const cls = module ? module.constructor : this; + if ( has(cls, 'requires') ) + return cls.requires; + } + + /** + * Attempt to get a static requirements list from a module's class. + * If no module instance is provided, return the requirements list + * from the class this static method is attached to. + * + * @param {Module} [module] The module from which to get requirements. + * @returns {Array|Object} Requirements + */ + static getLoadRequirements(module) { + const cls = module ? module.constructor : this; + if ( has(cls, 'load_requires') ) + return cls.load_requires; + } + + /** + * Attempt to get a static requirements list from a module's class. + * If no module instance is provided, return the requirements list + * from the class this static method is attached to. + * + * @param {Module} [module] The module from which to get requirements. + * @returns {Array|Object} Requirements + */ + static getConstructRequirements(module) { + const cls = module ? module.constructor : this; + if ( has(cls, 'construct_requires') ) + return cls.construct_requires; + + // We inject several modules by default, if there + // are no other requirements on the class. + return ['settings', 'i18n', 'experiments']; + } + + constructor(name, parent) { if ( ! parent && name instanceof Module ) { parent = name; @@ -39,34 +82,49 @@ export class Module extends EventEmitter { } super(name, parent); - this.__module_promises = parent ? parent.__module_promises : {}; - this.__module_dependents = parent ? parent.__module_dependents : {}; - this.__module_sources = parent ? parent.__module_sources : {}; - this.__modules = parent ? parent.__modules : {}; + + if ( ! this.__path && this.root !== this ) + throw new Error('Non-root module with no path'); + this.children = {}; + //this.__modules = parent ? parent.__modules : {}; + this.__module_data = parent ? parent.__module_data : {}; + const data = this.__data = this.__getModuleData(this.__path || ''); + data.instance = this; if ( parent && ! parent.children[this.name] ) parent.children[this.name] = this; if ( this.root === this ) - this.__modules[this.__path || ''] = this; + this.__module_data.core = data; - this.__constructed = false; + const c = this.constructor; + + data.requires = this.__mergeRequirements(this.__path, data.requires, c.getRequirements(this)); + data.load_requires = this.__mergeRequirements(this.__path, data.load_requires, c.getLoadRequirements(this)); + + // We do NOT do construct_requires here, because those are already handled + // outside of the module out of necessity. + //data.construct_requires = this.__mergeRequirements(data.construct_requires, c.getConstructRequirements(this)); + + data.constructed = false; + data.load_state = this.onLoad ? State.UNLOADED : State.LOADED; + data.state = State.DISABLED; + + /*this.__constructed = false; this.__load_injections = {}; this.__enable_injections = {}; this.__inject_state = State.UNINJECTED; this.__load_state = this.onLoad ? State.UNLOADED : State.LOADED; - this.__state = this.onLoad || this.onEnable ? - State.DISABLED : State.ENABLED; + this.__state = (this.onLoad || this.onEnable) ? + State.DISABLED : State.ENABLED;*/ - // Inject any pre-construction injections. - const injections = this.__get_construct_requires(); - if ( injections ) - for(const [key, path] of Object.entries(injections)) - this[key] = this.resolve(path, false, false); + // Inject our pre-construct requirements now. We don't need to freeze + // them or reflect them because that is handled in `resolve()` prior + // to the constructor being called. + this.__inject(data.construct_requires); - this.__time('instance'); - this.emit(':instanced'); + this.__time('constructed'); } @@ -74,14 +132,42 @@ export class Module extends EventEmitter { // Public Properties // ======================================================================== - get state() { return this.__state } - get load_state() { return this.__load_state } + get requires() { + const reqs = this.__data.requires; + if ( reqs ) + return Object.values(reqs).flat(); + return []; + } + get load_requires() { + const reqs = this.__data.load_requires; + if ( reqs ) + return Object.values(reqs).flat(); + return []; + } + get construct_requires() { + const reqs = this.__data.construct_requires; + if ( reqs ) + return Object.values(reqs).flat(); + return []; + } - get loaded() { return this.__load_state === State.LOADED } - get loading() { return this.__load_state === State.LOADING } + get dependents() { return this.__data.dependents } + get load_dependents() { return this.__data.load_dependents } + get construct_dependents() { return this.__data.construct_dependents } - get enabled() { return this.__state === State.ENABLED } - get enabling() { return this.__state === State.ENABLING } + get state() { return this.__data.state } + get load_state() { return this.__data.load_state } + get inject_state() { return this.__data.inject_state } + + get unloaded() { return this.__data.load_state === State.UNLOADED } + get loading() { return this.__data.load_state === State.LOADING } + get loaded() { return this.__data.load_state === State.LOADED } + get unloading() { return this.__data.load_state === State.UNLOADING } + + get disabled() { return this.__data.state === State.DISABLED } + get enabling() { return this.__data.state === State.ENABLING } + get enabled() { return this.__data.state === State.ENABLED } + get disabling() { return this.__data.state === State.DISABLING } get log() { if ( ! this.__log ) @@ -109,28 +195,128 @@ export class Module extends EventEmitter { // ======================================================================== - // State! Glorious State + // Slightly Easier Events // ======================================================================== - load(...args) { - return this.__load(args, this.__path, []); + on(event, fn, ctx) { + return super.on(event, fn, ctx === undefined ? this : ctx) } - unload(...args) { - return this.__unload(args, this.__path, []); + prependOn(event, fn, ctx) { + return super.prependOn(event, fn, ctx === undefined ? this : ctx) } - enable(...args) { - return this.__enable(args, this.__path, []); + many(event, ttl, fn, ctx) { + return super.many(event, ttl, fn, ctx === undefined ? this : ctx) } - disable(...args) { - return this.__disable(args, this.__path, []); + prependMany(event, ttl, fn, ctx) { + return super.prependMany(event, ttl, fn, ctx === undefined ? this : ctx) } + once(event, fn, ctx) { + return super.once(event, fn, ctx === undefined ? this : ctx) + } + + prependOnce(event, fn, ctx) { + return super.prependOnce(event, fn, ctx === undefined ? this : ctx) + } + + off(event, fn, ctx) { + return super.off(event, fn, ctx === undefined ? this : ctx) + } + + + // ======================================================================== + // Requirement Management + // ======================================================================== + + __getModuleData(path) { + if ( ! this.__module_data[path] ) + this.__module_data[path] = {}; + + return this.__module_data[path]; + } + + __reflectRequirements(source, requirements, key) { + if ( ! requirements ) + return; + + if ( ! source ) + source = this.__path; + + if ( typeof requirements !== 'object' ) + throw new Error('Invalid type for requirements'); + + // We only want the paths, not the keys for injection. + // Make sure to run `flat()` because the empty key is + // a nested array for all requirements that aren't to + // be injected. + if ( ! Array.isArray(requirements) ) + requirements = Object.values(requirements).flat(); + + const dep_key = key ? `${key}_dependents` : 'dependents'; + + for(const req of requirements) { + // We are not using abs_path because all requirements + // should already be absolute paths. + const data = this.__getModuleData(req), + depends = data[dep_key] = data[dep_key] || []; + + if ( ! depends.includes(source) ) + depends.push(source); + } + } + + __mergeRequirements(source, existing, added) { + if ( ! existing ) + existing = {}; + + if ( ! added ) + return existing; + + if ( Array.isArray(added) ) + for(const relative of added) { + const path = this.abs_path(relative), + key = nameFromPath(path).toSnakeCase(); + + existing[key] = path; + } + + else if ( typeof added !== 'object' ) + throw new Error(`Invalid value for requirements: $${added}`); + + else + // We just want to make sure we're dealing with absolute + // paths. We also want to handle an empty key, which is + // special and used for modules that we require but don't + // want injected. + for(const [key, relative] of Object.entries(added)) { + const path = this.abs_path(relative, source); + if ( key?.length ) + existing[key] = path; + else { + existing[''] = existing[''] || []; + if ( ! existing[''].includes(path) ) + existing[''].push(path); + } + } + + return existing; + } __inject(injections) { + if ( ! injections ) + return; + for(const [attr, name] of Object.entries(injections)) { + // Skip the '' key. It's for non-injected requirements. + if ( ! attr?.length ) + continue; + + // We don't do async resolve here. By the time we get to + // __inject, we should have already ensured our dependencies + // are ready elsewhere. const module = this.resolve(name); if ( ! module || !(module instanceof Module) ) throw new ModuleError(`unable to inject dependency ${name} for module ${this.name}`); @@ -140,145 +326,9 @@ export class Module extends EventEmitter { } - __load(args, initial, chain) { - const path = this.__path || this.name, - state = this.__load_state; - - if ( chain.includes(this) ) - return Promise.reject(new CyclicDependencyError(`cyclic load requirements when loading ${initial}`, [...chain, this])); - else if ( this.load_requires ) - for(const name of this.load_requires) { - const module = this.resolve(name); - if ( module && chain.includes(module) ) - return Promise.reject(new CyclicDependencyError(`cyclic load requirements when loading ${initial}`, [...chain, this, module])); - } - - chain.push(this); - - if ( state === State.LOADING ) - return this.__load_promise; - - else if ( state === State.LOADED ) - return Promise.resolve(); - - else if ( state === State.UNLOADING ) - return Promise.reject(new ModuleError(`attempted to load module ${path} while module is being unloaded`)); - - this.__time('load-start'); - this.__load_state = State.LOADING; - return this.__load_promise = (async () => { - if ( this.load_requires ) { - const promises = []; - for(const name of this.load_requires) { - // Resolve and instantiate the module. - promises.push(Promise.resolve(this.resolve(name, true)).then(module => { - if ( ! module || !(module instanceof Module) ) - throw new ModuleError(`cannot find required module ${name} when loading ${path}`); - - return module.__enable([], initial, Array.from(chain)); - })); - } - - await Promise.all(promises); - } - - if ( this.__inject_state === State.UNINJECTED ) { - if ( this.__load_injections ) { - this.__inject(this.__load_injections); - this.__load_injections = null; - } - - this.__inject_state = State.LOAD_INJECTED; - } - - if ( this.onLoad ) { - this.__time('load-self'); - return this.onLoad(...args); - } - - })().then(ret => { - this.__load_state = State.LOADED; - this.__load_promise = null; - this.__time('load-end'); - this.emit(':loaded', this); - return ret; - - }).catch(err => { - this.__load_state = State.UNLOADED; - this.__load_promise = null; - this.__time('load-end'); - throw err; - }); - } - - - __unload(args, initial, chain) { - const path = this.__path || this.name, - state = this.__load_state; - - if ( chain.includes(this) ) - return Promise.reject(new CyclicDependencyError(`cyclic load requirements when unloading ${initial}`, [...chain, this])); - else if ( this.load_dependents ) - for(const dep of this.load_dependents) { - const module = this.resolve(dep); - if ( module && chain.includes(module) ) - return Promise.reject(new CyclicDependencyError(`cyclic load requirements when unloading ${initial}`, [...chain, this, module])); - } - - chain.push(this); - - if ( state === State.UNLOADING ) - return this.__load_promise; - - else if ( state === State.UNLOADED ) - return Promise.resolve(); - - else if ( ! this.onUnload ) - return Promise.reject(new ModuleError(`attempted to unload module ${path} but module cannot be unloaded`)); - - else if ( state === State.LOADING ) - return Promise.reject(new ModuleError(`attempted to unload module ${path} while module is being loaded`)); - - this.__time('unload-start'); - this.__load_state = State.UNLOADING; - return this.__load_promise = (async () => { - if ( this.__state !== State.DISABLED ) - await this.disable(); - - if ( this.load_dependents ) { - const promises = []; - for(const name of this.load_dependents) { - // All our dependents should be instantiated. An uninstantiated module is not loaded - // so we obviously do not need to unload it at this time. - const module = this.resolve(name); - if ( ! module ) - //throw new ModuleError(`cannot find depending module ${name} when unloading ${path}`); - continue; - - promises.push(module.__unload([], initial, Array.from(chain))); - } - - await Promise.all(promises); - } - - this.__time('unload-self'); - return this.onUnload(...args); - - })().then(ret => { - this.__load_state = State.UNLOADED; - this.__load_promise = null; - this.__time('unload-end'); - this.emit(':unloaded', this); - return ret; - - }).catch(err => { - this.__load_state = State.LOADED; - this.__load_promise = null; - this.__time('unload-end'); - throw err; - }); - } - + // ======================================================================== + // State! Glorious State + // ======================================================================== /*generateLoadGraph(chain) { let initial = false; @@ -318,72 +368,209 @@ export class Module extends EventEmitter { }*/ - __enable(args, initial, chain) { - const path = this.__path || this.name, - state = this.__state; + load(...args) { + return this.__load(args, this.__path, []); + } - if ( chain.includes(this) ) - return Promise.reject(new CyclicDependencyError(`cyclic requirements when enabling ${initial}`, [...chain, this])); - else if ( this.requires ) - for(const name of this.requires) { - const module = this.resolve(name); - if ( module && chain.includes(module) ) - return Promise.reject(new CyclicDependencyError(`cyclic requirements when enabling ${initial}`, [...chain, this, module])); + unload(...args) { + return this.__unload(args, this.__path, []); + } + + enable(...args) { + return this.__enable(args, this.__path, []); + } + + disable(...args) { + return this.__disable(args, this.__path, []); + } + + + __load(args, initial, chain) { + const path = this.__path || this.name, + data = this.__data, + state = data.load_state, + requires = data.load_requires, + flat_reqs = Object.values(requires).flat(), + + included = chain.includes(path); + + chain.push(path); + + if ( included ) + return Promise.reject(new CyclicDependencyError(`cyclic load requirements when loading ${initial}`, chain)); + else if ( flat_reqs.length ) { + for(const req_path of flat_reqs) + if ( chain.includes(req_path) ) + return Promise.reject(new CyclicDependencyError(`cyclic load requirements when loading ${initial}`, [...chain, req_path])); + } + + if ( state === State.LOADING ) + return data.load_promise; + + else if ( state === State.LOADED ) + return Promise.resolve(); + + else if ( state === State.UNLOADING ) + return Promise.reject(new ModuleError(`attempted to load module ${path} while module is being unloaded`)); + + this.__time('load-start'); + data.load_state = State.LOADING; + return data.load_promise = (async () => { + // Prepare our requirements, freezing the object to avoid + // any further injections and reflecting the dependencies. + Object.freeze(requires); + this.__reflectRequirements(path, flat_reqs, 'load'); + + if ( flat_reqs.length ) { + const promises = []; + + for(const req_path of flat_reqs) { + promises.push(this.resolve(req_path, true).then(module => { + if ( ! module || !(module instanceof Module) ) + throw new ModuleError(`cannot find required module "${req_path}" when loading "${path}"`); + + if ( module.enabled ) + return module; + + return module.__enable([], initial, Array.from(chain)); + })); + } + + await Promise.all(promises); } - chain.push(this); + // Inject these requirements. + this.__inject(requires); + + if ( this.onLoad ) { + this.__time('load-self'); + return this.onLoad(...args); + } + + })().then(ret => { + data.load_state = State.LOADED; + data.load_promise = null; + this.__time('load-end'); + this.emit(':loaded', this); + return ret; + + }).catch(err => { + data.load_state = State.UNLOADED; + data.load_promise = null; + this.__time('load-end'); + throw err; + }); + } + + + __unload(args, initial, chain) { + const path = this.__path || this.name, + data = this.__data, + state = data.load_state; + + if ( chain.includes(path) ) + return Promise.reject(new CyclicDependencyError(`cyclic load requirements when unloading ${initial}`, [...chain, path])); + + if ( state === State.UNLOADING ) + return data.load_promise; + + else if ( state === State.UNLOADED ) + return Promise.resolve(); + + else if ( state === State.LOADING ) + return Promise.reject(new ModuleError(`attempted to unload module ${path} while module is being loaded`)); + + if ( ! this.onUnload ) + return Promise.reject(new ModuleError(`attempted to unload module ${path} but module cannot be unloaded`)); + + this.__time('unload-start'); + data.load_state = State.UNLOADING; + return data.load_promise = (async () => { + if ( data.state !== State.DISABLED ) + await this.__disable([], initial, Array.from(chain)); + + this.__time('unload-self'); + return this.onUnload(...args); + + })().then(ret => { + data.load_state = State.UNLOADED; + data.load_promise = null; + this.__time('unload-end'); + this.emit(':unloaded', this); + return ret; + + }).catch(err => { + data.load_state = State.LOADED; + data.load_promise = null; + this.__time('unload-end'); + throw err; + }); + } + + + __enable(args, initial, chain) { + const path = this.__path || this.name, + data = this.__data, + state = data.state, + requires = data.requires, + + flat_reqs = Object.values(requires).flat(); + + if ( chain.includes(path) ) + return Promise.reject(new CyclicDependencyError(`cyclic requirements when enabling ${initial}`, [...chain, path])); + else if ( flat_reqs.length ) { + for(const req_path of flat_reqs) + if ( chain.includes(req_path) ) + return Promise.reject(new CyclicDependencyError(`cyclic requirements when enabling ${initial}`, [...chain, path, req_path])); + } if ( state === State.ENABLING ) - return this.__state_promise; + return data.state_promise; else if ( state === State.ENABLED ) return Promise.resolve(); else if ( state === State.DISABLING ) - return Promise.reject(new ModuleError(`attempted to enable module ${path} while module is being disabled`)); + return Promise.reject(new ModuleError(`attempted to enable module "${path}" while module is being disabled`)); this.__time('enable-start'); - this.__state = State.ENABLING; - return this.__state_promise = (async () => { - const promises = [], - requires = this.requires, - load_state = this.__load_state; + data.state = State.ENABLING; + return data.state_promise = (async () => { + // Prepare our requirements, freezing the object to avoid + // any further injections and reflecting the dependencies. + Object.freeze(requires); + this.__reflectRequirements(path, flat_reqs); - // Make sure our module is loaded before enabling it. - if ( load_state === State.UNLOADING ) - // We'd abort for this later too, but kill it now before we start - // any unnecessary work. - throw new ModuleError(`attempted to load module ${path} while module is being unloaded`); + const promises = []; - else if ( load_state === State.LOADING || load_state === State.UNLOADED ) - promises.push(this.load()); + // Is the module unloaded? If so, add a load promise to the + // promises array. + if ( data.load_state !== State.LOADED ) + promises.push(this.__load([], initial, Array.from(chain))); - // We also want to load all our dependencies. - if ( requires ) - for(const name of requires) { - promises.push(Promise.resolve(this.resolve(name, true).then(module => { + if ( flat_reqs.length ) { + // Push ourself onto the chain. We do this after pushing + // __load to ensure __load doesn't just immediately throw + // a cyclic error. + chain.push(path); + + for(const req_path of flat_reqs) { + promises.push(this.resolve(req_path, true).then(module => { if ( ! module || !(module instanceof Module) ) - throw new ModuleError(`cannot find required module ${name} when enabling ${path}`); + throw new ModuleError(`cannot find required module "${req_path}" when loading "${path}"`); + + if ( module.enabled ) + return; return module.__enable([], initial, Array.from(chain)); - }))); + })) } + } await Promise.all(promises); - if ( this.__inject_state !== State.FULL_INJECTED ) { - if ( this.__load_injections ) { - this.__inject(this.__load_injections); - this.__load_injections = null; - } - - if ( this.__enable_injections ) { - this.__inject(this.__enable_injections); - this.__enable_injections = null; - } - - this.__inject_state = State.FULL_INJECTED; - } + // Inject these requirements. + this.__inject(requires); if ( this.onEnable ) { this.__time('enable-self'); @@ -391,15 +578,15 @@ export class Module extends EventEmitter { } })().then(ret => { - this.__state = State.ENABLED; - this.__state_promise = null; + data.state = State.ENABLED; + data.state_promise = null; this.__time('enable-end'); this.emit(':enabled', this); return ret; }).catch(err => { - this.__state = State.DISABLED; - this.__state_promise = null; + data.state = State.DISABLED; + data.state_promise = null; this.__time('enable-end'); throw err; }); @@ -408,139 +595,132 @@ export class Module extends EventEmitter { __disable(args, initial, chain) { const path = this.__path || this.name, - state = this.__state; + data = this.__data, + state = data.state, - if ( chain.includes(this) ) - return Promise.reject(new CyclicDependencyError(`cyclic requirements when disabling ${initial}`, [...chain, this])); - else if ( this.dependents ) - for(const dep of this.dependents) { - const module = this.resolve(dep); - if ( module && chain.includes(module) ) - return Promise.reject(new CyclicDependencyError(`cyclic requirements when disabling ${initial}`, [...chain, this, dep])); + dependents = data.dependents, + flat_deps = Object.values(dependents).flat(), + load_dependents = data.load_dependents, + flat_load_deps = Object.values(load_dependents).flat(), + construct_dependents = data.construct_dependents, + flat_const_deps = Object.values(construct_dependents).flat(), + + included = chain.includes(path); + + chain.push(path); + + if ( included ) + return Promise.reject(new CyclicDependencyError(`cyclic requirements when disabling ${initial}`, chain)); + + if ( flat_deps.length ) + for(const dep_path of flat_deps) + if ( chain.includes(dep_path) ) + return Promise.reject(new CyclicDependencyError(`cyclic requirements when disabling ${initial}`, [...chain, dep_path])); + + if ( flat_load_deps.length ) + for(const dep_path of flat_load_deps) + if ( chain.includes(dep_path) ) + return Promise.reject(new CyclicDependencyError(`cyclic requirements when disabling ${initial}`, [...chain, dep_path])); + + if ( flat_const_deps.length ) + for(const dep_path of flat_const_deps) { + if ( chain.includes(dep_path) ) + return Promise.reject(new CyclicDependencyError(`cyclic requirements when disabling ${initial}`, [...chain, dep_path])); + + const module = this.resolve(dep_path, false, true); + if ( module ) + return Promise.reject(new CyclicDependencyError(`attempd to disable module ${path} but module has permanent dependents`)); } - chain.push(this); - if ( state === State.DISABLING ) - return this.__state_promise; + return data.state_promise; else if ( state === State.DISABLED ) return Promise.resolve(); - else if ( ! this.onDisable ) - return Promise.reject(new ModuleError(`attempted to disable module ${path} but module cannot be disabled`)); - else if ( state === State.ENABLING ) return Promise.reject(new ModuleError(`attempted to disable module ${path} but module is being enabled`)); + if ( ! this.onDisable ) + return Promise.reject(new ModuleError(`attempted to disable module ${path} but module cannot be disabled`)); + this.__time('disable-start'); - this.__state = State.DISABLING; + data.state = State.DISABLING; - return this.__state_promise = (async () => { - if ( this.__load_state !== State.LOADED ) - // We'd abort for this later too, but kill it now before we start - // any unnecessary work. - throw new ModuleError(`attempted to disable module ${path} but module is unloaded -- weird state`); + return data.state_promise = (async () => { + const promises = []; - if ( this.dependents ) { - const promises = []; - for(const name of this.dependents) { - // All our dependents should be instantiated. An uninstantiated module is not enabled - // so we obviously do not need to disable it at this time. - const module = this.resolve(name); - if ( ! module ) - // Assume a non-existent module isn't enabled. - //throw new ModuleError(`cannot find depending module ${name} when disabling ${path}`); - continue; - - promises.push(module.__disable([], initial, Array.from(chain))); + if ( flat_deps.length ) { + for(const req_path of flat_deps) { + const module = this.resolve(req_path, false, true); + if ( module && ! module.disabled ) + promises.push(module.__disable([], initial, Array.from(chain))); } - - await Promise.all(promises); } + if ( flat_load_deps.length ) { + for(const req_path of flat_deps) { + const module = this.resolve(req_path, false, true); + if ( module && ! module.unloaded ) + promises.push(module.__unload([], initial, Array.from(chain))); + } + } + + await Promise.all(promises); + this.__time('disable-self'); return this.onDisable(...args); })().then(ret => { - this.__state = State.DISABLED; - this.__state_promise = null; + data.state = State.DISABLED; + data.state_promise = null; this.__time('disable-end'); this.emit(':disabled', this); return ret; }).catch(err => { - this.__state = State.ENABLED; - this.__state_promise = null; + data.state = State.ENABLED; + data.state_promise = null; this.__time('disable-end'); throw err; }); } - // ======================================================================== - // Slightly Easier Events - // ======================================================================== - - on(event, fn, ctx) { - return super.on(event, fn, ctx === undefined ? this : ctx) - } - - prependOn(event, fn, ctx) { - return super.prependOn(event, fn, ctx === undefined ? this : ctx) - } - - many(event, ttl, fn, ctx) { - return super.many(event, ttl, fn, ctx === undefined ? this : ctx) - } - - prependMany(event, ttl, fn, ctx) { - return super.prependMany(event, ttl, fn, ctx === undefined ? this : ctx) - } - - once(event, fn, ctx) { - return super.once(event, fn, ctx === undefined ? this : ctx) - } - - prependOnce(event, fn, ctx) { - return super.prependOnce(event, fn, ctx === undefined ? this : ctx) - } - - off(event, fn, ctx) { - return super.off(event, fn, ctx === undefined ? this : ctx) - } - - - // ======================================================================== - // Child Control - // ======================================================================== - - // These aren't being used anywhere. - /*loadModules(...names) { - return Promise.all(names.map(n => this.resolve(n, true).then(module => module.load()))); - } - - unloadModules(...names) { - return Promise.all(names.map(n => this.resolve(n)?.unload?.())); - } - - enableModules(...names) { - return Promise.all(names.map(n => this.resolve(n, true).then(module => module.enable()))); - } - - disableModules(...names) { - return Promise.all(names.map(n => this.resolve(n)?.disable?.())); - }*/ - - // ======================================================================== // Module Management // ======================================================================== + + __waitForRegistration(name, timeout = 5000) { + const path = this.abs_path(name), + data = this.__getModuleData(path); + + if ( data.source ) + return Promise.resolve(); + + let timer; + return new Promise((s,f) => { + const waiters = data.register_waiters = data.register_waiters || []; + waiters.push(s); + + if ( timeout ) + timer = setTimeout(() => f(new Error('timeout')), timeout); + + }).then(ret => { + clearTimeout(timer); + return ret; + }).catch(err => { + clearTimeout(timer); + throw err; + }); + } + + /** * Resolve a module. This will only return a module that has already been - * constructed, by default. If `construct` is true, a Promise will be - * returned and a module instance will be constructed. + * constructed, by default. If `construct` is true, a Promise will always + * be returned and a module instance will be constructed if possible. * * @param {String} name The name of the module to resolve. * @param {Boolean} [construct=false] Whether or not a module @@ -549,32 +729,55 @@ export class Module extends EventEmitter { * method will never return a promise. * @param {Boolean} [allow_missing=true] When this is false, an exception * will be thrown for a missing module, rather than returning null. + * @param {Number} [register_timeout=5000] When this is a non-zero value, + * wait up to this many ms for the module to be registered before giving + * up and erroring or returning null. * @returns {Module|Promise} A module, or a Promise that will return a * module, depending on the value of `construct`. */ - resolve(name, construct = false, allow_missing = true) { + resolve(name, construct = false, allow_missing = true, register_timeout=5000) { const path = this.abs_path(name), - source = this.__module_sources[path], - module = this.__modules[path]; + data = this.__getModuleData(path), + source = data.source, + module = data.instance; if ( ! construct ) { - if ( ! module && ! allow_missing ) { - if ( source ) - throw new ModuleError(`instance for module "${path}" has not been constructed`); - else - throw new ModuleError(`unknown module "${path}"`); + if ( ! module ) { + if ( data.error ) + throw data.error; + + if ( ! allow_missing ) { + if ( source ) + throw new ModuleError(`instance for module "${path}" has not been constructed`); + else + throw new ModuleError(`unknown module "${path}"`); + } } return module || null; } - // We have the module already, but wrap it in a promise for safety. + // We have the module already, but wrap it in a promise to ensure + // that we always return a Promise. if ( module ) return Promise.resolve(module); + if ( data.error ) + return Promise.reject(data.error); + // We do not have the module. Do we know how to load it? // If not, then return null or an exception. if ( ! source ) { + if ( register_timeout ) + return this.__waitForRegistration(name, register_timeout) + .then(() => this.resolve(name, construct, allow_missing, 0)) + .catch(() => { + if ( allow_missing ) + return null; + + throw new ModuleError(`unknown module "${path}"`); + }); + if ( allow_missing ) return Promise.resolve(null); @@ -588,181 +791,87 @@ export class Module extends EventEmitter { if ( idx !== -1 ) p_path = path.slice(0, idx); - console.log('resolve', name, path, nm, p_path); - // Is there an existing promise for constructing this module? - if ( this.__module_promises[path] ) - return new Promise((s,f) => this.__module_promises[path].push([s,f])); + if ( data.construct_promise ) + return data.construct_promise; - // We're still here, so load and instantiate the module, then - // return it. - return new Promise((s,f) => { - const proms = this.__module_promises[path] = [[s,f]]; + return data.construct_promise = (async () => { + let parent; + if ( p_path === this.__path ) + parent = this; + else if ( p_path ) + parent = await this.resolve(p_path, true, false); + else + parent = this.root; - (async () => { - let parent; - if ( p_path === this.__path ) - parent = this; - else if ( p_path ) - parent = await this.resolve(p_path, true, false); - else - parent = this.root; + if ( ! parent ) + throw new ModuleError(`invalid parent for module "${path}"`); - const loader = await source; - if ( ! loader ) - throw new ModuleError(`invalid loader for module "${path}`); + const loader = await source; + if ( ! loader ) + throw new ModuleError(`invalid loader for module "${path}"`); - // Do we have pre-construct requirements? - const pre_requires = name === 'settings' ? null : ['settings']; + const is_subclass = loader.prototype instanceof Module; - if ( Array.isArray(pre_requires) ) { - const promises = []; - for(const dep of pre_requires) - promises.push(Promise.resolve(this.resolve(dep, true)).then(module => { - if ( ! module || !(module instanceof Module) ) - throw new ModuleError(`cannot find required module ${dep} when loading ${path}`); + // Prepare our pre-construct requirements. + const requires = data.construct_requires = this.__mergeRequirements( + path, + data.construct_requires, + is_subclass ? loader.getConstructRequirements() : null + ); - return module.__enable([], path, []); - })); + Object.freeze(requires); + const flat_reqs = Object.values(requires).flat(); - await Promise.all(promises); - } + // Load our pre-construct requirements. + if ( flat_reqs.length ) { + this.__reflectRequirements(path, flat_reqs, 'construct'); + const promises = [], + chain = [this.__path, path]; - let module; - if ( loader.prototype instanceof Module ) - module = new loader(nm, parent); - else - module = loader(nm, parent); + for(const req_path of flat_reqs) + promises.push(this.resolve(req_path, true).then(module => { + if ( ! module || !(module instanceof Module) ) + throw new ModuleError(`cannot find required module "${req_path}" when loading "${path}"`); - if ( ! module || !(module instanceof Module)) - throw new ModuleError(`invalid return value from module constructor for module "${path}"`); + if ( module.enabled ) + return; - module.__constructed = true; - this.__modules[path] = module; + return module.__enable([], path, Array.from(chain)); + })); - const deps = this.__module_dependents[path]; - this.__module_dependents[path] = null; + await Promise.all(promises); + } - // Copy over the dependencies. - if ( deps ) { - if ( deps.load ) - module.load_dependents = module.load_dependents ? [...module.load_dependents, ...deps.load] : deps.load; - if ( deps.enable ) - module.dependents = module.dependents ? [...module.dependents, ...deps.enable] : deps.enable; - } + let module; + if ( is_subclass ) + module = new loader(nm, parent); + else + module = loader(nm, parent); - // Inject any requirements. - this.__reflectDependencies(); - return module; + if ( ! module || !(module instanceof Module) ) + throw new ModuleError(`invalid return value from module constructor for module "${path}"`); - })().then(result => { - this.__module_promises[path] = null; - for(const pair of proms) - pair[0](result); - }).catch(err => { - this.__module_promises[path] = null; - for(const pair of proms) - pair[1](err); - }); + data.constructed = true; + + module.__time('instance'); + module.emit(':instanced'); + + return module; + + })().then(ret => { + data.construct_promise = null; + return ret; + + }).catch(err => { + const wrapped = new ModuleInstantiationError(`Error while construction module instance for "${path}"`, err); + data.error = wrapped; + data.construct_promise = null; + throw wrapped; }); } - /** - * Reflect the dependencies of this module, registering it with - * all modules it depends on as a dependent so that those modules - * know to disable or unload this module when appropriate. - * - * @param {String[]} [load=null] An optional array of specific load dependencies to reflect. - * @param {String[]} [enable=null] An optional array of specific dependencies to reflect. - * @returns {undefined} Nothing - */ - __reflectDependencies(load = null, enable = null) { - if ( load == null ) - load = this.__get_load_requires(); - if ( enable == null ) - enable = this.__get_requires(); - - if ( load && ! Array.isArray(load) ) - load = [load]; - if ( enable && ! Array.isArray(enable) ) - enable = [enable]; - - const local = this.__path || 'core'; - - if ( load && load.length ) - for(const path of load) { - const module = this.__modules[path]; - if ( module ) { - const dependents = module.load_dependents = module.load_dependents || []; - if ( ! dependents.includes(local) ) - dependents.push(local); - } else { - const dependents = this.__module_dependents[path] = this.__module_dependents[path] || {}, - set = dependents.load = dependents.load || []; - if ( ! set.includes(local) ) - set.push(local); - } - } - - if ( enable && enable.length ) - for(const path of enable) { - const module = this.__modules[path]; - if ( module ) { - const dependents = module.dependents = module.dependents || []; - if ( ! dependents.includes(local) ) - dependents.push(local); - } else { - const dependents = this.__module_dependents[path] = this.__module_dependents[path] || {}, - set = dependents.enable = dependents.enable || []; - if ( ! set.includes(local) ) - set.push(local); - } - } - } - - - __get_requires() { - if ( has(this, 'requires') ) - return this.requires; - if ( has(this.constructor, 'requires') ) - return this.constructor.requires; - } - - - __get_load_requires() { - if ( has(this, 'load_requires') ) - return this.load_requires; - if ( has(this.constructor, 'load_requires') ) - return this.constructor.load_requires; - } - - - __get_construct_requires() { - let out; - if ( has(this, 'construct_requires') ) - out = this.construct_requires; - else if ( has(this.constructor, 'construct_requires') ) - out = this.constructor.construct_requires; - else - out = ['settings', 'i18n', 'experiments']; - - if ( ! out || ! Array.isArray(out) ) - return out; - - const obj = {}; - for(const path of out) { - const full = this.abs_path(path), - idx = full.lastIndexOf('.'), - name = full.slice(idx + 1); - - obj[name] = full; - } - - return obj; - } - - /** * Inject a dependency into this module. Dependencies are added as * requirements, and are saved as variables with the module's name @@ -775,6 +884,14 @@ export class Module extends EventEmitter { * or until the module is being loaded in the case of load * dependencies. * + * Modules may also have pre-construct requirements, but those have + * to be declared in metadata ahead of time, or in a static + * `construct_requires` object on the Module subclass. By default, + * all modules pre-construct requirements on settings, i18n, and + * experiments. If you do NOT need those requirements, you need + * to explicitly set a `construct_requires` on your Module + * subclass to override the defaults. + * * **Note:** Rather than providing a name, you can provide only * a Module class or instance and the name will be determined * based on the class name of the module. When doing so, you are @@ -794,7 +911,11 @@ export class Module extends EventEmitter { * @returns {undefined} Nothing */ inject(name, loader, load = false, key = null) { - if ( this.__constructed ) + const data = this.__data; + if ( ! data ) + throw new ModuleError(`Unable to use inject() before super()`); + + if ( data.constructed ) throw new ModuleError(`Unable to use inject() outside constructor`); // Did we get a name? @@ -809,35 +930,24 @@ export class Module extends EventEmitter { throw new Error(`invalid type for name`); } + // Allow someone to do `.inject('name', true);` for a load dependency. + if ( loader === true || loader === false ) { + key = load; + load = loader; + loader = null; + } + // If we have a loader, go ahead and register it. This will also give us // a name if we don't have one. if ( loader ) name = this.register(name, loader); - if ( ! key ) { - const idx = name.lastIndexOf('.'); - key = (idx === -1 ? name : name.slice(idx + 1)).toSnakeCase(); - } + if ( ! key ) + key = nameFromPath(name).toSnakeCase(); - const path = this.abs_path(name); - - // Save this dependency, and also save it on the target module. - if ( load ) { - const requires = this.load_requires = this.__get_load_requires() || []; - if ( ! requires.includes(path) ) - requires.push(path); - - this.__reflectDependencies(path, false); - this.__load_injections[key] = path; - - } else { - const requires = this.requires = this.__get_requires() || []; - if ( ! requires.includes(path) ) - requires.push(path); - - this.__reflectDependencies(false, path); - this.__enable_injections[key] = path; - } + // Push this dependency to this list. + const reqs = load ? data.load_requires : data.requires; + reqs[key] = this.abs_path(name); } @@ -872,11 +982,21 @@ export class Module extends EventEmitter { // Make sure the name is relative. name = `.${name}`; - const path = this.abs_path(name); - if ( this.__modules[path] || this.__module_sources[path] ) + const path = this.abs_path(name), + data = this.__getModuleData(path); + + if ( data.source || data.instance ) throw new ModuleError(`Name Collision for Module ${path}`); - this.__module_sources[path] = loader; + data.source = loader; + + const waiters = data.register_waiters; + data.register_waiters = undefined; + + if ( waiters ) + for(const fn of waiters) + fn(); + return name; } @@ -931,9 +1051,17 @@ export default Module; export class ModuleError extends Error { } +export class ModuleInstantiationError extends ModuleError { + constructor(message, original) { + super(message); + this.original = original; + this.stack = `${this.stack.split('\n')[0]}\n${original.stack}`; + } +} + export class CyclicDependencyError extends ModuleError { constructor(message, modules) { - super(`${message} ${modules ? `(${modules.map(x => x.path).join(' => ')})` : ''}`); + super(`${message} ${modules ? `(${modules.map(x => x.path || x).join(' => ')})` : ''}`); this.modules = modules; } } \ No newline at end of file