Emote mofule refactoring, vrwrapper and hasOwnProperty treewalk check
This commit is contained in:
parent
42e2569569
commit
8bac53e9be
|
@ -0,0 +1,46 @@
|
||||||
|
/**
|
||||||
|
* BetterDiscord Emote Component
|
||||||
|
* Copyright (c) 2015-present Jiiks/JsSucks - https://github.com/Jiiks / https://github.com/JsSucks
|
||||||
|
* All rights reserved.
|
||||||
|
* https://betterdiscord.net
|
||||||
|
*
|
||||||
|
* This source code is licensed under the MIT license found in the
|
||||||
|
* LICENSE file in the root directory of this source tree.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import VrWrapper from '../ui/vrwrapper';
|
||||||
|
import EmoteComponent from './EmoteComponent.vue';
|
||||||
|
|
||||||
|
const EMOTE_SOURCES = [
|
||||||
|
'https://static-cdn.jtvnw.net/emoticons/v1/:id/1.0',
|
||||||
|
'https://cdn.frankerfacez.com/emoticon/:id/1',
|
||||||
|
'https://cdn.betterttv.net/emote/:id/1x'
|
||||||
|
]
|
||||||
|
|
||||||
|
export default class Emote extends VrWrapper {
|
||||||
|
|
||||||
|
constructor(type, id, name) {
|
||||||
|
super();
|
||||||
|
this.jumboable = false;
|
||||||
|
this.type = type;
|
||||||
|
this.id = id;
|
||||||
|
this.name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
get component() { return EmoteComponent }
|
||||||
|
|
||||||
|
get props() {
|
||||||
|
return {
|
||||||
|
src: this.parseSrc(),
|
||||||
|
name: this.name,
|
||||||
|
jumboable: this.jumboable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parseSrc() {
|
||||||
|
const { type, id } = this;
|
||||||
|
if (type > 2 || type < 0) return '';
|
||||||
|
return EMOTE_SOURCES[type].replace(':id', id);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -1,11 +1,5 @@
|
||||||
<template>
|
<template>
|
||||||
<span class="bd-emotewrapper" :class="{'bd-emoteFavourite': favourite, 'bd-emoteNoWrapper': !hasWrapper}" v-tooltip="name" :data-emote-name="name">
|
<img class="emoji" :class="{jumboable}" :src="src" :alt="`;${name};`" v-tooltip="{ content: `;${name};`, delay: { show: 750, hide: 0 } }" />
|
||||||
<img class="bd-emote" :src="src" :alt="`;${name};`" />
|
|
||||||
|
|
||||||
<div class="bd-emoteFavouriteButton" :class="{'bd-active': favourite}" @click="toggleFavourite">
|
|
||||||
<MiStar :size="16" />
|
|
||||||
</div>
|
|
||||||
</span>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
@ -17,7 +11,7 @@
|
||||||
components: {
|
components: {
|
||||||
MiStar
|
MiStar
|
||||||
},
|
},
|
||||||
props: ['src', 'name', 'hasWrapper'],
|
props: ['src', 'name', 'hasWrapper', 'jumboable'],
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
EmoteModule
|
EmoteModule
|
||||||
|
@ -25,7 +19,7 @@
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
favourite() {
|
favourite() {
|
||||||
return EmoteModule.isFavourite(this.name);
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|
|
@ -8,221 +8,136 @@
|
||||||
* LICENSE file in the root directory of this source tree.
|
* LICENSE file in the root directory of this source tree.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Settings, Globals, WebpackModules, ReactComponents, MonkeyPatch } from 'modules';
|
import BuiltinModule from './BuiltinModule';
|
||||||
import { VueInjector, Reflection } from 'ui';
|
|
||||||
import { Utils, FileUtils, ClientLogger as Logger } from 'common';
|
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import EmoteComponent from './EmoteComponent.vue';
|
import { Utils, FileUtils, ClientLogger as Logger } from 'common';
|
||||||
import Autocomplete from '../ui/components/common/Autocomplete.vue';
|
import { Settings, Globals, WebpackModules, ReactComponents, MonkeyPatch, Cache } from 'modules';
|
||||||
|
|
||||||
const enforceWrapperFrom = (new Date('2018-05-01')).valueOf();
|
import Emote from './EmoteComponent.js';
|
||||||
|
|
||||||
export default new class EmoteModule {
|
export default new class EmoteModule extends BuiltinModule {
|
||||||
|
|
||||||
constructor() {
|
get dbpath() { return path.join(Globals.getPath('data'), 'emotes.json') }
|
||||||
this.emotes = new Map();
|
|
||||||
this.favourite_emotes = [];
|
get database() { return this._db || (this._db = new Map()) }
|
||||||
|
|
||||||
|
get favourites() { return this._favourites || (this._favourites = []) }
|
||||||
|
|
||||||
|
get settingPath() { return ['emotes', 'default', 'enable'] }
|
||||||
|
|
||||||
|
async enabled() {
|
||||||
|
|
||||||
|
if (!this.database.size) {
|
||||||
|
await this.loadLocalDb();
|
||||||
}
|
}
|
||||||
|
|
||||||
async init() {
|
this.patchMessageContent();
|
||||||
const dataPath = Globals.getPath('data');
|
const selector = `.${WebpackModules.getClassName('channelTextArea', 'emojiButton')}`;
|
||||||
|
const cta = await ReactComponents.getComponent('ChannelTextArea', { selector });
|
||||||
this.enabledSetting = Settings.getSetting('emotes', 'default', 'enable');
|
MonkeyPatch('BD:EMOTEMODULE', cta.component.prototype).before('handleSubmit', this.handleChannelTextAreaSubmit.bind(this));
|
||||||
this.enabledSetting.on('setting-updated', async event => {
|
|
||||||
// Load emotes if we haven't already
|
|
||||||
if (event.value && !this.emotes.size) await this.load(path.join(dataPath, 'emotes.json'));
|
|
||||||
|
|
||||||
// Rerender all messages (or if we're disabling emotes, those that have emotes)
|
|
||||||
for (const message of document.querySelectorAll(event.value ? '.message' : '.bd-emotewrapper')) {
|
|
||||||
Reflection(event.value ? message : message.closest('.message')).forceUpdate();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async disabled() {
|
||||||
|
for (const patch of Patcher.getPatchesByCaller('BD:EMOTEMODULE')) patch.unpatch();
|
||||||
|
}
|
||||||
|
|
||||||
|
processMarkup(markup) {
|
||||||
|
const newMarkup = [];
|
||||||
|
window.markup = markup;
|
||||||
|
const jumboable = !markup.some(child => {
|
||||||
|
if (typeof child !== 'string') return false;
|
||||||
|
|
||||||
|
return / \w+/g.test(child);
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
|
||||||
if (this.enabledSetting.value) await this.load(path.join(dataPath, 'emotes.json'));
|
|
||||||
else Logger.info('EmoteModule', ['Not loading emotes as they\'re disabled.']);
|
|
||||||
} catch (err) {
|
|
||||||
Logger.err('EmoteModule', [`Failed to load emote data. Make sure you've downloaded the emote data and placed it in ${dataPath}:`, err]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await Promise.all([
|
|
||||||
this.patchMessageContent(),
|
|
||||||
this.patchChannelTextArea()
|
|
||||||
]);
|
|
||||||
} catch (err) {
|
|
||||||
Logger.err('EmoteModule', ['Error patching Message / ChannelTextArea', err]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async load(dataPath) {
|
|
||||||
const emotes = await FileUtils.readJsonFromFile(dataPath);
|
|
||||||
for (const [index, emote] of emotes.entries()) {
|
|
||||||
// Pause every 10000 emotes so the window doesn't freeze
|
|
||||||
if ((index % 10000) === 0)
|
|
||||||
await Utils.wait();
|
|
||||||
|
|
||||||
const uri = emote.type === 2 ? 'https://cdn.betterttv.net/emote/:id/1x'
|
|
||||||
: emote.type === 1 ? 'https://cdn.frankerfacez.com/emoticon/:id/1'
|
|
||||||
: 'https://static-cdn.jtvnw.net/emoticons/v1/:id/1.0';
|
|
||||||
|
|
||||||
// emote.id is the emote's name
|
|
||||||
// emote.src is the emote's URL
|
|
||||||
emote.src = uri.replace(':id', emote.value.id || emote.value);
|
|
||||||
this.emotes.set(emote.id, emote);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets an emote as favourite.
|
|
||||||
* @param {String} emote The name of the emote
|
|
||||||
* @param {Boolean} favourite The new favourite state
|
|
||||||
* @param {Boolean} save Whether to save settings
|
|
||||||
* @return {Promise}
|
|
||||||
*/
|
|
||||||
setFavourite(emote, favourite, save = true) {
|
|
||||||
emote = emote.id || emote;
|
|
||||||
if (favourite && !this.favourite_emotes.includes(emote)) this.favourite_emotes.push(emote);
|
|
||||||
if (!favourite) Utils.removeFromArray(this.favourite_emotes, emote);
|
|
||||||
if (save) return Settings.saveSettings();
|
|
||||||
}
|
|
||||||
|
|
||||||
addFavourite(emote, save = true) {
|
|
||||||
return this.setFavourite(emote, true, save);
|
|
||||||
}
|
|
||||||
|
|
||||||
removeFavourite(emote, save = true) {
|
|
||||||
return this.setFavourite(emote, false, save);
|
|
||||||
}
|
|
||||||
|
|
||||||
isFavourite(emote) {
|
|
||||||
emote = emote.id || emote;
|
|
||||||
return this.favourite_emotes.includes(emote);
|
|
||||||
}
|
|
||||||
|
|
||||||
get searchCache() {
|
|
||||||
return this._searchCache || (this._searchCache = {});
|
|
||||||
}
|
|
||||||
|
|
||||||
processMarkup(markup, timestamp) {
|
|
||||||
timestamp = timestamp.valueOf();
|
|
||||||
const allowNoWrapper = timestamp < enforceWrapperFrom;
|
|
||||||
|
|
||||||
const newMarkup = [];
|
|
||||||
for (const child of markup) {
|
for (const child of markup) {
|
||||||
if (typeof child !== 'string') {
|
if (typeof child !== 'string') {
|
||||||
|
if (typeof child === 'object') {
|
||||||
|
const isEmoji = Utils.findInReactTree(child, 'emojiName');
|
||||||
|
if (isEmoji) child.props.children.props.jumboable = jumboable;
|
||||||
|
}
|
||||||
newMarkup.push(child);
|
newMarkup.push(child);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!this.testWord(child) && !allowNoWrapper) {
|
|
||||||
|
if (!/:(\w+):/g.test(child)) {
|
||||||
newMarkup.push(child);
|
newMarkup.push(child);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const words = child.split(/([^\s]+)([\s]|$)/g);
|
|
||||||
if (!words) continue;
|
|
||||||
let text = null;
|
|
||||||
for (const [wordIndex, word] of words.entries()) {
|
|
||||||
const emote = this.getEmote(word);
|
|
||||||
if (emote) {
|
|
||||||
if (text !== null) {
|
|
||||||
newMarkup.push(text);
|
|
||||||
text = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
newMarkup.push(VueInjector.createReactElement(EmoteComponent, {
|
const words = child.split(/([^\s]+)([\s]|$)/g).filter(f => f !== '');
|
||||||
src: emote.src,
|
|
||||||
name: emote.id,
|
|
||||||
hasWrapper: /;[\w]+;/gmi.test(word)
|
|
||||||
}));
|
|
||||||
|
|
||||||
|
let s = '';
|
||||||
|
for (const word of words) {
|
||||||
|
const isemote = /:(.*?):/g.exec(word);
|
||||||
|
if (!isemote) {
|
||||||
|
s += word;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (text === null) {
|
|
||||||
text = word;
|
const emote = this.findByName(isemote[1]);
|
||||||
} else {
|
if (!emote) {
|
||||||
text += word;
|
s += word;
|
||||||
}
|
continue;
|
||||||
if (wordIndex === words.length - 1) {
|
|
||||||
newMarkup.push(text);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
newMarkup.push(s);
|
||||||
|
s = '';
|
||||||
|
|
||||||
|
emote.jumboable = jumboable;
|
||||||
|
newMarkup.push(emote.render());
|
||||||
}
|
}
|
||||||
|
if (s !== '') newMarkup.push(s);
|
||||||
}
|
}
|
||||||
|
|
||||||
return newMarkup;
|
return newMarkup;
|
||||||
}
|
}
|
||||||
|
|
||||||
testWord(word) {
|
|
||||||
return !/;[\w]+;/gmi.test(word);
|
|
||||||
}
|
|
||||||
|
|
||||||
findByProp(obj, what, value) {
|
|
||||||
if (obj.hasOwnProperty(what) && obj[what] === value) return obj;
|
|
||||||
if (obj.props && !obj.children) return this.findByProp(obj.props, what, value);
|
|
||||||
if (!obj.children || !obj.children.length) return null;
|
|
||||||
for (const child of obj.children) {
|
|
||||||
if (!child) continue;
|
|
||||||
const findInChild = this.findByProp(child, what, value);
|
|
||||||
if (findInChild) return findInChild;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
getEmote(word) {
|
|
||||||
const name = word.replace(/;/g, '');
|
|
||||||
return this.emotes.get(name);
|
|
||||||
}
|
|
||||||
|
|
||||||
filter(regex, limit, start = 0) {
|
|
||||||
const key = `${regex}:${limit}:${start}`;
|
|
||||||
if (this.searchCache.hasOwnProperty(key)) return this.searchCache[key];
|
|
||||||
let index = 0;
|
|
||||||
let startIndex = 0;
|
|
||||||
|
|
||||||
const matching = this.searchCache[key] = [];
|
|
||||||
for (const emote of this.emotes.values()) {
|
|
||||||
if (index >= limit) break;
|
|
||||||
if (regex.test(emote.id)) {
|
|
||||||
if (startIndex < start) {
|
|
||||||
startIndex++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
index++;
|
|
||||||
matching.push(emote);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return matching;
|
|
||||||
}
|
|
||||||
|
|
||||||
async patchMessageContent() {
|
async patchMessageContent() {
|
||||||
const selector = `.${WebpackModules.getClassName('container', 'containerCozy', 'containerCompact', 'edited')}`;
|
const selector = `.${WebpackModules.getClassName('container', 'containerCozy', 'containerCompact', 'edited')}`;
|
||||||
const MessageContent = await ReactComponents.getComponent('MessageContent', { selector });
|
const MessageContent = await ReactComponents.getComponent('MessageContent', { selector });
|
||||||
|
MonkeyPatch('BD:EMOTEMODULE', MessageContent.component.prototype).after('render', this.afterRenderMessageContent.bind(this));
|
||||||
this.unpatchRender = MonkeyPatch('BD:EmoteModule', MessageContent.component.prototype).after('render', (component, args, retVal) => {
|
|
||||||
try {
|
|
||||||
// First child has all the actual text content, second is the edited timestamp
|
|
||||||
const markup = retVal.props.children[1].props;
|
|
||||||
if (!markup || !markup.children || !this.enabledSetting.value) return;
|
|
||||||
markup.children[1] = this.processMarkup(markup.children[1], component.props.message.editedTimestamp || component.props.message.timestamp);
|
|
||||||
} catch (err) {
|
|
||||||
Logger.err('EmoteModule', err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
MessageContent.forceUpdateAll();
|
MessageContent.forceUpdateAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
async patchChannelTextArea() {
|
afterRenderMessageContent(component, args, retVal) {
|
||||||
const selector = `.${WebpackModules.getClassName('channelTextArea', 'emojiButton')}`;
|
const markup = Utils.findInReactTree(retVal, filter =>
|
||||||
const ChannelTextArea = await ReactComponents.getComponent('ChannelTextArea', {selector});
|
filter &&
|
||||||
|
filter.className &&
|
||||||
|
filter.className.includes('markup') &&
|
||||||
|
filter.children.length >= 2);
|
||||||
|
|
||||||
this.unpatchChannelTextArea = MonkeyPatch('BD:EmoteModule', ChannelTextArea.component.prototype).after('render', (component, args, retVal) => {
|
if (!markup) return;
|
||||||
if (!(retVal.props.children instanceof Array)) retVal.props.children = [retVal.props.children];
|
markup.children[1] = this.processMarkup(markup.children[1]);
|
||||||
|
}
|
||||||
|
|
||||||
retVal.props.children.splice(0, 0, VueInjector.createReactElement(Autocomplete, {}, true));
|
handleChannelTextAreaSubmit(component, args, retVal) {
|
||||||
});
|
component.props.value = component.props.value.split(' ').map(word => {
|
||||||
|
const isEmote = /;(.*?);/g.exec(word);
|
||||||
|
return isEmote ? `:${isEmote[1]}:` : word;
|
||||||
|
}).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
ChannelTextArea.forceUpdateAll();
|
async loadLocalDb() {
|
||||||
|
const emotes = await FileUtils.readJsonFromFile(this.dbpath);
|
||||||
|
for (const [index, emote] of emotes.entries()) {
|
||||||
|
const { type, id, src, value } = emote;
|
||||||
|
if (index % 10000 === 0) await Utils.wait();
|
||||||
|
|
||||||
|
this.database.set(id, { id: emote.value.id || value, type });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
findByName(name) {
|
||||||
|
const emote = this.database.get(name);
|
||||||
|
if (!emote) return null;
|
||||||
|
return this.parseEmote(name, emote);
|
||||||
|
}
|
||||||
|
|
||||||
|
parseEmote(name, emote) {
|
||||||
|
const { type, id } = emote;
|
||||||
|
if (type < 0 || type > 2) return null;
|
||||||
|
return new Emote(type, id, name);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,3 +9,5 @@ export { default as Reflection } from './reflection';
|
||||||
|
|
||||||
export { default as ProfileBadges } from './profilebadges';
|
export { default as ProfileBadges } from './profilebadges';
|
||||||
export { default as ClassNormaliser } from './classnormaliser';
|
export { default as ClassNormaliser } from './classnormaliser';
|
||||||
|
|
||||||
|
export { default as VrWrapper } from './vrwrapper';
|
||||||
|
|
|
@ -0,0 +1,11 @@
|
||||||
|
/* Simple helper class for wrapping vue into react */
|
||||||
|
|
||||||
|
import VueInjector from './vueinjector';
|
||||||
|
|
||||||
|
export default class VueReactWrapper {
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return this.reactComponent = VueInjector.createReactElement(this.component, this.props);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -71,7 +71,10 @@ export class Utils {
|
||||||
* @param {Array<string>} [options.ignore=[]] Array of strings to use as keys to exclude from the search, most helpful when `walkable = null`.
|
* @param {Array<string>} [options.ignore=[]] Array of strings to use as keys to exclude from the search, most helpful when `walkable = null`.
|
||||||
*/
|
*/
|
||||||
static findInTree(tree, searchFilter, { walkable = null, ignore = [] }) {
|
static findInTree(tree, searchFilter, { walkable = null, ignore = [] }) {
|
||||||
if (searchFilter(tree)) return tree;
|
if (typeof searchFilter === 'string') {
|
||||||
|
if (tree.hasOwnProperty(searchFilter)) return tree[searchFilter];
|
||||||
|
} else if (searchFilter(tree)) return tree;
|
||||||
|
|
||||||
if (typeof tree !== 'object' || tree == null) return undefined;
|
if (typeof tree !== 'object' || tree == null) return undefined;
|
||||||
|
|
||||||
let tempReturn = undefined;
|
let tempReturn = undefined;
|
||||||
|
|
Loading…
Reference in New Issue