Merge pull request #240 from JsSucks/emote-stuff

Emote stuff
This commit is contained in:
Alexei Stukov 2018-08-22 20:28:29 +03:00 committed by GitHub
commit a1e32f8b89
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 212 additions and 76 deletions

View File

@ -0,0 +1,76 @@
/**
* BetterDiscord Emote Autocomplete 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 { Settings } from 'modules';
import BuiltinModule from './BuiltinModule';
import EmoteModule from './EmoteModule';
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'
]
export default new class EmoteAc extends BuiltinModule {
get settingPath() { return ['emotes', 'default', 'emoteac'] }
async enabled(e) {
GlobalAc.add(';', this);
}
disabled(e) {
GlobalAc.remove(';');
}
/**
* Search for autocomplete
* @param {any} regex
*/
acsearch(regex) {
const acType = Settings.getSetting('emotes', 'default', 'emoteactype').value;
if (regex.length <= 0) {
return {
type: 'imagetext',
title: [`Your ${acType ? 'most used' : 'favourite'} emotes`, '', `${acType ? 'Favourites' : 'Most Used'}`],
items: EmoteModule[acType ? 'mostUsed' : 'favourites'].sort((a, b) => b.useCount - a.useCount).slice(0, 10).map(mu => {
return {
key: acType ? mu.key : mu.name,
value: {
src: EMOTE_SOURCES[mu.type].replace(':id', mu.id),
replaceWith: `;${acType ? mu.key : mu.name};`,
hint: mu.useCount ? `Used ${mu.useCount} times` : null
}
}
})
}
}
const results = EmoteModule.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;
})
}
}
toggle(sterm) {
if (sterm.length > 1) return false;
Settings.getSetting('emotes', 'default', 'emoteactype').value = !Settings.getSetting('emotes', 'default', 'emoteactype').value;
return true;
}
}

View File

@ -1,5 +1,5 @@
<template> <template>
<img class="emoji" :class="{jumboable}" :src="src" :alt="`;${name};`" v-tooltip="{ content: `;${name};`, delay: { show: 750, hide: 0 } }" /> <img class="bd-emote emoji" :class="{jumboable}" :src="src" :alt="`;${name};`" v-tooltip="{ content: `;${name};`, delay: { show: 750, hide: 0 } }" />
</template> </template>
<script> <script>

View File

@ -14,7 +14,7 @@ import { request } from 'vendor';
import { Utils, FileUtils, ClientLogger as Logger } from 'common'; import { Utils, FileUtils, ClientLogger as Logger } from 'common';
import { DiscordApi, Settings, Globals, WebpackModules, ReactComponents, MonkeyPatch, Cache, Patcher, Database } from 'modules'; import { DiscordApi, Settings, Globals, WebpackModules, ReactComponents, MonkeyPatch, Cache, Patcher, Database } from 'modules';
import { VueInjector } from 'ui'; import { VueInjector, DiscordContextMenu } from 'ui';
import Emote from './EmoteComponent.js'; import Emote from './EmoteComponent.js';
import Autocomplete from '../ui/components/common/Autocomplete.vue'; import Autocomplete from '../ui/components/common/Autocomplete.vue';
@ -52,8 +52,27 @@ export default new class EmoteModule extends BuiltinModule {
get settingPath() { return ['emotes', 'default', 'enable'] } get settingPath() { return ['emotes', 'default', 'enable'] }
async enabled() { async enabled() {
// Add ; prefix for autocomplete
GlobalAc.add(';', this); // Add favourite button to context menu
this.removeFavCm = DiscordContextMenu.add('BD:EmoteModule:FavCM', target => [
{
text: 'Favourite',
type: 'toggle',
checked: target && target.alt ? this.favourites.find(e => e.name === target.alt.replace(/;/g, '')) : false,
onChange: (checked, target) => {
const { alt } = target;
if (!alt) return false;
const name = alt.replace(/;/g, '');
if (!checked) return this.removeFavourite(name);
const emote = this.findByName(name, true);
if (!emote) return false;
return this.addFavourite(emote);
}
}
], filter => filter.closest('.bd-emote'));
if (!this.database.size) { if (!this.database.size) {
await this.loadLocalDb(); await this.loadLocalDb();
@ -68,13 +87,28 @@ export default new class EmoteModule extends BuiltinModule {
this.patchMessageContent(); this.patchMessageContent();
this.patchSendAndEdit(); this.patchSendAndEdit();
const ImageWrapper = await ReactComponents.getComponent('ImageWrapper', { selector: WebpackModules.getSelector('imageWrapper') });
MonkeyPatch('BD:EMOTEMODULE', ImageWrapper.component.prototype).after('render', this.beforeRenderImageWrapper.bind(this));
}
addFavourite(emote) {
if (this.favourites.find(e => e.name === emote.name)) return true;
this.favourites.push(emote);
Database.insertOrUpdate({ 'id': 'EmoteModule' }, { 'id': 'EmoteModule', favourites: this.favourites, mostused: this.mostUsed })
return true;
}
removeFavourite(name) {
if (!this.favourites.find(e => e.name === name)) return false;
this._favourites = this._favourites.filter(e => e.name !== name);
Database.insertOrUpdate({ 'id': 'EmoteModule' }, { 'id': 'EmoteModule', favourites: this.favourites, mostused: this.mostUsed })
return false;
} }
async disabled() { async disabled() {
// Unpatch all patches // Unpatch all patches
for (const patch of Patcher.getPatchesByCaller('BD:EMOTEMODULE')) patch.unpatch(); for (const patch of Patcher.getPatchesByCaller('BD:EMOTEMODULE')) patch.unpatch();
// Remove ; prefix from autocomplete if (this.removeFavCm) this.removeFavCm();
GlobalAc.remove(';');
} }
/** /**
@ -108,7 +142,6 @@ export default new class EmoteModule extends BuiltinModule {
filter.className && filter.className &&
filter.className.includes('markup') && filter.className.includes('markup') &&
filter.children.length >= 2); filter.children.length >= 2);
if (!markup) return; if (!markup) return;
markup.children[1] = this.processMarkup(markup.children[1]); markup.children[1] = this.processMarkup(markup.children[1]);
} }
@ -160,7 +193,7 @@ export default new class EmoteModule extends BuiltinModule {
const arr = new Uint8Array(new ArrayBuffer(res.length)); const arr = new Uint8Array(new ArrayBuffer(res.length));
for (let i = 0; i < res.length; i++) arr[i] = res.charCodeAt(i); 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'; const suffix = arr[0] === 71 && arr[1] === 73 && arr[2] === 70 ? '.gif' : '.png';
Uploader.upload(args[0], FileActions.makeFile(arr, `${emote.name}${suffix}`)); Uploader.upload(args[0], FileActions.makeFile(arr, `${emote.name}.bdemote${suffix}`));
}); });
} }
@ -178,6 +211,20 @@ export default new class EmoteModule extends BuiltinModule {
return orig(...args); return orig(...args);
} }
/**
* 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();
}
/** /**
* Add/update emote to most used * Add/update emote to most used
* @param {Object} emote emote to add/update * @param {Object} emote emote to add/update
@ -205,6 +252,7 @@ export default new class EmoteModule extends BuiltinModule {
processMarkup(markup) { processMarkup(markup) {
const newMarkup = []; const newMarkup = [];
if (!(markup instanceof Array)) return markup; if (!(markup instanceof Array)) return markup;
const jumboable = !markup.some(child => { const jumboable = !markup.some(child => {
if (typeof child !== 'string') return false; if (typeof child !== 'string') return false;
return / \w+/g.test(child); return / \w+/g.test(child);
@ -278,39 +326,6 @@ export default new class EmoteModule extends BuiltinModule {
return simple ? { type, id, name } : new Emote(type, id, name); return simple ? { type, id, name } : new Emote(type, id, name);
} }
/**
* Search for autocomplete
* @param {any} regex
*/
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: {
src: EMOTE_SOURCES[mu.type].replace(':id', mu.id),
replaceWith: `;${mu.key};`
}
}
})
}
}
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 for anything else * Search for anything else
* @param {any} regex * @param {any} regex

View File

@ -8,6 +8,7 @@ import { default as TwentyFourHour } from './24Hour';
import { default as KillClyde } from './KillClyde'; import { default as KillClyde } from './KillClyde';
import { default as BlockedMessages } from './BlockedMessages'; import { default as BlockedMessages } from './BlockedMessages';
import { default as VoiceDisconnect } from './VoiceDisconnect'; import { default as VoiceDisconnect } from './VoiceDisconnect';
import { default as EmoteAc } from './EmoteAc';
export default class { export default class {
static initAll() { static initAll() {
@ -21,5 +22,6 @@ export default class {
KillClyde.init(); KillClyde.init();
BlockedMessages.init(); BlockedMessages.init();
VoiceDisconnect.init(); VoiceDisconnect.init();
EmoteAc.init();
} }
} }

View File

@ -170,6 +170,20 @@
"text": "Image Emote", "text": "Image Emote",
"hint": "Send single emotes as images if you have the permission", "hint": "Send single emotes as images if you have the permission",
"value": true "value": true
},
{
"id": "emoteac",
"type": "bool",
"text": "Emote Autocomplete",
"hint": "Autocomplete emotes when typing with ; prefix",
"value": true
},
{
"id": "emoteactype",
"type": "bool",
"text": "Show most used instead of favourites",
"hint": "Toggle with arrow keys in autocomplete menu",
"value": true
} }
] ]
} }

View File

@ -109,7 +109,7 @@ class BetterDiscord {
} }
showDummyNotif(); showDummyNotif();
DiscordContextMenu.add([ DiscordContextMenu.add('DummyThing', [
{ {
text: 'Hello', text: 'Hello',
onClick: () => { Toasts.info('Hello!'); } onClick: () => { Toasts.info('Hello!'); }

View File

@ -3,7 +3,7 @@
top: 22px; top: 22px;
left: 0; left: 0;
bottom: 0; bottom: 0;
z-index: 3000; z-index: 1001;
width: 310px; width: 310px;
transform: translateX(-100%) translateY(-100%); transform: translateX(-100%) translateY(-100%);
opacity: 0; opacity: 0;
@ -123,11 +123,11 @@
&.bd-stop { &.bd-stop {
.bd-sidebarRegion { .bd-sidebarRegion {
z-index: 3004; z-index: 1003;
} }
.bd-contentRegion { .bd-contentRegion {
z-index: 3003; z-index: 1002;
} }
} }
} }

View File

@ -3,7 +3,7 @@ bd-tooltips {
position: absolute; position: absolute;
right: 0; right: 0;
top: 0; top: 0;
z-index: 9001; z-index: 1004;
} }
.bd-tooltip, .bd-tooltip,
@ -19,7 +19,7 @@ bd-tooltips {
padding: 8px 12px; padding: 8px 12px;
position: absolute; position: absolute;
word-wrap: break-word; word-wrap: break-word;
z-index: 9001; z-index: 1002;
&::after { &::after {
content: none; content: none;

View File

@ -1,4 +1,4 @@
.bd-autocomplete { .bd-ac {
border-radius: 5px 5px 0 0; border-radius: 5px 5px 0 0;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@ -10,16 +10,16 @@
right: 0; right: 0;
background-color: #2f3136; background-color: #2f3136;
.bd-autocompleteInner { .bd-acInner {
padding-bottom: 8px; padding-bottom: 8px;
white-space: nowrap; white-space: nowrap;
.bd-autocompleteRow { .bd-acRow {
padding: 0 8px; padding: 0 8px;
font-size: 14px; font-size: 14px;
line-height: 16px; line-height: 16px;
.bd-autocompleteSelector { .bd-acSelector {
border-radius: 3px; border-radius: 3px;
padding: 8px; padding: 8px;
@ -35,7 +35,7 @@
} }
} }
.bd-autocompleteTitle { .bd-acTitle {
color: #72767d; color: #72767d;
padding: 4px 0; padding: 4px 0;
text-transform: uppercase; text-transform: uppercase;
@ -50,7 +50,7 @@
} }
} }
.bd-autocompleteField { .bd-acField {
display: flex; display: flex;
flex: 1 1 auto; flex: 1 1 auto;
color: #f6f6f7; color: #f6f6f7;
@ -73,6 +73,10 @@
margin-left: 8px; margin-left: 8px;
color: #f6f6f7; color: #f6f6f7;
} }
.bd-acHint {
color: #72767d;
}
} }
} }
} }

View File

@ -44,6 +44,11 @@ export default new class AutoComplete {
return this.sets.hasOwnProperty(prefix); return this.sets.hasOwnProperty(prefix);
} }
toggle(prefix, sterm) {
if (!this.sets[prefix].toggle) return false;
return this.sets[prefix].toggle(sterm);
}
items(prefix, sterm) { items(prefix, sterm) {
if (!this.validPrefix(prefix)) return []; if (!this.validPrefix(prefix)) return [];
return this.sets[prefix].acsearch(sterm); return this.sets[prefix].acsearch(sterm);

View File

@ -9,21 +9,23 @@
*/ */
<template> <template>
<div class="bd-autocomplete"> <div class="bd-ac">
<div v-if="search.items.length" class="bd-autocompleteInner"> <div v-if="search.items.length" class="bd-acInner">
<div class="bd-autocompleteRow"> <div class="bd-acRow">
<div class="bd-autocompleteSelector"> <div class="bd-acSelector">
<div class="bd-autocompleteTitle"> <div class="bd-acTitle">
{{search.title[0] || search.title}} {{search.title[0] || search.title}}
<strong>{{search.title[1] || sterm}}</strong> <strong v-if="search.title.length >= 2">{{search.title[1] || sterm}}</strong>
<strong v-if="search.title.length === 3" :style="{float: 'right'}">{{search.title[2]}}</strong>
</div> </div>
</div> </div>
</div> </div>
<div v-for="(item, index) in search.items" class="bd-autocompleteRow" @mouseover="selectedIndex = index" @click="inject"> <div v-for="(item, index) in search.items" class="bd-acRow" @mouseover="selectedIndex = index" @click="inject">
<div class="bd-autocompleteSelector bd-selectable" :class="{'bd-selected': index === selectedIndex}"> <div class="bd-acSelector bd-selectable" :class="{'bd-selected': index === selectedIndex}">
<div class="bd-autocompleteField"> <div class="bd-acField">
<img v-if="search.type === 'imagetext'" :src="item.value.src" :alt="item.key" /> <img v-if="search.type === 'imagetext'" :src="item.src || item.value.src" :alt="item.key || item.text || item.alt" />
<div class="bd-flexGrow">{{item.key}}</div> <div class="bd-flexGrow">{{item.key || item.text}}</div>
<div class="bd-acHint" v-if="item.hint || (item.value && item.value.hint)">{{item.hint || item.value.hint}}</div>
</div> </div>
</div> </div>
</div> </div>
@ -66,7 +68,9 @@
const { which, key } = e; const { which, key } = e;
if (key === 'ArrowDown' || key === 'ArrowUp') this.traverse(key); if (key === 'ArrowDown' || key === 'ArrowUp') this.traverse(key);
else if (key !== 'Tab' && key !== 'Enter') return; else if (key === 'ArrowLeft' || key === 'ArrowRight') {
if (!this.toggle(e)) return;
} else if (key !== 'Tab' && key !== 'Enter') return;
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
@ -119,6 +123,12 @@
this.insertText(this.ta.selectionStart - this.fsterm.length, this.search.items[this.selectedIndex].value.replaceWith); this.insertText(this.ta.selectionStart - this.fsterm.length, this.search.items[this.selectedIndex].value.replaceWith);
this.open = false; this.open = false;
this.search = { type: null, items: [] }; this.search = { type: null, items: [] };
},
toggle(e) {
const { selectionEnd, value } = e.target;
const sterm = value.slice(0, selectionEnd).split(/\s+/g).pop();
const prefix = sterm.slice(0, 1);
return this.controller.toggle(prefix, sterm);
} }
} }
} }

View File

@ -12,14 +12,14 @@
<div class="bd-cmGroup" ref="test"> <div class="bd-cmGroup" ref="test">
<template v-for="(item, index) in items"> <template v-for="(item, index) in items">
<CMButton v-if="!item.type || item.type === 'button'" :item="item" :onClick="() => { item.onClick(); closeMenu(); }" /> <CMButton v-if="!item.type || item.type === 'button'" :item="item" :onClick="() => { item.onClick(); closeMenu(); }" />
<CMToggle v-else-if="item.type === 'toggle'" :item="item" :onClick="() => { item.checked = item.onChange(!item.checked) }" /> <CMToggle v-else-if="item.type === 'toggle'" :item="item" :checked="item.checked" :onClick="() => { item.checked = item.onChange(!item.checked, target) }" />
<div v-else-if="item.type === 'sub'" class="bd-cmItem bd-cmSub" @mouseenter="e => subMenuMouseEnter(e, index, item)" @mouseleave="e => subMenuMouseLeave(e, index, item)"> <div v-else-if="item.type === 'sub'" class="bd-cmItem bd-cmSub" @mouseenter="e => subMenuMouseEnter(e, index, item)" @mouseleave="e => subMenuMouseLeave(e, index, item)">
{{item.text}} {{item.text}}
<MiChevronDown /> <MiChevronDown />
<div ref="test2" class="bd-cm" v-if="index === visibleSub" :style="subStyle"> <div class="bd-cm" v-if="index === visibleSub" :style="subStyle">
<template v-for="(item, index) in item.items"> <template v-for="(item, index) in item.items">
<CMButton v-if="!item.type || item.type === 'button'" :item="item" :onClick="() => { item.onClick(); closeMenu(); }" /> <CMButton v-if="!item.type || item.type === 'button'" :item="item" :onClick="() => { item.onClick(); closeMenu(); }" />
<CMToggle v-else-if="item.type === 'toggle'" :item="item" :onClick="() => { item.checked = item.onChange(!item.checked) }" /> <CMToggle v-else-if="item.type === 'toggle'" :item="item" :checked="item.checked" :onClick="() => { item.checked = item.onChange(!item.checked, target) }" />
</template> </template>
</div> </div>
</div> </div>
@ -40,7 +40,7 @@
subStyle: {} subStyle: {}
} }
}, },
props: ['items', 'closeMenu', 'left', 'top'], props: ['items', 'closeMenu', 'left', 'top', 'target'],
components: { CMButton, CMToggle, MiChevronDown }, components: { CMButton, CMToggle, MiChevronDown },
methods: { methods: {
subMenuMouseEnter(e, index, sub) { subMenuMouseEnter(e, index, sub) {
@ -57,3 +57,5 @@
} }
} }
</script> </script>
// return typeof this.item.checked === 'function' ? this.item.checked(target) : this.item.checked;

View File

@ -11,9 +11,9 @@
<template> <template>
<div class="bd-cmItem bd-cmToggle" @click="onClick"> <div class="bd-cmItem bd-cmToggle" @click="onClick">
<div class="bd-cmLabel">{{item.text}}</div> <div class="bd-cmLabel">{{item.text}}</div>
<div class="bd-cmCheckbox"> <div class="bd-cmCheckbox" :checked="checked">
<div class="bd-cmCheckboxInner"> <div class="bd-cmCheckboxInner">
<input type="checkbox" :checked="item.checked || item.enabled"/> <input type="checkbox" :checked="checked"/>
<span></span> <span></span>
</div> </div>
</div> </div>
@ -22,6 +22,6 @@
<script> <script>
export default { export default {
props: ['item', 'onClick'] props: ['item', 'onClick', 'checked']
} }
</script> </script>

View File

@ -35,12 +35,18 @@ export class DiscordContextMenu {
/** /**
* add items to Discord context menu * add items to Discord context menu
* @param {any} id unique id for group
* @param {any} items items to add * @param {any} items items to add
* @param {Function} [filter] filter function for target filtering * @param {Function} [filter] filter function for target filtering
*/ */
static add(items, filter) { static add(id, items, filter) {
if (!this.patched) this.patch(); if (!this.patched) this.patch();
this.menus.push({ items, filter }); this.menus.push({ id, items, filter });
return () => this.remove(id);
}
static remove(id) {
this._menus = this._menus.filter(menu => menu.id !== id);
} }
static get menus() { static get menus() {
@ -66,17 +72,19 @@ export class DiscordContextMenu {
static renderCm(component, args, retVal, res) { static renderCm(component, args, retVal, res) {
if (!retVal.props || !res.props) return; if (!retVal.props || !res.props) return;
const { target } = res.props; const { target } = component.props;
const { top, left } = retVal.props.style; const { top, left } = retVal.props.style;
if (!target || !top || !left) return; if (!target || !top || !left) return;
if (!retVal.props.children) return; if (!retVal.props.children) return;
if (!(retVal.props.children instanceof Array)) retVal.props.children = [retVal.props.children]; if (!(retVal.props.children instanceof Array)) retVal.props.children = [retVal.props.children];
for (const menu of this.menus.filter(menu => { if (!menu.filter) return true; return menu.filter(target)})) { for (const menu of this.menus.filter(menu => { if (!menu.filter) return true; return menu.filter(target)})) {
retVal.props.children.push(VueInjector.createReactElement(CMGroup, { retVal.props.children.push(VueInjector.createReactElement(CMGroup, {
target,
top, top,
left, left,
closeMenu: () => WebpackModules.getModuleByProps(['closeContextMenu']).closeContextMenu(), closeMenu: () => WebpackModules.getModuleByProps(['closeContextMenu']).closeContextMenu(),
items: menu.items items: typeof menu.items === 'function' ? menu.items(target) : menu.items
})); }));
} }
} }