BetterDiscordApp-v2/client/src/builtin/EmoteModule.js

227 lines
8.0 KiB
JavaScript
Raw Normal View History

/**
* BetterDiscord Emote Module
* 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.
*/
2018-03-21 21:47:46 +01:00
2018-03-31 02:17:42 +02:00
import { Events, Settings, Globals, WebpackModules, ReactComponents, MonkeyPatch } from 'modules';
import { DOM, VueInjector, Reflection } from 'ui';
2018-03-31 02:17:42 +02:00
import { Utils, FileUtils, ClientLogger as Logger } from 'common';
2018-03-22 03:13:32 +01:00
import path from 'path';
import EmoteComponent from './EmoteComponent.vue';
import Autocomplete from '../ui/components/common/Autocomplete.vue';
2018-03-21 21:47:46 +01:00
2018-03-31 02:17:42 +02:00
const enforceWrapperFrom = (new Date('2018-05-01')).valueOf();
2018-03-31 02:17:42 +02:00
export default new class EmoteModule {
2018-03-22 03:13:32 +01:00
2018-03-31 02:17:42 +02:00
constructor() {
2018-03-31 05:37:26 +02:00
this.emotes = new Map();
2018-03-31 02:17:42 +02:00
this.favourite_emotes = [];
}
2018-03-31 05:37:26 +02:00
async init() {
2018-03-31 04:26:42 +02:00
this.enabledSetting = Settings.getSetting('emotes', 'default', 'enable');
this.enabledSetting.on('setting-updated', event => {
// Rerender all messages (or if we're disabling emotes, those that have emotes)
for (const message of document.querySelectorAll(event.value ? '.message' : '.bd-emotewrapper')) {
2018-03-31 04:26:42 +02:00
Reflection(event.value ? message : message.closest('.message')).forceUpdate();
}
});
2018-03-31 05:37:26 +02:00
const dataPath = Globals.getPath('data');
try {
2018-04-04 22:53:02 +02:00
await this.load(path.join(dataPath, 'emotes.json'));
2018-03-31 05:37:26 +02:00
} 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.patchMessage(),
this.patchChannelTextArea()
]);
2018-03-31 05:37:26 +02:00
} catch (err) {
Logger.err('EmoteModule', ['Error patching Message / ChannelTextArea', err]);
2018-03-31 05:37:26 +02:00
}
2018-03-31 04:26:42 +02:00
}
2018-04-04 22:38:56 +02:00
async load(dataPath) {
2018-04-04 22:53:02 +02:00
const emotes = await FileUtils.readJsonFromFile(dataPath);
2018-04-04 22:38:56 +02:00
for (let [index, emote] of emotes.entries()) {
// Pause every 10000 emotes so the window doesn't freeze
if ((index % 10000) === 0)
2018-04-04 22:53:02 +02:00
await Utils.wait();
2018-04-04 22:38:56 +02:00
2018-06-10 23:19:59 +02:00
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
2018-04-04 22:38:56 +02:00
emote.src = uri.replace(':id', emote.value.id || emote.value);
this.emotes.set(emote.id, emote);
}
}
2018-03-31 02:17:42 +02:00
/**
* 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) {
2018-04-04 21:51:25 +02:00
emote = emote.id || emote;
2018-03-31 02:17:42 +02:00
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) {
2018-04-04 21:51:25 +02:00
emote = emote.id || emote;
2018-03-31 02:17:42 +02:00
return this.favourite_emotes.includes(emote);
}
get searchCache() {
return this._searchCache || (this._searchCache = {});
}
2018-03-22 03:13:32 +01:00
2018-03-31 02:17:42 +02:00
processMarkup(markup, timestamp) {
timestamp = timestamp.valueOf();
const allowNoWrapper = timestamp < enforceWrapperFrom;
const newMarkup = [];
for (const child of markup) {
2018-03-31 02:17:42 +02:00
if (typeof child !== 'string') {
newMarkup.push(child);
continue;
}
2018-03-31 02:17:42 +02:00
if (!this.testWord(child) && !allowNoWrapper) {
newMarkup.push(child);
continue;
}
const words = child.split(/([^\s]+)([\s]|$)/g);
if (!words) continue;
let text = null;
for (const [wordIndex, word] of words.entries()) {
2018-03-31 05:37:26 +02:00
const emote = this.getEmote(word);
if (emote) {
if (text !== null) {
newMarkup.push(text);
text = null;
}
2018-05-14 17:55:18 +02:00
newMarkup.push(VueInjector.createReactElement(EmoteComponent, {
src: emote.src,
2018-06-10 23:19:59 +02:00
name: emote.id,
hasWrapper: /;[\w]+;/gmi.test(word)
2018-03-31 02:17:42 +02:00
}));
2018-05-14 17:55:18 +02:00
continue;
}
if (text === null) {
text = word;
} else {
text += word;
}
if (wordIndex === words.length - 1) {
newMarkup.push(text);
}
}
}
return newMarkup;
}
2018-03-31 02:17:42 +02:00
testWord(word) {
return !/;[\w]+;/gmi.test(word);
}
2018-03-31 02:17:42 +02:00
findByProp(obj, what, value) {
2018-03-17 17:05:44 +01:00
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;
}
2018-03-31 05:37:26 +02:00
getEmote(word) {
const name = word.replace(/;/g, '');
2018-03-31 05:37:26 +02:00
return this.emotes.get(name);
}
2018-03-10 04:29:04 +01:00
2018-03-31 02:17:42 +02:00
filter(regex, limit, start = 0) {
const key = `${regex}:${limit}:${start}`;
if (this.searchCache.hasOwnProperty(key)) return this.searchCache[key];
2018-03-10 04:29:04 +01:00
let index = 0;
2018-03-15 18:44:41 +01:00
let startIndex = 0;
2018-03-31 05:37:26 +02:00
const matching = this.searchCache[key] = [];
for (let emote of this.emotes.values()) {
if (index >= limit) break;
2018-03-10 04:29:04 +01:00
if (regex.test(emote.id)) {
2018-03-15 18:44:41 +01:00
if (startIndex < start) {
startIndex++;
2018-03-31 05:37:26 +02:00
continue;
2018-03-15 18:44:41 +01:00
}
2018-03-10 04:29:04 +01:00
index++;
2018-03-31 05:37:26 +02:00
matching.push(emote);
2018-03-10 04:29:04 +01:00
}
2018-03-31 05:37:26 +02:00
}
return matching;
2018-03-10 04:29:04 +01:00
}
2018-03-22 03:13:32 +01:00
async patchMessage() {
const Message = await ReactComponents.getComponent('Message');
this.unpatchRender = MonkeyPatch('BD:EmoteModule', Message.component.prototype).after('render', (component, args, retVal) => {
try {
// First child has all the actual text content, second is the edited timestamp
const markup = this.findByProp(retVal, 'className', 'markup');
if (!markup || !this.enabledSetting.value) return;
markup.children[0] = this.processMarkup(markup.children[0], component.props.message.editedTimestamp || component.props.message.timestamp);
} catch (err) {
Logger.err('EmoteModule', err);
}
});
for (const message of document.querySelectorAll('.message')) {
Reflection(message).forceUpdate();
}
}
async patchChannelTextArea() {
const selector = '.' + WebpackModules.getModuleByProps(['channelTextArea', 'emojiButton']).channelTextArea;
const ChannelTextArea = await ReactComponents.getComponent('ChannelTextArea', {selector});
this.unpatchChannelTextArea = MonkeyPatch('BD:ReactComponents', ChannelTextArea.component.prototype).after('render', (component, args, retVal) => {
if (!(retVal.props.children instanceof Array)) retVal.props.children = [retVal.props.children];
retVal.props.children.splice(0, 0, VueInjector.createReactElement(Autocomplete, {}, true));
});
for (const e of document.querySelectorAll(selector)) {
Reflection(e).forceUpdate();
}
}
}