Merge remote-tracking branch 'upstream/master' into plugin-api

# Conflicts:
#	client/src/index.js
#	client/src/ui/components/contextmenu/Group.vue
#	client/src/ui/contextmenus.js
This commit is contained in:
Samuel Elliott 2018-08-22 22:16:39 +01:00
commit d11e8d4fe2
No known key found for this signature in database
GPG Key ID: 8420C7CDE43DC4D6
13 changed files with 236 additions and 74 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).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>
<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>
<script>

View File

@ -14,7 +14,7 @@ import { request } from 'vendor';
import { Utils, FileUtils, ClientLogger as Logger } from 'common';
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 Autocomplete from '../ui/components/common/Autocomplete.vue';
@ -52,8 +52,26 @@ export default new class EmoteModule extends BuiltinModule {
get settingPath() { return ['emotes', 'default', 'enable'] }
async enabled() {
// Add ; prefix for autocomplete
GlobalAc.add(';', this);
// Add favourite button to context menu
this.favCm = DiscordContextMenu.add(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) {
await this.loadLocalDb();
@ -68,13 +86,28 @@ export default new class EmoteModule extends BuiltinModule {
this.patchMessageContent();
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() {
// Unpatch all patches
for (const patch of Patcher.getPatchesByCaller('BD:EMOTEMODULE')) patch.unpatch();
// Remove ; prefix from autocomplete
GlobalAc.remove(';');
DiscordContextMenu.remove(this.favCm);
}
/**
@ -108,7 +141,6 @@ export default new class EmoteModule extends BuiltinModule {
filter.className &&
filter.className.includes('markup') &&
filter.children.length >= 2);
if (!markup) return;
markup.children[1] = this.processMarkup(markup.children[1]);
}
@ -160,7 +192,7 @@ export default new class EmoteModule extends BuiltinModule {
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}`));
Uploader.upload(args[0], FileActions.makeFile(arr, `${emote.name}.bdemote${suffix}`));
});
}
@ -178,6 +210,20 @@ export default new class EmoteModule extends BuiltinModule {
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
* @param {Object} emote emote to add/update
@ -205,6 +251,7 @@ export default new class EmoteModule extends BuiltinModule {
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);
@ -278,39 +325,6 @@ export default new class EmoteModule extends BuiltinModule {
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
* @param {any} regex

View File

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

View File

@ -170,6 +170,20 @@
"text": "Image Emote",
"hint": "Send single emotes as images if you have the permission",
"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

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

View File

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

View File

@ -1,4 +1,4 @@
.bd-autocomplete {
.bd-ac {
border-radius: 5px 5px 0 0;
overflow: hidden;
text-overflow: ellipsis;
@ -9,17 +9,18 @@
position: absolute;
right: 0;
background-color: #2f3136;
max-height: 370px;
.bd-autocompleteInner {
.bd-acInner {
padding-bottom: 8px;
white-space: nowrap;
.bd-autocompleteRow {
.bd-acRow {
padding: 0 8px;
font-size: 14px;
line-height: 16px;
.bd-autocompleteSelector {
.bd-acSelector {
border-radius: 3px;
padding: 8px;
@ -35,7 +36,7 @@
}
}
.bd-autocompleteTitle {
.bd-acTitle {
color: #72767d;
padding: 4px 0;
text-transform: uppercase;
@ -50,7 +51,7 @@
}
}
.bd-autocompleteField {
.bd-acField {
display: flex;
flex: 1 1 auto;
color: #f6f6f7;
@ -73,6 +74,10 @@
margin-left: 8px;
color: #f6f6f7;
}
.bd-acHint {
color: #72767d;
}
}
}
}
@ -82,4 +87,31 @@
border-top-left-radius: 0;
border-top-right-radius: 0;
}
.bd-acScroller {
overflow-x: hidden;
overflow-y: auto;
max-height: 320px;
&::-webkit-scrollbar {
width: 14px;
}
&::-webkit-scrollbar-thumb {
background-color: #202225;
border: 3px solid #2f3136;
}
&::-webkit-scrollbar-thumb,
&::-webkit-scrollbar-track-piece {
background-clip: padding-box;
border-radius: 7px;
}
&::-webkit-scrollbar-track-piece {
background-color: #2f3136;
border-color: #2f3136;
border: 3px solid #2f3136;
}
}
}

View File

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

View File

@ -9,21 +9,25 @@
*/
<template>
<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">
<div class="bd-ac">
<div v-if="search.items.length" class="bd-acInner">
<div class="bd-acRow">
<div class="bd-acSelector">
<div class="bd-acTitle">
{{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 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 v-if="search.type === 'imagetext'" :src="item.value.src" :alt="item.key" />
<div class="bd-flexGrow">{{item.key}}</div>
<div class="bd-acScroller" ref="scroller">
<div v-for="(item, index) in search.items" class="bd-acRow" @mouseover="selectedIndex = index" @click="inject">
<div class="bd-acSelector bd-selectable" :class="{'bd-selected': index === selectedIndex}">
<div class="bd-acField">
<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 || 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>
@ -66,7 +70,9 @@
const { which, key } = e;
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.preventDefault();
@ -102,11 +108,13 @@
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;
this.selectedIndex = (this.selectedIndex - 1) < 0 ? this.search.items.length - 1 : this.selectedIndex - 1;
this.$refs.scroller.scrollTop = (this.selectedIndex + 1) * 32 - 320;
return;
}
if (key === 'ArrowDown') {
this.selectedIndex = (this.selectedIndex + 1) >= Math.min(this.search.items.length, 10) ? 0 : this.selectedIndex + 1;
this.selectedIndex = (this.selectedIndex + 1) >= this.search.items.length ? 0 : this.selectedIndex + 1;
this.$refs.scroller.scrollTop = (this.selectedIndex + 1) * 32 - 320;
return;
}
},
@ -119,6 +127,12 @@
this.insertText(this.ta.selectionStart - this.fsterm.length, this.search.items[this.selectedIndex].value.replaceWith);
this.open = false;
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

@ -19,7 +19,7 @@
</div>
</div>
<CMToggle v-else-if="item.type === 'toggle'" :item="item" @click="item.checked = item.onChange(!item.checked)" />
<CMToggle v-else-if="item.type === 'toggle'" :item="item" :checked="item.checked" @click="item.checked = item.onChange(!item.checked, target)" />
<CMButton v-else :item="item" @click="item.onClick ? item.onClick($event) : undefined; item.type === 'button' ? $emit('close') : undefined" />
</template>
</div>
@ -36,7 +36,7 @@
components: {
CMButton, CMToggle, MiChevronDown
},
props: ['items', 'left', 'top'],
props: ['items', 'left', 'top', 'target'],
data() {
return {
visibleSub: -1,
@ -63,3 +63,5 @@
}
}
</script>
// return typeof this.item.checked === 'function' ? this.item.checked(target) : this.item.checked;

View File

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

View File

@ -47,10 +47,11 @@ export class DiscordContextMenu {
/**
* add items to Discord context menu
* @param {any} id unique id for group
* @param {any} items items to add
* @param {Function} [filter] filter function for target filtering
*/
static add(items, filter) {
static add(id, items, filter) {
if (!this.patched) this.patch();
const menu = { items, filter };
this.menus.push(menu);
@ -84,17 +85,19 @@ export class DiscordContextMenu {
static renderCm(component, args, retVal, res) {
if (!retVal.props || !res.props) return;
const { target } = res.props;
const { target } = component.props;
const { top, left } = retVal.props.style;
if (!target || !top || !left) return;
if (!retVal.props.children) return;
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)})) {
retVal.props.children.push(VueInjector.createReactElement(CMGroup, {
target,
top,
left,
closeMenu: () => WebpackModules.getModuleByProps(['closeContextMenu']).closeContextMenu(),
items: menu.items
items: typeof menu.items === 'function' ? menu.items(target) : menu.items
}));
}
}