2018-03-07 00:37:14 +01:00
|
|
|
/**
|
|
|
|
* 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-08-16 13:33:22 +02:00
|
|
|
import BuiltinModule from './BuiltinModule';
|
2018-03-22 03:13:32 +01:00
|
|
|
import path from 'path';
|
2018-08-19 19:34:32 +02:00
|
|
|
import { request } from 'vendor';
|
|
|
|
|
2018-08-16 13:33:22 +02:00
|
|
|
import { Utils, FileUtils, ClientLogger as Logger } from 'common';
|
2018-08-20 01:35:51 +02:00
|
|
|
import { DiscordApi, Settings, Globals, WebpackModules, ReactComponents, MonkeyPatch, Cache, Patcher, Database } from 'modules';
|
2018-08-22 15:31:49 +02:00
|
|
|
import { VueInjector, DiscordContextMenu } from 'ui';
|
2018-03-21 21:47:46 +01:00
|
|
|
|
2018-08-16 13:33:22 +02:00
|
|
|
import Emote from './EmoteComponent.js';
|
2018-08-17 16:05:23 +02:00
|
|
|
import Autocomplete from '../ui/components/common/Autocomplete.vue';
|
|
|
|
|
|
|
|
import GlobalAc from '../ui/autocomplete';
|
|
|
|
|
|
|
|
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'
|
|
|
|
]
|
2018-03-07 00:37:14 +01:00
|
|
|
|
2018-08-16 13:33:22 +02:00
|
|
|
export default new class EmoteModule extends BuiltinModule {
|
2018-03-22 03:13:32 +01:00
|
|
|
|
2018-08-20 02:26:56 +02:00
|
|
|
/**
|
|
|
|
* @returns {String} Path to local emote database
|
|
|
|
*/
|
2018-08-16 13:33:22 +02:00
|
|
|
get dbpath() { return path.join(Globals.getPath('data'), 'emotes.json') }
|
2018-08-19 19:34:32 +02:00
|
|
|
|
2018-08-20 02:26:56 +02:00
|
|
|
/**
|
|
|
|
* @returns {Map} Cached raw emote database
|
|
|
|
*/
|
2018-08-16 13:33:22 +02:00
|
|
|
get database() { return this._db || (this._db = new Map()) }
|
2018-03-31 02:17:42 +02:00
|
|
|
|
2018-08-20 02:26:56 +02:00
|
|
|
/**
|
|
|
|
* @returns {Array} Favourite emotes
|
|
|
|
*/
|
2018-08-16 13:33:22 +02:00
|
|
|
get favourites() { return this._favourites || (this._favourites = []) }
|
2018-06-12 21:56:40 +02:00
|
|
|
|
2018-08-20 02:26:56 +02:00
|
|
|
/**
|
|
|
|
* @returns {Array} Most used emotes
|
|
|
|
*/
|
2018-08-19 20:00:53 +02:00
|
|
|
get mostUsed() { return this._mostUsed || (this._mostUsed = []) }
|
|
|
|
|
2018-08-16 13:33:22 +02:00
|
|
|
get settingPath() { return ['emotes', 'default', 'enable'] }
|
2018-06-12 21:56:40 +02:00
|
|
|
|
2018-08-16 13:33:22 +02:00
|
|
|
async enabled() {
|
2018-08-20 02:26:56 +02:00
|
|
|
// Add ; prefix for autocomplete
|
2018-08-17 16:05:23 +02:00
|
|
|
GlobalAc.add(';', this);
|
|
|
|
|
2018-08-22 15:31:49 +02:00
|
|
|
// Add favourite button to context menu
|
2018-08-22 17:39:09 +02:00
|
|
|
this.removeFavCm = DiscordContextMenu.add('BD:EmoteModule:FavCM', target => [
|
2018-08-22 15:31:49 +02:00
|
|
|
{
|
|
|
|
text: 'Favourite',
|
|
|
|
type: 'toggle',
|
2018-08-22 17:33:00 +02:00
|
|
|
checked: target && target.alt ? this.favourites.find(e => e.alt === target.alt) : false,
|
2018-08-22 16:17:37 +02:00
|
|
|
onChange: (checked, target) => {
|
|
|
|
const { alt } = target;
|
2018-08-22 17:33:00 +02:00
|
|
|
if (checked) {
|
|
|
|
this.favourites.push({ alt: target.alt });
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
if (!this._favourites) this._favourites = [];
|
|
|
|
this._favourites = this._favourites.filter(e => e.alt !== target.alt);
|
|
|
|
return false;
|
2018-08-22 16:17:37 +02:00
|
|
|
}
|
2018-08-22 15:31:49 +02:00
|
|
|
}
|
|
|
|
], filter => filter.closest('.bd-emote'));
|
|
|
|
|
2018-08-16 13:33:22 +02:00
|
|
|
if (!this.database.size) {
|
|
|
|
await this.loadLocalDb();
|
2018-03-31 05:37:26 +02:00
|
|
|
}
|
2018-03-31 02:17:42 +02:00
|
|
|
|
2018-08-20 02:26:56 +02:00
|
|
|
// Read favourites and most used from database
|
2018-08-20 01:39:48 +02:00
|
|
|
const userData = await Database.findOne({ 'id': 'EmoteModule' });
|
|
|
|
if (userData) {
|
|
|
|
if (userData.hasOwnProperty('favourites')) this._favourites = userData.favourites;
|
|
|
|
if (userData.hasOwnProperty('mostused')) this._mostUsed = userData.mostused;
|
2018-08-20 01:35:51 +02:00
|
|
|
}
|
|
|
|
|
2018-08-16 13:33:22 +02:00
|
|
|
this.patchMessageContent();
|
2018-08-20 02:26:56 +02:00
|
|
|
this.patchSendAndEdit();
|
2018-08-22 15:20:26 +02:00
|
|
|
const ImageWrapper = await ReactComponents.getComponent('ImageWrapper', { selector: WebpackModules.getSelector('imageWrapper') });
|
|
|
|
MonkeyPatch('BD:EMOTEMODULE', ImageWrapper.component.prototype).after('render', this.beforeRenderImageWrapper.bind(this));
|
2018-03-31 02:17:42 +02:00
|
|
|
}
|
|
|
|
|
2018-08-16 13:33:22 +02:00
|
|
|
async disabled() {
|
2018-08-20 02:48:49 +02:00
|
|
|
// Unpatch all patches
|
2018-08-16 13:33:22 +02:00
|
|
|
for (const patch of Patcher.getPatchesByCaller('BD:EMOTEMODULE')) patch.unpatch();
|
2018-08-20 02:26:56 +02:00
|
|
|
// Remove ; prefix from autocomplete
|
2018-08-20 00:56:57 +02:00
|
|
|
GlobalAc.remove(';');
|
2018-08-22 17:39:09 +02:00
|
|
|
if (this.removeFavCm) this.removeFavCm();
|
2018-08-20 00:56:57 +02:00
|
|
|
}
|
|
|
|
|
2018-08-20 02:26:56 +02:00
|
|
|
/**
|
|
|
|
* 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();
|
2018-08-16 13:33:22 +02:00
|
|
|
|
2018-08-20 02:26:56 +02:00
|
|
|
this.database.set(id, { id: emote.value.id || value, type });
|
2018-03-07 00:37:14 +01:00
|
|
|
}
|
2018-03-13 23:34:03 +01:00
|
|
|
}
|
|
|
|
|
2018-08-20 02:26:56 +02:00
|
|
|
/**
|
|
|
|
* Patches MessageContent render method
|
|
|
|
*/
|
2018-08-16 13:33:22 +02:00
|
|
|
async patchMessageContent() {
|
2018-08-20 02:26:56 +02:00
|
|
|
const MessageContent = await ReactComponents.getComponent('MessageContent', { selector: WebpackModules.getSelector('container', 'containerCozy', 'containerCompact', 'edited') });
|
2018-08-22 15:31:49 +02:00
|
|
|
MonkeyPatch('BD:EMOTEMODULE', MessageContent.component.prototype).after('render', this.afterRenderMessageContent.bind(this));
|
2018-08-16 13:33:22 +02:00
|
|
|
MessageContent.forceUpdateAll();
|
2018-03-13 23:34:03 +01:00
|
|
|
}
|
|
|
|
|
2018-08-20 02:26:56 +02:00
|
|
|
/**
|
|
|
|
* Handle message render
|
|
|
|
*/
|
2018-08-16 13:33:22 +02:00
|
|
|
afterRenderMessageContent(component, 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]);
|
2018-03-07 00:37:14 +01:00
|
|
|
}
|
2018-03-10 04:29:04 +01:00
|
|
|
|
2018-08-20 02:26:56 +02:00
|
|
|
/**
|
|
|
|
* Patches MessageActions send and edit
|
|
|
|
*/
|
|
|
|
patchSendAndEdit() {
|
|
|
|
MonkeyPatch('BD:EMOTEMODULE', WebpackModules.getModuleByName('MessageActions')).instead('sendMessage', this.handleSendMessage.bind(this));
|
|
|
|
MonkeyPatch('BD:EMOTEMODULE', WebpackModules.getModuleByName('MessageActions')).instead('editMessage', this.handleEditMessage.bind(this));
|
2018-08-19 19:34:32 +02:00
|
|
|
}
|
|
|
|
|
2018-08-20 02:26:56 +02:00
|
|
|
/**
|
|
|
|
* Handle send message
|
|
|
|
*/
|
2018-08-19 19:34:32 +02:00
|
|
|
async handleSendMessage(component, args, orig) {
|
|
|
|
if (!args.length) return orig(...args);
|
|
|
|
const { content } = args[1];
|
|
|
|
if (!content) return orig(...args);
|
|
|
|
|
|
|
|
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);
|
2018-08-19 20:00:53 +02:00
|
|
|
if (isEmote) {
|
|
|
|
const emote = this.findByName(isEmote[1], true);
|
|
|
|
if (!emote) return word;
|
|
|
|
this.addToMostUsed(emote);
|
|
|
|
return emote ? `:${isEmote[1]}:` : word;
|
|
|
|
}
|
|
|
|
return word;
|
2018-08-19 19:34:32 +02:00
|
|
|
}).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);
|
2018-08-19 20:00:53 +02:00
|
|
|
this.addToMostUsed(emote);
|
2018-08-19 19:34:32 +02:00
|
|
|
|
|
|
|
const FileActions = WebpackModules.getModuleByProps(['makeFile']);
|
|
|
|
const Uploader = WebpackModules.getModuleByProps(['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';
|
2018-08-22 14:50:20 +02:00
|
|
|
Uploader.upload(args[0], FileActions.makeFile(arr, `${emote.name}.bdemote${suffix}`));
|
2018-08-19 19:34:32 +02:00
|
|
|
});
|
2018-03-10 04:29:04 +01:00
|
|
|
}
|
2018-03-22 03:13:32 +01:00
|
|
|
|
2018-08-20 02:26:56 +02:00
|
|
|
/**
|
|
|
|
* 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);
|
|
|
|
}
|
2018-05-29 17:58:57 +02:00
|
|
|
|
2018-08-22 15:20:26 +02:00
|
|
|
/**
|
|
|
|
* Handle imagewrapper render
|
|
|
|
*/
|
|
|
|
beforeRenderImageWrapper(component, args, retVal) {
|
|
|
|
if (!component.props || !component.props.src) return;
|
|
|
|
|
|
|
|
const src = component.props.original || component.props.src.split('?')[0];
|
|
|
|
if (!src || !src.includes('.bdemote.')) return;
|
|
|
|
const emoteName = src.split('/').pop().split('.')[0];
|
|
|
|
const emote = this.findByName(emoteName);
|
|
|
|
if (!emote) return;
|
|
|
|
retVal.props.children = emote.render();
|
|
|
|
}
|
|
|
|
|
2018-08-20 02:26:56 +02:00
|
|
|
/**
|
|
|
|
* Add/update emote to most used
|
2018-08-20 02:48:49 +02:00
|
|
|
* @param {Object} emote emote to add/update
|
2018-08-20 02:26:56 +02:00
|
|
|
*/
|
|
|
|
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
|
|
|
|
Database.insertOrUpdate({ 'id': 'EmoteModule' }, { 'id': 'EmoteModule', favourites: this.favourites, mostused: this.mostUsed })
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Inject emotes into markup
|
|
|
|
*/
|
|
|
|
processMarkup(markup) {
|
|
|
|
const newMarkup = [];
|
|
|
|
if (!(markup instanceof Array)) return markup;
|
2018-08-22 15:20:26 +02:00
|
|
|
|
2018-08-20 02:26:56 +02:00
|
|
|
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, 'emojiName');
|
|
|
|
if (isEmoji) child.props.children.props.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);
|
2018-08-16 13:33:22 +02:00
|
|
|
}
|
2018-08-20 02:26:56 +02:00
|
|
|
if (newMarkup.length === 1) return newMarkup[0];
|
|
|
|
return newMarkup;
|
2018-05-29 17:58:57 +02:00
|
|
|
}
|
|
|
|
|
2018-08-20 02:26:56 +02:00
|
|
|
/**
|
|
|
|
* Find an emote by name
|
|
|
|
* @param {String} name Emote name
|
|
|
|
* @param {Boolean} simple Simple object or Emote instance
|
|
|
|
* @returns {Object|Emote}
|
|
|
|
*/
|
2018-08-19 20:00:53 +02:00
|
|
|
findByName(name, simple = false) {
|
2018-08-16 13:33:22 +02:00
|
|
|
const emote = this.database.get(name);
|
|
|
|
if (!emote) return null;
|
2018-08-19 20:00:53 +02:00
|
|
|
return this.parseEmote(name, emote, simple);
|
2018-08-16 13:33:22 +02:00
|
|
|
}
|
2018-05-29 17:58:57 +02:00
|
|
|
|
2018-08-20 02:26:56 +02:00
|
|
|
/**
|
2018-08-20 02:48:49 +02:00
|
|
|
* Parse emote object
|
2018-08-20 02:26:56 +02:00
|
|
|
* @param {String} name Emote name
|
|
|
|
* @param {Object} emote Emote object
|
|
|
|
* @param {Boolean} simple Simple object or Emote instance
|
|
|
|
* @returns {Object|Emote}
|
|
|
|
*/
|
2018-08-19 20:00:53 +02:00
|
|
|
parseEmote(name, emote, simple = false) {
|
2018-08-16 13:33:22 +02:00
|
|
|
const { type, id } = emote;
|
|
|
|
if (type < 0 || type > 2) return null;
|
2018-08-19 20:00:53 +02:00
|
|
|
return simple ? { type, id, name } : new Emote(type, id, name);
|
2018-05-29 17:58:57 +02:00
|
|
|
}
|
|
|
|
|
2018-08-20 02:26:56 +02:00
|
|
|
/**
|
|
|
|
* Search for autocomplete
|
|
|
|
* @param {any} regex
|
|
|
|
*/
|
2018-08-19 19:34:32 +02:00
|
|
|
acsearch(regex) {
|
|
|
|
if (regex.length <= 0) {
|
|
|
|
return {
|
|
|
|
type: 'imagetext',
|
|
|
|
title: ['Your most used emotes'],
|
2018-08-19 20:07:14 +02:00
|
|
|
items: this.mostUsed.sort((a,b) => b.useCount - a.useCount).slice(0, 10).map(mu => {
|
2018-08-19 20:00:53 +02:00
|
|
|
return {
|
|
|
|
key: `${mu.key} | ${mu.useCount}`,
|
2018-08-20 02:16:09 +02:00
|
|
|
value: {
|
|
|
|
src: EMOTE_SOURCES[mu.type].replace(':id', mu.id),
|
|
|
|
replaceWith: `;${mu.key};`
|
|
|
|
}
|
2018-08-19 19:34:32 +02:00
|
|
|
}
|
2018-08-19 20:00:53 +02:00
|
|
|
})
|
2018-08-19 19:34:32 +02:00
|
|
|
}
|
|
|
|
}
|
2018-08-19 20:00:53 +02:00
|
|
|
|
2018-08-19 19:34:32 +02:00
|
|
|
const results = this.search(regex);
|
|
|
|
return {
|
|
|
|
type: 'imagetext',
|
|
|
|
title: ['Matching', regex.length],
|
|
|
|
items: results.map(result => {
|
|
|
|
result.value.src = EMOTE_SOURCES[result.value.type].replace(':id', result.value.id);
|
|
|
|
result.value.replaceWith = `;${result.key};`;
|
|
|
|
return result;
|
|
|
|
})
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2018-08-20 02:26:56 +02:00
|
|
|
/**
|
|
|
|
* Search for anything else
|
|
|
|
* @param {any} regex
|
|
|
|
* @param {any} limit
|
|
|
|
*/
|
2018-08-17 16:05:23 +02:00
|
|
|
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 })
|
|
|
|
}
|
|
|
|
|
2018-08-19 19:34:32 +02:00
|
|
|
return matching;
|
2018-08-17 16:05:23 +02:00
|
|
|
}
|
|
|
|
|
2018-03-07 00:37:14 +01:00
|
|
|
}
|