1
0
Fork 0
mirror of https://github.com/FrankerFaceZ/FrankerFaceZ.git synced 2025-06-27 21:05:53 +00:00
* Fixed: When unloading an emote set, also unload its CSS block.
* API Added: `badges.removeBadge()` method for removing a badge from the system.
* API Added: `chat.iterateUsers()` method to iterate all User instances.
* API Added: `getUser().removeAllBadges()` method to remove all badges from a provider.
* API Added: `getRoom().iterateUsers()` method to iterate all User instances on a Room instance.
* API Added: `tooltips.define()` method to add a tool-tip handler to the system.
* API Changed: All core modules now use the add-on proxies to track which add-ons are responsible for injecting content, and to make it easier to unload (and reload) add-ons.
* API Changed: Many core module methods now display warning messages in the console when used improperly. For now, this mainly checks that IDs are correct, but there may be more warnings in the future.
* API Fixed: Calling `resolve()` will now properly use add-on proxies.
* Experiment Changed: Improved handling for server disconnects with the MQTT experiment, as well as handling for topic mapping changes.
This commit is contained in:
SirStendec 2023-11-04 19:00:27 -04:00
parent c4da7fa9d9
commit 675512e811
13 changed files with 1008 additions and 146 deletions

View file

@ -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",

View file

@ -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;
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');
this._updateContexts();
}
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();
}

View file

@ -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;

View file

@ -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;
}

View file

@ -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,21 +1736,46 @@ 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];
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];
for(const room of Object.values(this.rooms)) {
if ( room && ! room.destroyed && ! visited.has(room) )
yield room;
}

View file

@ -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;

View file

@ -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

View file

@ -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]);
}
});
}

View file

@ -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,7 +122,28 @@ 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') ||

View file

@ -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 => {

View file

@ -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
// ============================================================================

View file

@ -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 )

View file

@ -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,7 +586,9 @@ export default class PubSubClient extends EventEmitter {
}
}
if ( topics.length )
if ( ! topics.length )
return Promise.resolve();
return this._client.subscribe({topicFilter: topics })
.catch(() => {
// If there was an error, we did NOT subscribe.
@ -482,17 +596,17 @@ export default class PubSubClient extends EventEmitter {
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());
});
else
return Promise.resolve();
}
_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 )
// 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);
});
else
return Promise.resolve();
}
}