diff --git a/client/src/builtin/EmoteModule.js b/client/src/builtin/EmoteModule.js index 07548c4e..fe44a897 100644 --- a/client/src/builtin/EmoteModule.js +++ b/client/src/builtin/EmoteModule.js @@ -13,7 +13,7 @@ import path from 'path'; import { request } from 'vendor'; import { Utils, FileUtils, ClientLogger as Logger } from 'common'; -import { DiscordApi, Settings, Globals, WebpackModules, ReactComponents, MonkeyPatch, Cache, Patcher } from 'modules'; +import { DiscordApi, Settings, Globals, WebpackModules, ReactComponents, MonkeyPatch, Cache, Patcher, Database } from 'modules'; import { VueInjector } from 'ui'; import Emote from './EmoteComponent.js'; @@ -29,33 +29,159 @@ const EMOTE_SOURCES = [ export default new class EmoteModule extends BuiltinModule { + /** + * @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 ; prefix for autocomplete GlobalAc.add(';', this); if (!this.database.size) { await this.loadLocalDb(); } + // Read favourites and most used from database + 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; + } + this.patchMessageContent(); + this.patchSendAndEdit(); + } + + async disabled() { + // Unpatch all patches + for (const patch of Patcher.getPatchesByCaller('BD:EMOTEMODULE')) patch.unpatch(); + // Remove ; prefix from autocomplete + GlobalAc.remove(';'); + } + + /** + * 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 }); + } + } + + /** + * Patches MessageContent render method + */ + async patchMessageContent() { + const MessageContent = await ReactComponents.getComponent('MessageContent', { selector: WebpackModules.getSelector('container', 'containerCozy', 'containerCompact', 'edited') }); + MonkeyPatch('BD:EMOTEMODULE', MessageContent.component.prototype).after('render', this.afterRenderMessageContent.bind(this)); + MessageContent.forceUpdateAll(); + } + + /** + * Handle message render + */ + 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]); + } + + /** + * 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)); } - async disabled() { - for (const patch of Patcher.getPatchesByCaller('BD:EMOTEMODULE')) patch.unpatch(); - GlobalAc.remove(';'); + /** + * Handle send message + */ + 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); + 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 = 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'; + Uploader.upload(args[0], FileActions.makeFile(arr, `${emote.name}${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); + } + + /** + * Add/update emote to most used + * @param {Object} emote emote to add/update + */ addToMostUsed(emote) { const isMostUsed = this.mostUsed.find(mu => mu.key === emote.name); if (isMostUsed) { @@ -63,15 +189,19 @@ export default new class EmoteModule extends BuiltinModule { } else { this.mostUsed.push({ key: emote.name, - useCount: 1, - value: { - src: EMOTE_SOURCES[emote.type].replace(':id', emote.id), - replaceWith: `;${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; @@ -123,97 +253,35 @@ export default new class EmoteModule extends BuiltinModule { return newMarkup; } - async patchMessageContent() { - const selector = `.${WebpackModules.getClassName('container', 'containerCozy', 'containerCompact', 'edited')}`; - const MessageContent = await ReactComponents.getComponent('MessageContent', { selector }); - MonkeyPatch('BD:EMOTEMODULE', MessageContent.component.prototype).after('render', this.afterRenderMessageContent.bind(this)); - MessageContent.forceUpdateAll(); - } - - 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]); - } - - 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); - } - - 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); - 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 = 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'; - Uploader.upload(args[0], FileActions.makeFile(arr, `${emote.name}${suffix}`)); - }); - } - - 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 }); - } - } - + /** + * 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 autocomplete + * @param {any} regex + */ acsearch(regex) { if (regex.length <= 0) { return { @@ -222,7 +290,10 @@ export default new class EmoteModule extends BuiltinModule { items: this.mostUsed.sort((a,b) => b.useCount - a.useCount).slice(0, 10).map(mu => { return { key: `${mu.key} | ${mu.useCount}`, - value: mu.value + value: { + src: EMOTE_SOURCES[mu.type].replace(':id', mu.id), + replaceWith: `;${mu.key};` + } } }) } @@ -240,6 +311,11 @@ export default new class EmoteModule extends BuiltinModule { } } + /** + * 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 = []; diff --git a/client/src/modules/database.js b/client/src/modules/database.js index d926bb9b..f1a744bb 100644 --- a/client/src/modules/database.js +++ b/client/src/modules/database.js @@ -43,4 +43,19 @@ export default class { } } + /** + * Find first in the database + * @param {Object} args The record to find + * @return {Promise} null if record was not found + */ + static async findOne(args) { + try { + const find = await this.find(args); + if (find && find.length) return find[0]; + return null; + } catch (err) { + throw err; + } + } + }