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

407 lines
13 KiB
JavaScript

/**
* 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.
*/
import BuiltinModule from '../BuiltinModule';
import path from 'path';
import { request } from 'vendor';
import { Utils, FileUtils, ClientLogger as Logger } from 'common';
import { DiscordApi, Settings, Globals, Reflection, ReactComponents, Database } from 'modules';
import { DiscordContextMenu } from 'ui';
import Emote from './EmoteComponent.js';
export 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 new class EmoteModule extends BuiltinModule {
/* Getters */
get moduleName() { return 'EmoteModule' }
/**
* @returns {String} Path to local emote database
*/
get dbpath() { return path.join(Globals.getPath('data'), 'emotes.json') }
/**
* @returns {Map} Cached raw emote database
*/
get database() { return this._db || (this._db = new Map()) }
/**
* @returns {Array} Favourite emotes
*/
get favourites() { return this._favourites || (this._favourites = []) }
/**
* @returns {Array} Most used emotes
*/
get mostUsed() { return this._mostUsed || (this._mostUsed = []) }
get settingPath() { return ['emotes', 'default', 'enable'] }
async enabled() {
// Add favourite button to context menu
this.favCm = DiscordContextMenu.add(target => [
{
text: 'Favourite',
type: 'toggle',
checked: target && target.alt && this.isFavourite(target.alt.replace(/;/g, '')),
onChange: (checked, target) => {
const { alt } = target;
if (!alt) return false;
const emote = alt.replace(/;/g, '');
if (!checked) return this.removeFavourite(emote), false;
return this.addFavourite(emote), true;
}
}
], target => target.closest('.bd-emote'));
if (!this.database.size) {
await this.loadLocalDb();
}
// Read favourites and most used from database
await this.loadUserData();
}
async disabled() {
DiscordContextMenu.remove(this.favCm);
}
/* Methods */
/**
* Adds an emote to favourites.
* @param {Object|String} emote
* @return {Promise}
*/
addFavourite(emote) {
if (this.isFavourite(emote)) return;
if (typeof emote === 'string') emote = this.findByName(emote, true);
this.favourites.push(emote);
return this.saveUserData();
}
/**
* Removes an emote from favourites.
* @param {Object|String} emote
* @return {Promise}
*/
removeFavourite(emote) {
if (!this.isFavourite(emote)) return;
Utils.removeFromArray(this.favourites, e => e.name === emote || e.name === emote.name, true);
return this.saveUserData();
}
/**
* Checks if an emote is in favourites.
* @param {Object|String} emote
* @return {Boolean}
*/
isFavourite(emote) {
return !!this.favourites.find(e => e.name === emote || e.name === emote.name);
}
/**
* Load emotes from local database
*/
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 });
}
Logger.log('EmoteModule', ['Loaded emote database']);
}
async loadUserData() {
const userData = await Database.findOne({ type: 'builtin', id: 'EmoteModule' });
if (!userData) return;
if (userData.hasOwnProperty('favourites')) this._favourites = userData.favourites;
if (userData.hasOwnProperty('mostused')) this._mostUsed = userData.mostused;
}
async saveUserData() {
await Database.insertOrUpdate({ type: 'builtin', id: 'EmoteModule' }, {
type: 'builtin',
id: 'EmoteModule',
favourites: this.favourites,
mostused: this.mostUsed
});
}
/**
* Add/update emote to most used
* @param {Object} emote emote to add/update
* @return {Promise}
*/
addToMostUsed(emote) {
const isMostUsed = this.mostUsed.find(mu => mu.key === emote.name);
if (isMostUsed) {
isMostUsed.useCount += 1;
} else {
this.mostUsed.push({
key: emote.name,
id: emote.id,
type: emote.type,
useCount: 1
});
}
// Save most used to database
// TODO only save first n
return this.saveUserData();
}
/**
* Find an emote by name
* @param {String} name Emote name
* @param {Boolean} simple Simple object or Emote instance
* @returns {Object|Emote}
*/
findByName(name, simple = false) {
const emote = this.database.get(name);
if (!emote) return null;
return this.parseEmote(name, emote, simple);
}
/**
* Parse emote object
* @param {String} name Emote name
* @param {Object} emote Emote object
* @param {Boolean} simple Simple object or Emote instance
* @returns {Object|Emote}
*/
parseEmote(name, emote, simple = false) {
const { type, id } = emote;
if (type < 0 || type > 2) return null;
return simple ? { type, id, name } : new Emote(type, id, name);
}
/**
* Search for anything else
* @param {any} regex
* @param {any} limit
*/
search(regex, limit = 10) {
if (typeof regex === 'string') regex = new RegExp(regex, 'i');
const matching = [];
for (const [key, value] of this.database.entries()) {
if (matching.length >= limit) break;
if (regex.test(key)) matching.push({ key, value })
}
return matching;
}
/* Patches */
async applyPatches() {
this.patchMessageContent();
this.patchSendAndEdit();
this.patchSpoiler();
const MessageAccessories = await ReactComponents.getComponent('MessageAccessories');
this.patch(MessageAccessories.component.prototype, 'render', this.afterRenderMessageAccessories, 'after');
MessageAccessories.forceUpdateAll();
}
/**
* Patches MessageContent render method
*/
async patchMessageContent() {
const MessageContent = await ReactComponents.getComponent('MessageContent');
this.childPatch(MessageContent.component.prototype, 'render', ['props', 'children'], this.afterRenderMessageContent);
MessageContent.forceUpdateAll();
}
/**
* Patches MessageActions send and edit
*/
patchSendAndEdit() {
const { MessageActions } = Reflection.modules;
this.patch(MessageActions, 'sendMessage', this.handleSendMessage, 'instead');
this.patch(MessageActions, 'editMessage', this.handleEditMessage, 'instead');
}
async patchSpoiler() {
const Spoiler = await ReactComponents.getComponent('Spoiler');
this.childPatch(Spoiler.component.prototype, 'render', ['props', 'children', 'props', 'children'], this.afterRenderSpoiler);
Spoiler.forceUpdateAll();
}
afterRenderSpoiler(component, _childrenObject, args, retVal) {
const markup = Utils.findInReactTree(retVal, filter =>
filter &&
filter.className &&
filter.className.includes('inlineContent'));
if (!markup) return;
markup.children = this.processMarkup(markup.children);
}
/**
* Handle message render
*/
afterRenderMessageContent(component, _childrenObject, args, retVal) {
const markup = Utils.findInReactTree(retVal, filter =>
filter &&
filter.className &&
filter.className.includes('markup') &&
filter.children.length >= 2);
if (!markup) return;
markup.children[1] = this.processMarkup(markup.children[1]);
}
/**
* Handle send message
*/
async handleSendMessage(MessageActions, args, orig) {
if (!args.length) return orig(...args);
const { content } = args[1];
if (!content) return orig(...args);
Logger.log('EmoteModule', ['Sending message', MessageActions, args, orig]);
const emoteAsImage = Settings.getSetting('emotes', 'default', 'emoteasimage').value &&
(DiscordApi.currentChannel.type === 'DM' || DiscordApi.currentChannel.checkPermissions(DiscordApi.modules.DiscordPermissions.ATTACH_FILES));
if (!emoteAsImage || content.split(' ').length > 1) {
args[1].content = args[1].content.split(' ').map(word => {
const isEmote = /;(.*?);/g.exec(word);
if (isEmote) {
const emote = this.findByName(isEmote[1], true);
if (!emote) return word;
this.addToMostUsed(emote);
return emote ? `;${isEmote[1]};` : word;
}
return word;
}).join(' ');
return orig(...args);
}
const isEmote = /;(.*?);/g.exec(content);
if (!isEmote) return orig(...args);
const emote = this.findByName(isEmote[1]);
if (!emote) return orig(...args);
this.addToMostUsed(emote);
const FileActions = Reflection.module.byProps('makeFile');
const Uploader = Reflection.module.byProps('instantBatchUpload');
request.get(emote.props.src, { encoding: 'binary' }).then(res => {
const arr = new Uint8Array(new ArrayBuffer(res.length));
for (let i = 0; i < res.length; i++) arr[i] = res.charCodeAt(i);
const suffix = arr[0] === 71 && arr[1] === 73 && arr[2] === 70 ? '.gif' : '.png';
Uploader.upload(args[0], FileActions.makeFile(arr, `${emote.name}.bdemote${suffix}`));
});
}
/**
* Handle edit message
*/
handleEditMessage(component, args, orig) {
if (!args.length) return orig(...args);
const { content } = args[2];
if (!content) return orig(...args);
args[2].content = args[2].content.split(' ').map(word => {
const isEmote = /;(.*?);/g.exec(word);
return isEmote ? `;${isEmote[1]};` : word;
}).join(' ');
return orig(...args);
}
/**
* Handle MessageAccessories render
*/
afterRenderMessageAccessories(component, args, retVal) {
if (!component.props || !component.props.message) return;
if (!component.props.message.attachments || component.props.message.attachments.length !== 1) return;
const filename = component.props.message.attachments[0].filename;
const match = filename.match(/([^/]*)\.bdemote\.(gif|png)$/i);
if (!match) return;
const emote = this.findByName(match[1]);
if (!emote) return;
emote.jumboable = true;
retVal.props.children[2] = emote.render();
}
/**
* Inject emotes into markup
*/
processMarkup(markup) {
const newMarkup = [];
if (!(markup instanceof Array)) return markup;
const jumboable = !markup.some(child => {
if (typeof child !== 'string') return false;
return / \w+/g.test(child);
});
for (const child of markup) {
if (typeof child !== 'string') {
if (typeof child === 'object') {
const isEmoji = Utils.findInReactTree(child, filter => filter && filter.emojiName);
if (isEmoji) isEmoji.jumboable = jumboable;
}
newMarkup.push(child);
continue;
}
if (!/;(\w+);/g.test(child)) {
newMarkup.push(child);
continue;
}
const words = child.split(/([^\s]+)([\s]|$)/g).filter(f => f !== '');
let s = '';
for (const word of words) {
const isemote = /;(.*?);/g.exec(word);
if (!isemote) {
s += word;
continue;
}
const emote = this.findByName(isemote[1]);
if (!emote) {
s += word;
continue;
}
newMarkup.push(s);
s = '';
emote.jumboable = jumboable;
newMarkup.push(emote.render());
}
if (s !== '') newMarkup.push(s);
}
if (newMarkup.length === 1) return newMarkup[0];
return newMarkup;
}
}