commit
d22188ea2a
|
@ -63,8 +63,8 @@
|
|||
if (!dialogResult) return;
|
||||
|
||||
const readFile = fs.readFileSync(dialogResult[0]);
|
||||
const FileActions = _bd.WebpackModules.getModuleByProps(["makeFile"]);
|
||||
const Uploader = _bd.WebpackModules.getModuleByProps(["instantBatchUpload"]);
|
||||
const FileActions = WebpackModules.getModuleByProps(["makeFile"]);
|
||||
const Uploader = WebpackModules.getModuleByProps(["instantBatchUpload"]);
|
||||
|
||||
const img = await Utils.getImageFromBuffer(readFile);
|
||||
|
||||
|
|
|
@ -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>
|
||||
<span class="bd-emotewrapper" :class="{'bd-emoteFavourite': favourite, 'bd-emoteNoWrapper': !hasWrapper}" v-tooltip="name" :data-emote-name="name">
|
||||
<img class="bd-emote" :src="src" :alt="`;${name};`" />
|
||||
|
||||
<div class="bd-emoteFavouriteButton" :class="{'bd-active': favourite}" @click="toggleFavourite">
|
||||
<MiStar :size="16" />
|
||||
</div>
|
||||
</span>
|
||||
<img class="emoji" :class="{jumboable}" :src="src" :alt="`;${name};`" v-tooltip="{ content: `;${name};`, delay: { show: 750, hide: 0 } }" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
@ -17,7 +11,7 @@
|
|||
components: {
|
||||
MiStar
|
||||
},
|
||||
props: ['src', 'name', 'hasWrapper'],
|
||||
props: ['src', 'name', 'hasWrapper', 'jumboable'],
|
||||
data() {
|
||||
return {
|
||||
EmoteModule
|
||||
|
@ -25,7 +19,7 @@
|
|||
},
|
||||
computed: {
|
||||
favourite() {
|
||||
return EmoteModule.isFavourite(this.name);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
|
|
@ -8,221 +8,248 @@
|
|||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
import { Settings, Globals, WebpackModules, ReactComponents, MonkeyPatch } from 'modules';
|
||||
import { VueInjector, Reflection } from 'ui';
|
||||
import { Utils, FileUtils, ClientLogger as Logger } from 'common';
|
||||
import BuiltinModule from './BuiltinModule';
|
||||
import path from 'path';
|
||||
import EmoteComponent from './EmoteComponent.vue';
|
||||
import { request } from 'vendor';
|
||||
|
||||
import { Utils, FileUtils, ClientLogger as Logger } from 'common';
|
||||
import { DiscordApi, Settings, Globals, WebpackModules, ReactComponents, MonkeyPatch, Cache, Patcher } from 'modules';
|
||||
import { VueInjector } from 'ui';
|
||||
|
||||
import Emote from './EmoteComponent.js';
|
||||
import Autocomplete from '../ui/components/common/Autocomplete.vue';
|
||||
|
||||
const enforceWrapperFrom = (new Date('2018-05-01')).valueOf();
|
||||
import GlobalAc from '../ui/autocomplete';
|
||||
|
||||
export default new class EmoteModule {
|
||||
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'
|
||||
]
|
||||
|
||||
constructor() {
|
||||
this.emotes = new Map();
|
||||
this.favourite_emotes = [];
|
||||
export default new class EmoteModule extends BuiltinModule {
|
||||
|
||||
get dbpath() { return path.join(Globals.getPath('data'), 'emotes.json') }
|
||||
|
||||
get database() { return this._db || (this._db = new Map()) }
|
||||
|
||||
get favourites() { return this._favourites || (this._favourites = []) }
|
||||
|
||||
get mostUsed() { return this._mostUsed || (this._mostUsed = []) }
|
||||
|
||||
get settingPath() { return ['emotes', 'default', 'enable'] }
|
||||
|
||||
async enabled() {
|
||||
GlobalAc.add(';', this);
|
||||
|
||||
if (!this.database.size) {
|
||||
await this.loadLocalDb();
|
||||
}
|
||||
|
||||
this.patchMessageContent();
|
||||
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 init() {
|
||||
const dataPath = Globals.getPath('data');
|
||||
async disabled() {
|
||||
for (const patch of Patcher.getPatchesByCaller('BD:EMOTEMODULE')) patch.unpatch();
|
||||
GlobalAc.remove(';');
|
||||
}
|
||||
|
||||
this.enabledSetting = Settings.getSetting('emotes', 'default', 'enable');
|
||||
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'));
|
||||
addToMostUsed(emote) {
|
||||
const isMostUsed = this.mostUsed.find(mu => mu.key === emote.name);
|
||||
if (isMostUsed) {
|
||||
isMostUsed.useCount += 1;
|
||||
} else {
|
||||
this.mostUsed.push({
|
||||
key: emote.name,
|
||||
useCount: 1,
|
||||
value: {
|
||||
src: EMOTE_SOURCES[emote.type].replace(':id', emote.id),
|
||||
replaceWith: `;${emote.name};`
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 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();
|
||||
}
|
||||
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);
|
||||
});
|
||||
|
||||
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) {
|
||||
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 (!this.testWord(child) && !allowNoWrapper) {
|
||||
|
||||
if (!/:(\w+):/g.test(child)) {
|
||||
newMarkup.push(child);
|
||||
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, {
|
||||
src: emote.src,
|
||||
name: emote.id,
|
||||
hasWrapper: /;[\w]+;/gmi.test(word)
|
||||
}));
|
||||
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;
|
||||
}
|
||||
if (text === null) {
|
||||
text = word;
|
||||
} else {
|
||||
text += word;
|
||||
}
|
||||
if (wordIndex === words.length - 1) {
|
||||
newMarkup.push(text);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
testWord(word) {
|
||||
return !/;[\w]+;/gmi.test(word);
|
||||
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();
|
||||
}
|
||||
|
||||
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;
|
||||
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]);
|
||||
}
|
||||
|
||||
getEmote(word) {
|
||||
const name = word.replace(/;/g, '');
|
||||
return this.emotes.get(name);
|
||||
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);
|
||||
}
|
||||
|
||||
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;
|
||||
async handleSendMessage(component, args, orig) {
|
||||
if (!args.length) return orig(...args);
|
||||
const { content } = args[1];
|
||||
if (!content) return orig(...args);
|
||||
|
||||
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;
|
||||
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;
|
||||
}
|
||||
index++;
|
||||
matching.push(emote);
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
findByName(name, simple = false) {
|
||||
const emote = this.database.get(name);
|
||||
if (!emote) return null;
|
||||
return this.parseEmote(name, emote, simple);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
acsearch(regex) {
|
||||
if (regex.length <= 0) {
|
||||
return {
|
||||
type: 'imagetext',
|
||||
title: ['Your most used emotes'],
|
||||
items: this.mostUsed.sort((a,b) => b.useCount - a.useCount).slice(0, 10).map(mu => {
|
||||
return {
|
||||
key: `${mu.key} | ${mu.useCount}`,
|
||||
value: mu.value
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
async patchMessageContent() {
|
||||
const selector = `.${WebpackModules.getClassName('container', 'containerCozy', 'containerCompact', 'edited')}`;
|
||||
const MessageContent = await ReactComponents.getComponent('MessageContent', {selector});
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
async patchChannelTextArea() {
|
||||
const selector = `.${WebpackModules.getClassName('channelTextArea', 'emojiButton')}`;
|
||||
const ChannelTextArea = await ReactComponents.getComponent('ChannelTextArea', {selector});
|
||||
|
||||
this.unpatchChannelTextArea = MonkeyPatch('BD:EmoteModule', 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));
|
||||
});
|
||||
|
||||
ChannelTextArea.forceUpdateAll();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -118,6 +118,13 @@
|
|||
"type": "bool",
|
||||
"text": "Enable emotes",
|
||||
"value": true
|
||||
},
|
||||
{
|
||||
"id": "emoteasimage",
|
||||
"type": "bool",
|
||||
"text": "Image Emote",
|
||||
"hint": "Send single emotes as images if you have the permission",
|
||||
"value": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
@ -399,6 +399,10 @@ class WebpackModules {
|
|||
const class_module = await this.waitForModuleByProps([base, ...additional_classes]);
|
||||
if (class_module && class_module[base]) return class_module[base].split(' ')[0];
|
||||
}
|
||||
static getSelector(base, ...additional_classes) {
|
||||
const gcn = this.getClassName(base, ...additional_classes);
|
||||
if (gcn) return `.${gcn}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all loaded modules.
|
||||
|
|
|
@ -0,0 +1,52 @@
|
|||
import { Settings, Globals, WebpackModules, ReactComponents, MonkeyPatch, Cache } from 'modules';
|
||||
import { VueInjector } from 'ui';
|
||||
|
||||
import AutocompleteComponent from './components/common/Autocomplete.vue';
|
||||
import { Utils } from 'common';
|
||||
|
||||
export default new class AutoComplete {
|
||||
|
||||
get sets() {
|
||||
return this._sets || (this._sets = {});
|
||||
}
|
||||
|
||||
async init() {
|
||||
this.cta = await ReactComponents.getComponent('ChannelTextArea', { selector: WebpackModules.getSelector('channelTextArea', 'emojiButton') });
|
||||
MonkeyPatch('BD:EMOTEMODULE', this.cta.component.prototype).after('render', this.channelTextAreaAfterRender.bind(this));
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
get latestComponent() {
|
||||
return this._latestComponent;
|
||||
}
|
||||
|
||||
channelTextAreaAfterRender(component, args, retVal) {
|
||||
const inner = Utils.findInReactTree(retVal, filter => filter && filter.className && filter.className.includes('inner'));
|
||||
|
||||
if (!inner || !inner.children) return;
|
||||
inner.children.splice(0, 0, VueInjector.createReactElement(AutocompleteComponent, {
|
||||
controller: this,
|
||||
_insertText: component.insertText.bind(component)
|
||||
}));
|
||||
}
|
||||
|
||||
add(prefix, controller) {
|
||||
if (!this.initialized) this.init();
|
||||
if (this.sets.hasOwnProperty(prefix)) return;
|
||||
this.sets[prefix] = controller;
|
||||
}
|
||||
|
||||
remove(prefix) {
|
||||
if (this.sets.hasOwnProperty(prefix)) delete this.sets[prefix];
|
||||
}
|
||||
|
||||
validPrefix(prefix) {
|
||||
return this.sets.hasOwnProperty(prefix);
|
||||
}
|
||||
|
||||
items(prefix, sterm) {
|
||||
if (!this.validPrefix(prefix)) return [];
|
||||
return this.sets[prefix].acsearch(sterm);
|
||||
}
|
||||
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
/**
|
||||
* BetterDiscord Emote Autocomplete Component
|
||||
* BetterDiscord Autocomplete Component
|
||||
* Copyright (c) 2015-present Jiiks/JsSucks - https://github.com/Jiiks / https://github.com/JsSucks
|
||||
* All rights reserved.
|
||||
* https://betterdiscord.net
|
||||
|
@ -9,24 +9,21 @@
|
|||
*/
|
||||
|
||||
<template>
|
||||
<div class="bd-autocomplete" :class="{'bd-active': emotes && emotes.length}">
|
||||
<div v-if="emotes && emotes.length" class="bd-autocompleteInner">
|
||||
<div class="bd-autocomplete">
|
||||
<div v-if="search.items.length" class="bd-autocompleteInner">
|
||||
<div class="bd-autocompleteRow">
|
||||
<div class="bd-autocompleteSelector">
|
||||
<div class="bd-autocompleteTitle">
|
||||
Emotes Matching:
|
||||
<strong>{{title}}</strong>
|
||||
{{search.title[0] || search.title}}
|
||||
<strong>{{search.title[1] || sterm}}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-for="(emote, index) in emotes" class="bd-autocompleteRow" :key="index">
|
||||
<div class="bd-autocompleteSelector bd-selectable" :class="{'bd-selected': index === selectedIndex, 'bd-emoteFavourite': isFavourite(emote)}" @mouseover="selected = emote.id" @click="inject(emote)">
|
||||
<div v-for="(item, index) in search.items" class="bd-autocompleteRow" @mouseover="selectedIndex = index" @click="inject">
|
||||
<div class="bd-autocompleteSelector bd-selectable" :class="{'bd-selected': index === selectedIndex}">
|
||||
<div class="bd-autocompleteField">
|
||||
<img :src="emote.src" :alt="emote.name" />
|
||||
<div class="bd-flexGrow">{{emote.id}}</div>
|
||||
<div class="bd-emoteFavouriteButton" :class="{'bd-active': isFavourite(emote)}" @click.stop="toggleFavourite(emote)">
|
||||
<MiStar :size="16" />
|
||||
</div>
|
||||
<img v-if="search.type === 'imagetext'" :src="item.value.src" :alt="item.key" />
|
||||
<div class="bd-flexGrow">{{item.key}}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -35,142 +32,93 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import { EmoteModule } from 'builtin';
|
||||
import { Events, Settings } from 'modules';
|
||||
import { DOMManip as Manip } from 'ui';
|
||||
import { MiStar } from './MaterialIcon';
|
||||
|
||||
import { WebpackModules, DiscordApi, Events } from 'modules';
|
||||
let wtf = null;
|
||||
export default {
|
||||
components: {
|
||||
MiStar
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
EmoteModule,
|
||||
emotes: [],
|
||||
title: '',
|
||||
selIndex: 0,
|
||||
selected: '',
|
||||
open: false,
|
||||
selectedIndex: 0,
|
||||
fsterm: '',
|
||||
sterm: '',
|
||||
settingUpdatedHandler: null
|
||||
};
|
||||
search: { type: null, items: [] },
|
||||
selectedIndex: 0,
|
||||
textArea: null
|
||||
}
|
||||
},
|
||||
props: ['prefix', 'controller', '_insertText', '_ref'],
|
||||
computed: {
|
||||
},
|
||||
created() {
|
||||
const enabledSetting = Settings.getSetting('emotes', 'default', 'enable');
|
||||
enabledSetting.on('setting-updated', this.settingUpdatedHandler = event => {
|
||||
if (event.value) return this.addEventListeners();
|
||||
this.removeEventListeners();
|
||||
this.reset();
|
||||
});
|
||||
|
||||
if (enabledSetting.value) this.addEventListeners();
|
||||
},
|
||||
destroyed() {
|
||||
if (this.settingUpdatedHandler) {
|
||||
const enabledSetting = Settings.getSetting('emotes', 'default', 'enable');
|
||||
enabledSetting.removeListener('setting-updated', this.settingUpdatedHandler);
|
||||
}
|
||||
|
||||
this.removeEventListeners();
|
||||
this.attachListeners();
|
||||
},
|
||||
methods: {
|
||||
addEventListeners() {
|
||||
window.addEventListener('keydown', this.prevents);
|
||||
const ta = document.querySelector('.chat textarea');
|
||||
if (!ta) return;
|
||||
ta.addEventListener('keydown', this.setCaret);
|
||||
ta.addEventListener('keyup', this.searchEmotes);
|
||||
attachListeners() {
|
||||
if (this._isDestroyed) return;
|
||||
const ta = document.querySelector('.da-textAreaEdit') || document.querySelector('.da-textArea');
|
||||
if (!ta) return setTimeout(this.attachListeners, 10);
|
||||
this.ta = ta;
|
||||
ta.addEventListener('keydown', this.keyDown);
|
||||
ta.addEventListener('keyup', this.keyUp);
|
||||
},
|
||||
removeEventListeners() {
|
||||
window.removeEventListener('keydown', this.prevents);
|
||||
const ta = document.querySelector('.chat textarea');
|
||||
if (!ta) return;
|
||||
ta.removeEventListener('keydown', this.setCaret);
|
||||
ta.removeEventListener('keyup', this.searchEmotes);
|
||||
},
|
||||
prevents(e) {
|
||||
keyDown(e) {
|
||||
if (!this.open) return;
|
||||
if (e.which === 27) this.reset();
|
||||
else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') this.traverse(e);
|
||||
else if (e.key !== 'Tab' && e.key !== 'Enter') return;
|
||||
|
||||
const { which, key } = e;
|
||||
|
||||
if (key === 'ArrowDown' || key === 'ArrowUp') this.traverse(key);
|
||||
else if (key !== 'Tab' && key !== 'Enter') return;
|
||||
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
},
|
||||
setCaret(e) {
|
||||
this.caret = e.target.selectionEnd;
|
||||
},
|
||||
getEmoteSrc(emote) {
|
||||
let { id, value } = emote;
|
||||
if (value.id) value = value.id;
|
||||
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';
|
||||
return uri.replace(':id', value);
|
||||
},
|
||||
isFavourite(emote) {
|
||||
return EmoteModule.isFavourite(emote);
|
||||
},
|
||||
toggleFavourite(emote) {
|
||||
return EmoteModule.setFavourite(emote, !this.isFavourite(emote));
|
||||
},
|
||||
searchEmotes(e) {
|
||||
if (e.which === 27 || e.key === 'ArrowDown' || e.key === 'ArrowUp') return;
|
||||
if (e.key === 'Tab' || e.key === 'Enter' && this.open) {
|
||||
const selected = this.emotes[this.selectedIndex];
|
||||
if (!selected) return;
|
||||
this.inject(selected);
|
||||
this.reset();
|
||||
keyUp(e) {
|
||||
const { which, key, target } = e;
|
||||
if (which === 27 || key === 'ArrowDown' || key === 'ArrowUp') return;
|
||||
if ((key === 'Tab' || key === 'Enter') && this.open) {
|
||||
this.inject();
|
||||
return;
|
||||
}
|
||||
|
||||
const { selectionEnd, value } = e.target;
|
||||
this.sterm = value.substr(0, selectionEnd).split(/\s+/g).pop();
|
||||
const { selectionEnd, value } = target;
|
||||
const sterm = value.slice(0, selectionEnd).split(/\s+/g).pop();
|
||||
|
||||
if (!this.sterm.startsWith(';')) {
|
||||
this.reset();
|
||||
const prefix = sterm.slice(0, 1);
|
||||
const search = this.controller.items(prefix, sterm.slice(1));
|
||||
const { type, items } = search;
|
||||
|
||||
if (!items || !items.length) {
|
||||
this.open = false;
|
||||
this.search = { type: null, items: [] };
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.sterm.length < 4) {
|
||||
this.reset();
|
||||
return;
|
||||
}
|
||||
this.title = this.sterm.substr(1);
|
||||
this.emotes = EmoteModule.filter(new RegExp(this.sterm.substr(1), 'i'), 10);
|
||||
this.open = this.emotes.length;
|
||||
},
|
||||
traverse(e) {
|
||||
if (!this.open) return;
|
||||
if (e.key === 'ArrowUp') {
|
||||
this.selectedIndex = (this.selectedIndex - 1) < 0 ? Math.min(this.emotes.length, 10) - 1 : this.selectedIndex - 1;
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowDown') {
|
||||
this.selectedIndex = (this.selectedIndex + 1) >= Math.min(this.emotes.length, 10) ? 0 : this.selectedIndex + 1;
|
||||
return;
|
||||
}
|
||||
return;
|
||||
},
|
||||
reset() {
|
||||
this.emotes = [];
|
||||
this.title = '';
|
||||
this.selIndex = 0;
|
||||
this.selected = '';
|
||||
this.open = false;
|
||||
this.textArea = target;
|
||||
this.selectedIndex = 0;
|
||||
this.sterm = '';
|
||||
this.fsterm = sterm;
|
||||
this.sterm = sterm.slice(1);
|
||||
this.open = true;
|
||||
this.search = search;
|
||||
},
|
||||
inject(emote) {
|
||||
const ta = document.querySelector('.chat textarea');
|
||||
if (!ta) return;
|
||||
const { selectionEnd, value } = ta;
|
||||
const en = `;${emote.id};`;
|
||||
let substr = value.substr(0, selectionEnd);
|
||||
substr = substr.replace(new RegExp(this.sterm + '$'), en);
|
||||
|
||||
Manip.setText(substr + value.substr(selectionEnd, value.length), false);
|
||||
ta.selectionEnd = ta.selectionStart = selectionEnd + en.length - this.sterm.length;
|
||||
this.reset();
|
||||
traverse(key) {
|
||||
if (!this.open) return;
|
||||
if (key === 'ArrowUp') {
|
||||
this.selectedIndex = (this.selectedIndex - 1) < 0 ? Math.min(this.search.items.length, 10) - 1 : this.selectedIndex - 1;
|
||||
return;
|
||||
}
|
||||
if (key === 'ArrowDown') {
|
||||
this.selectedIndex = (this.selectedIndex + 1) >= Math.min(this.search.items.length, 10) ? 0 : this.selectedIndex + 1;
|
||||
return;
|
||||
}
|
||||
},
|
||||
insertText(startIndex, text) {
|
||||
this.ta.selectionStart = startIndex;
|
||||
this._insertText(text);
|
||||
},
|
||||
inject() {
|
||||
if (!this.ta) return;
|
||||
this.insertText(this.ta.selectionStart - this.fsterm.length, this.search.items[this.selectedIndex].value.replaceWith);
|
||||
this.open = false;
|
||||
this.search = { type: null, items: [] };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,177 @@
|
|||
/**
|
||||
* BetterDiscord Emote Autocomplete 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.
|
||||
*/
|
||||
|
||||
<template>
|
||||
<div class="bd-autocomplete" :class="{'bd-active': emotes && emotes.length}">
|
||||
<div v-if="emotes && emotes.length" class="bd-autocompleteInner">
|
||||
<div class="bd-autocompleteRow">
|
||||
<div class="bd-autocompleteSelector">
|
||||
<div class="bd-autocompleteTitle">
|
||||
Emotes Matching:
|
||||
<strong>{{title}}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-for="(emote, index) in emotes" class="bd-autocompleteRow" :key="index">
|
||||
<div class="bd-autocompleteSelector bd-selectable" :class="{'bd-selected': index === selectedIndex, 'bd-emoteFavourite': isFavourite(emote)}" @mouseover="selected = emote.id" @click="inject(emote)">
|
||||
<div class="bd-autocompleteField">
|
||||
<img :src="emote.src" :alt="emote.name" />
|
||||
<div class="bd-flexGrow">{{emote.id}}</div>
|
||||
<div class="bd-emoteFavouriteButton" :class="{'bd-active': isFavourite(emote)}" @click.stop="toggleFavourite(emote)">
|
||||
<MiStar :size="16" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { EmoteModule } from 'builtin';
|
||||
import { Events, Settings } from 'modules';
|
||||
import { DOMManip as Manip } from 'ui';
|
||||
import { MiStar } from './MaterialIcon';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
MiStar
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
EmoteModule,
|
||||
emotes: [],
|
||||
title: '',
|
||||
selIndex: 0,
|
||||
selected: '',
|
||||
open: false,
|
||||
selectedIndex: 0,
|
||||
sterm: '',
|
||||
settingUpdatedHandler: null
|
||||
};
|
||||
},
|
||||
created() {
|
||||
const enabledSetting = Settings.getSetting('emotes', 'default', 'enable');
|
||||
enabledSetting.on('setting-updated', this.settingUpdatedHandler = event => {
|
||||
if (event.value) return this.addEventListeners();
|
||||
this.removeEventListeners();
|
||||
this.reset();
|
||||
});
|
||||
|
||||
if (enabledSetting.value) this.addEventListeners();
|
||||
},
|
||||
destroyed() {
|
||||
if (this.settingUpdatedHandler) {
|
||||
const enabledSetting = Settings.getSetting('emotes', 'default', 'enable');
|
||||
enabledSetting.removeListener('setting-updated', this.settingUpdatedHandler);
|
||||
}
|
||||
|
||||
this.removeEventListeners();
|
||||
},
|
||||
methods: {
|
||||
addEventListeners() {
|
||||
window.addEventListener('keydown', this.prevents);
|
||||
const ta = document.querySelector('.chat textarea');
|
||||
if (!ta) return;
|
||||
ta.addEventListener('keydown', this.setCaret);
|
||||
ta.addEventListener('keyup', this.searchEmotes);
|
||||
},
|
||||
removeEventListeners() {
|
||||
window.removeEventListener('keydown', this.prevents);
|
||||
const ta = document.querySelector('.chat textarea');
|
||||
if (!ta) return;
|
||||
ta.removeEventListener('keydown', this.setCaret);
|
||||
ta.removeEventListener('keyup', this.searchEmotes);
|
||||
},
|
||||
prevents(e) {
|
||||
if (!this.open) return;
|
||||
if (e.which === 27) this.reset();
|
||||
else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') this.traverse(e);
|
||||
else if (e.key !== 'Tab' && e.key !== 'Enter') return;
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
},
|
||||
setCaret(e) {
|
||||
this.caret = e.target.selectionEnd;
|
||||
},
|
||||
getEmoteSrc(emote) {
|
||||
let { id, value } = emote;
|
||||
if (value.id) value = value.id;
|
||||
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';
|
||||
return uri.replace(':id', value);
|
||||
},
|
||||
isFavourite(emote) {
|
||||
return EmoteModule.isFavourite(emote);
|
||||
},
|
||||
toggleFavourite(emote) {
|
||||
return EmoteModule.setFavourite(emote, !this.isFavourite(emote));
|
||||
},
|
||||
searchEmotes(e) {
|
||||
if (e.which === 27 || e.key === 'ArrowDown' || e.key === 'ArrowUp') return;
|
||||
if (e.key === 'Tab' || e.key === 'Enter' && this.open) {
|
||||
const selected = this.emotes[this.selectedIndex];
|
||||
if (!selected) return;
|
||||
this.inject(selected);
|
||||
this.reset();
|
||||
return;
|
||||
}
|
||||
|
||||
const { selectionEnd, value } = e.target;
|
||||
this.sterm = value.substr(0, selectionEnd).split(/\s+/g).pop();
|
||||
|
||||
if (!this.sterm.startsWith(';')) {
|
||||
this.reset();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.sterm.length < 4) {
|
||||
this.reset();
|
||||
return;
|
||||
}
|
||||
this.title = this.sterm.substr(1);
|
||||
this.emotes = EmoteModule.filter(new RegExp(this.sterm.substr(1), 'i'), 10);
|
||||
this.open = this.emotes.length;
|
||||
},
|
||||
traverse(e) {
|
||||
if (!this.open) return;
|
||||
if (e.key === 'ArrowUp') {
|
||||
this.selectedIndex = (this.selectedIndex - 1) < 0 ? Math.min(this.emotes.length, 10) - 1 : this.selectedIndex - 1;
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowDown') {
|
||||
this.selectedIndex = (this.selectedIndex + 1) >= Math.min(this.emotes.length, 10) ? 0 : this.selectedIndex + 1;
|
||||
return;
|
||||
}
|
||||
return;
|
||||
},
|
||||
reset() {
|
||||
this.emotes = [];
|
||||
this.title = '';
|
||||
this.selIndex = 0;
|
||||
this.selected = '';
|
||||
this.open = false;
|
||||
this.selectedIndex = 0;
|
||||
this.sterm = '';
|
||||
},
|
||||
inject(emote) {
|
||||
const ta = document.querySelector('.chat textarea');
|
||||
if (!ta) return;
|
||||
const { selectionEnd, value } = ta;
|
||||
const en = `;${emote.id};`;
|
||||
let substr = value.substr(0, selectionEnd);
|
||||
substr = substr.replace(new RegExp(this.sterm + '$'), en);
|
||||
|
||||
Manip.setText(substr + value.substr(selectionEnd, value.length), false);
|
||||
ta.selectionEnd = ta.selectionStart = selectionEnd + en.length - this.sterm.length;
|
||||
this.reset();
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
|
@ -9,3 +9,5 @@ export { default as Reflection } from './reflection';
|
|||
|
||||
export { default as ProfileBadges } from './profilebadges';
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
|
@ -70,8 +70,11 @@ export class Utils {
|
|||
* @param {Array<string>|null} [options.walkable=null] Array of strings to use as keys that are allowed to be walked on. Null value indicates all keys are walkable
|
||||
* @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 = []}) {
|
||||
if (searchFilter(tree)) return tree;
|
||||
static findInTree(tree, searchFilter, { walkable = null, ignore = [] }) {
|
||||
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;
|
||||
|
||||
let tempReturn = undefined;
|
||||
|
|
|
@ -87,6 +87,6 @@
|
|||
"package_release": "node scripts/package-release.js",
|
||||
"release": "npm run lint && npm run build_release && gulp release && npm run package_release",
|
||||
"update_release": "npm run build_release && gulp build-release",
|
||||
"inject": "node scripts/inject.js"
|
||||
"inject": "node scripts/inject.js"
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue