diff --git a/package.json b/package.json index aa587e37..a9d99610 100755 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "frankerfacez", "author": "Dan Salvato LLC", - "version": "4.57.4", + "version": "4.58.0", "description": "FrankerFaceZ is a Twitch enhancement suite.", "private": true, "license": "Apache-2.0", diff --git a/src/modules/chat/actions/actions.jsx b/src/modules/chat/actions/actions.jsx index 2ef25805..9bccd293 100644 --- a/src/modules/chat/actions/actions.jsx +++ b/src/modules/chat/actions/actions.jsx @@ -4,7 +4,8 @@ // Emoji Handling // ============================================================================ -import Module from 'utilities/module'; +import { DEBUG } from 'utilities/constants'; +import Module, { buildAddonProxy } from 'utilities/module'; import {has, maybe_call, deep_copy} from 'utilities/object'; import {createElement, ClickOutside} from 'utilities/dom'; import Tooltip from 'utilities/tooltip'; @@ -261,6 +262,91 @@ export default class Actions extends Module { for(const key in RENDERERS) if ( has(RENDERERS, key) ) this.addRenderer(key, RENDERERS[key]); + + this.on('addon:fully-unload', addon_id => { + let removed = 0; + for(const [key, def] of Object.entries(this.actions)) { + if ( def?.__source === addon_id ) { + removed++; + delete this.actions[key]; + } + } + + for(const [key, def] of Object.entries(this.renderers)) { + if ( def?.__source === addon_id ) { + removed++; + delete this.renderers[key]; + } + } + + if ( removed ) { + this.log.debug(`Cleaned up ${removed} entries when unloading addon:`, addon_id); + this._updateContexts(); + } + }); + } + + + getAddonProxy(addon_id, addon, module) { + if ( ! addon_id ) + return this; + + const overrides = {}, + warnings = {}, + is_dev = DEBUG || addon?.dev; + + overrides.addAction = (key, data) => { + if ( data ) + data.__source = addon_id; + + if ( is_dev && ! key.includes(addon_id) ) + module.log.warn('[DEV-CHECK] Call to actions.addAction() did not include add-on ID in key:', key); + + return this.addAction(key, data); + } + + overrides.addRenderer = (key, data) => { + if ( data ) + data.__source = addon_id; + + if ( is_dev && ! key.includes(addon_id) ) + module.log.warn('[DEV-CHECK] Call to actions.addRenderer() did not include add-on ID in key:', key); + + return this.addRenderer(key, data); + } + + if ( is_dev ) { + overrides.removeAction = key => { + const existing = this.actions[key]; + if ( existing && existing.__source !== addon_id ) + module.log.warn('[DEV-CHECK] Removed un-owned action with actions.removeAction:', key, ' owner:', existing.__source ?? 'ffz'); + + return this.removeAction(key); + }; + + overrides.removeRenderer = key => { + const existing = this.renderers[key]; + if ( existing && existing.__source !== addon_id ) + module.log.warn('[DEV-CHECK] Removed un-owned renderer with actions.removeRenderer:', key, ' owner:', existing.__source ?? 'ffz'); + + return this.removeRenderer(key); + } + + warnings.actions = 'Please use addAction() or removeAction()'; + warnings.renderers = 'Please use addRenderer() or removeRenderer()'; + } + + return buildAddonProxy(module, this, 'actions', overrides, warnings); + } + + + _updateContexts() { + for(const ctx of this.settings.__contexts) { + ctx.update('chat.actions.inline'); + ctx.update('chat.actions.hover'); + ctx.update('chat.actions.user-context'); + ctx.update('chat.actions.room'); + } } @@ -269,13 +355,7 @@ export default class Actions extends Module { return this.log.warn(`Attempted to add action "${key}" which is already defined.`); this.actions[key] = data; - - for(const ctx of this.settings.__contexts) { - ctx.update('chat.actions.inline'); - ctx.update('chat.actions.hover'); - ctx.update('chat.actions.user-context'); - ctx.update('chat.actions.room'); - } + this._updateContexts(); } @@ -284,14 +364,25 @@ export default class Actions extends Module { return this.log.warn(`Attempted to add renderer "${key}" which is already defined.`); this.renderers[key] = data; + this._updateContexts(); + } - for(const ctx of this.settings.__contexts) { - ctx.update('chat.actions.inline'); - ctx.update('chat.actions.inline'); - ctx.update('chat.actions.hover'); - ctx.update('chat.actions.user-context'); - ctx.update('chat.actions.room'); - } + + removeAction(key) { + if ( ! has(this.actions, key) ) + return; + + delete this.actions[key]; + this._updateContexts(); + } + + + removeRenderer(key) { + if ( ! has(this.renderers, key) ) + return; + + delete this.renderers[key]; + this._updateContexts(); } @@ -1127,7 +1218,7 @@ export default class Actions extends Module { if ( target._ffz_tooltip ) target._ffz_tooltip.hide(); - + return data.definition.click.call(this, event, data); } diff --git a/src/modules/chat/badges.jsx b/src/modules/chat/badges.jsx index e8f5fa30..71274db1 100644 --- a/src/modules/chat/badges.jsx +++ b/src/modules/chat/badges.jsx @@ -4,13 +4,13 @@ // Badge Handling // ============================================================================ -import {NEW_API, SERVER, IS_WEBKIT, IS_FIREFOX, WEBKIT_CSS as WEBKIT} from 'utilities/constants'; +import {NEW_API, SERVER, IS_WEBKIT, IS_FIREFOX, WEBKIT_CSS as WEBKIT, DEBUG} from 'utilities/constants'; import {createElement, ManagedStyle} from 'utilities/dom'; -import {has, maybe_call, SourcedSet} from 'utilities/object'; -import Module from 'utilities/module'; -import { ColorAdjuster } from 'src/utilities/color'; -import { NoContent } from 'src/utilities/tooltip'; +import {has, makeAddonIdChecker, maybe_call, SourcedSet} from 'utilities/object'; +import Module, { buildAddonProxy } from 'utilities/module'; +import { ColorAdjuster } from 'utilities/color'; +import { NoContent } from 'utilities/tooltip'; const CSS_BADGES = { 1: { @@ -513,6 +513,22 @@ export default class Badges extends Module { this.loadGlobalBadges(); }); + this.on('addon:fully-unload', addon_id => { + let removed = 0; + for(const [key, val] of Object.entries(this.badges)) { + if ( val?.__source === addon_id ) { + removed++; + this.removeBadge(key, false); + } + } + + if ( removed ) { + this.log.debug(`Cleaned up ${removed} entries when unloading addon:`, addon_id); + this.generateBadgeCSS(); + // TODO: Debounced re-badge all chat messages. + } + }); + this.tooltips.types.badge = (target, tip) => { tip.add_class = 'ffz__tooltip--badges'; @@ -608,48 +624,61 @@ export default class Badges extends Module { if ( ! addon_id ) return this; - const is_dev = addon?.dev ?? false; + const is_dev = DEBUG || (addon?.dev ?? false), + id_checker = makeAddonIdChecker(addon_id); - const overrides = {}; + const overrides = {}, + warnings = {}; overrides.loadBadgeData = (badge_id, data, ...args) => { if ( data && data.addon === undefined ) data.addon = addon_id; + if ( is_dev && ! id_checker.test(badge_id) ) + module.log.warn('[DEV-CHECK] Call to chat.badges.loadBadgeData() did not include addon ID in badge_id:', badge_id); + return this.loadBadgeData(badge_id, data, ...args); }; if ( is_dev ) { + overrides.removeBadge = (badge_id, ...args) => { + // Note: We aren't checking that the badge_id contains the add-on + // ID because that should be handled by loadBadgeData for badges + // from this add-on. Checking if we're removing a badge from + // another source covers the rest. + + const existing = this.badges[badge_id]; + if ( existing && existing.addon !== addon_id ) + module.log.warn('[DEV-CHECK] Removed un-owned badge with chat.badges.removeBadge():', key, ' owner:', existing.addon ?? 'ffz'); + + return this.removeBadge(badge_id, ...args); + }; + overrides.setBulk = (source, ...args) => { - if ( ! source.includes(addon_id) ) - module.log.warn('[DEV-CHECK] Call to badges.setBulk did not include addon ID in source:', source); + if ( ! id_checker.test(source) ) + module.log.warn('[DEV-CHECK] Call to chat.badges.setBulk() did not include addon ID in source:', source); return this.setBulk(source, ...args); }; overrides.deleteBulk = (source, ...args) => { - if ( ! source.includes(addon_id) ) - module.log.warn('[DEV-CHECK] Call to badges.deleteBulk did not include addon ID in source:', source); + if ( ! id_checker.test(source) ) + module.log.warn('[DEV-CHECK] Call to chat.badges.deleteBulk() did not include addon ID in source:', source); return this.deleteBulk(source, ...args); } overrides.extendBulk = (source, ...args) => { - if ( ! source.includes(addon_id) ) - module.log.warn('[DEV-CHECK] Call to badges.extendBulk did not include addon ID in source:', source); + if ( ! id_checker.test(source) ) + module.log.warn('[DEV-CHECK] Call to chat.badges.extendBulk() did not include addon ID in source:', source); return this.extendBulk(source, ...args); } + + warnings.badges = 'Please use loadBadgeData() or removeBadge()'; } - return new Proxy(this, { - get(obj, prop) { - const thing = overrides[prop]; - if ( thing ) - return thing; - return Reflect.get(...arguments); - } - }); + return buildAddonProxy(module, this, 'chat.badges', overrides, warnings); } @@ -1194,6 +1223,17 @@ export default class Badges extends Module { } + removeBadge(badge_id, generate_css = true) { + if ( ! this.badges[badge_id] ) + return; + + delete this.badges[badge_id]; + + if ( generate_css ) + this.buildBadgeCSS(); + } + + loadBadgeData(badge_id, data, generate_css = true) { this.badges[badge_id] = data; diff --git a/src/modules/chat/emotes.js b/src/modules/chat/emotes.js index abf476f9..84dfdb09 100644 --- a/src/modules/chat/emotes.js +++ b/src/modules/chat/emotes.js @@ -4,14 +4,14 @@ // Emote Handling and Default Provider // ============================================================================ -import Module from 'utilities/module'; +import Module, { buildAddonProxy } from 'utilities/module'; import {ManagedStyle} from 'utilities/dom'; -import {get, has, timeout, SourcedSet, make_enum_flags} from 'utilities/object'; -import {NEW_API, IS_OSX, EmoteTypes, TWITCH_GLOBAL_SETS, TWITCH_POINTS_SETS, TWITCH_PRIME_SETS} from 'utilities/constants'; +import { FFZEvent } from 'utilities/events'; +import {get, has, timeout, SourcedSet, make_enum_flags, makeAddonIdChecker} from 'utilities/object'; +import {NEW_API, IS_OSX, EmoteTypes, TWITCH_GLOBAL_SETS, TWITCH_POINTS_SETS, TWITCH_PRIME_SETS, DEBUG} from 'utilities/constants'; import GET_EMOTE from './emote_info.gql'; import GET_EMOTE_SET from './emote_set_info.gql'; -import { FFZEvent } from 'src/utilities/events'; const HoverRAF = Symbol('FFZ:Hover:RAF'); const HoverState = Symbol('FFZ:Hover:State'); @@ -434,6 +434,7 @@ export default class Emotes extends Module { this.EmoteTypes = EmoteTypes; this.ModifierFlags = MODIFIER_FLAGS; + this.inject('i18n'); this.inject('settings'); this.inject('experiments'); this.inject('staging'); @@ -604,46 +605,72 @@ export default class Emotes extends Module { if ( ! addon_id ) return this; - const overrides = {}; + const is_dev = DEBUG || addon?.dev, + id_checker = makeAddonIdChecker(addon_id); - if ( addon?.dev ) { - overrides.addDefaultSet = (provider, ...args) => { - if ( ! provider.includes(addon_id) ) + const overrides = {}, + warnings = {}; + + overrides.addDefaultSet = (provider, set_id, data) => { + if ( is_dev && ! id_checker.test(provider) ) module.log.warn('[DEV-CHECK] Call to emotes.addDefaultSet did not include addon ID in provider:', provider); - return this.addDefaultSet(provider, ...args); + if ( data ) { + if ( is_dev && ! id_checker.test(set_id) ) + module.log.warn('[DEV-CHECK] Call to emotes.addDefaultSet loaded set data but did not include addon ID in set ID:', set_id); + + data.__source = addon_id; } + return this.addDefaultSet(provider, set_id, data); + } + + overrides.addSubSet = (provider, set_id, data) => { + if ( is_dev && ! id_checker.test(provider) ) + module.log.warn('[DEV-CHECK] Call to emotes.addSubSet did not include addon ID in provider:', provider); + + if ( data ) { + if ( is_dev && ! id_checker.test(set_id) ) + module.log.warn('[DEV-CHECK] Call to emotes.addSubSet loaded set data but did not include addon ID in set ID:', set_id); + + data.__source = addon_id; + } + + return this.addSubSet(provider, set_id, data); + } + + overrides.loadSetData = (set_id, data, ...args) => { + if ( is_dev && ! id_checker.test(set_id) ) + module.log.warn('[DEV-CHECK] Call to emotes.loadSetData did not include addon ID in set ID:', set_id); + + if ( data ) + data.__source = addon_id; + + return this.loadSetData(set_id, data, ...args); + } + + if ( is_dev ) { overrides.removeDefaultSet = (provider, ...args) => { - if ( ! provider.includes(addon_id) ) + if ( ! id_checker.test(provider) ) module.log.warn('[DEV-CHECK] Call to emotes.removeDefaultSet did not include addon ID in provider:', provider); return this.removeDefaultSet(provider, ...args); } - overrides.addSubSet = (provider, ...args) => { - if ( ! provider.includes(addon_id) ) - module.log.warn('[DEV-CHECK] Call to emotes.addSubSet did not include addon ID in provider:', provider); - - return this.addSubSet(provider, ...args); - } - overrides.removeSubSet = (provider, ...args) => { - if ( ! provider.includes(addon_id) ) + if ( ! id_checker.test(provider) ) module.log.warn('[DEV-CHECK] Call to emotes.removeSubSet did not include addon ID in provider:', provider); return this.removeSubSet(provider, ...args); } + + warnings.style = true; + warnings.effect_style = true; + warnings.emote_sets = true; + warnings.loadSetUserIds = warnings.loadSetUsers = 'This method is meant for internal use.'; } - return new Proxy(this, { - get(obj, prop) { - const thing = overrides[prop]; - if ( thing ) - return thing; - return Reflect.get(...arguments); - } - }); + return buildAddonProxy(module, this, 'emotes', overrides, warnings); } @@ -690,14 +717,21 @@ export default class Emotes extends Module { this.on('pubsub:command:add_emote', msg => { const set_id = msg.set_id, - emote = msg.emote; + emote = msg.emote, - if ( ! this.emote_sets[set_id] ) + emote_set = this.emote_sets[set_id]; + + if ( ! emote_set ) return; - this.addEmoteToSet(set_id, emote); + const has_old = !! emote_set.emotes?.[emote.id]; + const processed = this.addEmoteToSet(set_id, emote); - // TODO: Notify users? + this.maybeNotifyChange( + has_old ? 'modified' : 'added', + set_id, + processed + ); }); this.on('pubsub:command:remove_emote', msg => { @@ -707,9 +741,17 @@ export default class Emotes extends Module { if ( ! this.emote_sets[set_id] ) return; - this.removeEmoteFromSet(set_id, emote_id); + // If removing it returns nothing, there was no + // emote to remove with that ID. + const removed = this.removeEmoteFromSet(set_id, emote_id); + if ( ! removed ) + return; - // TODO: Notify users? + this.maybeNotifyChange( + 'removed', + set_id, + removed + ); }); this.on('chat:reload-data', flags => { @@ -717,10 +759,122 @@ export default class Emotes extends Module { this.loadGlobalSets(); }); + this.on('addon:fully-unload', addon_id => { + let removed = 0; + for(const [key, set] of Object.entries(this.emote_sets)) { + if ( set?.__source === addon_id ) { + removed++; + this.loadSetData(key, null, true); + } + } + + if ( removed ) { + this.log.debug(`Cleaned up ${removed} entries when unloading addon:`, addon_id); + // TODO: Debounced retokenize all chat messages. + } + }) + this.loadGlobalSets(); } + // ======================================================================== + // Chat Notices + // ======================================================================== + + maybeNotifyChange(action, set_id, emote) { + if ( ! this._pending_notifications ) + this._pending_notifications = []; + + this._pending_notifications.push({action, set_id, emote}); + + if ( ! this._pending_timer ) + this._pending_timer = setTimeout(() => this._handleNotifyChange(), 1000); + } + + _handleNotifyChange() { + clearTimeout(this._pending_timer); + this._pending_timer = null; + + const notices = this._pending_notifications; + this._pending_notifications = null; + + // Make sure we are equipped to send notices. + const chat = this.resolve('site.chat'); + if ( ! chat?.addNotice ) + return; + + // Get the current user. + const me = this.resolve('site').getUser(); + if ( ! me?.id ) + return; + + // Get the current channel. + let room_id = this.parent.context.get('context.channelID'), + room_login = this.parent.context.get('context.channel'); + + // And now get the current user's available emote sets. + const sets = this.getSetIDs(me.id, me.login, room_id, room_login); + const set_changes = {}; + + // Build a data structure for reducing the needed number of notices. + for(const notice of notices) { + // Make sure the set ID is a string. + const set_id = `${notice.set_id}`, + action = notice.action; + + if ( sets.includes(set_id) ) { + const changes = set_changes[set_id] = set_changes[set_id] || {}, + list = changes[action] = changes[action] || []; + + // Deduplicate while we're at it. + if ( list.find(em => em.id === notice.emote.id) ) + continue; + + list.push(notice.emote); + } + } + + // Iterate over everything, sending chat notices. + for(const [set_id, notices] of Object.entries(set_changes)) { + const emote_set = this.emote_sets[set_id]; + if ( ! emote_set ) + continue; + + for(const [action, emotes] of Object.entries(notices)) { + const emote_list = emotes + .map(emote => emote.name) + .join(', '); + + let msg; + if ( action === 'added' ) + msg = this.i18n.t('emote-updates.added', 'The {count, plural, one {emote {emotes} has} other {emotes {emotes} have}} been added to {set}.', { + count: emotes.length, + emotes: emote_list, + set: emote_set.title + }); + + else if ( action === 'modified' ) + msg = this.i18n.t('emote-updates.modified', 'The {count, plural, one {emote {emotes} has} other {emotes {emotes} have}} been updated in {set}.', { + count: emotes.length, + emotes: emote_list, + set: emote_set.title + }); + + else if ( action === 'removed' ) + msg = this.i18n.t('emote-updates.removed', 'The {count, plural, one {emote {emotes} has} other {emotes {emotes} have}} been removed from {set}.', { + count: emotes.length, + emotes: emote_list, + set: emote_set.title + }); + + if ( msg ) + chat.addNotice('*', `[FFZ] ${msg}`); + } + } + } + + // ======================================================================== // Load Modifier Effects // ======================================================================== @@ -1845,6 +1999,9 @@ export default class Emotes extends Module { // Send a loaded event because this emote set changed. this.emit(':loaded', set_id, set); + + // Return the processed emote object. + return processed; } @@ -1894,14 +2051,20 @@ export default class Emotes extends Module { // Send a loaded event because this emote set changed. this.emit(':loaded', set_id, set); + + // Return the removed emote. + return emote; } loadSetData(set_id, data, suppress_log = false) { const old_set = this.emote_sets[set_id]; if ( ! data ) { - if ( old_set ) + if ( old_set ) { + if ( this.style ) + this.style.delete(`es--${set_id}`); this.emote_sets[set_id] = null; + } return; } diff --git a/src/modules/chat/index.js b/src/modules/chat/index.js index dc5f3b3c..5032239d 100644 --- a/src/modules/chat/index.js +++ b/src/modules/chat/index.js @@ -6,12 +6,13 @@ import dayjs from 'dayjs'; -import Module from 'utilities/module'; +import { DEBUG, LINK_DATA_HOSTS } from 'utilities/constants'; +import Module, { buildAddonProxy } from 'utilities/module'; import {Color} from 'utilities/color'; import {createElement, ManagedStyle} from 'utilities/dom'; import {FFZEvent} from 'utilities/events'; import {getFontsList} from 'utilities/fonts'; -import {timeout, has, addWordSeparators, glob_to_regex, escape_regex, split_chars} from 'utilities/object'; +import {timeout, has, addWordSeparators, glob_to_regex, escape_regex, split_chars, makeAddonIdChecker} from 'utilities/object'; import Badges from './badges'; import Emotes from './emotes'; @@ -25,7 +26,6 @@ import * as RICH_PROVIDERS from './rich_providers'; import * as LINK_PROVIDERS from './link_providers'; import Actions from './actions/actions'; -import { LINK_DATA_HOSTS } from 'src/utilities/constants'; function sortPriorityColorTerms(list) { @@ -1281,6 +1281,208 @@ export default class Chat extends Module { } + getAddonProxy(addon_id, addon, module) { + if ( ! addon_id ) + return this; + + const is_dev = DEBUG || addon?.dev, + id_checker = makeAddonIdChecker(addon_id); + + const overrides = {}, + warnings = {}; + + const user_proxy = buildAddonProxy(module, null, 'getUser()', { + addBadge: is_dev ? function(provider, ...args) { + if ( ! id_checker.test(provider) ) + module.log.warn('[DEV-CHECK] Call to getUser().addBadge() did not include addon ID in provider:', provider); + + return this.addBadge(provider, ...args); + } : undefined, + + removeBadge: is_dev ? function(provider, ...args) { + if ( ! id_checker.test(provider) ) + module.log.warn('[DEV-CHECK] Call to getUser().removeBadge() did not include addon ID in provider:', provider); + + return this.removeBadge(provider, ...args); + } : undefined, + + addSet(provider, set_id, data) { + if ( is_dev && ! id_checker.test(provider) ) + module.log.warn('[DEV-CHECK] Call to getUser().addSet() did not include addon ID in provider:', provider); + + if ( data ) { + if ( is_dev && ! id_checker.test(set_id) ) + module.log.warn('[DEV-CHECK] Call to getUser().addSet() loaded set data but did not include addon ID in set ID:', set_id); + data.__source = addon_id; + } + + return this.addSet(provider, set_id, data); + }, + + removeAllSets: is_dev ? function(provider) { + if ( ! id_checker.test(provider) ) + module.log.warn('[DEV-CHECK] Call to getUser().removeAllSets() did not include addon ID in provider:', provider); + + return this.removeAllSets(provider); + } : undefined, + + removeSet: is_dev ? function(provider, ...args) { + if ( ! id_checker.test(provider) ) + module.log.warn('[DEV-CHECK] Call to getUser().removeSet() did not include addon ID in provider:', provider); + + return this.removeSet(provider, ...args); + } : undefined + + }, is_dev ? { + badges: 'Please use addBadge(), getBadge(), or removeBadge()', + emote_sets: 'Please use addSet(), removeSet(), or removeAllSets()', + room: true + } : null, true); + + const room_proxy = buildAddonProxy(module, null, 'getRoom()', { + getUser(...args) { + const result = this.getUser(...args); + if ( result ) + return new Proxy(result, user_proxy); + }, + + addSet(provider, set_id, data) { + if ( is_dev && ! id_checker.test(provider) ) + module.log.warn('[DEV-CHECK] Call to getRoom().addSet() did not include addon ID in provider:', provider); + + if ( data ) { + if ( is_dev && ! id_checker.test(set_id) ) + module.log.warn('[DEV-CHECK] Call to getRoom().addSet() loaded set data but did not include addon ID in set ID:', set_id); + data.__source = addon_id; + } + + return this.addSet(provider, set_id, data); + }, + + removeAllSets: is_dev ? function(provider) { + if ( ! id_checker.test(provider) ) + module.log.warn('[DEV-CHECK] Call to getRoom().removeAllSets() did not include addon ID in provider:', provider); + + return this.removeAllSets(provider); + } : undefined, + + removeSet: is_dev ? function(provider, ...args) { + if ( ! id_checker.test(provider) ) + module.log.warn('[DEV-CHECK] Call to getRoom().removeSet() did not include addon ID in provider:', provider); + + return this.removeSet(provider, ...args); + } : undefined + + }, { + badges: true, + load_data: true, + emote_sets: 'Please use addSet(), removeSet(), or removeAllSets()', + refs: 'Please use ref() or unref()', + style: true, + users: 'Please use getUser()', + user_ids: 'Please use getUser()' + }, true); + + overrides.iterateUsers = function*() { + for(const user of this.iterateUsers()) + yield new Proxy(user, user_proxy); + } + + overrides.iterateRooms = function*() { + for(const room of this.iterateRooms()) + yield new Proxy(room, room_proxy); + } + + overrides.iterateAllRoomsAndUsers = function*() { + for(const thing of this.iterateAllRoomsAndUsers()) + yield new Proxy(thing, (thing instanceof Room) + ? room_proxy + : user_proxy + ); + } + + overrides.addTokenizer = tokenizer => { + if ( tokenizer ) + tokenizer.__source = addon_id; + + return this.addTokenizer(tokenizer); + } + + overrides.addLinkProvider = provider => { + if ( provider ) + provider.__source = addon_id; + + return this.addLinkProvider(provider); + } + + overrides.addRichProvider = provider => { + if ( provider ) + provider.__source = addon_id; + + return this.addRichProvider(provider); + } + + if ( is_dev ) { + overrides.getUser = (...args) => { + let result = this.getUser(...args); + if ( result ) + return new Proxy(result, user_proxy); + } + + overrides.getRoom = (...args) => { + let result = this.getRoom(...args); + if ( result ) + return new Proxy(result, room_proxy); + } + + overrides.removeTokenizer = tokenizer => { + let type; + if ( typeof tokenizer === 'string' ) + type = tokenizer; + else + type = tokenizer.type; + + const existing = this.tokenizers[type]; + if ( existing && existing.__source !== addon_id ) + module.log.warn('[DEV-CHECK] Removed un-owned tokenizer with chat.removeTokenizer:', type, ' owner:', existing.__source ?? 'ffz'); + + return this.removeTokenizer(tokenizer); + } + + overrides.removeLinkProvider = provider => { + let type; + if ( typeof provider === 'string' ) + type = provider; + else + type = provider.type; + + const existing = this.link_providers[type]; + if ( existing && existing.__source !== addon_id ) + module.log.warn('[DEV-CHECK] Removed un-owned link provider with chat.removeLinkProvider:', type, ' owner:', existing.__source ?? 'ffz'); + + return this.removeLinkProvider(provider); + } + + overrides.removeRichProvider = provider => { + let type; + if ( typeof provider === 'string' ) + type = provider; + else + type = provider.type; + + const existing = this.link_providers[type]; + if ( existing && existing.__source !== addon_id ) + module.log.warn('[DEV-CHECK] Removed un-owned rich provider with chat.removeRichProvider:', type, ' owner:', existing.__source ?? 'ffz'); + + return this.removeRichProvider(provider); + } + } + + return buildAddonProxy(module, this, 'chat', overrides, warnings); + } + + + onEnable() { this.socket = this.resolve('socket'); this.pubsub = this.resolve('pubsub'); @@ -1339,6 +1541,40 @@ export default class Chat extends Module { this.triggered_reload = false; }); + + this.on('addon:fully-unload', addon_id => { + let removed = 0; + for(const [key, def] of Object.entries(this.link_providers)) { + if ( def?.__source === addon_id ) { + removed++; + this.removeLinkProvider(key); + } + } + + for(const [key, def] of Object.entries(this.rich_providers)) { + if ( def?.__source === addon_id ) { + removed++; + this.removeRichProvider(key); + } + } + + for(const [key, def] of Object.entries(this.tokenizers)) { + if ( def?.__source === addon_id ) { + removed++; + this.removeTokenizer(key); + } + } + + for(const item of this.iterateAllRoomsAndUsers()) + removed += item._unloadAddon(addon_id) ?? 0; + + // If we removed things, retokenize all chat messages. + // TODO: Debounce this. + if ( removed ) { + this.log.debug(`Cleaned up ${removed} entries when unloading addon:`, addon_id); + this.emit(':update-line-tokens'); + } + }); } @@ -1500,24 +1736,49 @@ export default class Chat extends Module { } + *iterateAllRoomsAndUsers() { + for(const room of this.iterateRooms()) { + yield room; + for(const user of room.iterateUsers()) + yield user; + } + + for(const user of this.iterateUsers()) + yield user; + } + + + *iterateUsers() { + const visited = new Set; + + for(const user of Object.values(this.user_ids)) { + if ( user && ! user.destroyed ) { + visited.add(user); + yield user; + } + } + + for(const user of Object.values(this.users)) { + if ( user && ! user.destroyed ) + yield user; + } + } + + *iterateRooms() { const visited = new Set; - for(const id in this.room_ids) - if ( has(this.room_ids, id) ) { - const room = this.room_ids[id]; - if ( room && ! room.destroyed ) { - visited.add(room); - yield room; - } + for(const room of Object.values(this.room_ids)) { + if ( room && ! room.destroyed ) { + visited.add(room); + yield room; } + } - for(const login in this.rooms) - if ( has(this.rooms, login) ) { - const room = this.rooms[login]; - if ( room && ! room.destroyed && ! visited.has(room) ) - yield room; - } + for(const room of Object.values(this.rooms)) { + if ( room && ! room.destroyed && ! visited.has(room) ) + yield room; + } } diff --git a/src/modules/chat/room.js b/src/modules/chat/room.js index a4ebfede..fd4460f1 100644 --- a/src/modules/chat/room.js +++ b/src/modules/chat/room.js @@ -150,6 +150,12 @@ export default class Room { } + _unloadAddon(addon_id) { + // TODO: This + return 0; + } + + get id() { return this._id; } @@ -188,6 +194,23 @@ export default class Room { } + *iterateUsers() { + const visited = new Set; + + for(const user of Object.values(this.user_ids)) { + if ( user && ! user.destroyed ) { + visited.add(user); + yield user; + } + } + + for(const user of Object.values(this.users)) { + if ( user && ! user.destroyed ) + yield user; + } + } + + getUser(id, login, no_create, no_login, error = false) { if ( this.destroyed ) return null; diff --git a/src/modules/chat/user.js b/src/modules/chat/user.js index 5a1569a2..7c699d87 100644 --- a/src/modules/chat/user.js +++ b/src/modules/chat/user.js @@ -64,6 +64,11 @@ export default class User { } } + _unloadAddon(addon_id) { + // TODO: This + return 0; + } + get id() { return this._id; } @@ -153,6 +158,18 @@ export default class User { } + removeAllBadges(provider) { + if ( this.destroyed || ! this.badges ) + return false; + + if ( ! this.badges.has(provider) ) + return false; + + // Just yeet them all since we don't ref badges. + this.badges.delete(provider); + return true; + } + // ======================================================================== // Emote Sets diff --git a/src/modules/metadata.jsx b/src/modules/metadata.jsx index 4575d320..4be0c984 100644 --- a/src/modules/metadata.jsx +++ b/src/modules/metadata.jsx @@ -11,6 +11,7 @@ import {duration_to_string, durationForURL} from 'utilities/time'; import Tooltip from 'utilities/tooltip'; import Module from 'utilities/module'; +import { DEBUG } from 'src/utilities/constants'; const CLIP_URL = /^https:\/\/[^/]+\.(?:twitch\.tv|twitchcdn\.net)\/.+?\.mp4(?:\?.*)?$/; @@ -558,11 +559,12 @@ export default class Metadata extends Module { } - getAddonProxy(addon_id) { + getAddonProxy(addon_id, addon, module) { if ( ! addon_id ) return this; - const overrides = {}; + const overrides = {}, + is_dev = DEBUG || addon?.dev; overrides.define = (key, definition) => { if ( definition ) @@ -576,6 +578,9 @@ export default class Metadata extends Module { const thing = overrides[prop]; if ( thing ) return thing; + if ( prop === 'definitions' && is_dev ) + module.log.warn('[DEV-CHECK] Accessed metadata.definitions directly. Please use define()'); + return Reflect.get(...arguments); } }); @@ -627,8 +632,10 @@ export default class Metadata extends Module { } } - if ( removed.size ) + if ( removed.size ) { + this.log.debug(`Cleaned up ${removed.size} entries when unloading addon:`, addon_id); this.updateMetadata([...removed]); + } }); } diff --git a/src/modules/tooltips.js b/src/modules/tooltips.js index 18fb322d..ba3fb98b 100644 --- a/src/modules/tooltips.js +++ b/src/modules/tooltips.js @@ -10,6 +10,7 @@ import {has, maybe_call, once} from 'utilities/object'; import Tooltip from 'utilities/tooltip'; import Module from 'utilities/module'; import awaitMD, {getMD} from 'utilities/markdown'; +import { DEBUG } from 'src/utilities/constants'; export default class TooltipProvider extends Module { constructor(...args) { @@ -74,6 +75,41 @@ export default class TooltipProvider extends Module { this.onFSChange = this.onFSChange.bind(this); } + + getAddonProxy(addon_id, addon, module) { + if ( ! addon_id ) + return this; + + const overrides = {}, + is_dev = DEBUG || addon?.dev; + + overrides.define = (key, handler) => { + if ( handler ) + handler.__source = addon_id; + + return this.define(key, handler); + }; + + if ( is_dev ) + overrides.cleanup = () => { + module.log.warn('[DEV-CHECK] Instead of calling tooltips.cleanup(), you can emit the event "tooltips:cleanup"'); + return this.cleanup(); + }; + + return new Proxy(this, { + get(obj, prop) { + const thing = overrides[prop]; + if ( thing ) + return thing; + if ( prop === 'types' && is_dev ) + module.log.warn('[DEV-CHECK] Accessed tooltips.types directly. Please use tooltips.define()'); + + return Reflect.get(...arguments); + } + }); + } + + onEnable() { const container = this.getRoot(); @@ -86,8 +122,29 @@ export default class TooltipProvider extends Module { this.tips = this._createInstance(container); this.on(':cleanup', this.cleanup); + + this.on('addon:fully-unload', addon_id => { + let removed = 0; + for(const [key, handler] of Object.entries(this.types)) { + if ( handler?.__source === addon_id ) { + removed++; + this.types[key] = undefined; + } + } + + if ( removed ) { + this.log.debug(`Cleaned up ${removed} entries when unloading addon:`, addon_id); + this.cleanup(); + } + }); } + + define(key, handler) { + this.types[key] = handler; + } + + getRoot() { // eslint-disable-line class-methods-use-this return document.querySelector('.sunlight-root') || //document.querySelector('#root>div') || diff --git a/src/pubsub/index.js b/src/pubsub/index.js index d0a89438..9d48ecdc 100644 --- a/src/pubsub/index.js +++ b/src/pubsub/index.js @@ -146,12 +146,12 @@ export default class PubSub extends Module { } : null }); - client.on('connect', () => { - this.log.info('Connected to PubSub.'); + client.on('connect', msg => { + this.log.info('Connected to PubSub.', msg); }); - client.on('disconnect', () => { - this.log.info('Disconnected from PubSub.'); + client.on('disconnect', msg => { + this.log.info('Disconnected from PubSub.', msg); }); client.on('error', err => { diff --git a/src/utilities/module.js b/src/utilities/module.js index 9d3763b3..39a9426e 100644 --- a/src/utilities/module.js +++ b/src/utilities/module.js @@ -132,7 +132,7 @@ export class Module extends EventEmitter { 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); + const module = this.__resolve(name); if ( module && chain.includes(module) ) return Promise.reject(new CyclicDependencyError(`cyclic load requirements when loading ${initial}`, [...chain, this, module])); } @@ -154,7 +154,7 @@ export class Module extends EventEmitter { if ( this.load_requires ) { const promises = []; for(const name of this.load_requires) { - const module = this.resolve(name); + const module = this.__resolve(name); if ( ! module || !(module instanceof Module) ) throw new ModuleError(`cannot find required module ${name} when loading ${path}`); @@ -194,7 +194,7 @@ export class Module extends EventEmitter { chain.push(this); for(const dep of this.load_dependents) { - const module = this.resolve(dep); + const module = this.__resolve(dep); if ( module ) { if ( chain.includes(module) ) throw new CyclicDependencyError(`cyclic load requirements when checking if can unload ${initial}`, [...chain, this, module]); @@ -229,7 +229,7 @@ export class Module extends EventEmitter { 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); + const module = this.__resolve(dep); if ( module && chain.includes(module) ) return Promise.reject(new CyclicDependencyError(`cyclic load requirements when unloading ${initial}`, [...chain, this, module])); } @@ -257,8 +257,8 @@ export class Module extends EventEmitter { if ( this.load_dependents ) { const promises = []; for(const name of this.load_dependents) { - const module = this.resolve(name); - if ( ! module ) + const module = this.__resolve(name); + if ( ! module || !(module instanceof Module) ) //throw new ModuleError(`cannot find depending module ${name} when unloading ${path}`); continue; @@ -296,7 +296,7 @@ export class Module extends EventEmitter { 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); + const module = this.__resolve(name); if ( module && chain.includes(module) ) return Promise.reject(new CyclicDependencyError(`cyclic requirements when enabling ${initial}`, [...chain, this, module])); } @@ -329,7 +329,7 @@ export class Module extends EventEmitter { if ( requires ) for(const name of requires) { - const module = this.resolve(name); + const module = this.__resolve(name); if ( ! module || !(module instanceof Module) ) throw new ModuleError(`cannot find required module ${name} when enabling ${path}`); @@ -368,8 +368,8 @@ export class Module extends EventEmitter { chain.push(this); for(const dep of this.dependents) { - const module = this.resolve(dep); - if ( module ) { + const module = this.__resolve(dep); + if ( module && (module instanceof Module) ) { if ( chain.includes(module) ) throw new CyclicDependencyError(`cyclic load requirements when checking if can disable ${initial}`, [...chain, this, module]); @@ -400,7 +400,7 @@ export class Module extends EventEmitter { 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); + const module = this.__resolve(dep); if ( module && chain.includes(module) ) return Promise.reject(new CyclicDependencyError(`cyclic requirements when disabling ${initial}`, [...chain, this, dep])); } @@ -430,8 +430,8 @@ export class Module extends EventEmitter { if ( this.dependents ) { const promises = []; for(const name of this.dependents) { - const module = this.resolve(name); - if ( ! module ) + const module = this.__resolve(name); + if ( ! module || !(module instanceof Module) ) // Assume a non-existent module isn't enabled. //throw new ModuleError(`cannot find depending module ${name} when disabling ${path}`); continue; @@ -499,19 +499,19 @@ export class Module extends EventEmitter { // ======================================================================== loadModules(...names) { - return Promise.all(names.map(n => this.resolve(n).load())) + return Promise.all(names.map(n => this.__resolve(n).load())) } unloadModules(...names) { - return Promise.all(names.map(n => this.resolve(n).unload())) + return Promise.all(names.map(n => this.__resolve(n).unload())) } enableModules(...names) { - return Promise.all(names.map(n => this.resolve(n).enable())) + return Promise.all(names.map(n => this.__resolve(n).enable())) } disableModules(...names) { - return Promise.all(names.map(n => this.resolve(n).disable())) + return Promise.all(names.map(n => this.__resolve(n).disable())) } @@ -519,13 +519,24 @@ export class Module extends EventEmitter { // Module Management // ======================================================================== - resolve(name) { + __resolve(name) { if ( name instanceof Module ) return name; return this.__modules[this.abs_path(name)]; } + resolve(name) { + let module = this.__resolve(name); + if ( !(module instanceof Module) ) + return null; + + if ( this.__processModule ) + module = this.__processModule(module); + + return module; + } + hasModule(name) { const module = this.__modules[this.abs_path(name)]; @@ -549,7 +560,7 @@ export class Module extends EventEmitter { } - __processModule(module, name) { + __processModule(module) { if ( this.addon_root && module.getAddonProxy ) { const addon_id = this.addon_id; if ( ! module.__proxies ) @@ -558,7 +569,7 @@ export class Module extends EventEmitter { if ( module.__proxies[addon_id] ) return module.__proxies[addon_id]; - const addon = this.resolve('addons')?.getAddon?.(addon_id), + const addon = this.__resolve('addons')?.getAddon?.(addon_id), out = module.getAddonProxy(addon_id, addon, this.addon_root, this); if ( out !== module ) @@ -596,7 +607,7 @@ export class Module extends EventEmitter { // Just a Name const full_name = name; name = name.replace(/^(?:[^.]*\.)+/, ''); - module = this.resolve(full_name); + module = this.__resolve(full_name); // Allow injecting a module that doesn't exist yet? @@ -625,8 +636,8 @@ export class Module extends EventEmitter { module.references.push([this.__path, name]); - if ( this.__processModule ) - module = this.__processModule(module, name); + if ( (module instanceof Module) && this.__processModule ) + module = this.__processModule(module); return this[name] = module; } @@ -657,7 +668,7 @@ export class Module extends EventEmitter { // Just a Name const full_name = name; name = name.replace(/^(?:[^.]*\.)+/, ''); - module = this.resolve(full_name); + module = this.__resolve(full_name); // Allow injecting a module that doesn't exist yet? @@ -687,7 +698,7 @@ export class Module extends EventEmitter { module.references.push([this.__path, variable]); - if ( this.__processModule ) + if ( (module instanceof Module) && this.__processModule ) module = this.__processModule(module, name); return this[variable] = module; @@ -711,9 +722,9 @@ export class Module extends EventEmitter { if ( old_val instanceof Module ) throw new ModuleError(`Name Collision for Module ${path}`); - const dependents = old_val || [[], [], []], - inst = this.__modules[path] = new module(name, this), - requires = inst.requires = inst.__get_requires() || [], + const dependents = old_val || [[], [], []]; + let inst = this.__modules[path] = new module(name, this); + const requires = inst.requires = inst.__get_requires() || [], load_requires = inst.load_requires = inst.__get_load_requires() || []; inst.dependents = dependents[0]; @@ -748,13 +759,16 @@ export class Module extends EventEmitter { } for(const [in_path, in_name] of dependents[2]) { - const in_mod = this.resolve(in_path); + const in_mod = this.__resolve(in_path); if ( in_mod ) in_mod[in_name] = inst; else this.log.warn(`Unable to find module "${in_path}" that wanted "${in_name}".`); } + if ( (inst instanceof Module) && this.__processModule ) + inst = this.__processModule(inst, name); + if ( inject_reference ) this[name] = inst; @@ -806,6 +820,45 @@ export class SiteModule extends Module { export default Module; +export function buildAddonProxy(accessor, thing, name, overrides, access_warnings, no_proxy = false) { + + const handler = { + get(obj, prop) { + // First, handle basic overrides behavior. + let value = overrides[prop]; + if ( value !== undefined ) { + // Check for functions, and bind their this. + if ( typeof value === 'function' ) + return value.bind(obj); + return value; + } + + // Next, handle access warnings. + const warning = access_warnings && access_warnings[prop]; + if ( accessor?.log && warning ) + accessor.log.warn(`[DEV-CHECK] Accessed ${name}.${prop} directly. ${typeof warning === 'string' ? warning : ''}`) + + // Check for functions, and bind their this. + value = obj[prop]; + if ( typeof value === 'function' ) + return value.bind(obj); + + // Make sure all module access is proxied. + if ( accessor && (value instanceof Module) ) + return accessor.resolve(value); + + // Return whatever it would be normally. + return Reflect.get(...arguments); + } + }; + + return no_proxy ? handler : new Proxy(thing, handler); + +} + +Module.buildAddonProxy = buildAddonProxy; + + // ============================================================================ // Errors // ============================================================================ diff --git a/src/utilities/object.js b/src/utilities/object.js index c7f46e4b..2cdeb604 100644 --- a/src/utilities/object.js +++ b/src/utilities/object.js @@ -580,6 +580,27 @@ export function deep_copy(object, seen) { } +export function normalizeAddonIdForComparison(input) { + return input.toLowerCase().replace(/[\.\_\-]+/, '-'); +} + +export function makeAddonIdChecker(input) { + input = escape_regex(normalizeAddonIdForComparison(input)); + input = input.replace(/-+/g, '[\.\_\-]+'); + + // Special: ffzap-bttv + input = input.replace(/\bbttv\b/g, '(?:bttv|betterttv)'); + + // Special: which seven tho + input = input.replace(/\b7tv\b/g, '(?:7tv|seventv)'); + + // Special: pronouns (badges) + input = input.replace(/\bpronouns\b/g, '(?:pronouns|addon-pn)'); + + return new RegExp('\\b' + input + '\\b', 'i'); +} + + export function maybe_call(fn, ctx, ...args) { if ( typeof fn === 'function' ) { if ( ctx ) diff --git a/src/utilities/pubsub.js b/src/utilities/pubsub.js index af1c5881..83e7e7b4 100644 --- a/src/utilities/pubsub.js +++ b/src/utilities/pubsub.js @@ -46,6 +46,11 @@ export default class PubSubClient extends EventEmitter { // Topics is a map of topics to sub-topic IDs. this._topics = new Map; + // Incorrect Topics is a map of every incorrect topic mapping + // we are currently subscribed to, for the purpose of unsubscribing + // from them. + this._incorrect_topics = new Map; + // Live Topics is a set of every topic we have sent subscribe // packets to the server for. this._live_topics = new Set; @@ -60,6 +65,8 @@ export default class PubSubClient extends EventEmitter { // Debounce a few things. this.scheduleHeartbeat = this.scheduleHeartbeat.bind(this); this._sendHeartbeat = this._sendHeartbeat.bind(this); + this.scheduleResub = this.scheduleResub.bind(this); + this._sendResub = this._sendResub.bind(this); this._fetchNewTopics = this._fetchNewTopics.bind(this); this._sendSubscribes = debounce(this._sendSubscribes, 250); @@ -157,9 +164,28 @@ export default class PubSubClient extends EventEmitter { // Record all the topic mappings we just got. // TODO: Check for subtopic mismatches. // TODO: Check for removed subtopic assignments. - if ( data.topics ) - for(const [key, val] of Object.entries(data.topics)) + if ( data.topics ) { + const removed = new Set(this._topics.keys()); + + for(const [key, val] of Object.entries(data.topics)) { + removed.delete(key); + const existing = this._topics.get(key); + if ( existing != null && existing != val ) + this._incorrect_topics.set(key, existing); this._topics.set(key, val); + } + + for(const key of removed) { + const existing = this._topics.get(key); + this._topics.delete(key); + this._incorrect_topics.set(key, existing); + } + + // If we have a mismatch, handle it. + if ( this._incorrect_topics.size ) + this._sendUnsubscribes() + .then(() => this._sendSubscribes()); + } // Update the heartbeat timer. this.scheduleHeartbeat(); @@ -307,7 +333,7 @@ export default class PubSubClient extends EventEmitter { } // ======================================================================== - // Client Management + // Keep Alives // ======================================================================== clearHeartbeat() { @@ -329,10 +355,38 @@ export default class PubSubClient extends EventEmitter { .finally(this.scheduleHeartbeat); } + + clearResubTimer() { + if ( this._resub_timer ) { + clearTimeout(this._resub_timer); + this._resub_timer = null; + } + } + + scheduleResub() { + if ( this._resub_timer ) + clearTimeout(this._resub_timer); + + // Send a resubscription every 30 minutes. + this._resub_timer = setTimeout(this._sendResub, 30 * 60 * 1000); + } + + _sendResub() { + this._resendSubscribes() + .finally(this.scheduleResub); + } + + + // ======================================================================== + // Client Management + // ======================================================================== + _destroyClient() { if ( ! this._client ) return; + this.clearResubTimer(); + try { this._client.disconnect().catch(() => {}); } catch(err) { /* no-op */ } @@ -361,8 +415,14 @@ export default class PubSubClient extends EventEmitter { maxMessagesPerSecond: 10 }); + let disconnected = false; + this._client.onMqttMessage = message => { if ( message.type === DISCONNECT ) { + if ( disconnected ) + return; + + disconnected = true; this.emit('disconnect', message); this._destroyClient(); @@ -441,12 +501,64 @@ export default class PubSubClient extends EventEmitter { clean: true }).then(msg => { this._state = State.Connected; - this.emit('connect'); + this.emit('connect', msg); + + // Periodically re-send our subscriptions. This + // is done because the broker seems to be forgetful + // for long-lived connections. + this.scheduleResub(); + + // Reconnect when this connection ends. + if ( client.connectionCompletion ) + client.connectionCompletion.finally(() => { + if ( disconnected ) + return; + + disconnected = true; + this.emit('disconnect', null); + this._destroyClient(); + + if ( this._should_connect ) + this._createClient(data); + }); return this._sendSubscribes() }); } + _resendSubscribes() { + if ( ! this._client ) + return Promise.resolve(); + + const topics = [], + batch = []; + + for(const topic of this._live_topics) { + const subtopic = this._topics.get(topic); + if ( subtopic != null ) { + if ( subtopic === 0 ) + topics.push(topic); + else + topics.push(`${topic}/s${subtopic}`); + + batch.push(topic); + } + } + + if ( ! topics.length ) + return Promise.resolve(); + + return this._client.subscribe({topicFilter: topics}) + .catch(() => { + // If there was an error, we did NOT subscribe. + for(const topic of batch) + this._live_topics.delete(topic); + + if ( this._live_topics.size != this._active_topics.size ) + return sleep(2000).then(() => this._sendSubscribes()); + }); + } + _sendSubscribes() { if ( ! this._client ) return Promise.resolve(); @@ -474,25 +586,27 @@ export default class PubSubClient extends EventEmitter { } } - if ( topics.length ) - return this._client.subscribe({topicFilter: topics }) - .catch(() => { - // If there was an error, we did NOT subscribe. - for(const topic of batch) - this._live_topics.delete(topic); - - // Call sendSubscribes again after a bit. - return sleep(2000).then(() => this._sendSubscribes()); - }); - else + if ( ! topics.length ) return Promise.resolve(); + + return this._client.subscribe({topicFilter: topics }) + .catch(() => { + // If there was an error, we did NOT subscribe. + for(const topic of batch) + this._live_topics.delete(topic); + + // Call sendSubscribes again after a bit. + if ( this._live_topics.size != this._active_topics.size ) + return sleep(2000).then(() => this._sendSubscribes()); + }); } _sendUnsubscribes() { if ( ! this._client ) return Promise.resolve(); - const topics = []; + const topics = [], + batch = new Set; // iterate over a copy to support removal for(const topic of [...this._live_topics]) { @@ -511,17 +625,32 @@ export default class PubSubClient extends EventEmitter { real_topic = `${topic}/s${subtopic}`; topics.push(real_topic); + batch.add(topic); this._live_topics.delete(topic); } - if ( topics.length ) - return this._client.unsubscribe({topicFilter: topics}) - .catch(error => { - if ( this.logger ) - this.logger.warn('Received error when unsubscribing from topics:', error); - }); - else + // handle incorrect topics + for(const [topic, subtopic] of this._incorrect_topics) { + if ( batch.has(topic) ) + continue; + + batch.add(topic); + if ( subtopic === 0 ) + topics.push(topic); + else + topics.push(`${topic}/s${subtopic}`); + + this._live_topics.delete(topic); + } + + if ( ! topics.length ) return Promise.resolve(); + + return this._client.unsubscribe({topicFilter: topics}) + .catch(error => { + if ( this.logger ) + this.logger.warn('Received error when unsubscribing from topics:', error); + }); } } \ No newline at end of file