Merge pull request #242 from samuelthomas2774/plugin-api

Plugin API
This commit is contained in:
Alexei Stukov 2018-08-23 22:06:42 +03:00 committed by GitHub
commit da28828880
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
44 changed files with 634 additions and 255 deletions

View File

@ -25,30 +25,28 @@
<div v-else class="bd-e2eeTaBtn bd-e2eeLock bd-ok">
<MiLock v-tooltip="'Ready!'" />
</div>
<template slot="popover">
<div @click="toggleEncrypt" :class="{'bd-warn': !E2EE.encryptNewMessages, 'bd-ok': E2EE.encryptNewMessages}"><MiLock size="16" v-tooltip="'Toggle Encryption'" /></div>
<div v-close-popover @click="showUploadDialog" v-if="!error"><MiImagePlus size="16" v-tooltip="'Upload Encrypted Image'" /></div>
<!-- Using these icons for now -->
<div v-close-popover @click="generatePublicKey" v-if="DiscordApi.currentChannel.type === 'DM'"><MiPencil size="16" v-tooltip="'Begin Key Exchange'" /></div>
<div v-close-popover @click="generatePublicKey" v-if="DiscordApi.currentChannel.type === 'DM'"><MiIcVpnKey size="16" v-tooltip="'Begin Key Exchange'" /></div>
</template>
</v-popover>
<div class="bd-taDivider"></div>
</div>
</template>
<script>
import fs from 'fs';
import { Utils } from 'common';
import { remote } from 'electron';
import { Utils, FileUtils, ClientIPC } from 'common';
import { E2EE } from 'builtin';
import { DiscordApi, Security, WebpackModules } from 'modules';
import { MiLock, MiPlus, MiImagePlus, MiPencil, MiRefresh } from '../ui/components/common/MaterialIcon';
import { DiscordApi, WebpackModules } from 'modules';
import { Toasts } from 'ui';
import { MiLock, MiImagePlus, MiIcVpnKey } from '../ui/components/common/MaterialIcon';
export default {
components: { MiLock, MiPlus, MiImagePlus, MiPencil, MiRefresh },
components: {
MiLock, MiImagePlus, MiIcVpnKey
},
data() {
return {
E2EE,
@ -59,12 +57,12 @@
},
methods: {
async showUploadDialog() {
const dialogResult = remote.dialog.showOpenDialog({ properties: ['openFile'] });
if (!dialogResult) return;
const dialogResult = await ClientIPC.send('bd-native-open', {properties: ['openFile']});
if (!dialogResult || !dialogResult.length) return;
const readFile = fs.readFileSync(dialogResult[0]);
const FileActions = WebpackModules.getModuleByProps(["makeFile"]);
const Uploader = WebpackModules.getModuleByProps(["instantBatchUpload"]);
const readFile = await FileUtils.readFileBuffer(dialogResult[0]);
const FileActions = WebpackModules.getModuleByProps(['makeFile']);
const Uploader = WebpackModules.getModuleByProps(['instantBatchUpload']);
const img = await Utils.getImageFromBuffer(readFile);

View File

@ -25,7 +25,7 @@ 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 EmoteModule extends BuiltinModule {
@ -52,38 +52,30 @@ export default new class EmoteModule extends BuiltinModule {
get settingPath() { return ['emotes', 'default', 'enable'] }
async enabled() {
// Add favourite button to context menu
this.removeFavCm = DiscordContextMenu.add('BD:EmoteModule:FavCM', target => [
this.favCm = DiscordContextMenu.add(target => [
{
text: 'Favourite',
type: 'toggle',
checked: target && target.alt ? this.favourites.find(e => e.name === target.alt.replace(/;/g, '')) : false,
checked: target && target.alt && this.isFavourite(target.alt.replace(/;/g, '')),
onChange: (checked, target) => {
const { alt } = target;
if (!alt) return false;
const name = alt.replace(/;/g, '');
const emote = alt.replace(/;/g, '');
if (!checked) return this.removeFavourite(name);
const emote = this.findByName(name, true);
if (!emote) return false;
return this.addFavourite(emote);
if (!checked) return this.removeFavourite(emote), false;
return this.addFavourite(emote), true;
}
}
], filter => filter.closest('.bd-emote'));
], target => target.closest('.bd-emote'));
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;
}
await this.loadUserData();
this.patchMessageContent();
this.patchSendAndEdit();
@ -91,24 +83,42 @@ export default new class EmoteModule extends BuiltinModule {
MonkeyPatch('BD:EMOTEMODULE', ImageWrapper.component.prototype).after('render', this.beforeRenderImageWrapper.bind(this));
}
/**
* Adds an emote to favourites.
* @param {Object|String} emote
* @return {Promise}
*/
addFavourite(emote) {
if (this.favourites.find(e => e.name === emote.name)) return true;
if (this.isFavourite(emote)) return;
if (typeof emote === 'string') emote = this.findByName(emote, true);
this.favourites.push(emote);
Database.insertOrUpdate({ 'id': 'EmoteModule' }, { 'id': 'EmoteModule', favourites: this.favourites, mostused: this.mostUsed })
return true;
return this.saveUserData();
}
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;
/**
* Removes an emote from favourites.
* @param {Object|String} emote
* @return {Promise}
*/
removeFavourite(emote) {
if (!this.isFavourite(emote)) return;
Utils.removeFromArray(this.favourites, e => e.name === emote || e.name === emote.name, true);
return this.saveUserData();
}
/**
* Checks if an emote is in favourites.
* @param {Object|String} emote
* @return {Boolean}
*/
isFavourite(emote) {
return !!this.favourites.find(e => e.name === emote || e.name === emote.name);
}
async disabled() {
// Unpatch all patches
for (const patch of Patcher.getPatchesByCaller('BD:EMOTEMODULE')) patch.unpatch();
if (this.removeFavCm) this.removeFavCm();
DiscordContextMenu.remove(this.favCm);
}
/**
@ -124,6 +134,23 @@ export default new class EmoteModule extends BuiltinModule {
}
}
async loadUserData() {
const userData = await Database.findOne({ type: 'builtin', id: 'EmoteModule' });
if (!userData) return;
if (userData.hasOwnProperty('favourites')) this._favourites = userData.favourites;
if (userData.hasOwnProperty('mostused')) this._mostUsed = userData.mostused;
}
async saveUserData() {
await Database.insertOrUpdate({ type: 'builtin', id: 'EmoteModule' }, {
type: 'builtin',
id: 'EmoteModule',
favourites: this.favourites,
mostused: this.mostUsed
});
}
/**
* Patches MessageContent render method
*/
@ -228,6 +255,7 @@ export default new class EmoteModule extends BuiltinModule {
/**
* Add/update emote to most used
* @param {Object} emote emote to add/update
* @return {Promise}
*/
addToMostUsed(emote) {
const isMostUsed = this.mostUsed.find(mu => mu.key === emote.name);
@ -243,7 +271,7 @@ export default new class EmoteModule extends BuiltinModule {
}
// Save most used to database
// TODO only save first n
Database.insertOrUpdate({ 'id': 'EmoteModule' }, { 'id': 'EmoteModule', favourites: this.favourites, mostused: this.mostUsed })
return this.saveUserData();
}
/**

View File

@ -28,17 +28,18 @@ export default new class ReactDevtoolsModule extends BuiltinModule {
}
enabled(e) {
electron.remote.BrowserWindow.getAllWindows()[0].webContents.on('devtools-opened', this.devToolsOpened);
electron.remote.getCurrentWindow().webContents.on('devtools-opened', this.devToolsOpened);
if (electron.remote.getCurrentWindow().isDevToolsOpened) this.devToolsOpened();
}
disabled(e) {
electron.remote.BrowserWindow.removeDevToolsExtension('React Developer Tools');
electron.remote.BrowserWindow.getAllWindows()[0].webContents.removeListener('devtools-opened', this.devToolsOpened);
electron.remote.getCurrentWindow().webContents.removeListener('devtools-opened', this.devToolsOpened);
}
devToolsOpened() {
electron.remote.BrowserWindow.removeDevToolsExtension('React Developer Tools');
electron.webFrame.registerURLSchemeAsPrivileged('chrome-extension');
try {
const res = electron.remote.BrowserWindow.addDevToolsExtension(path.join(Globals.getPath('ext'), 'extensions', 'rdt'));
if (res !== undefined) {

View File

@ -28,21 +28,22 @@ export default new class VueDevtoolsModule extends BuiltinModule {
}
enabled(e) {
electron.remote.BrowserWindow.getAllWindows()[0].webContents.on('devtools-opened', this.devToolsOpened);
electron.remote.getCurrentWindow().webContents.on('devtools-opened', this.devToolsOpened);
if (electron.remote.getCurrentWindow().isDevToolsOpened) this.devToolsOpened();
}
disabled(e) {
electron.remote.BrowserWindow.removeDevToolsExtension('Vue.js devtools');
electron.remote.BrowserWindow.getAllWindows()[0].webContents.removeListener('devtools-opened', this.devToolsOpened);
electron.remote.getCurrentWindow().webContents.removeListener('devtools-opened', this.devToolsOpened);
}
devToolsOpened() {
electron.remote.BrowserWindow.removeDevToolsExtension('Vue.js devtools');
electron.webFrame.registerURLSchemeAsPrivileged('chrome-extension');
try {
const res = electron.remote.BrowserWindow.addDevToolsExtension(path.join(Globals.getPath('ext'), 'extensions', 'vdt'));
if (res !== undefined) {
Toasts.success(`${res } Installed`);
Toasts.success(`${res} Installed`);
return;
}
Toasts.error('Vue.js devtools install failed');

View File

@ -61,5 +61,19 @@
"developer": false,
"webdev": false,
"contributor": true
},
{
"__user": "Lucario 🌌 V5.0.0#7902",
"id": "438469378418409483",
"developer": false,
"webdev": false,
"contributor": true
},
{
"__user": "Maks#3712",
"id": "340975736037048332",
"developer": false,
"webdev": false,
"contributor": true
}
]

View File

@ -73,11 +73,20 @@ class BetterDiscord {
Globals.initg();
}
globalReady() {
BdUI.initUiEvents();
this.vueInstance = BdUI.injectUi();
this.init();
}
async init() {
try {
await Database.init();
await Settings.loadSettings();
await ModuleManager.initModules();
BuiltinManager.initAll();
if (tests) this.initTests();
if (!ignoreExternal) {
await ExtModuleManager.loadAllModules(true);
@ -85,45 +94,41 @@ class BetterDiscord {
await ThemeManager.loadAllThemes(true);
}
if (!Settings.get('core', 'advanced', 'ignore-content-manager-errors'))
Modals.showContentManagerErrors();
Events.emit('ready');
Events.emit('discord-ready');
BuiltinManager.initAll();
function showDummyNotif() { // eslint-disable-line no-inner-declarations
Notifications.add('Dummy Notification', [
{
text: 'Show Again', onClick: function () {
setTimeout(showDummyNotif, 5000);
return true;
}
},
{
text: 'Ignore', onClick: function () {
return true;
}
}
]);
}
showDummyNotif();
DiscordContextMenu.add('DummyThing', [
{
text: 'Hello',
onClick: () => { Toasts.info('Hello!'); }
}
]);
if (!Settings.get('core', 'advanced', 'ignore-content-manager-errors'))
Modals.showContentManagerErrors();
} catch (err) {
Logger.err('main', ['FAILED TO LOAD!', err]);
}
}
globalReady() {
BdUI.initUiEvents();
this.vueInstance = BdUI.injectUi();
this.init();
initTests() {
let notifications = 0;
function showDummyNotif() { // eslint-disable-line no-inner-declarations
Notifications.add(notifications++ ? `Notification ${notifications}` : undefined, 'Dummy Notification', [
{
text: 'Show Again', onClick: function () {
setTimeout(showDummyNotif, 5000);
return true;
}
},
{
text: 'Ignore', onClick: function () {
return true;
}
}
]);
}
showDummyNotif();
DiscordContextMenu.add([
{
text: 'Hello',
onClick: () => { Toasts.info('Hello!'); }
}
]);
}
}

View File

@ -266,7 +266,7 @@ export default class {
if (!content) throw {message: `Could not find a ${this.contentType} from ${content}.`};
try {
await Modals.confirm(`Delete ${this.contentType} ?`, `Are you sure you want to delete ${content.info.name} ?`, 'Delete').promise;
await Modals.confirm(`Delete ${this.contentType}?`, `Are you sure you want to delete ${content.info.name} ?`, 'Delete').promise;
} catch (err) {
return false;
}
@ -277,8 +277,7 @@ export default class {
if (!force)
await unload;
await FileUtils.directoryExists(content.paths.contentPath);
FileUtils.deleteDirectory(content.paths.contentPath);
await FileUtils.recursiveDeleteDirectory(content.paths.contentPath);
return true;
} catch (err) {
Logger.err(this.moduleName, err);
@ -308,7 +307,7 @@ export default class {
const index = this.getContentIndex(content);
delete Globals.require.cache[Globals.require.resolve(content.paths.mainPath)];
if (this.unloadContentHook) this.unloadContentHook(content);
if (reload) {
const newcontent = await this.preloadContent(content.dirName, true, index);

View File

@ -10,7 +10,7 @@
import { EmoteModule } from 'builtin';
import { SettingsSet, SettingsCategory, Setting, SettingsScheme } from 'structs';
import { BdMenu, Modals, DOM, DOMObserver, Reflection, VueInjector, Toasts } from 'ui';
import { BdMenu, Modals, DOM, DOMObserver, Reflection, VueInjector, Toasts, Notifications, BdContextMenu, DiscordContextMenu } from 'ui';
import * as CommonComponents from 'commoncomponents';
import { Utils, Filters, ClientLogger as Logger, ClientIPC, AsyncEventEmitter } from 'common';
import Settings from './settings';
@ -23,6 +23,7 @@ import { WebpackModules } from './webpackmodules';
import DiscordApi from './discordapi';
import { ReactComponents, ReactHelpers } from './reactcomponents';
import { Patcher, MonkeyPatch } from './patcher';
import GlobalAc from '../ui/autocomplete';
export default class PluginApi {
@ -197,6 +198,25 @@ export default class PluginApi {
});
}
/**
* BdContextMenu
*/
showContextMenu(event, groups) {
BdContextMenu.show(event, groups);
this.activeMenu.menu = BdContextMenu.activeMenu.menu;
}
get activeMenu() {
return this._activeMenu || (this._activeMenu = { menu: null });
}
get BdContextMenu() {
return Object.defineProperty({
show: this.showContextMenu.bind(this)
}, 'activeMenu', {
get: () => this.activeMenu
});
}
/**
* CssUtils
*/
@ -255,16 +275,9 @@ export default class PluginApi {
get modalStack() {
return this._modalStack || (this._modalStack = []);
}
get baseModalComponent() {
return Modals.baseComponent;
}
addModal(_modal, component) {
const modal = Modals.add(_modal, component);
modal.on('close', () => {
let index;
while ((index = this.modalStack.findIndex(m => m === modal)) > -1)
this.modalStack.splice(index, 1);
});
modal.on('close', () => Utils.removeFromArray(this.modalStack, modal));
this.modalStack.push(modal);
return modal;
}
@ -300,7 +313,7 @@ export default class PluginApi {
get: () => this.modalStack
},
baseComponent: {
get: () => this.baseModalComponent
get: () => Modals.baseComponent
}
});
}
@ -308,6 +321,7 @@ export default class PluginApi {
/**
* Toasts
*/
showToast(message, options = {}) {
return Toasts.push(message, options);
}
@ -334,33 +348,122 @@ export default class PluginApi {
};
}
/**
* Notifications
*/
get notificationStack() {
return this._notificationStack || (this._notificationStack = []);
}
addNotification(title, text, buttons = []) {
if (arguments.length <= 1) text = title, title = undefined;
if (arguments[1] instanceof Array) [text, buttons] = arguments, title = undefined;
const notification = Notifications.add(title, text, buttons, () => Utils.removeFromArray(this.notificationStack, notification));
this.notificationStack.push(notification);
return notification;
}
dismissNotification(index) {
index = Notifications.stack.indexOf(this.notificationStack[index]);
if (index) Notifications.dismiss(index);
}
dismissAllNotifications() {
for (const index in this.notificationStack) {
this.dismissNotification(index);
}
}
get Notifications() {
return Object.defineProperty({
add: this.addNotification.bind(this),
dismiss: this.dismissNotification.bind(this),
dismissAll: this.dismissAllNotifications.bind(this)
}, 'stack', {
get: () => this.notificationStack
});
}
/**
* Autocomplete
*/
get autocompleteSets() {
return this._autocompleteSets || (this._autocompleteSets = new Map());
}
addAutocompleteController(prefix, controller) {
if (!controller) controller = this.plugin;
if (GlobalAc.validPrefix(prefix)) return;
GlobalAc.add(prefix, controller);
this.autocompleteSets.set(prefix, controller);
}
removeAutocompleteController(prefix) {
if (this.autocompleteSets.get(prefix) !== GlobalAc.sets.get(prefix)) return;
GlobalAc.remove(prefix);
this.autocompleteSets.delete(prefix);
}
removeAllAutocompleteControllers() {
for (const [prefix] of this.autocompleteSets) {
this.removeAutocompleteController(prefix);
}
}
validAutocompletePrefix(prefix) {
return GlobalAc.validPrefix(prefix);
}
toggleAutocompleteMode(prefix, sterm) {
return GlobalAc.toggle(prefix, sterm);
}
searchAutocomplete(prefix, sterm) {
return GlobalAc.items(prefix, sterm);
}
get Autocomplete() {
return Object.defineProperty({
add: this.addAutocompleteController.bind(this),
remove: this.removeAutocompleteController.bind(this),
removeAll: this.removeAllAutocompleteControllers.bind(this),
validPrefix: this.validAutocompletePrefix.bind(this),
toggle: this.toggleAutocompleteMode.bind(this),
search: this.searchAutocomplete.bind(this)
}, 'sets', {
get: () => this.autocompleteSets
});
}
/**
* Emotes
*/
get emotes() {
return EmoteModule.emotes;
return EmoteModule.database;
}
get favourite_emotes() {
return EmoteModule.favourite_emotes;
get favouriteEmotes() {
return EmoteModule.favourites;
}
get mostUsedEmotes() {
return EmoteModule.mostUsed;
}
setFavouriteEmote(emote, favourite) {
return EmoteModule.setFavourite(emote, favourite);
return EmoteModule[favourite ? 'removeFavourite' : 'addFavourite'](emote);
}
addFavouriteEmote(emote) {
return EmoteModule.addFavourite(emote);
}
removeFavouriteEmote(emote) {
return EmoteModule.addFavourite(emote);
return EmoteModule.removeFavourite(emote);
}
isFavouriteEmote(emote) {
return EmoteModule.isFavourite(emote);
}
getEmote(emote) {
return EmoteModule.getEmote(emote);
return EmoteModule.findByName(emote, true);
}
filterEmotes(regex, limit, start = 0) {
return EmoteModule.filterEmotes(regex, limit, start);
getEmoteUseCount(emote) {
const mostUsed = EmoteModule.mostUsed.find(mu => mu.key === emote.name);
return mostUsed ? mostUsed.useCount : 0;
}
incrementEmoteUseCount(emote) {
return EmoteModule.addToMostUsed(emote);
}
searchEmotes(regex, limit) {
return EmoteModule.search(regex, limit);
}
get Emotes() {
return Object.defineProperties({
@ -369,13 +472,18 @@ export default class PluginApi {
removeFavourite: this.removeFavouriteEmote.bind(this),
isFavourite: this.isFavouriteEmote.bind(this),
getEmote: this.getEmote.bind(this),
filter: this.filterEmotes.bind(this)
getUseCount: this.getEmoteUseCount.bind(this),
incrementUseCount: this.incrementEmoteUseCount.bind(this),
search: this.searchEmotes.bind(this)
}, {
emotes: {
get: () => this.emotes
},
favourite_emotes: {
get: () => this.favourite_emotes
favourites: {
get: () => this.favouriteEmotes
},
mostused: {
get: () => this.mostUsedEmotes
}
});
}
@ -552,6 +660,37 @@ export default class PluginApi {
return m => MonkeyPatch(this.plugin.id, m);
}
/**
* DiscordContextMenu
*/
get discordContextMenus() {
return this._discordContextMenus || (this._discordContextMenus = []);
}
addDiscordContextMenu(items, filter) {
const menu = DiscordContextMenu.add(items, filter);
this.discordContextMenus.push(menu);
return menu;
}
removeDiscordContextMenu(menu) {
DiscordContextMenu.remove(menu);
Utils.removeFromArray(this.discordContextMenus, menu);
}
removeAllDiscordContextMenus() {
for (const menu of this.discordContextMenus) {
this.removeDiscordContextMenu(menu);
}
}
get DiscordContextMenu() {
return Object.defineProperty({
add: this.addDiscordContextMenu.bind(this),
remove: this.removeDiscordContextMenu.bind(this),
removeAll: this.removeAllDiscordContextMenus.bind(this)
}, 'menus', {
get: () => this.discordContextMenus
});
}
}
// Stop plugins from modifying the plugin API for all plugins

View File

@ -128,6 +128,10 @@ export default class extends ContentManager {
static get unloadPlugin() { return this.unloadContent }
static get reloadPlugin() { return this.reloadContent }
static unloadContentHook(content, reload) {
delete Globals.require.cache[Globals.require.resolve(content.paths.mainPath)];
}
/**
* Stops a plugin.
* @param {Plugin|String} plugin

View File

@ -9,7 +9,6 @@
*/
import { Toasts } from 'ui';
import { EmoteModule } from 'builtin';
import { SettingsSet } from 'structs';
import { FileUtils, ClientLogger as Logger } from 'common';
import path from 'path';
@ -63,7 +62,6 @@ export default new class Settings {
CssEditor.setState(scss, css, css_editor_files, scss_error);
CssEditor.editor_bounds = css_editor_bounds || {};
EmoteModule.favourite_emotes = favourite_emotes || [];
} catch (err) {
// There was an error loading settings
// This probably means that the user doesn't have any settings yet
@ -87,8 +85,7 @@ export default new class Settings {
css: CssEditor.css,
css_editor_files: CssEditor.files,
scss_error: CssEditor.error,
css_editor_bounds: CssEditor.editor_bounds,
favourite_emotes: EmoteModule.favourite_emotes
css_editor_bounds: CssEditor.editor_bounds
});
for (const set of this.settings) {

View File

@ -367,7 +367,7 @@ export class ChannelCategory extends GuildChannel {
* A list of channels in this category.
*/
get channels() {
return List.from(this.guild.channels, c => c.parentId === this.id);
return List.from(this.guild.channels.filter(c => c.parentId === this.id));
}
/**

View File

@ -32,8 +32,8 @@
background-repeat: no-repeat;
background-size: cover;
cursor: pointer;
height: 16px;
width: 16px;
height: 24px;
width: 24px;
margin-right: 6px;
}

View File

@ -18,11 +18,9 @@
.bd-cm {
left: 170px;
max-height: 270px;
overflow-y: auto;
contain: layout;
flex: 1;
min-height: 1px;
margin-left: 170px;
&::-webkit-scrollbar {
height: 8px;

View File

@ -49,6 +49,13 @@
.bd-notificationBody {
padding: 10px 25px;
flex-grow: 1;
flex-direction: column;
.bd-notificationTitle {
margin-bottom: 10px;
color: #fff;
font-size: 16px;
}
.bd-notificationText {
color: #fff;

View File

@ -23,8 +23,8 @@
> .bd-scroller {
overflow-y: scroll;
.platform-darwin { // sass-lint:disable-line class-name-format
.bd-settings & {
.bd-settings & {
.platform-darwin & { // sass-lint:disable-line class-name-format
padding-top: 22px;
}
}

View File

@ -4,15 +4,15 @@ import { VueInjector } from 'ui';
import AutocompleteComponent from './components/common/Autocomplete.vue';
import { Utils } from 'common';
export default new class AutoComplete {
export default new class Autocomplete {
get sets() {
return this._sets || (this._sets = {});
return this._sets || (this._sets = new Map());
}
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));
MonkeyPatch('BD:Autocomplete', this.cta.component.prototype).after('render', this.channelTextAreaAfterRender.bind(this));
this.initialized = true;
}
@ -32,26 +32,26 @@ export default new class AutoComplete {
add(prefix, controller) {
if (!this.initialized) this.init();
if (this.sets.hasOwnProperty(prefix)) return;
this.sets[prefix] = controller;
if (this.validPrefix(prefix)) return;
this.sets.set(prefix, controller);
}
remove(prefix) {
if (this.sets.hasOwnProperty(prefix)) delete this.sets[prefix];
this.sets.delete(prefix);
}
validPrefix(prefix) {
return this.sets.hasOwnProperty(prefix);
return this.sets.has(prefix);
}
toggle(prefix, sterm) {
if (!this.sets[prefix].toggle) return false;
return this.sets[prefix].toggle(sterm);
toggle(prefix, sterm, event) {
const controller = this.sets.get(prefix);
return controller && controller.toggle && controller.toggle(sterm, event);
}
items(prefix, sterm) {
if (!this.validPrefix(prefix)) return [];
return this.sets[prefix].acsearch(sterm);
return this.sets.get(prefix).acsearch(sterm);
}
}

View File

@ -25,13 +25,19 @@ export default class {
});
if (hideButtonSetting.value) document.body.classList.add('bd-hideButton');
const currentWindow = remote.getCurrentWindow();
const windowOptions = currentWindow.__bd_options;
if (!windowOptions.hasOwnProperty('frame') || windowOptions.frame) document.body.classList.add('bd-windowHasFrame');
if (windowOptions.transparent) document.body.classList.add('bd-windowIsTransparent');
this.pathCache = {
isDm: null,
server: DiscordApi.currentGuild,
channel: DiscordApi.currentChannel
};
remote.getCurrentWindow().webContents.on('did-navigate-in-page', (e, url, isMainFrame) => {
currentWindow.webContents.on('did-navigate-in-page', (e, url, isMainFrame) => {
const { currentGuild, currentChannel } = DiscordApi;
if (!this.pathCache.server)

View File

@ -1,4 +1,4 @@
export { ReactComponent } from './vue';
export { ReactComponent } from './vueinjector';
export * from './components/common';

View File

@ -9,8 +9,8 @@
*/
<template>
<div ref="root" class="bd-cm" :class="{'bd-cmRenderLeft': renderLeft}" v-if="activeMenu && activeMenu.menu" :style="calculatePosition()">
<CMGroup v-for="(group, index) in activeMenu.menu.groups" :items="group.items" :key="index" :closeMenu="hide" :left="left" :top="top"/>
<div class="bd-cm" :class="{'bd-cmRenderLeft': renderLeft}" v-if="activeMenu && activeMenu.menu" :style="calculatePosition()">
<CMGroup v-for="(group, index) in activeMenu.menu.groups" :items="group.items" :key="index" :left="left" :top="top" @close="hide" />
</div>
</template>
@ -20,6 +20,9 @@
import CMGroup from './contextmenu/Group.vue';
export default {
components: {
CMGroup
},
data() {
return {
activeMenu: BdContextMenu.activeMenu,
@ -29,27 +32,24 @@
renderLeft: false
};
},
components: { CMGroup },
methods: {
calculatePosition() {
if (!this.activeMenu.menu.groups.length) return {};
this.mouseX = this.activeMenu.menu.x;
this.mouseY = this.activeMenu.menu.y;
const mouseX = this.activeMenu.menu.x;
const mouseY = this.activeMenu.menu.y;
const height = this.activeMenu.menu.groups.reduce((total, group) => total + group.items.length, 0) * 28;
this.top = window.innerHeight - this.mouseY - height < 0 ? this.mouseY - height : this.mouseY;
this.left = window.innerWidth - this.mouseX - 170 < 0 ? this.mouseX - 170 : this.mouseX;
this.top = window.innerHeight - mouseY - height < 0 ? mouseY - height : mouseY;
this.left = window.innerWidth - mouseX - 170 < 0 ? mouseX - 170 : mouseX;
this.renderLeft = (this.left + 170 * 2) > window.innerWidth;
window.addEventListener('mouseup', this.clickHide);
window.addEventListener('mousedown', this.clickHide);
return { top: `${this.top}px`, left: `${this.left}px` };
},
hide() {
window.removeEventListener('mouseup', this.clickHide);
window.removeEventListener('mousedown', this.clickHide);
this.activeMenu.menu = null;
},
clickHide(e) {
if (!this.$refs.root) return;
if (this.$refs.root.contains(e.target)) return;
this.hide();
if (!this.$el.contains(e.target)) this.hide();
}
}
}

View File

@ -15,6 +15,7 @@
<div @click="this.dismissFirst" class="bd-notificationDismissBtn"><MiArrowLeft size="20"/></div>
</div>
<div class="bd-notificationBody bd-flex">
<div v-if="notifications[0].title" class="bd-notificationTitle">{{notifications[0].title}}</div>
<div class="bd-notificationText">{{notifications[0].text}}</div>
</div>
<div class="bd-notificationFooter bd-flex">

View File

@ -10,10 +10,10 @@
<template>
<div class="bd-settingsWrapper" :class="[{'bd-active': active}, 'platform-' + this.platform]">
<div class="bd-settingsButton" :class="{'bd-active': active, 'bd-animating': animating, 'bd-hideButton': hideButton}" @click="active = true">
<div class="bd-settingsButton" :class="{'bd-active': active, 'bd-animating': animating, 'bd-hideButton': hideButton}" @click="active = true" v-contextmenu="buttonContextMenu">
<div v-if="updating === 0" v-tooltip.right="'Checking for updates'" class="bd-settingsButtonBtn bd-loading"></div>
<div v-else-if="updating === 2" v-tooltip.right="'Updates available!'" class="bd-settingsButtonBtn bd-updates"></div>
<div v-else class="bd-settingsButtonBtn" :class="[{'bd-loading': !loaded}]"></div>
<div v-else class="bd-settingsButtonBtn" :class="{'bd-loading': !loaded}"></div>
</div>
<BdSettings ref="settings" :active="active" @close="active = false" />
</div>
@ -21,7 +21,7 @@
<script>
// Imports
import { Events, Settings } from 'modules';
import { Events, Settings, Updater } from 'modules';
import { Modals } from 'ui';
import process from 'process';
import BdSettings from './BdSettings.vue';
@ -38,7 +38,21 @@
eventHandlers: {},
keybindHandler: null,
hideButton: false,
hideButtonToggleHandler: null
hideButtonToggleHandler: null,
buttonContextMenu: [
{
items: [
{
text: 'Check for updates',
updating: false,
onClick(event) {
if (this.updating === 2) Updater.update();
else if (this.updating !== 0) Updater.checkForUpdates();
}
}
]
}
]
};
},
components: {
@ -67,6 +81,12 @@
this.animating = false;
this.timeout = null;
}, 400);
},
updating(updating) {
const updateItem = this.buttonContextMenu[0].items[0];
updateItem.updating = updating;
updateItem.text = updating === 0 ? 'Checking for updates...' : updating === 2 ? 'Install updates' : 'Check for updates';
}
},
created() {

View File

@ -59,8 +59,7 @@
filePath() {
try {
return Globals.require.resolve(path.join(Globals.getPath('data'), 'window'));
}
catch (err) {
} catch (err) {
FileUtils.writeJsonToFile(this.defaultFilePath, {});
return this.defaultFilePath;
}
@ -79,11 +78,13 @@
if (event.category.id === 'default' && event.setting.id === 'transparent') {
newPreferences.transparent = event.value;
if (event.value) delete newPreferences.backgroundColor;
if (event.value) newPreferences.backgroundColor = null;
else if (newPreferences.backgroundColor === null) delete newPreferences.backgroundColor;
}
if (event.category.id === 'default' && event.setting.id === 'background-colour') {
newPreferences.backgroundColor = event.value;
if (event.value) newPreferences.transparent = false;
}
if (event.category.id === 'default' && event.setting.id === 'frame') {

View File

@ -9,7 +9,7 @@
*/
<template>
<Modal :class="['bd-modalBasic', {'bd-modal-out': modal.closing}]" :headerText="modal.title" @close="modal.close">
<Modal class="bd-modalBasic" :headertext="modal.title" :closing="modal.closing" @close="modal.close">
<div slot="body" class="bd-modalBasicBody">{{ modal.text }}</div>
<div slot="footer" class="bd-modalControls">
<div class="bd-flexGrow"></div>

View File

@ -9,7 +9,7 @@
*/
<template>
<Modal :class="['bd-modalBasic', {'bd-modal-out': modal.closing}]" :headerText="modal.title" @close="modal.close">
<Modal class="bd-modalBasic" :headertext="modal.title" :closing="modal.closing" @close="modal.close">
<div slot="body" class="bd-modalBasicBody">{{ modal.text }}</div>
<div slot="footer" class="bd-modalControls">
<div class="bd-flexGrow"></div>

View File

@ -1,14 +1,14 @@
<template>
<Modal :headerText="modal.event.header" @close="modal.close"
:class="{'bd-err': modal.event.type && modal.event.type === 'err', 'bd-modalOut': modal.closing}">
<Modal :headertext="modal.event.header" :closing="modal.closing" @close="modal.close"
:class="{'bd-err': modal.event.type && modal.event.type === 'err'}">
<MiError v-if="modal.event.type === 'err'" slot="icon" size="20"/>
<div slot="body">
<div v-for="(content, index) in modal.event.content">
<div class="bd-modalError" :class="{'bd-open': content.showStack}">
<div class="bd-modalErrorTitle bd-flex">
<span class="bd-modalTitleText bd-flexGrow">{{content.message}}</span>
<span class="bd-modalTitlelink" v-if="content.showStack" @click="() => { content.showStack = false; $forceUpdate(); }">Hide Stacktrace</span>
<span class="bd-modalTitlelink" v-else @click="() => { content.showStack = true; $forceUpdate(); }">Show Stacktrace</span>
<span class="bd-modalTitlelink" v-if="content.showStack" @click="content.showStack = false; $forceUpdate();">Hide Stacktrace</span>
<span class="bd-modalTitlelink" v-else @click="content.showStack = true; $forceUpdate();">Show Stacktrace</span>
</div>
<div class="bd-scrollerWrap">
<div class="bd-scroller">

View File

@ -9,15 +9,14 @@
*/
<template>
<Modal :class="['bd-modalBasic', {'bd-modalOut': modal.closing}]" :headerText="modal.title" @close="modal.close">
<Modal class="bd-modalBasic" :headertext="modal.title" :closing="modal.closing" @close="modal.close">
<div slot="body" class="bd-modalBasicBody bd-inputModalBody bd-formTextinput">
{{ modal.text }}
<input v-if="modal.password" ref="input" type="password" @keyup.stop="keyup" />
<input v-else ref="input" type="text" @keyup.stop="keyup"/>
<input ref="input" :type="modal.password ? 'password' : 'text'" @keyup.stop="keyup"/>
</div>
<div slot="footer" class="bd-modalControls">
<div class="bd-flexGrow"></div>
<div class="bd-button bd-ok" @click="() => { modal.confirm(value); modal.close(); }">OK</div>
<div class="bd-button bd-ok" @click="modal.confirm(value); modal.close();">OK</div>
</div>
</Modal>
</template>

View File

@ -9,7 +9,7 @@
*/
<template>
<Modal :class="['bd-modalBasic', {'bd-modalOut': modal.closing}]" :headerText="modal.title" @close="modal.close">
<Modal class="bd-modalBasic" :headertext="modal.title" :closing="modal.closing" @close="modal.close">
<div slot="body" class="bd-modalBasicBody">
<div v-for="(perm, i) in permissions" :key="`perm-${i}`" class="bd-permScope">
<div class="bd-permAllow">
@ -26,7 +26,7 @@
<div slot="footer" class="bd-modalControls">
<div class="bd-flexGrow"></div>
<div class="bd-button" @click="modal.close">Cancel</div>
<div class="bd-button bd-ok" @click="() => { modal.confirm(); modal.close(); }">Authorize</div>
<div class="bd-button bd-ok" @click="modal.confirm(); modal.close();">Authorize</div>
</div>
</Modal>
</template>

View File

@ -10,8 +10,9 @@
<template>
<div class="bd-settingsModal" :class="{'bd-edited': changed}">
<Modal :class="{'bd-modalOut': modal.closing}" :headerText="modal.headertext" @close="modal.close">
<Modal :headertext="modal.headertext" :closing="modal.closing" @close="modal.close">
<SettingsPanel :settings="settings" :schemes="modal.schemes" slot="body" class="bd-settingsModalBody" />
<div slot="footer" class="bd-footerAlert" :class="{'bd-active': changed || saving, 'bd-warn': warnclose}" :style="{pointerEvents: changed ? 'all' : 'none'}">
<div class="bd-footerAlertText">Unsaved changes</div>
<div class="bd-button bd-resetButton bd-tp" :class="{'bd-disabled': saving}" @click="resetSettings">Reset</div>

View File

@ -132,7 +132,7 @@
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);
return this.controller.toggle(prefix, sterm, e);
}
}
}

View File

@ -20,4 +20,5 @@ export { default as MiSuccess } from './materialicons/Success.vue';
export { default as AccountCircle } from './materialicons/AccountCircle.vue';
export { default as MiLock } from './materialicons/Lock.vue';
export { default as MiImagePlus } from './materialicons/ImagePlus.vue';
export { default as MiIcVpnKey } from './materialicons/IcVpnKey.vue';
export { default as MiArrowLeft } from './materialicons/ArrowLeft.vue';

View File

@ -9,25 +9,27 @@
*/
<template>
<div :class="['bd-modal', {'bd-modalScrolled': scrolled}]">
<div :class="['bd-modal', {'bd-modalOut': closing, 'bd-modalScrolled': scrolled}]">
<div class="bd-modalInner">
<div class="bd-modalHeader">
<div class="bd-modalIcon">
<slot name="icon" />
</div>
<span class="bd-modalHeadertext">{{ headerText }}</span>
<slot name="header">
<div v-if="$slots.icon" class="bd-modalIcon">
<slot name="icon" />
</div>
<span class="bd-modalHeadertext">{{ headertext }}</span>
</slot>
<div class="bd-modalX" @click="$emit('close', $event.shiftKey, $event)">
<MiClose size="18" />
</div>
</div>
<div class="bd-modalBody">
<div class="bd-scrollerWrap">
<div class="bd-scroller" @scroll="e => scrolled = e.target.scrollTop !== 0">
<div class="bd-scroller" @scroll="e => scrolled = e.target.scrollTop > 0">
<slot name="body"></slot>
</div>
</div>
</div>
<div class="bd-modalFooter">
<div v-if="$slots.footer" class="bd-modalFooter">
<slot name="footer"></slot>
</div>
</div>
@ -39,7 +41,7 @@
import { MiClose } from './MaterialIcon';
export default {
props: ['headerText'],
props: ['headertext', 'closing'],
components: {
MiClose
},

View File

@ -0,0 +1,29 @@
/**
* BetterDiscord Material Design Icon
* 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.
*
* Material Design Icons
* Copyright (c) 2014 Google
* Apache 2.0 LICENSE
* https://www.apache.org/licenses/LICENSE-2.0.txt
*/
<template>
<span class="bd-materialDesignIcon">
<svg :width="size || 24" :height="size || 24" viewBox="0 0 24 24">
<path d="M0 0h24v24H0z" fill="none"/>
<path d="M12.65 10C11.83 7.67 9.61 6 7 6c-3.31 0-6 2.69-6 6s2.69 6 6 6c2.61 0 4.83-1.67 5.65-4H17v4h4v-4h2v-4H12.65zM7 14c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2z"/>
</svg>
</span>
</template>
<script>
export default {
props: ['size']
}
</script>

View File

@ -9,7 +9,7 @@
*/
<template>
<div class="bd-cmItem" :style="{color: item.color || ''}" @click="onClick">
<div class="bd-cmItem" :style="{color: item.color || ''}" @click="$emit('click', $event)">
<span>{{item.text}}</span>
<div class="bd-cmHint" v-if="item.hint">{{item.hint}}</div>
<img :src="item.icon" v-else-if="item.icon"/>
@ -18,6 +18,6 @@
<script>
export default {
props: ['item', 'onClick']
props: ['item']
}
</script>

View File

@ -9,20 +9,18 @@
*/
<template>
<div class="bd-cmGroup" ref="test">
<div class="bd-cmGroup">
<template v-for="(item, index) in items">
<CMButton v-if="!item.type || item.type === 'button'" :item="item" :onClick="() => { item.onClick(); closeMenu(); }" />
<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-if="item.type === 'sub'" class="bd-cmItem bd-cmSub" @mouseenter="subMenuMouseEnter($event, index, item)" @mouseleave="subMenuMouseLeave($event, index, item)">
{{item.text}}
<MiChevronDown />
<div class="bd-cm" v-if="index === visibleSub" :style="subStyle">
<template v-for="(item, index) in item.items">
<CMButton v-if="!item.type || item.type === 'button'" :item="item" :onClick="() => { item.onClick(); closeMenu(); }" />
<CMToggle v-else-if="item.type === 'toggle'" :item="item" :checked="item.checked" :onClick="() => { item.checked = item.onChange(!item.checked, target) }" />
</template>
<div v-if="index === visibleSub" :class="['bd-cm', {'bd-cmRenderLeft': subRenderLeft}]" :style="subStyle">
<CMGroup :items="item.items" :top="subTop" :left="subLeft" @close="$emit('close')" />
</div>
</div>
<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>
</template>
@ -34,21 +32,29 @@
import { MiChevronDown } from '../common';
export default {
name: 'CMGroup',
components: {
CMButton, CMToggle, MiChevronDown
},
props: ['items', 'left', 'top', 'target'],
data() {
return {
visibleSub: -1,
subStyle: {}
}
subStyle: {},
subTop: 0,
subLeft: 0,
subRenderLeft: false
};
},
props: ['items', 'closeMenu', 'left', 'top', 'target'],
components: { CMButton, CMToggle, MiChevronDown },
methods: {
subMenuMouseEnter(e, index, sub) {
const subHeight = sub.items.length > 9 ? 270 : sub.items.length * e.target.offsetHeight;
const top = this.top + subHeight + e.target.offsetTop > window.innerHeight ?
this.subTop = this.top + subHeight + e.target.offsetTop > window.innerHeight ?
this.top - subHeight + e.target.offsetTop + e.target.offsetHeight :
this.top + e.target.offsetTop;
this.subStyle = { top: `${top}px`, left: `${this.left}px` };
this.subRenderLeft = (this.left + 170 * 2) > window.innerWidth;
this.subLeft = this.left + (!this.subRenderLeft ? e.target.clientWidth : 0);
this.subStyle = { top: `${this.subTop - 2}px`, left: `${this.subLeft}px` };
this.visibleSub = index;
},
subMenuMouseLeave(e, index, sub) {

View File

@ -9,7 +9,7 @@
*/
<template>
<div class="bd-cmItem bd-cmToggle" @click="onClick">
<div class="bd-cmItem bd-cmToggle" @click="$emit('click', $event)">
<div class="bd-cmLabel">{{item.text}}</div>
<div class="bd-cmCheckbox" :checked="checked">
<div class="bd-cmCheckboxInner">

View File

@ -8,6 +8,7 @@
* LICENSE file in the root directory of this source tree.
*/
import { Utils, ClientLogger as Logger } from 'common';
import { ReactComponents, WebpackModules, MonkeyPatch } from 'modules';
import { VueInjector, Toasts } from 'ui';
import CMGroup from './components/contextmenu/Group.vue';
@ -17,7 +18,7 @@ export class BdContextMenu {
/**
* Show a context menu
* @param {MouseEvent|Object} e MouseEvent or Object { x: 0, y: 0 }
* @param {Object[]} grops Groups of items to show in context menu
* @param {Object[]} groups Groups of items to show in context menu
*/
static show(e, groups) {
const x = e.x || e.clientX;
@ -29,6 +30,17 @@ export class BdContextMenu {
return this._activeMenu || (this._activeMenu = { menu: null });
}
static install(Vue) {
Vue.directive('contextmenu', {
bind(el, binding) {
el.addEventListener('contextmenu', event => {
Logger.log('BdContextMenu', ['Showing context menu', event, el, binding]);
BdContextMenu.show(event, binding.value);
});
}
});
}
}
export class DiscordContextMenu {
@ -39,14 +51,15 @@ export class DiscordContextMenu {
* @param {any} items items to add
* @param {Function} [filter] filter function for target filtering
*/
static add(id, items, filter) {
static add(items, filter) {
if (!this.patched) this.patch();
this.menus.push({ id, items, filter });
return () => this.remove(id);
const menu = { items, filter };
this.menus.push(menu);
return menu;
}
static remove(id) {
this._menus = this._menus.filter(menu => menu.id !== id);
static remove(menu) {
Utils.removeFromArray(this.menus, menu);
}
static get menus() {
@ -58,8 +71,8 @@ export class DiscordContextMenu {
this.patched = true;
const self = this;
MonkeyPatch('BD:DiscordCMOCM', WebpackModules.getModuleByProps(['openContextMenu'])).instead('openContextMenu', (_, [e, fn], originalFn) => {
const overrideFn = function (...args) {
const res = fn(...args);
const overrideFn = function () {
const res = fn.apply(this, arguments);
if (!res.hasOwnProperty('type')) return res;
if (!res.type.prototype || !res.type.prototype.render || res.type.prototype.render.__patched) return res;
MonkeyPatch('BD:DiscordCMRender', res.type.prototype).after('render', (c, a, r) => self.renderCm(c, a, r, res));
@ -78,7 +91,7 @@ export class DiscordContextMenu {
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)})) {
for (const menu of this.menus.filter(menu => !menu.filter || menu.filter(target))) {
retVal.props.children.push(VueInjector.createReactElement(CMGroup, {
target,
top,

View File

@ -15,11 +15,17 @@ export default class Notifications {
/**
* Add a new notification to the stack.
* Notifications should only be used for important things.
* @param {String} [title]
* @param {String} text
* @param {Object} [buttons] buttons to show { text: 'Text for the button', onClick: fn() { return true if notification should be dismissed } }
* @param {Object[]} [buttons] buttons to show { text: 'Text for the button', onClick: fn() { return true if notification should be dismissed } }
*/
static add(text, buttons = []) {
this.stack.push({ text, buttons });
static add(title, text, buttons = [], ondismiss) {
if (arguments.length <= 1) text = title, title = undefined;
if (arguments[1] instanceof Array) [text, buttons, ondismiss] = arguments, title = undefined;
const notification = { title, text, buttons, ondismiss };
this.stack.push(notification);
return notification;
}
/**
@ -35,6 +41,9 @@ export default class Notifications {
* @param {Number} index Index of the notification
*/
static dismiss(index) {
const notification = this.stack[index];
if (!notification) return;
if (notification.ondismiss) notification.ondismiss();
this.stack.splice(index, 1);
}

View File

@ -8,10 +8,11 @@
* LICENSE file in the root directory of this source tree.
*/
import { WebpackModules } from 'modules';
import Vue from 'vue';
import VTooltip from 'v-tooltip';
import DOM from './dom';
import VueInjector from './vueinjector';
import { BdContextMenu } from './contextmenus';
Vue.use(VTooltip, {
defaultContainer: 'bd-tooltips',
@ -45,22 +46,7 @@ Vue.use(VTooltip, {
}
});
export const ReactComponent = {
props: ['component', 'component-props', 'component-children', 'react-element'],
render(createElement) {
return createElement('div');
},
mounted() {
const { React, ReactDOM } = WebpackModules;
ReactDOM.unmountComponentAtNode(this.$el);
ReactDOM.render(this.reactElement || React.createElement(this.component, this.componentProps, ...(this.componentChildren || [])), this.$el);
},
beforeDestroy() {
WebpackModules.ReactDOM.unmountComponentAtNode(this.$el);
}
};
Vue.component('ReactComponent', ReactComponent);
Vue.use(VueInjector);
Vue.use(BdContextMenu);
export default Vue;

View File

@ -9,7 +9,7 @@
*/
import { WebpackModules } from 'modules';
import Vue from './vue';
import Vue from 'vue';
export default class {
@ -93,4 +93,24 @@ export default class {
}
}
static install(Vue) {
Vue.component('ReactComponent', ReactComponent);
}
}
export const ReactComponent = {
props: ['component', 'component-props', 'component-children', 'react-element'],
render(createElement) {
return createElement('div');
},
mounted() {
const { React, ReactDOM } = WebpackModules;
ReactDOM.unmountComponentAtNode(this.$el);
ReactDOM.render(this.reactElement || React.createElement(this.component, this.componentProps, ...(this.componentChildren || [])), this.$el);
},
beforeDestroy() {
WebpackModules.ReactDOM.unmountComponentAtNode(this.$el);
}
};

View File

@ -132,16 +132,21 @@ export class Utils {
* @param {Function} exclude A function to filter objects that shouldn't be cloned
* @return {Any} The cloned value
*/
static deepclone(value, exclude) {
static deepclone(value, exclude, cloned) {
if (exclude && exclude(value)) return value;
if (typeof value === 'object') {
if (value instanceof Array) return value.map(i => this.deepclone(i, exclude));
if (!cloned) cloned = new WeakMap();
if (typeof value === 'object' && value !== null) {
if (value instanceof Array) return value.map(i => this.deepclone(i, exclude, cloned));
if (cloned.has(value)) return cloned.get(value);
const clone = Object.assign({}, value);
cloned.set(value, clone);
for (const key in clone) {
clone[key] = this.deepclone(clone[key], exclude);
clone[key] = this.deepclone(clone[key], exclude, cloned);
}
return clone;
@ -159,6 +164,8 @@ export class Utils {
if (exclude && exclude(object)) return;
if (typeof object === 'object' && object !== null) {
if (Object.isFrozen(object)) return object;
const properties = Object.getOwnPropertyNames(object);
for (const property of properties) {
@ -177,9 +184,9 @@ export class Utils {
* @param {Any} item The item to remove from the array
* @return {Array}
*/
static removeFromArray(array, item) {
static removeFromArray(array, item, filter) {
let index;
while ((index = array.indexOf(item)) > -1)
while ((index = filter ? array.findIndex(item) : array.indexOf(item)) > -1)
array.splice(index, 1);
return array;
}
@ -496,30 +503,52 @@ export class FileUtils {
}
/**
* Delete a directory
* Deletes a file.
* @param {String} path The file's path
* @return {Promise}
*/
static async deleteFile(path) {
await this.fileExists(path);
return new Promise((resolve, reject) => {
fs.unlink(path, (err, files) => {
if (err) reject(err);
else resolve(files);
});
});
}
/**
* Deletes a directory.
* @param {String} path The directory's path
* @return {Promise}
*/
static async deleteDirectory(pathToDir) {
try {
await this.directoryExists(pathToDir);
const files = await this.listDirectory(pathToDir);
static async deleteDirectory(path) {
await this.directoryExists(path);
for (const file of files) {
const pathToFile = path.join(pathToDir, file);
try {
await this.directoryExists(pathToFile);
await this.deleteDirectory(pathToFile);
} catch (err) {
fs.unlinkSync(pathToFile);
}
return new Promise((resolve, reject) => {
fs.rmdir(path, (err, files) => {
if (err) reject(err);
else resolve(files);
});
});
}
/**
* Deletes a directory and it's contents.
* @param {String} path The directory's path
* @return {Promise}
*/
static async recursiveDeleteDirectory(pathToDir) {
for (const file of await this.listDirectory(pathToDir)) {
const pathToFile = path.join(pathToDir, file);
try {
await this.recursiveDeleteDirectory(pathToFile);
} catch (err) {
await this.deleteFile(pathToFile);
}
fs.rmdirSync(pathToDir);
return true;
} catch (err) {
throw err;
}
await this.deleteDirectory(pathToDir);
}
}

View File

@ -1,7 +1,6 @@
import gulp from 'gulp';
import pump from 'pump';
import babel from 'gulp-babel';
import watch from 'gulp-watch';
gulp.task('build', function () {
return pump([
@ -12,9 +11,5 @@ gulp.task('build', function () {
});
gulp.task('watch', function () {
return pump([
watch('src/**/*.js'),
babel(),
gulp.dest('dist')
]);
return gulp.watch('src/**/*.js', gulp.series('build'));
});

View File

@ -74,7 +74,7 @@ class PatchedBrowserWindow extends BrowserWindow {
super(options);
this.__bd_preload = [];
Object.defineProperty(this, '__bd_preload', {value: []});
if (originalOptions.webPreferences && originalOptions.webPreferences.preload) {
this.__bd_preload.push(originalOptions.webPreferences.preload);
@ -82,6 +82,11 @@ class PatchedBrowserWindow extends BrowserWindow {
if (userOptions.webPreferences && userOptions.webPreferences.preload) {
this.__bd_preload.push(path.resolve(_dataPath, userOptions.webPreferences.preload));
}
Object.defineProperty(this, '__bd_options', {value: options});
Object.freeze(options);
Object.freeze(options.webPreferences);
Object.freeze(this.__bd_preload);
}
static get userWindowPreferences() {

View File

@ -17,6 +17,8 @@ import electron, { ipcRenderer } from 'electron';
console.log('[BetterDiscord|Sparkplug]');
electron.webFrame.registerURLSchemeAsPrivileged('chrome-extension');
const currentWindow = electron.remote.getCurrentWindow();
if (currentWindow.__bd_preload) {

View File

@ -1,20 +1,29 @@
exports.main = (Plugin, { Logger, Settings, Modals, BdMenu: { BdMenuItems }, CommonComponents, Api }) => class extends Plugin {
exports.main = (Plugin, { Logger, Settings, Modals, BdMenu: { BdMenuItems }, CommonComponents, DiscordContextMenu, Autocomplete, Notifications, Api }) => class extends Plugin {
async onstart() {
this.keybindEvent = this.keybindEvent.bind(this);
// Some array event examples
/**
* Array setting events.
*/
const arraySetting = this.settings.getSetting('default', 'array-1');
Logger.log('Array setting', arraySetting);
arraySetting.on('item-added', event => Logger.log('Item', event.item, 'was added to the array setting'));
arraySetting.on('item-updated', event => Logger.log('Item', event.item, 'of the array setting was updated', event));
arraySetting.on('item-removed', event => Logger.log('Item', event.item, 'removed from the array setting'));
// Keybind setting examples
/**
* Keybind setting events.
*/
const keybindSetting = this.settings.getSetting('default', 'keybind-1');
Logger.log('Keybind setting', keybindSetting);
keybindSetting.on('keybind-activated', this.keybindEvent);
// Create a new settings set and add it to the menu
/**
* Settings.
*/
const set = Settings.createSet({
text: this.name
});
@ -41,7 +50,7 @@ exports.main = (Plugin, { Logger, Settings, Modals, BdMenu: { BdMenuItems }, Com
Logger.log('Updated settings', updatedSettings);
await new Promise(resolve => setTimeout(resolve, 500));
set.setSaved();
})
});
const setting2 = await category.addSetting({
id: 'setting-2',
@ -52,6 +61,10 @@ exports.main = (Plugin, { Logger, Settings, Modals, BdMenu: { BdMenuItems }, Com
setting2.on('setting-updated', event => Logger.log('Setting 2 was changed to', event.value));
/**
* Menu items.
*/
this.menuItem = BdMenuItems.addSettingsSet('Plugins', set, 'Plugin 4');
this.menuItem2 = BdMenuItems.addVueComponent('Plugins', 'Also Plugin 4', {
@ -65,6 +78,32 @@ exports.main = (Plugin, { Logger, Settings, Modals, BdMenu: { BdMenuItems }, Com
Api, plugin: Api.plugin
}; }
});
/**
* Discord context menus.
*/
this.contextMenu = DiscordContextMenu.add([
{
text: 'Test',
onClick: () => Modals.basic('Test', 'Hello from Plugin 4')
}
]);
/**
* Autocomplete.
* This calls `acsearch` on the controller (the plugin object). You can add multiple autocomplete sets by passing another controller.
*/
Autocomplete.add('|');
/**
* Notifications.
*/
Notifications.add('Notification from Plugin 4', [
{text: 'Dismiss', onClick: () => true}
]);
}
onstop() {
@ -72,10 +111,34 @@ exports.main = (Plugin, { Logger, Settings, Modals, BdMenu: { BdMenuItems }, Com
keybindSetting.off('keybind-activated', this.keybindEvent);
BdMenuItems.removeAll();
DiscordContextMenu.removeAll();
Autocomplete.removeAll();
}
keybindEvent(event) {
Logger.log('Keybind pressed', event);
Modals.basic('Example Plugin 4', 'Test keybind activated.');
}
acsearch(sterm) {
// sterm is the text after the prefix
Logger.log('Searching for', sterm);
return {
title: ['Plugin 4 autocomplete'],
items: [
{key: 'Item 1', value: {replaceWith: 'Something to insert when selected'}},
{key: 'Item 2', value: {replaceWith: 'Something to insert when selected'}},
{key: 'Item 3', value: {replaceWith: 'Something to insert when selected'}},
{key: 'Item 4', value: {replaceWith: 'Something to insert when selected'}}
]
// `title` can also be an array - the second item will be white
// You can also add `type: 'imagetext'` here and add an `src` property to each item's value to show an image
};
}
get api() {
return Api;
}
};