diff --git a/core/.babelrc b/.babelrc similarity index 73% rename from core/.babelrc rename to .babelrc index 01673122..3ef5e0ea 100644 --- a/core/.babelrc +++ b/.babelrc @@ -3,8 +3,7 @@ ["env", { "targets": { "node": "6.7.0" - }, - "debug": true + } }] ] } diff --git a/.travis.yml b/.travis.yml index 14b21a51..b9a8c60e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,8 @@ language: node_js + node_js: - stable + branches: only: - master diff --git a/client/src/builtin/EmoteModule.js b/client/src/builtin/EmoteModule.js index 82f188ed..95921f02 100644 --- a/client/src/builtin/EmoteModule.js +++ b/client/src/builtin/EmoteModule.js @@ -8,11 +8,12 @@ * LICENSE file in the root directory of this source tree. */ -import { Events, Settings, Globals, WebpackModules, ReactComponents, MonkeyPatch } from 'modules'; -import { DOM, VueInjector, Reflection } from 'ui'; +import { Settings, Globals, WebpackModules, ReactComponents, MonkeyPatch } from 'modules'; +import { VueInjector, Reflection } from 'ui'; import { Utils, FileUtils, ClientLogger as Logger } from 'common'; import path from 'path'; import EmoteComponent from './EmoteComponent.vue'; +import Autocomplete from '../ui/components/common/Autocomplete.vue'; const enforceWrapperFrom = (new Date('2018-05-01')).valueOf(); @@ -24,26 +25,34 @@ export default new class EmoteModule { } async init() { + const dataPath = Globals.getPath('data'); + this.enabledSetting = Settings.getSetting('emotes', 'default', 'enable'); - this.enabledSetting.on('setting-updated', event => { + 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')); + // Rerender all messages (or if we're disabling emotes, those that have emotes) - for (const message of document.querySelectorAll(event.value ? '.message' : '.bd-emote-outer')) { + for (const message of document.querySelectorAll(event.value ? '.message' : '.bd-emotewrapper')) { Reflection(event.value ? message : message.closest('.message')).forceUpdate(); } }); - const dataPath = Globals.getPath('data'); try { - await this.load(path.join(dataPath, 'emotes.json')); + 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 this.observe(); + await Promise.all([ + this.patchMessageContent(), + this.patchChannelTextArea() + ]); } catch (err) { - Logger.err('EmoteModule', ['Error patching Message', err]); + Logger.err('EmoteModule', ['Error patching Message / ChannelTextArea', err]); } } @@ -54,8 +63,12 @@ export default new class EmoteModule { 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.name = emote.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'; + + // 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); } @@ -92,17 +105,7 @@ export default new class EmoteModule { return this._searchCache || (this._searchCache = {}); } - get React() { - return WebpackModules.getModuleByName('React'); - } - - get ReactDOM() { - return WebpackModules.getModuleByName('ReactDOM'); - } - processMarkup(markup, timestamp) { - if (!this.enabledSetting.value) return markup; - timestamp = timestamp.valueOf(); const allowNoWrapper = timestamp < enforceWrapperFrom; @@ -126,12 +129,13 @@ export default new class EmoteModule { newMarkup.push(text); text = null; } - newMarkup.push(this.React.createElement('span', { - className: 'bd-emote-outer', - 'data-bdemote-name': emote.name, - 'data-bdemote-src': emote.src, - 'data-has-wrapper': /;[\w]+;/gmi.test(word) + + newMarkup.push(VueInjector.createReactElement(EmoteComponent, { + src: emote.src, + name: emote.id, + hasWrapper: /;[\w]+;/gmi.test(word) })); + continue; } if (text === null) { @@ -151,16 +155,6 @@ export default new class EmoteModule { return !/;[\w]+;/gmi.test(word); } - injectAll() { - if (!this.enabledSetting.value) return; - - const all = document.getElementsByClassName('bd-emote-outer'); - for (const ec of all) { - if (ec.children.length) continue; - this.injectEmote(ec); - } - } - 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); @@ -173,56 +167,6 @@ export default new class EmoteModule { return null; } - async observe() { - const Message = await ReactComponents.getComponent('Message'); - this.unpatchRender = MonkeyPatch('BD:EmoteModule', Message.component.prototype).after('render', (component, args, retVal) => { - try { - // First child has all the actual text content, second is the edited timestamp - const markup = this.findByProp(retVal, 'className', 'markup'); - if (!markup) return; - markup.children[0] = this.processMarkup(markup.children[0], component.props.message.editedTimestamp || component.props.message.timestamp); - } catch (err) { - Logger.err('EmoteModule', err); - } - }); - for (const message of document.querySelectorAll('.message')) { - Reflection(message).forceUpdate(); - } - this.injectAll(); - this.unpatchMount = MonkeyPatch('BD:EmoteModule', Message.component.prototype).after('componentDidMount', component => { - const element = this.ReactDOM.findDOMNode(component); - if (!element) return; - this.injectEmotes(element); - }); - this.unpatchUpdate = MonkeyPatch('BD:EmoteModule', Message.component.prototype).after('componentDidUpdate', component => { - const element = this.ReactDOM.findDOMNode(component); - if (!element) return; - this.injectEmotes(element); - }); - } - - injectEmote(root) { - if (!this.enabledSetting.value) return; - - while (root.firstChild) { - root.removeChild(root.firstChild); - } - const { bdemoteName, bdemoteSrc, hasWrapper } = root.dataset; - if (!bdemoteName || !bdemoteSrc) return; - VueInjector.inject(root, { - components: { EmoteComponent }, - data: { src: bdemoteSrc, name: bdemoteName, hasWrapper }, - template: '' - }, DOM.createElement('span')); - root.classList.add('bd-is-emote'); - } - - injectEmotes(element) { - if (!this.enabledSetting.value || !element) return; - - for (const beo of element.getElementsByClassName('bd-emote-outer')) this.injectEmote(beo); - } - getEmote(word) { const name = word.replace(/;/g, ''); return this.emotes.get(name); @@ -250,4 +194,35 @@ export default new class EmoteModule { 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(); + } + } diff --git a/client/src/data/user.settings.default.json b/client/src/data/user.settings.default.json index 7038d652..cfe23ae5 100644 --- a/client/src/data/user.settings.default.json +++ b/client/src/data/user.settings.default.json @@ -65,6 +65,18 @@ "value": false } ] + }, + { + "id": "window-preferences", + "name": "Window Preferences", + "type": "drawer", + "settings": [ + { + "id": "window-preferences", + "type": "custom", + "component": "WindowPreferences" + } + ] } ] }, diff --git a/client/src/index.js b/client/src/index.js index 77ad5d24..03c0b662 100644 --- a/client/src/index.js +++ b/client/src/index.js @@ -8,9 +8,9 @@ * LICENSE file in the root directory of this source tree. */ -import { DOM, BdUI, BdMenu, Modals, Reflection } from 'ui'; +import { DOM, BdUI, BdMenu, Modals, Reflection, Toasts } from 'ui'; import BdCss from './styles/index.scss'; -import { Events, CssEditor, Globals, Settings, Database, Updater, ModuleManager, PluginManager, ThemeManager, ExtModuleManager, Vendor, WebpackModules, Patcher, MonkeyPatch, ReactComponents, ReactAutoPatcher, DiscordApi } from 'modules'; +import { Events, CssEditor, Globals, Settings, Database, Updater, ModuleManager, PluginManager, ThemeManager, ExtModuleManager, Vendor, WebpackModules, Patcher, MonkeyPatch, ReactComponents, ReactHelpers, ReactAutoPatcher, DiscordApi } from 'modules'; import { ClientLogger as Logger, ClientIPC, Utils } from 'common'; import { EmoteModule } from 'builtin'; import electron from 'electron'; @@ -23,19 +23,30 @@ class BetterDiscord { constructor() { Logger.file = tests ? path.resolve(__dirname, '..', '..', 'tests', 'log.txt') : path.join(__dirname, 'log.txt'); + Logger.trimLogFile(); Logger.log('main', 'BetterDiscord starting'); this._bd = { - DOM, BdUI, BdMenu, Modals, Reflection, + DOM, BdUI, BdMenu, Modals, Reflection, Toasts, Events, CssEditor, Globals, Settings, Database, Updater, ModuleManager, PluginManager, ThemeManager, ExtModuleManager, Vendor, - WebpackModules, Patcher, MonkeyPatch, ReactComponents, DiscordApi, + WebpackModules, Patcher, MonkeyPatch, ReactComponents, ReactHelpers, ReactAutoPatcher, DiscordApi, EmoteModule, - Logger, ClientIPC, Utils + Logger, ClientIPC, Utils, + + plugins: PluginManager.localContent, + themes: ThemeManager.localContent, + extmodules: ExtModuleManager.localContent, + + __filename, __dirname, + module: Globals.require.cache[__filename], + require: Globals.require, + webpack_require: __webpack_require__, // eslint-disable-line no-undef + get discord_require() { return WebpackModules.require } }; const developermode = Settings.getSetting('core', 'advanced', 'developer-mode'); diff --git a/client/src/modules/content.js b/client/src/modules/content.js index 854761a6..61d0101d 100644 --- a/client/src/modules/content.js +++ b/client/src/modules/content.js @@ -8,7 +8,7 @@ * LICENSE file in the root directory of this source tree. */ -import { Utils, FileUtils, ClientLogger as Logger, AsyncEventEmitter } from 'common'; +import { Utils, ClientLogger as Logger, AsyncEventEmitter } from 'common'; import { Modals } from 'ui'; import Database from './database'; diff --git a/client/src/modules/contentmanager.js b/client/src/modules/contentmanager.js index 2cbdd863..f89e7f94 100644 --- a/client/src/modules/contentmanager.js +++ b/client/src/modules/contentmanager.js @@ -12,7 +12,6 @@ import Content from './content'; import Globals from './globals'; import Database from './database'; import { Utils, FileUtils, ClientLogger as Logger } from 'common'; -import { Events } from 'modules'; import { SettingsSet, ErrorEvent } from 'structs'; import { Modals } from 'ui'; import path from 'path'; @@ -240,7 +239,7 @@ export default class { mainPath }; - const content = await this.loadContent(paths, configs, readConfig.info, readConfig.main, readConfig.dependencies, readConfig.permissions); + const content = await this.loadContent(paths, configs, readConfig.info, readConfig.main, readConfig.dependencies, readConfig.permissions, readConfig.mainExport); if (!content) return undefined; if (!reload && this.getContentById(content.id)) throw {message: `A ${this.contentType} with the ID ${content.id} already exists.`}; @@ -256,20 +255,26 @@ export default class { /** * Unload content. * @param {Content|String} content Content to unload + * @param {Boolean} force If true the content will be unloaded even if an exception is thrown when disabling/unloading * @param {Boolean} reload Whether to reload the content after * @return {Content} */ - static async unloadContent(content, reload) { + static async unloadContent(content, force, reload) { content = this.findContent(content); if (!content) throw {message: `Could not find a ${this.contentType} from ${content}.`}; try { - await content.disable(false); - await content.emit('unload', reload); + const disablePromise = content.disable(false); + const unloadPromise = content.emit('unload', reload); + + if (!force) { + await disablePromise; + await unloadPromise; + } const index = this.getContentIndex(content); - delete window.require.cache[window.require.resolve(content.paths.mainPath)]; + delete Globals.require.cache[Globals.require.resolve(content.paths.mainPath)]; if (reload) { const newcontent = await this.preloadContent(content.dirName, true, index); @@ -288,10 +293,11 @@ export default class { /** * Reload content. * @param {Content|String} content Content to reload + * @param {Boolean} force If true the content will be unloaded even if an exception is thrown when disabling/unloading * @return {Content} */ - static reloadContent(content) { - return this.unloadContent(content, true); + static reloadContent(content, force) { + return this.unloadContent(content, force, true); } /** @@ -335,18 +341,10 @@ export default class { /** * Wait for content to load * @param {String} content_id - * @return {Promise} + * @return {Promise => Content} */ static waitForContent(content_id) { - return new Promise((resolve, reject) => { - const check = () => { - const content = this.getContentById(content_id); - if (content) return resolve(content); - - setTimeout(check, 100); - }; - check(); - }); + return Utils.until(() => this.getContentById(content_id), 100); } } diff --git a/client/src/modules/csseditor.js b/client/src/modules/csseditor.js index 812b894c..c791f2c6 100644 --- a/client/src/modules/csseditor.js +++ b/client/src/modules/csseditor.js @@ -8,12 +8,12 @@ * LICENSE file in the root directory of this source tree. */ -import { FileUtils, ClientLogger as Logger, ClientIPC } from 'common'; -import Settings from './settings'; import { DOM } from 'ui'; -import filewatcher from 'filewatcher'; +import { FileUtils, ClientLogger as Logger, ClientIPC } from 'common'; import path from 'path'; import electron from 'electron'; +import filewatcher from 'filewatcher'; +import Settings from './settings'; /** * Custom css editor communications diff --git a/client/src/modules/discordapi.js b/client/src/modules/discordapi.js index 948a73da..b9c4113e 100644 --- a/client/src/modules/discordapi.js +++ b/client/src/modules/discordapi.js @@ -1,47 +1,25 @@ +/** + * BetterDiscord Discord API + * 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 { List } from 'structs'; +import { User, Channel, Guild, Message } from 'discordstructs'; import { WebpackModules } from './webpackmodules'; -import { $ } from 'vendor'; -class List extends Array { - - constructor() { - super(...arguments); - } - - get(...filters) { - return this.find(item => { - for (let filter of filters) { - for (let key in filter) { - if (filter.hasOwnProperty(key)) { - if (item[key] !== filter[key]) return false; - } - } - } - return true; - }); - } -} - -class PermissionsError extends Error { - constructor(message) { - super(message); - this.name = 'PermissionsError'; - } -} - -class InsufficientPermissions extends PermissionsError { - constructor(message) { - super(`Missing Permission — ${message}`) - this.name = 'InsufficientPermissions'; - } -} - -const Modules = { +export const Modules = { _getModule(name) { const foundModule = WebpackModules.getModuleByName(name); if (!foundModule) return null; delete this[name]; return this[name] = foundModule; }, + get ChannelSelector() { return this._getModule('ChannelSelector'); }, get MessageActions() { return this._getModule('MessageActions'); }, get MessageParser() { return this._getModule('MessageParser'); }, @@ -64,356 +42,258 @@ const Modules = { get UserStore() { return this._getModule('UserStore'); }, get RelationshipStore() { return this._getModule('RelationshipStore'); }, get RelationshipManager() { return this._getModule('RelationshipManager'); }, + get ChangeNicknameModal() { return this._getModule('ChangeNicknameModal'); }, + get UserSettingsStore() { return this._getModule('UserSettingsStore'); }, + get UserSettingsWindow() { return this._getModule('UserSettingsWindow'); }, + get UserStatusStore() { return this._getModule('UserStatusStore'); }, + get ChannelSettingsWindow() { return this._getModule('ChannelSettingsWindow'); }, + get GuildSettingsWindow() { return this._getModule('GuildSettingsWindow'); }, + get CreateChannelModal() { return this._getModule('CreateChannelModal'); }, + get PruneMembersModal() { return this._getModule('PruneMembersModal'); }, + get NotificationSettingsModal() { return this._getModule('NotificationSettingsModal'); }, + get PrivacySettingsModal() { return this._getModule('PrivacySettingsModal'); }, + get UserProfileModal() { return this._getModule('UserProfileModal'); }, + get APIModule() { return this._getModule('APIModule'); }, + get UserNoteStore() { return this._getModule('UserNoteStore'); }, get DiscordPermissions() { return this.DiscordConstants.Permissions; } - }; -class User { - constructor(data) { - for (let key in data) - if (data.hasOwnProperty(key)) - this[key] = data[key]; - this.discordObject = data; - } - - static fromId(id) { - return new User(Modules.UserStore.getUser(id)); - } - - async sendMessage(content, parse = true) { - const id = await Modules.PrivateChannelActions.ensurePrivateChannel(DiscordApi.currentUser.id, this.id); - const channel = new PrivateChannel(Modules.ChannelStore.getChannel(id)); - channel.sendMessage(content, parse); - } - - get isFriend() { - return Modules.RelationshipStore.isFriend(this.id); - } - - get isBlocked() { - return Modules.RelationshipStore.isBlocked(this.id); - } - - addFriend() { - Modules.RelationshipManager.addRelationship(this.id, {location: 'Context Menu'}); - } - - removeFriend() { - Modules.RelationshipManager.removeRelationship(this.id, {location: 'Context Menu'}); - } - - block() { - Modules.RelationshipManager.addRelationship(this.id, {location: 'Context Menu'}, Modules.DiscordConstants.RelationshipTypes.BLOCKED); - } - - unblock() { - Modules.RelationshipManager.removeRelationship(this.id, {location: 'Context Menu'}); - } -} - -class Member extends User { - constructor(data, guild) { - super(data); - const userData = Modules.UserStore.getUser(data.userId); - for (let key in userData) - if (userData.hasOwnProperty(key)) - this[key] = userData[key]; - this.guild_id = guild; - } - - checkPermissions(perms) { - return Modules.PermissionUtils.can(perms, DiscordApi.currentUser, Modules.GuildStore.getGuild(this.guild_id)); - } - - kick(reason = '') { - if (!this.checkPermissions(Modules.DiscordPermissions.KICK_MEMBERS)) throw new InsufficientPermissions('KICK_MEMBERS'); - Modules.GuildActions.kickUser(this.guild_id, this.id, reason); - } - - ban(daysToDelete = '1', reason = '') { - if (!this.checkPermissions(Modules.DiscordPermissions.BAN_MEMBERS)) throw new InsufficientPermissions('BAN_MEMBERS'); - Modules.GuildActions.banUser(this.guild_id, this.id, daysToDelete, reason); - } - - unban() { - if (!this.checkPermissions(Modules.DiscordPermissions.BAN_MEMBERS)) throw new InsufficientPermissions('BAN_MEMBERS'); - Modules.GuildActions.unbanUser(this.guild_id, this.id); - } - - move(channel_id) { - if (!this.checkPermissions(Modules.DiscordPermissions.MOVE_MEMBERS)) throw new InsufficientPermissions('MOVE_MEMBERS'); - Modules.GuildActions.setChannel(this.guild_id, this.id, channel_id); - } - - mute(active = true) { - if (!this.checkPermissions(Modules.DiscordPermissions.MUTE_MEMBERS)) throw new InsufficientPermissions('MUTE_MEMBERS'); - Modules.GuildActions.setServerMute(this.guild_id, this.id, active); - } - - unmute(active = true) { - this.mute(false); - } - - deafen(active = true) { - if (!this.checkPermissions(Modules.DiscordPermissions.DEAFEN_MEMBERS)) throw new InsufficientPermissions('DEAFEN_MEMBERS'); - Modules.GuildActions.setServerDeaf(this.guild_id, this.id, active); - } - - undeafen(active = true) { - this.deafen(false); - } -} - -class Guild { - constructor(data) { - for (let key in data) - if (data.hasOwnProperty(key)) - this[key] = data[key]; - this.discordObject = data; - } - - get channels() { - const channels = Modules.GuildChannelsStore.getChannels(this.id); - const returnChannels = new List(); - for (const category in channels) { - if (channels.hasOwnProperty(category)) { - if (!Array.isArray(channels[category])) continue; - const channelList = channels[category]; - for (const channel of channelList) { - returnChannels.push(new GuildChannel(channel.channel)); - } - } - } - return returnChannels; - } - - get defaultChannel() { - return new GuildChannel(Modules.GuildChannelsStore.getDefaultChannel(this.id)); - } - - get members() { - const members = Modules.GuildMemberStore.getMembers(this.id); - const returnMembers = new List(); - for (const member of members) returnMembers.push(new Member(member, this.id)); - return returnMembers; - } - - get memberCount() { - return Modules.MemberCountStore.getMemberCount(this.id); - } - - get emojis() { - return Modules.EmojiUtils.getGuildEmoji(this.id); - } - - get permissions() { - return Modules.GuildPermissions.getGuildPermissions(this.id); - } - - getMember(userId) { - return Modules.GuildMemberStore.getMember(this.id, userId); - } - - isMember(userId) { - return Modules.GuildMemberStore.isMember(this.id, userId); - } - - markAsRead() { - Modules.GuildActions.markGuildAsRead(this.id); - } - - select() { - Modules.GuildActions.selectGuild(this.id); - } - - nsfwAgree() { - Modules.GuildActions.nsfwAgree(this.id); - } - - nsfwDisagree() { - Modules.GuildActions.nsfwDisagree(this.id); - } - - changeSortLocation(index) { - Modules.GuildActions.move(DiscordApi.guildPositions.indexOf(this.id), index); - } -} - -class Channel { - constructor(data) { - for (let key in data) - if (data.hasOwnProperty(key)) - this[key] = data[key]; - this.discordObject = data; - } - - checkPermissions(perms) { - return Modules.PermissionUtils.can(perms, DiscordApi.currentUser, this.discordObject) || this.isPrivate(); - } - - async sendMessage(content, parse = true) { - if (!this.checkPermissions(Modules.DiscordPermissions.VIEW_CHANNEL | Modules.DiscordPermissions.SEND_MESSAGES)) throw new InsufficientPermissions('SEND_MESSAGES'); - let response = {}; - if (parse) response = await Modules.MessageActions._sendMessage(this.id, Modules.MessageParser.parse(this.discordObject, content)); - else response = await Modules.MessageActions._sendMessage(this.id, {content}); - return new Message(Modules.MessageStore.getMessage(this.id, response.body.id)); - } - - get messages() { - const messages = Modules.MessageStore.getMessages(this.id).toArray(); - for (let i in messages) - if (messages.hasOwnProperty(i)) - messages[i] = new Message(messages[i]); - return new List(...messages); - } - - jumpToPresent() { - if (!this.checkPermissions(Modules.DiscordPermissions.VIEW_CHANNEL)) throw new InsufficientPermissions('VIEW_CHANNEL'); - if (this.hasMoreAfter) Modules.MessageActions.jumpToPresent(this.id, Modules.DiscordConstants.MAX_MESSAGES_PER_CHANNEL); - else this.messages[this.messages.length - 1].jumpTo(false); - } - - get hasMoreAfter() { - return Modules.MessageStore.getMessages(this.id).hasMoreAfter; - } - - sendInvite(inviteId) { - if (!this.checkPermissions(Modules.DiscordPermissions.VIEW_CHANNEL | Modules.DiscordPermissions.SEND_MESSAGES)) throw new InsufficientPermissions('SEND_MESSAGES'); - Modules.MessageActions.sendInvite(this.id, inviteId); - } - - select() { - if (!this.checkPermissions(Modules.DiscordPermissions.VIEW_CHANNEL)) throw new InsufficientPermissions('VIEW_CHANNEL'); - Modules.NavigationUtils.transitionToGuild(this.guild_id ? this.guild_id : Modules.DiscordConstants.ME, this.id); - } -} - -class GuildChannel extends Channel { - - constructor(data) { - super(data); - } - - get permissions() { - return Modules.GuildPermissions.getChannelPermissions(this.id); - } - - get guild() { - return new Guild(Modules.GuildStore.getGuild(this.guild_id)); - } - - isDefaultChannel() { - return Modules.GuildChannelsStore.getDefaultChannel(this.guild_id).id === this.id; - } - -} - -class PrivateChannel extends Channel { - constructor(data) { - super(data); - } -} - -class Message { - constructor(data) { - for (let key in data) - if (data.hasOwnProperty(key)) - this[key] = data[key]; - this.discordObject = data; - } - - delete() { - Modules.MessageActions.deleteMessage(this.channel_id, this.id); - } - - // programmatically update the content - edit(content, parse = false) { - if (this.author.id !== DiscordApi.currentUser.id) return; - if (parse) Modules.MessageActions.editMessage(this.channel_id, this.id, Modules.MessageParser.parse(this.discordObject, content)); - else Modules.MessageActions.editMessage(this.channel_id, this.id, {content}); - } - - // start the editing mode of GUI - startEdit() { - if (this.author.id !== DiscordApi.currentUser.id) return; - Modules.MessageActions.startEditMessage(this.channel_id, this.id, this.content); - } - - // end editing mode of GUI - endEdit() { - Modules.MessageActions.endEditMessage(); - } - - jumpTo(flash = true) { - Modules.MessageActions.jumpToMessage(this.channel_id, this.id, flash); - } -} - export default class DiscordApi { - static get channels() { - const channels = Modules.ChannelStore.getChannels(); - const returnChannels = new List(); - for (const [key, value] of Object.entries(channels)) { - returnChannels.push(value.isPrivate() ? new PrivateChannel(value) : new GuildChannel(value)); - } - return returnChannels; - } + static get modules() { return Modules } + static get User() { return User } + static get Channel() { return Channel } + static get Guild() { return Guild } + static get Message() { return Message } + /** + * A list of loaded guilds. + */ static get guilds() { const guilds = Modules.GuildStore.getGuilds(); - const returnGuilds = new List(); - for (const [key, value] of Object.entries(guilds)) { - returnGuilds.push(new Guild(value)); - } - return returnGuilds; + return List.from(Object.entries(guilds), ([i, g]) => Guild.from(g)); } + /** + * A list of loaded channels. + */ + static get channels() { + const channels = Modules.ChannelStore.getChannels(); + return List.from(Object.entries(channels), ([i, c]) => Channel.from(c)); + } + + /** + * A list of loaded users. + */ static get users() { const users = Modules.UserStore.getUsers(); - const returnUsers = new List(); - for (const [key, value] of Object.entries(users)) { - returnUsers.push(new User(value)); - } - return returnUsers; + return List.from(Object.entries(users), ([i, u]) => User.from(u)); } + /** + * An object mapping guild IDs to their member counts. + */ static get memberCounts() { return Modules.MemberCountStore.getMemberCounts(); } + /** + * A list of guilds in the order they appear in the server list. + */ static get sortedGuilds() { const guilds = Modules.SortedGuildStore.getSortedGuilds(); - const returnGuilds = new List(); - for (const guild of guilds) { - returnGuilds.push(new Guild(guild)); - } - return returnGuilds; + return List.from(guilds, g => Guild.from(g)); } + /** + * An array of guild IDs in the order they appear in the server list. + */ static get guildPositions() { return Modules.SortedGuildStore.guildPositions; } + /** + * The currently selected guild. + */ static get currentGuild() { - return new Guild(Modules.GuildStore.getGuild(Modules.SelectedGuildStore.getGuildId())); + const guild = Modules.GuildStore.getGuild(Modules.SelectedGuildStore.getGuildId()); + if (guild) return Guild.from(guild); } + /** + * The currently selected channel. + */ static get currentChannel() { const channel = Modules.ChannelStore.getChannel(Modules.SelectedChannelStore.getChannelId()); - if (channel) return channel.isPrivate() ? new PrivateChannel(channel) : new GuildChannel(channel); + if (channel) return Channel.from(channel); } + /** + * The current user. + */ static get currentUser() { - return Modules.UserStore.getCurrentUser(); + const user = Modules.UserStore.getCurrentUser(); + if (user) return User.from(user); } + /** + * A list of the current user's friends. + */ static get friends() { const friends = Modules.RelationshipStore.getFriendIDs(); - const returnUsers = new List(); - for (const id of friends) returnUsers.push(User.fromId(id)); - return returnUsers; + return List.from(friends, id => User.fromId(id)); + } + + static get UserSettings() { + return UserSettings; } } + +export class UserSettings { + /** + * Opens Discord's settings UI. + */ + static open(section = 'ACCOUNT') { + Modules.UserSettingsWindow.setSection(section); + Modules.UserSettingsWindow.open(); + } + + /** + * The user's current status. Either "online", "idle", "dnd" or "invisible". + */ + static get status() { return Modules.UserSettingsStore.status } + + /** + * The user's selected explicit content filter level. + * 0 == off, 1 == everyone except friends, 2 == everyone + * Configurable in the privacy and safety panel. + */ + static get explicitContentFilter() { return Modules.UserSettingsStore.explicitContentFilter } + + /** + * Whether to disallow direct messages from server members by default. + */ + static get defaultGuildsRestricted() { return Modules.UserSettingsStore.defaultGuildsRestricted } + + /** + * An array of guilds to disallow direct messages from their members. + * This is bypassed if the member is has another mutual guild with this disabled, or the member is friends with the current user. + * Configurable in each server's privacy settings. + */ + static get restrictedGuildIds() { return Modules.UserSettingsStore.restrictedGuilds } + + static get restrictedGuilds() { + return List.from(this.restrictedGuildIds, id => Guild.fromId(id) || id); + } + + /** + * An array of flags specifying who should be allowed to add the current user as a friend. + * If everyone is checked, this will only have one item, "all". Otherwise it has either "mutual_friends", "mutual_guilds", both or neither. + * Configurable in the privacy and safety panel. + */ + static get friendSourceFlags() { return Object.keys(Modules.UserSettingsStore.friendSourceFlags) } + static get friendSourceEveryone() { return this.friendSourceFlags.include('all') } + static get friendSourceMutual_friends() { return this.friendSourceFlags.include('all') || this.friendSourceFlags.include('mutual_friends') } + static get friendSourceMutual_guilds() { return this.friendSourceFlags.include('all') || this.friendSourceFlags.include('mutual_guilds') } + static get friendSourceAnyone() { return this.friendSourceFlags.length > 0 } + + /** + * Whether to automatically add accounts from other platforms running on the user's computer. + * Configurable in the connections panel. + */ + static get detectPlatformAccounts() { return Modules.UserSettingsStore.detectPlatformAccounts } + + /** + * The number of seconds Discord will wait for activity before sending mobile push notifications. + * Configurable in the notifications panel. + */ + static get afkTimeout() { return Modules.UserSettingsStore.afkTimeout } + + /** + * Whether to display the currently running game as a status message. + * Configurable in the games panel. + */ + static get showCurrentGame() { return Modules.UserSettingsStore.showCurrentGame } + + /** + * Whether to show images uploaded directly to Discord. + * Configurable in the text and images panel. + */ + static get inlineAttachmentMedia() { return Modules.UserSettingsStore.inlineAttachmentMedia } + + /** + * Whether to show images linked in Discord. + * Configurable in the text and images panel. + */ + static get inlineEmbedMedia() { return Modules.UserSettingsStore.inlineEmbedMedia } + + /** + * Whether to automatically play GIFs when the Discord window is active without having to hover the mouse over the image. + * Configurable in the text and images panel. + */ + static get autoplayGifs() { return Modules.UserSettingsStore.gifAutoPlay } + + /** + * Whether to show content from HTTP[s] links as embeds. + * Configurable in the text and images panel. + */ + static get showEmbeds() { return Modules.UserSettingsStore.renderEmbeds } + + /** + * Whether to show a message's reactions. + * Configurable in the text and images panel. + */ + static get showReactions() { return Modules.UserSettingsStore.renderReactions } + + /** + * Whether to play animated emoji. + * Configurable in the text and images panel. + */ + static get animateEmoji() { return Modules.UserSettingsStore.animateEmoji } + + /** + * Whether to convert ASCII emoticons to emoji. + * Configurable in the text and images panel. + */ + static get convertEmoticons() { return Modules.UserSettingsStore.convertEmoticons } + + /** + * Whether to allow playing text-to-speech messages. + * Configurable in the text and images panel. + */ + static get allowTts() { return Modules.UserSettingsStore.enableTTSCommand } + + /** + * The user's selected theme. Either "dark" or "light". + * Configurable in the appearance panel. + */ + static get theme() { return Modules.UserSettingsStore.theme } + + /** + * Whether the user has enabled compact mode. + * `true` if compact mode is enabled, `false` if cozy mode is enabled. + * Configurable in the appearance panel. + */ + static get displayCompact() { return Modules.UserSettingsStore.messageDisplayCompact } + + /** + * Whether the user has enabled developer mode. + * Currently only adds a "Copy ID" option to the context menu on users, guilds and channels. + * Configurable in the appearance panel. + */ + static get developerMode() { return Modules.UserSettingsStore.developerMode } + + /** + * The user's selected language code. + * Configurable in the language panel. + */ + static get locale() { return Modules.UserSettingsStore.locale } + + /** + * The user's timezone offset in hours. + * This is not configurable. + */ + static get timezoneOffset() { return Modules.UserSettingsStore.timezoneOffset } +} diff --git a/client/src/modules/eventhook.js b/client/src/modules/eventhook.js index c2665e18..b1b76650 100644 --- a/client/src/modules/eventhook.js +++ b/client/src/modules/eventhook.js @@ -8,7 +8,6 @@ * LICENSE file in the root directory of this source tree. */ -import { Utils, ClientLogger as Logger } from 'common'; import { WebpackModules } from './webpackmodules'; import Events from './events'; import EventListener from './eventlistener'; @@ -22,7 +21,6 @@ import * as SocketStructs from '../structs/socketstructs'; export default class extends EventListener { init() { - Logger.log('EventHook', SocketStructs); this.hook(); } @@ -38,18 +36,16 @@ export default class extends EventListener { hook() { const self = this; - const orig = this.eventsModule.prototype.emit; - this.eventsModule.prototype.emit = function (...args) { + const Events = WebpackModules.getModuleByName('Events'); + + const orig = Events.prototype.emit; + Events.prototype.emit = function (...args) { orig.call(this, ...args); self.wsc = this; self.emit(...args); }; } - get eventsModule() { - return WebpackModules.getModuleByName('Events'); - } - /** * Discord emit overload * @param {any} event @@ -68,22 +64,29 @@ export default class extends EventListener { * @param {any} event Event * @param {any} data Event data */ - dispatch(e, d) { - Events.emit('raw-event', { type: e, data: d }); - if (e === this.actions.READY || e === this.actions.RESUMED) { - Events.emit(e, d); + dispatch(type, data) { + Events.emit('raw-event', { type, data }); + + if (type === this.actions.READY || type === this.actions.RESUMED) { + Events.emit(type, data); return; } - if (!Object.keys(SocketStructs).includes(e)) return; - const evt = new SocketStructs[e](d); - Events.emit(`discord:${e}`, evt); + + if (!Object.keys(SocketStructs).includes(type)) return; + Events.emit(`discord:${type}`, new SocketStructs[type](data)); + } + + get SocketStructs() { + return SocketStructs; } /** * All known socket actions */ get actions() { - return { + if (this._actions) return this._actions; + + return this._actions = { READY: 'READY', // Socket ready RESUMED: 'RESUMED', // Socket resumed TYPING_START: 'TYPING_START', // User typing start diff --git a/client/src/modules/events.js b/client/src/modules/events.js index 7021d33e..72e85d25 100644 --- a/client/src/modules/events.js +++ b/client/src/modules/events.js @@ -40,13 +40,15 @@ export default class { emitter.removeListener(event, callback); } + static get removeListener() { return this.off } + /** * Emits an event * @param {String} event The event to emit * @param {Any} ...data Data to pass to the event listeners */ - static emit(...args) { - emitter.emit(...args); + static emit(event, ...data) { + emitter.emit(event, ...data); } } diff --git a/client/src/modules/eventswrapper.js b/client/src/modules/eventswrapper.js index bd8ece2b..ac5deacc 100644 --- a/client/src/modules/eventswrapper.js +++ b/client/src/modules/eventswrapper.js @@ -14,6 +14,7 @@ export default class EventsWrapper { constructor(eventemitter, bind) { eventemitters.set(this, eventemitter); + this.bind = bind || this; } get eventSubs() { @@ -37,16 +38,16 @@ export default class EventsWrapper { get off() { return this.unsubscribe } unsubscribe(event, callback) { - for (let index of this.eventSubs) { + for (let index in this.eventSubs) { if (this.eventSubs[index].event !== event || (callback && this.eventSubs[index].callback === callback)) continue; - eventemitters.get(this).off(event, this.eventSubs[index].boundCallback); + eventemitters.get(this).removeListener(event, this.eventSubs[index].boundCallback); this.eventSubs.splice(index, 1); } } unsubscribeAll() { for (let event of this.eventSubs) { - eventemitters.get(this).off(event.event, event.boundCallback); + eventemitters.get(this).removeListener(event.event, event.boundCallback); } this.eventSubs.splice(0, this.eventSubs.length); } diff --git a/client/src/modules/extmodule.js b/client/src/modules/extmodule.js index 4b8fb0db..262fbd84 100644 --- a/client/src/modules/extmodule.js +++ b/client/src/modules/extmodule.js @@ -8,13 +8,14 @@ * LICENSE file in the root directory of this source tree. */ +import Globals from './globals'; import Content from './content'; export default class ExtModule extends Content { constructor(internals) { super(internals); - this.__require = window.require(this.paths.mainPath); + this.__require = Globals.require(this.paths.mainPath); } get type() { return 'module' } diff --git a/client/src/modules/extmodulemanager.js b/client/src/modules/extmodulemanager.js index 89bf2efd..8055c84c 100644 --- a/client/src/modules/extmodulemanager.js +++ b/client/src/modules/extmodulemanager.js @@ -10,8 +10,6 @@ import ContentManager from './contentmanager'; import ExtModule from './extmodule'; -import { ClientLogger as Logger } from 'common'; -import { Events } from 'modules'; export default class extends ContentManager { diff --git a/client/src/modules/globals.js b/client/src/modules/globals.js index 6b0312f4..3b0b5b3b 100644 --- a/client/src/modules/globals.js +++ b/client/src/modules/globals.js @@ -17,6 +17,10 @@ export default new class extends Module { constructor(args) { super(args); + + // webpack replaces this with the normal require function + // eslint-disable-next-line no-undef + this.require = __non_webpack_require__; } initg() { diff --git a/client/src/modules/modulemanager.js b/client/src/modules/modulemanager.js index 0186cfa7..4b2e25fd 100644 --- a/client/src/modules/modulemanager.js +++ b/client/src/modules/modulemanager.js @@ -9,8 +9,8 @@ */ import { ClientLogger as Logger } from 'common'; -import { Events, SocketProxy, EventHook, CssEditor } from 'modules'; -import { ProfileBadges } from 'ui'; +import { SocketProxy, EventHook, CssEditor } from 'modules'; +import { ProfileBadges, ClassNormaliser } from 'ui'; import Updater from './updater'; /** @@ -24,6 +24,7 @@ export default class { static get modules() { return this._modules ? this._modules : (this._modules = [ new ProfileBadges(), + new ClassNormaliser(), new SocketProxy(), new EventHook(), CssEditor, diff --git a/client/src/modules/modules.js b/client/src/modules/modules.js index c305d11a..a038ac76 100644 --- a/client/src/modules/modules.js +++ b/client/src/modules/modules.js @@ -21,4 +21,4 @@ export { default as Module } from './module'; export { default as EventListener } from './eventlistener'; export { default as SocketProxy } from './socketproxy'; export { default as EventHook } from './eventhook'; -export { default as DiscordApi } from './discordapi'; +export { default as DiscordApi, Modules as DiscordApiModules } from './discordapi'; diff --git a/client/src/modules/patcher.js b/client/src/modules/patcher.js index b4891842..8f2cbce5 100644 --- a/client/src/modules/patcher.js +++ b/client/src/modules/patcher.js @@ -9,7 +9,7 @@ */ import { WebpackModules } from './webpackmodules'; -import { ClientLogger as Logger, Utils } from 'common'; +import { ClientLogger as Logger } from 'common'; export class Patcher { @@ -37,9 +37,8 @@ export class Patcher { } static resolveModule(module) { - if (module instanceof Function || (module instanceof Object && !(module instanceof Array))) return module; + if (module instanceof Function || (module instanceof Object)) return module; if (typeof module === 'string') return WebpackModules.getModuleByName(module); - if (module instanceof Array) return WebpackModules.getModuleByProps(module); return null; } @@ -70,7 +69,7 @@ export class Patcher { for (const slavePatch of patch.children.filter(c => c.type === 'after')) { try { - slavePatch.callback(this, arguments, retVal); + slavePatch.callback(this, arguments, retVal, r => retVal = r); } catch (err) { Logger.err(`Patcher:${patch.id}`, err); } @@ -80,7 +79,9 @@ export class Patcher { } static rePatch(patch) { - patch.proxyFunction = patch.module[patch.functionName] = this.overrideFn(patch); + if (patch.module instanceof Array && typeof patch.functionName === 'number') + patch.module.splice(patch.functionName, 1, patch.proxyFunction = this.overrideFn(patch)); + else patch.proxyFunction = patch.module[patch.functionName] = this.overrideFn(patch); } static pushPatch(caller, id, module, functionName) { diff --git a/client/src/modules/plugin.js b/client/src/modules/plugin.js index 039ba05e..5a6b36ef 100644 --- a/client/src/modules/plugin.js +++ b/client/src/modules/plugin.js @@ -18,12 +18,12 @@ export default class Plugin extends Content { get start() { return this.enable } get stop() { return this.disable } - reload() { - return PluginManager.reloadPlugin(this); + reload(force) { + return PluginManager.reloadPlugin(this, force); } - unload() { - return PluginManager.unloadPlugin(this); + unload(force) { + return PluginManager.unloadPlugin(this, force); } } diff --git a/client/src/modules/pluginapi.js b/client/src/modules/pluginapi.js index f72ceab5..5f7cbfbb 100644 --- a/client/src/modules/pluginapi.js +++ b/client/src/modules/pluginapi.js @@ -10,8 +10,9 @@ import { EmoteModule } from 'builtin'; import { SettingsSet, SettingsCategory, Setting, SettingsScheme } from 'structs'; -import { BdMenu, Modals, DOM, Reflection } from 'ui'; -import { Utils, ClientLogger as Logger, ClientIPC, AsyncEventEmitter } from 'common'; +import { BdMenu, Modals, DOM, DOMObserver, Reflection, VueInjector, Toasts } from 'ui'; +import * as CommonComponents from 'commoncomponents'; +import { Utils, Filters, ClientLogger as Logger, ClientIPC, AsyncEventEmitter } from 'common'; import Settings from './settings'; import ExtModuleManager from './extmodulemanager'; import PluginManager from './pluginmanager'; @@ -20,7 +21,7 @@ import Events from './events'; import EventsWrapper from './eventswrapper'; import { WebpackModules } from './webpackmodules'; import DiscordApi from './discordapi'; -import { ReactComponents } from './reactcomponents'; +import { ReactComponents, ReactHelpers } from './reactcomponents'; import { Patcher, MonkeyPatch } from './patcher'; export default class PluginApi { @@ -58,6 +59,20 @@ export default class PluginApi { get AsyncEventEmitter() { return AsyncEventEmitter } get EventsWrapper() { return EventsWrapper } + get CommonComponents() { return CommonComponents } + get Filters() { return Filters } + get Discord() { return DiscordApi } + get DiscordApi() { return DiscordApi } + get ReactComponents() { return ReactComponents } + get ReactHelpers() { return ReactHelpers } + get Reflection() { return Reflection } + get DOM() { return DOM } + get VueInjector() { return VueInjector } + + get observer() { + return this._observer || (this._observer = new DOMObserver()); + } + /** * Logger */ @@ -216,7 +231,7 @@ export default class PluginApi { this.injectedStyles.splice(this.injectedStyles.indexOf(styleid), 1); DOM.deleteStyle(styleid); } - deleteAllStyles(id, css) { + deleteAllStyles(id) { for (let id of this.injectedStyles) { this.deleteStyle(id); } @@ -267,10 +282,10 @@ export default class PluginApi { return this.modalStack[this.modalStack.length - 1].close(force); } basicModal(title, text) { - return this.addModal(Modals.basic(title, text)); + return this.addModal(Modals.createBasicModal(title, text)); } settingsModal(settingsset, headertext, options) { - return this.addModal(Modals.settings(settingsset, headertext, options)); + return this.addModal(Modals.createSettingsModal(settingsset, headertext, options)); } get Modals() { return Object.defineProperties({ @@ -290,6 +305,36 @@ export default class PluginApi { }); } + + /** + * Toasts + */ + showToast(message, options = {}) { + return Toasts.push(message, options); + } + showSuccessToast(message, options = {}) { + return Toasts.success(message, options); + } + showInfoToast(message, options = {}) { + return Toasts.info(message, options); + } + showErrorToast(message, options = {}) { + return Toasts.error(message, options); + } + showWarningToast(message, options = {}) { + return Toasts.warning(message, options); + } + get Toasts() { + return { + push: this.showToast.bind(this), + success: this.showSuccessToast.bind(this), + error: this.showErrorToast.bind(this), + info: this.showInfoToast.bind(this), + warning: this.showWarningToast.bind(this) + }; + } + + /** * Emotes */ @@ -403,6 +448,9 @@ export default class PluginApi { getWebpackModuleByName(name, fallback) { return WebpackModules.getModuleByName(name, fallback); } + getWebpackModuleByDisplayName(name) { + return WebpackModules.getModuleByDisplayName(name); + } getWebpackModuleByRegex(regex) { return WebpackModules.getModuleByRegex(regex, true); } @@ -421,38 +469,58 @@ export default class PluginApi { getWebpackModulesByPrototypeFields(...props) { return WebpackModules.getModuleByPrototypes(props, false); } + waitForWebpackModule(filter) { + return WebpackModules.waitForModule(filter); + } + waitForWebpackModuleByName(name, fallback) { + return WebpackModules.waitForModuleByName(name, fallback); + } + waitForWebpackModuleByDisplayName(name) { + return WebpackModules.waitForModuleByDisplayName(name); + } + waitForWebpackModuleByRegex(regex) { + return WebpackModules.waitForModuleByRegex(regex); + } + waitForWebpackModuleByProperties(...props) { + return WebpackModules.waitForModuleByProps(props); + } + waitForWebpackModuleByPrototypeFields(...props) { + return WebpackModules.waitForModuleByPrototypes(props); + } + getWebpackClassName(...classes) { + return WebpackModules.getClassName(...classes); + } + waitForWebpackClassName(...classes) { + return WebpackModules.waitForClassName(...classes); + } get WebpackModules() { - return Object.defineProperty({ + return new Proxy({ getModule: this.getWebpackModule.bind(this), getModuleByName: this.getWebpackModuleByName.bind(this), - getModuleByDisplayName: this.getWebpackModuleByName.bind(this), + getModuleByDisplayName: this.getWebpackModuleByDisplayName.bind(this), getModuleByRegex: this.getWebpackModuleByRegex.bind(this), getModulesByRegex: this.getWebpackModulesByRegex.bind(this), getModuleByProperties: this.getWebpackModuleByProperties.bind(this), getModuleByPrototypeFields: this.getWebpackModuleByPrototypeFields.bind(this), getModulesByProperties: this.getWebpackModulesByProperties.bind(this), - getModulesByPrototypeFields: this.getWebpackModulesByPrototypeFields.bind(this) - }, 'require', { - get: () => this.webpackRequire + getModulesByPrototypeFields: this.getWebpackModulesByPrototypeFields.bind(this), + waitForModule: this.waitForWebpackModule.bind(this), + waitForModuleByName: this.waitForWebpackModuleByName.bind(this), + waitForModuleByDisplayName: this.waitForWebpackModuleByDisplayName.bind(this), + waitForModuleByRegex: this.waitForWebpackModuleByRegex.bind(this), + waitForModuleByProperties: this.waitForWebpackModuleByProperties.bind(this), + waitForModuleByPrototypeFields: this.waitForWebpackModuleByPrototypeFields.bind(this), + getClassName: this.getWebpackClassName.bind(this), + waitForClassName: this.waitForWebpackClassName.bind(this), + get KnownModules() { return WebpackModules.KnownModules }, + get require() { return WebpackModules.require } + }, { + get(WebpackModules, property) { + return WebpackModules[property] || WebpackModules.getModuleByName(property); + } }); } - /** - * DiscordApi - */ - - get Discord() { - return DiscordApi; - } - - get ReactComponents() { - return ReactComponents; - } - - get Reflection() { - return Reflection; - } - /** * Patcher */ diff --git a/client/src/modules/pluginmanager.js b/client/src/modules/pluginmanager.js index 181335a0..906b62b0 100644 --- a/client/src/modules/pluginmanager.js +++ b/client/src/modules/pluginmanager.js @@ -8,15 +8,16 @@ * LICENSE file in the root directory of this source tree. */ +import { Permissions } from 'modules'; +import { Modals } from 'ui'; +import { ErrorEvent } from 'structs'; +import { ClientLogger as Logger } from 'common'; +import Globals from './globals'; import ContentManager from './contentmanager'; import ExtModuleManager from './extmodulemanager'; import Plugin from './plugin'; import PluginApi from './pluginapi'; import Vendor from './vendor'; -import { ClientLogger as Logger } from 'common'; -import { Events, Permissions } from 'modules'; -import { Modals } from 'ui'; -import { ErrorEvent } from 'structs'; export default class extends ContentManager { @@ -73,7 +74,7 @@ export default class extends ContentManager { static get refreshPlugins() { return this.refreshContent } static get loadContent() { return this.loadPlugin } - static async loadPlugin(paths, configs, info, main, dependencies, permissions) { + static async loadPlugin(paths, configs, info, main, dependencies, permissions, mainExport) { if (permissions && permissions.length > 0) { for (let perm of permissions) { Logger.log(this.moduleName, `Permission: ${Permissions.permissionText(perm).HEADER} - ${Permissions.permissionText(perm).BODY}`); @@ -85,7 +86,7 @@ export default class extends ContentManager { } } - const deps = []; + const deps = {}; if (dependencies) { for (const [key, value] of Object.entries(dependencies)) { const extModule = ExtModuleManager.findModule(key); @@ -96,8 +97,15 @@ export default class extends ContentManager { } } - const plugin = window.require(paths.mainPath)(Plugin, new PluginApi(info, paths.contentPath), Vendor, deps); - if (!(plugin.prototype instanceof Plugin)) + const pluginExports = Globals.require(paths.mainPath); + + const pluginFunction = mainExport ? pluginExports[mainExport] + : pluginExports.__esModule ? pluginExports.default : pluginExports; + if (typeof pluginFunction !== 'function') + throw {message: `Plugin ${info.name} did not export a function.`}; + + const plugin = pluginFunction.call(pluginExports, Plugin, new PluginApi(info, paths.contentPath), Vendor, deps); + if (!plugin || !(plugin.prototype instanceof Plugin)) throw {message: `Plugin ${info.name} did not return a class that extends Plugin.`}; const instance = new plugin({ diff --git a/client/src/modules/reactcomponents.js b/client/src/modules/reactcomponents.js index 55f0c040..7d62dcbc 100644 --- a/client/src/modules/reactcomponents.js +++ b/client/src/modules/reactcomponents.js @@ -1,6 +1,7 @@ /** * BetterDiscord React Component Manipulations - * original concept and some code by samogot - https://github.com/samogot / https://github.com/samogot/betterdiscord-plugins/tree/master/v2/1Lib%20Discord%20Internals + * Original concept and some code by samogot - https://github.com/samogot / https://github.com/samogot/betterdiscord-plugins/tree/master/v2/1Lib%20Discord%20Internals + * * Copyright (c) 2015-present JsSucks - https://github.com/JsSucks * All rights reserved. * https://github.com/JsSucks - https://betterdiscord.net @@ -9,11 +10,10 @@ * LICENSE file in the root directory of this source tree. */ -import { EmoteModule } from 'builtin'; -import { Reflection } from 'ui'; -import { ClientLogger as Logger } from 'common'; -import { MonkeyPatch, Patcher } from './patcher'; -import { WebpackModules, Filters } from './webpackmodules'; +import { DOM, Reflection } from 'ui'; +import { Utils, Filters, ClientLogger as Logger } from 'common'; +import { MonkeyPatch } from './patcher'; +import { WebpackModules } from './webpackmodules'; import DiscordApi from './discordapi'; class Helpers { @@ -166,22 +166,18 @@ class Helpers { export { Helpers as ReactHelpers }; class ReactComponent { - constructor(id, component, retVal) { - this._id = id; - this._component = component; - this._retVal = retVal; + constructor(id, component, retVal, important) { + this.id = id; + this.component = component; + this.retVal = retVal; + this.important = important; } - get id() { - return this._id; - } - - get component() { - return this._component; - } - - get retVal() { - return this._retVal; + forceUpdateAll() { + if (!this.important || !this.important.selector) return; + for (let e of document.querySelectorAll(this.important.selector)) { + Reflection(e).forceUpdate(this); + } } } @@ -191,60 +187,85 @@ export class ReactComponents { static get listeners() { return this._listeners || (this._listeners = []) } static get nameSetters() { return this._nameSetters || (this._nameSetters = []) } - static push(component, retVal) { + static get ReactComponent() { return ReactComponent } + + static push(component, retVal, important) { if (!(component instanceof Function)) return null; const { displayName } = component; if (!displayName) { return this.processUnknown(component, retVal); } + const have = this.components.find(comp => comp.id === displayName); - if (have) return component; - const c = new ReactComponent(displayName, component, retVal); - this.components.push(c); - const listener = this.listeners.find(listener => listener.id === displayName); - if (!listener) return c; - for (const l of listener.listeners) { - l(c); + if (have) { + if (!have.important) have.important = important; + return component; } - this.listeners.splice(this.listeners.findIndex(listener => listener.id === displayName), 1); + + const c = new ReactComponent(displayName, component, retVal, important); + this.components.push(c); + + const listener = this.listeners.find(listener => listener.id === displayName); + if (listener) { + for (const l of listener.listeners) l(c); + Utils.removeFromArray(this.listeners, listener); + } + return c; } - static async getComponent(name, important) { + /** + * Finds a component from the components array or by waiting for it to be mounted. + * @param {String} name The component's name + * @param {Object} important An object containing a selector to look for + * @param {Function} filter A function to filter components if a single element is rendered by multiple components + * @return {Promise => ReactComponent} + */ + static async getComponent(name, important, filter) { const have = this.components.find(c => c.id === name); if (have) return have; + if (important) { - const importantInterval = setInterval(() => { + const callback = () => { if (this.components.find(c => c.id === name)) { Logger.info('ReactComponents', `Important component ${name} already found`); - clearInterval(importantInterval); + DOM.observer.unsubscribe(observerSubscription); return; } - const select = document.querySelector(important.selector); - if (!select) return; - const reflect = Reflection(select); - if (!reflect.component) { - clearInterval(importantInterval); - Logger.error('ReactComponents', [`FAILED TO GET IMPORTANT COMPONENT ${name} WITH REFLECTION FROM`, select]); + + const element = document.querySelector(important.selector); + if (!element) return; + + DOM.observer.unsubscribe(observerSubscription); + const reflect = Reflection(element); + const component = filter ? reflect.components.find(filter) : reflect.component; + if (!component) { + Logger.err('ReactComponents', [`FAILED TO GET IMPORTANT COMPONENT ${name} WITH REFLECTION FROM`, element]); return; } - if (!reflect.component.displayName) reflect.component.displayName = name; + + if (!component.displayName) component.displayName = name; Logger.info('ReactComponents', [`Found important component ${name} with reflection`, reflect]); - this.push(reflect.component); - clearInterval(importantInterval); - }, 50); + important.filter = filter; + this.push(component, undefined, important); + }; + + const observerSubscription = DOM.observer.subscribeToQuerySelector(callback, important.selector); + setTimeout(callback, 0); } - const listener = this.listeners.find(l => l.id === name); - if (!listener) this.listeners.push({ + + let listener = this.listeners.find(l => l.id === name); + if (!listener) this.listeners.push(listener = { id: name, listeners: [] }); + return new Promise(resolve => { - this.listeners.find(l => l.id === name).listeners.push(resolve); + listener.listeners.push(resolve); }); } - static setName(name, filter, callback) { + static setName(name, filter) { const have = this.components.find(c => c.id === name); if (have) return have; @@ -262,7 +283,7 @@ export class ReactComponents { const have = this.unknownComponents.find(c => c.component === component); for (const [fi, filter] of this.nameSetters.entries()) { if (filter.filter.filter(component)) { - console.log('filter match!'); + Logger.log('ReactComponents', 'Filter match!'); component.displayName = filter.name; this.nameSetters.splice(fi, 1); return this.push(component, retVal); @@ -275,36 +296,42 @@ export class ReactComponents { } export class ReactAutoPatcher { + /** + * Wait for React to be loaded and patch it's createElement to store all unknown components. + * Also patches of some known components. + */ static async autoPatch() { - await this.ensureReact(); - this.React = {}; - this.React.unpatchCreateElement = MonkeyPatch('BD:ReactComponents:createElement', 'React').before('createElement', (component, args) => { - ReactComponents.push(args[0]); - }); + const React = await WebpackModules.waitForModuleByName('React'); + + this.unpatchCreateElement = MonkeyPatch('BD:ReactComponents:createElement', React).before('createElement', (component, args) => ReactComponents.push(args[0])); + this.patchComponents(); - return 1; } - static async ensureReact() { - while (!window.webpackJsonp || !WebpackModules.getModuleByName('React')) await new Promise(resolve => setTimeout(resolve, 10)); - return 1; - } - - static async patchComponents() { - await this.patchMessage(); - await this.patchMessageGroup(); - await this.patchChannelMember(); - await this.patchGuild(); - await this.patchChannel(); - await this.patchChannelList(); - this.forceUpdate(); + /** + * Patches a few known components. + */ + static patchComponents() { + return Promise.all([ + this.patchMessage(), + this.patchMessageGroup(), + this.patchChannelMember(), + this.patchGuild(), + this.patchChannel(), + this.patchChannelList(), + this.patchUserProfileModal(), + this.patchUserPopout() + ]); } static async patchMessage() { - this.Message = await ReactComponents.getComponent('Message', { selector: '.message' }); + const selector = '.' + WebpackModules.getClassName('message', 'messageCozy', 'messageCompact'); + this.Message = await ReactComponents.getComponent('Message', {selector}, m => m.prototype && m.prototype.renderCozy); + this.unpatchMessageRender = MonkeyPatch('BD:ReactComponents', this.Message.component.prototype).after('render', (component, args, retVal) => { - const { message } = component.props; + const { message, jumpSequenceId, canFlash } = component.props; const { id, colorString, bot, author, attachments, embeds } = message; + if (jumpSequenceId && canFlash) retVal = retVal.props.children; retVal.props['data-message-id'] = id; retVal.props['data-colourstring'] = colorString; if (author && author.id) retVal.props['data-user-id'] = author.id; @@ -312,63 +339,128 @@ export class ReactAutoPatcher { if (attachments && attachments.length) retVal.props.className += ' bd-hasAttachments'; if (embeds && embeds.length) retVal.props.className += ' bd-hasEmbeds'; if (author && author.id === DiscordApi.currentUser.id) retVal.props.className += ' bd-isCurrentUser'; + + const dapiMessage = DiscordApi.Message.from(message); + if (dapiMessage.guild && author.id === dapiMessage.guild.ownerId) retVal.props.className += ' bd-isGuildOwner'; + if (dapiMessage.guild && dapiMessage.guild.isMember(author.id)) retVal.props.className += ' bd-isGuildMember'; }); + + this.Message.forceUpdateAll(); } static async patchMessageGroup() { - this.MessageGroup = await ReactComponents.getComponent('MessageGroup', { selector: '.message-group' }); + const selector = '.' + WebpackModules.getClassName('container', 'message', 'messageCozy'); + this.MessageGroup = await ReactComponents.getComponent('MessageGroup', {selector}); + this.unpatchMessageGroupRender = MonkeyPatch('BD:ReactComponents', this.MessageGroup.component.prototype).after('render', (component, args, retVal) => { const { author, type } = component.props.messages[0]; retVal.props['data-author-id'] = author.id; if (author.id === DiscordApi.currentUser.id) retVal.props.className += ' bd-isCurrentUser'; if (type !== 0) retVal.props.className += ' bd-isSystemMessage'; + + const dapiMessage = DiscordApi.Message.from(component.props.messages[0]); + if (dapiMessage.guild && author.id === dapiMessage.guild.ownerId) retVal.props.className += ' bd-isGuildOwner'; + if (dapiMessage.guild && dapiMessage.guild.isMember(author.id)) retVal.props.className += ' bd-isGuildMember'; }); + + this.MessageGroup.forceUpdateAll(); } static async patchChannelMember() { - this.ChannelMember = await ReactComponents.getComponent('ChannelMember', { selector: '.member-2FrNV0' }); + const selector = '.' + WebpackModules.getClassName('member', 'memberInner', 'activity'); + this.ChannelMember = await ReactComponents.getComponent('ChannelMember', {selector}, m => m.prototype.renderActivity); + this.unpatchChannelMemberRender = MonkeyPatch('BD:ReactComponents', this.ChannelMember.component.prototype).after('render', (component, args, retVal) => { - // Logger.log('ReactComponents', ['Rendering ChannelMember', component, args, retVal]); if (!retVal.props || !retVal.props.children) return; const user = Helpers.findProp(component, 'user'); if (!user) return; retVal.props['data-user-id'] = user.id; + retVal.props['data-colourstring'] = component.props.colorString; + if (component.props.isOwner) retVal.props.className += ' bd-isGuildOwner'; }); + + this.ChannelMember.forceUpdateAll(); } static async patchGuild() { - this.Guild = await ReactComponents.getComponent('Guild'); + const selector = `div.${WebpackModules.getClassName('guild', 'guildsWrapper')}:not(:first-child)`; + this.Guild = await ReactComponents.getComponent('Guild', {selector}, m => m.prototype.renderBadge); + this.unpatchGuild = MonkeyPatch('BD:ReactComponents', this.Guild.component.prototype).after('render', (component, args, retVal) => { const { guild } = component.props; if (!guild) return; retVal.props['data-guild-id'] = guild.id; retVal.props['data-guild-name'] = guild.name; }); + + this.Guild.forceUpdateAll(); } static async patchChannel() { - this.Channel = await ReactComponents.getComponent('Channel'); + const selector = '.chat'; + this.Channel = await ReactComponents.getComponent('Channel', {selector}); + this.unpatchChannel = MonkeyPatch('BD:ReactComponents', this.Channel.component.prototype).after('render', (component, args, retVal) => { const channel = component.props.channel || component.state.channel; if (!channel) return; retVal.props['data-channel-id'] = channel.id; retVal.props['data-channel-name'] = channel.name; + if ([0, 2, 4].includes(channel.type)) retVal.props.className += ' bd-isGuildChannel'; + if ([1, 3].includes(channel.type)) retVal.props.className += ' bd-isPrivateChannel'; + if (channel.type === 3) retVal.props.className += ' bd-isGroupChannel'; }); + + this.Channel.forceUpdateAll(); } static async patchChannelList() { - this.GuildChannel = await ReactComponents.getComponent('GuildChannel', { selector: '.containerDefault-7RImuF' }); + const selector = '.' + WebpackModules.getClassName('containerDefault', 'actionIcon'); + this.GuildChannel = await ReactComponents.getComponent('GuildChannel', {selector}); + this.unpatchGuildChannel = MonkeyPatch('BD:ReactComponents', this.GuildChannel.component.prototype).after('render', (component, args, retVal) => { const { channel } = component.props; if (!channel) return; retVal.props['data-channel-id'] = channel.id; retVal.props['data-channel-name'] = channel.name; + if ([0, 2, 4].includes(channel.type)) retVal.props.className += ' bd-isGuildChannel'; + if ([1, 3].includes(channel.type)) retVal.props.className += ' bd-isPrivateChannel'; + if (channel.type === 3) retVal.props.className += ' bd-isGroupChannel'; }); + + this.GuildChannel.forceUpdateAll(); } - static forceUpdate() { - for (const e of document.querySelectorAll('.message, .message-group, .guild, .containerDefault-7RImuF, .channel-members .member-2FrNV0')) { - Reflection(e).forceUpdate(); - } + static async patchUserProfileModal() { + const selector = '.' + WebpackModules.getClassName('root', 'topSectionNormal'); + this.UserProfileModal = await ReactComponents.getComponent('UserProfileModal', {selector}, Filters.byPrototypeFields(['renderHeader', 'renderBadges'])); + + this.unpatchUserProfileModal = MonkeyPatch('BD:ReactComponents', this.UserProfileModal.component.prototype).after('render', (component, args, retVal) => { + const { user } = component.props; + if (!user) return; + retVal.props['data-user-id'] = user.id; + if (user.bot) retVal.props.className += ' bd-isBot'; + if (user.id === DiscordApi.currentUser.id) retVal.props.className += ' bd-isCurrentUser'; + }); + + this.UserProfileModal.forceUpdateAll(); + } + + static async patchUserPopout() { + const selector = '.' + WebpackModules.getClassName('userPopout', 'headerNormal'); + this.UserPopout = await ReactComponents.getComponent('UserPopout', {selector}); + + this.unpatchUserPopout = MonkeyPatch('BD:ReactComponents', this.UserPopout.component.prototype).after('render', (component, args, retVal) => { + const { user, guild, guildMember } = component.props; + if (!user) return; + retVal.props['data-user-id'] = user.id; + if (user.bot) retVal.props.className += ' bd-isBot'; + if (user.id === DiscordApi.currentUser.id) retVal.props.className += ' bd-isCurrentUser'; + if (guild) retVal.props['data-guild-id'] = guild.id; + if (guild && user.id === guild.ownerId) retVal.props.className += ' bd-isGuildOwner'; + if (guild && guildMember) retVal.props.className += ' bd-isGuildMember'; + if (guildMember && guildMember.roles.length) retVal.props.className += ' bd-hasRoles'; + }); + + this.UserPopout.forceUpdateAll(); } } diff --git a/client/src/modules/settings.js b/client/src/modules/settings.js index bb3486ad..403df8cc 100644 --- a/client/src/modules/settings.js +++ b/client/src/modules/settings.js @@ -8,9 +8,10 @@ * LICENSE file in the root directory of this source tree. */ +import { Toasts } from 'ui'; import { EmoteModule } from 'builtin'; -import { SettingsSet, SettingUpdatedEvent } from 'structs'; -import { Utils, FileUtils, ClientLogger as Logger } from 'common'; +import { SettingsSet } from 'structs'; +import { FileUtils, ClientLogger as Logger } from 'common'; import path from 'path'; import Globals from './globals'; import CssEditor from './csseditor'; @@ -28,6 +29,7 @@ export default new class Settings { Logger.log('Settings', [`${set.id}/${category.id}/${setting.id} was changed from`, old_value, 'to', value]); Events.emit('setting-updated', event); Events.emit(`setting-updated-${set.id}_${category.id}_${setting.id}`, event); + Toasts.success(`${set.id}/${category.id}/${setting.id} was changed from ${old_value} to ${value}`); // Just for debugging purposes remove in prod }); set.on('settings-updated', async event => { @@ -61,7 +63,7 @@ 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; + 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 @@ -94,7 +96,7 @@ export default new class Settings { } } catch (err) { // There was an error saving settings - Logger.err('Settings', err); + Logger.err('Settings', ['Failed to save internal settings', err]); throw err; } } diff --git a/client/src/modules/thememanager.js b/client/src/modules/thememanager.js index 2ddf8bd8..4914cb05 100644 --- a/client/src/modules/thememanager.js +++ b/client/src/modules/thememanager.js @@ -10,8 +10,6 @@ import ContentManager from './contentmanager'; import Theme from './theme'; -import { FileUtils } from 'common'; -import path from 'path'; export default class ThemeManager extends ContentManager { @@ -116,8 +114,7 @@ export default class ThemeManager extends ContentManager { * @return {Promise} */ static async parseSetting(setting) { - const { type, id, value } = setting; - const name = id.replace(/[^a-zA-Z0-9-]/g, '-').replace(/--/g, '-'); + const name = setting.id.replace(/[^a-zA-Z0-9-]/g, '-').replace(/--/g, '-'); const scss = await setting.toSCSS(); if (scss) return [name, scss]; diff --git a/client/src/modules/updater.js b/client/src/modules/updater.js index 0b79fb8e..76b4ca29 100644 --- a/client/src/modules/updater.js +++ b/client/src/modules/updater.js @@ -10,8 +10,8 @@ import Events from './events'; import Globals from './globals'; -import { $ } from 'vendor'; import { ClientLogger as Logger } from 'common'; +import request from 'request-promise-native'; export default new class { @@ -57,40 +57,32 @@ export default new class { * Checks for updates. * @return {Promise} */ - checkForUpdates() { - return new Promise((resolve, reject) => { - if (this.updatesAvailable) return resolve(true); - Events.emit('update-check-start'); - Logger.info('Updater', 'Checking for updates'); + async checkForUpdates() { + if (this.updatesAvailable) return true; + Events.emit('update-check-start'); + Logger.info('Updater', 'Checking for updates'); - $.ajax({ - type: 'GET', - url: 'https://rawgit.com/JsSucks/BetterDiscordApp/master/package.json', - cache: false, - success: e => { - try { - this.latestVersion = e.version; - Events.emit('update-check-end'); - Logger.info('Updater', `Latest Version: ${e.version} - Current Version: ${Globals.version}`); - - if (this.latestVersion !== Globals.version) { - this.updatesAvailable = true; - Events.emit('updates-available'); - resolve(true); - } - - resolve(false); - } catch (err) { - Events.emit('update-check-fail', err); - reject(err); - } - }, - fail: err => { - Events.emit('update-check-fail', err); - reject(err); - } + try { + const response = await request({ + uri: 'https://rawgit.com/JsSucks/BetterDiscordApp/master/package.json', + json: true }); - }); + + this.latestVersion = response.version; + Events.emit('update-check-end'); + Logger.info('Updater', `Latest Version: ${response.version} - Current Version: ${Globals.version}`); + + if (this.latestVersion !== Globals.version) { + this.updatesAvailable = true; + Events.emit('updates-available'); + return true; + } + + return false; + } catch (err) { + Events.emit('update-check-fail', err); + throw err; + } } } diff --git a/client/src/modules/vendor.js b/client/src/modules/vendor.js index 4b5c2d58..efdf35c7 100644 --- a/client/src/modules/vendor.js +++ b/client/src/modules/vendor.js @@ -8,12 +8,18 @@ * LICENSE file in the root directory of this source tree. */ -import { WebpackModules } from './webpackmodules'; import jQuery from 'jquery'; import lodash from 'lodash'; import Vue from 'vue'; -export { jQuery as $ }; +import request from 'request-promise-native'; + +import Combokeys from 'combokeys'; +import filetype from 'file-type'; +import filewatcher from 'filewatcher'; +import VTooltip from 'v-tooltip'; + +export { jQuery as $, request }; export default class { @@ -29,18 +35,16 @@ export default class { static get lodash() { return lodash } static get _() { return this.lodash } - /** - * Moment - */ - static get moment() { - return WebpackModules.getModuleByName('Moment'); - } - /** * Vue */ - static get Vue() { - return Vue; - } + static get Vue() { return Vue } + + static get request() { return request } + + static get Combokeys() { return Combokeys } + static get filetype() { return filetype } + static get filewatcher() { return filewatcher } + static get VTooltip() { return VTooltip } } diff --git a/client/src/modules/webpackmodules.js b/client/src/modules/webpackmodules.js index 59bcf371..0175a190 100644 --- a/client/src/modules/webpackmodules.js +++ b/client/src/modules/webpackmodules.js @@ -8,44 +8,8 @@ * LICENSE file in the root directory of this source tree. */ -export class Filters { - static byProperties(props, selector = m => m) { - return module => { - const component = selector(module); - if (!component) return false; - return props.every(property => component[property] !== undefined); - }; - } - - static byPrototypeFields(fields, selector = m => m) { - return module => { - const component = selector(module); - if (!component) return false; - if (!component.prototype) return false; - return fields.every(field => component.prototype[field] !== undefined); - }; - } - - static byCode(search, selector = m => m) { - return module => { - const method = selector(module); - if (!method) return false; - return method.toString().search(search) !== -1; - }; - } - - static byDisplayName(name) { - return module => { - return module && module.displayName === name; - }; - } - - static combine(...filters) { - return module => { - return filters.every(filter => filter(module)); - }; - } -} +import { Utils, Filters } from 'common'; +import Events from './events'; const KnownModules = { React: Filters.byProperties(['createElement', 'cloneElement']), @@ -90,6 +54,8 @@ const KnownModules = { UserTypingStore: Filters.byProperties(['isTyping']), UserActivityStore: Filters.byProperties(['getActivity']), UserNameResolver: Filters.byProperties(['getName']), + UserNoteStore: Filters.byProperties(['getNote']), + UserNoteActions: Filters.byProperties(['updateNote']), /* Emoji Store and Utils */ EmojiInfo: Filters.byProperties(['isEmojiDisabled']), @@ -136,7 +102,7 @@ const KnownModules = { DNDSources: Filters.byProperties(["addTarget"]), DNDObjects: Filters.byProperties(["DragSource"]), - /* Electron & Other Internals with Utils*/ + /* Electron & Other Internals with Utils */ ElectronModule: Filters.byProperties(["_getMainWindow"]), Dispatcher: Filters.byProperties(['dirtyDispatch']), PathUtils: Filters.byProperties(["hasBasename"]), @@ -162,7 +128,6 @@ const KnownModules = { WindowInfo: Filters.byProperties(['isFocused', 'windowSize']), TagInfo: Filters.byProperties(['VALID_TAG_NAMES']), DOMInfo: Filters.byProperties(['canUseDOM']), - HTMLUtils: Filters.byProperties(['htmlFor', 'sanitizeUrl']), /* Locale/Location and Time */ LocaleManager: Filters.byProperties(['setLocale']), @@ -179,15 +144,27 @@ const KnownModules = { URLParser: Filters.byProperties(['Url', 'parse']), ExtraURLs: Filters.byProperties(['getArticleURL']), + /* Text Processing */ + hljs: Filters.byProperties(['highlight', 'highlightBlock']), + SimpleMarkdown: Filters.byProperties(['parseBlock', 'parseInline', 'defaultOutput']), + /* DOM/React Components */ /* ==================== */ - UserSettingsWindow: Filters.byProperties(['open', 'updateAccount']), LayerManager: Filters.byProperties(['popLayer', 'pushLayer']), + UserSettingsWindow: Filters.byProperties(['open', 'updateAccount']), + ChannelSettingsWindow: Filters.byProperties(['open', 'updateChannel']), + GuildSettingsWindow: Filters.byProperties(['open', 'updateGuild']), /* Modals */ ModalStack: Filters.byProperties(['push', 'update', 'pop', 'popWithKey']), - UserProfileModals: Filters.byProperties(['fetchMutualFriends', 'setSection']), ConfirmModal: Filters.byPrototypeFields(['handleCancel', 'handleSubmit', 'handleMinorConfirm']), + UserProfileModal: Filters.byProperties(['fetchMutualFriends', 'setSection']), + ChangeNicknameModal: Filters.byProperties(['open', 'changeNickname']), + CreateChannelModal: Filters.byProperties(['open', 'createChannel']), + PruneMembersModal: Filters.byProperties(['open', 'prune']), + NotificationSettingsModal: Filters.byProperties(['open', 'updateNotificationSettings']), + PrivacySettingsModal: Filters.byCode(/PRIVACY_SETTINGS_MODAL_OPEN/, m => m.open), + CreateInviteModal: Filters.byProperties(['open', 'createInvite']), /* Popouts */ PopoutStack: Filters.byProperties(['open', 'close', 'closeAll']), @@ -203,16 +180,17 @@ const KnownModules = { ExternalLink: Filters.byCode(/\.trusted\b/) }; -export class WebpackModules { +class WebpackModules { /** * Finds a module using a filter function. * @param {Function} filter A function to use to filter modules * @param {Boolean} first Whether to return only the first matching module + * @param {Array} modules An array of modules to search in * @return {Any} */ - static getModule(filter, first = true) { - const modules = this.getAllModules(); + static getModule(filter, first = true, _modules) { + const modules = _modules || this.getAllModules(); const rm = []; for (let index in modules) { if (!modules.hasOwnProperty(index)) continue; @@ -227,7 +205,7 @@ export class WebpackModules { if (first) return foundModule; rm.push(foundModule); } - return first || rm.length == 0 ? undefined : rm; + return first ? undefined : rm; } /** @@ -288,15 +266,139 @@ export class WebpackModules { */ static get require() { if (this._require) return this._require; - const id = 'bd-webpackmodules'; - const __webpack_require__ = window['webpackJsonp']([], { - [id]: (module, exports, __webpack_require__) => exports.default = __webpack_require__ - }, [id]).default; - delete __webpack_require__.m[id]; - delete __webpack_require__.c[id]; + + const __webpack_require__ = this.getWebpackRequire(); + if (!__webpack_require__) return; + + this.hookWebpackRequireCache(__webpack_require__); return this._require = __webpack_require__; } + static getWebpackRequire() { + const id = 'bd-webpackmodules'; + + if (typeof window.webpackJsonp === 'function') { + const __webpack_require__ = window['webpackJsonp']([], { + [id]: (module, exports, __webpack_require__) => exports.default = __webpack_require__ + }, [id]).default; + delete __webpack_require__.m[id]; + delete __webpack_require__.c[id]; + return __webpack_require__; + } else if (window.webpackJsonp && window.webpackJsonp.push) { + const __webpack_require__ = window['webpackJsonp'].push([[], { + [id]: (module, exports, req) => exports.default = req + }, [[id]]]).default; + window['webpackJsonp'].pop(); + delete __webpack_require__.m[id]; + delete __webpack_require__.c[id]; + return __webpack_require__; + } + } + + static hookWebpackRequireCache(__webpack_require__) { + __webpack_require__.c = new Proxy(__webpack_require__.c, { + set(module_cache, module_id, module) { + // Add it to our emitter cache and emit a module-loading event + this.moduleLoading(module_id, module); + Events.emit('module-loading', module); + + // Add the module to the cache as normal + module_cache[module_id] = module; + } + }); + } + + static moduleLoading(module_id, module) { + if (this.require.c[module_id]) return; + + if (!this.moduleLoadedEventTimeout) { + this.moduleLoadedEventTimeout = setTimeout(() => { + this.moduleLoadedEventTimeout = undefined; + + // Emit a module-loaded event for every module + for (let module of this.modulesLoadingCache) { + Events.emit('module-loaded', module); + } + + // Emit a modules-loaded event + Events.emit('modules-loaded', this.modulesLoadingCache); + + this.modulesLoadedCache = []; + }, 0); + } + + // Add this to our own cache + if (!this.modulesLoadingCache) this.modulesLoadingCache = []; + this.modulesLoadingCache.push(module); + } + + static waitForWebpackRequire() { + return Utils.until(() => this.require, 10); + } + + /** + * Waits for a module to load. + * This only returns a single module, as it can't guarentee there are no more modules that could + * match the filter, which is pretty much what that would be asking for. + * @param {Function} filter The name of a known module or a filter function + * @return {Any} + */ + static async waitForModule(filter) { + const module = this.getModule(filter); + if (module) return module; + + while (this.require.m.length > this.require.c.length) { + const additionalModules = await Events.once('modules-loaded'); + + const module = this.getModule(filter, true, additionalModules); + if (module) return module; + } + + throw new Error('All modules have now been loaded. None match the passed filter.'); + } + + /** + * Finds a module by it's name. + * @param {String} name The name of the module + * @param {Function} fallback A function to use to filter modules if not finding a known module + * @return {Any} + */ + static async waitForModuleByName(name, fallback) { + if (Cache.hasOwnProperty(name)) return Cache[name]; + if (KnownModules.hasOwnProperty(name)) fallback = KnownModules[name]; + if (!fallback) return undefined; + const module = await this.waitForModule(fallback, true); + return module ? Cache[name] = module : undefined; + } + + static waitForModuleByDisplayName(props) { + return this.waitForModule(Filters.byDisplayName(props)); + } + static waitForModuleByRegex(props) { + return this.waitForModule(Filters.byCode(props)); + } + static waitForModuleByProps(props) { + return this.waitForModule(Filters.byProperties(props)); + } + static waitForModuleByPrototypes(props) { + return this.waitForModule(Filters.byPrototypeFields(props)); + } + + /** + * Searches for a class module and returns a class from it. + * @param {String} base The first part of the class to find + * @param {String} ...additional_classes Additional classes to look for to filter duplicate class modules + * @return {String} + */ + static getClassName(base, ...additional_classes) { + const class_module = this.getModuleByProps([base, ...additional_classes]); + if (class_module && class_module[base]) return class_module[base].split(' ')[0]; + } + static async waitForClassName(base, ...additional_classes) { + const class_module = await this.waitForModuleByProps([base, ...additional_classes]); + if (class_module && class_module[base]) return class_module[base].split(' ')[0]; + } + /** * Returns all loaded modules. * @return {Array} @@ -313,4 +415,14 @@ export class WebpackModules { return Object.keys(KnownModules); } + static get KnownModules() { return KnownModules } + } + +const WebpackModulesProxy = new Proxy(WebpackModules, { + get(WebpackModules, property) { + return WebpackModules[property] || WebpackModules.getModuleByName(property); + } +}); + +export { WebpackModulesProxy as WebpackModules }; diff --git a/client/src/structs/discord/channel.js b/client/src/structs/discord/channel.js new file mode 100644 index 00000000..cedbddee --- /dev/null +++ b/client/src/structs/discord/channel.js @@ -0,0 +1,436 @@ +/** + * BetterDiscord Channel Struct + * 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 { DiscordApi, DiscordApiModules as Modules } from 'modules'; +import { List, InsufficientPermissions } from 'structs'; +import { Guild } from './guild'; +import { Message } from './message'; +import { User, GuildMember } from './user'; + +const channels = new WeakMap(); + +export class Channel { + + constructor(data) { + if (channels.has(data)) return channels.get(data); + channels.set(data, this); + + this.discordObject = data; + } + + static from(channel) { + switch (channel.type) { + default: return new Channel(channel); + case 0: return new GuildTextChannel(channel); + case 1: return new DirectMessageChannel(channel); + case 2: return new GuildVoiceChannel(channel); + case 3: return new GroupChannel(channel); + case 4: return new ChannelCategory(channel); + } + } + + static fromId(id) { + const channel = Modules.ChannelStore.getChannel(id); + if (channel) return Channel.from(channel); + } + + static get GuildChannel() { return GuildChannel } + static get GuildTextChannel() { return GuildTextChannel } + static get GuildVoiceChannel() { return GuildVoiceChannel } + static get ChannelCategory() { return ChannelCategory } + static get PrivateChannel() { return PrivateChannel } + static get DirectMessageChannel() { return DirectMessageChannel } + static get GroupChannel() { return GroupChannel } + + get id() { return this.discordObject.id } + get applicationId() { return this.discordObject.application_id } + get type() { return this.discordObject.type } + get name() { return this.discordObject.name } + + /** + * Send a message in this channel. + * @param {String} content The new message's content + * @param {Boolean} parse Whether to parse the message or send it as it is + * @return {Promise => Message} + */ + async sendMessage(content, parse = false) { + if (this.assertPermissions) this.assertPermissions('SEND_MESSAGES', Modules.DiscordPermissions.VIEW_CHANNEL | Modules.DiscordPermissions.SEND_MESSAGES); + + this.select(); + + if (parse) content = Modules.MessageParser.parse(this.discordObject, content); + else content = {content}; + + const response = await Modules.MessageActions._sendMessage(this.id, content); + return Message.from(Modules.MessageStore.getMessage(this.id, response.body.id)); + } + + /** + * Send a bot message in this channel that only the current user can see. + * @param {String} content The new message's content + * @return {Message} + */ + sendBotMessage(content) { + this.select(); + const message = Modules.MessageParser.createBotMessage(this.id, content); + Modules.MessageActions.receiveMessage(this.id, message); + return Message.from(Modules.MessageStore.getMessage(this.id, message.id)); + } + + /** + * A list of messages in this channel. + */ + get messages() { + const messages = Modules.MessageStore.getMessages(this.id).toArray(); + return List.from(messages, m => Message.from(m)); + } + + /** + * Jumps to the latest message in this channel. + */ + jumpToPresent() { + if (this.assertPermissions) this.assertPermissions('VIEW_CHANNEL', Modules.DiscordPermissions.VIEW_CHANNEL); + if (this.hasMoreAfter) Modules.MessageActions.jumpToPresent(this.id, Modules.DiscordConstants.MAX_MESSAGES_PER_CHANNEL); + else this.messages[this.messages.length - 1].jumpTo(false); + } + + get hasMoreAfter() { + return Modules.MessageStore.getMessages(this.id).hasMoreAfter; + } + + /** + * Sends an invite in this channel. + * @param {String} code The invite code + * @return {Promise => Messaage} + */ + async sendInvite(code) { + if (this.assertPermissions) this.assertPermissions('SEND_MESSAGES', Modules.DiscordPermissions.VIEW_CHANNEL | Modules.DiscordPermissions.SEND_MESSAGES); + const response = Modules.MessageActions.sendInvite(this.id, code); + return Message.from(Modules.MessageStore.getMessage(this.id, response.body.id)); + } + + /** + * Opens this channel in the UI. + */ + select() { + if (this.assertPermissions) this.assertPermissions('VIEW_CHANNEL', Modules.DiscordPermissions.VIEW_CHANNEL); + Modules.NavigationUtils.transitionToGuild(this.guildId ? this.guildId : Modules.DiscordConstants.ME, this.id); + } + + /** + * Whether this channel is currently selected. + */ + get isSelected() { + return DiscordApi.currentChannel === this; + } + + /** + * Updates this channel. + * @return {Promise} + */ + async updateChannel(body) { + if (this.assertPermissions) this.assertPermissions('MANAGE_CHANNELS', Modules.DiscordPermissions.MANAGE_CHANNELS); + const response = await Modules.APIModule.patch({ + url: `${Modules.DiscordConstants.Endpoints.CHANNELS}/${this.id}`, + body + }); + + this.discordObject = Modules.ChannelStore.getChannel(this.id); + channels.set(this.discordObject, this); + } + +} + +export class PermissionOverwrite { + constructor(data, channel_id) { + this.discordObject = data; + this.channelId = channel_id; + } + + static from(data, channel_id) { + switch (data.type) { + default: return new PermissionOverwrite(data, channel_id); + case 'role': return new RolePermissionOverwrite(data, channel_id); + case 'member': return new MemberPermissionOverwrite(data, channel_id); + } + } + + static get RolePermissionOverwrite() { return RolePermissionOverwrite } + static get MemberPermissionOverwrite() { return MemberPermissionOverwrite } + + get type() { return this.discordObject.type } + get allow() { return this.discordObject.allow } + get deny() { return this.discordObject.deny } + + get channel() { + return Channel.fromId(this.channelId); + } + + get guild() { + if (this.channel) return this.channel.guild; + } +} + +export class RolePermissionOverwrite extends PermissionOverwrite { + get roleId() { return this.discordObject.id } + + get role() { + if (this.guild) return this.guild.roles.find(r => r.id === this.roleId); + } +} + +export class MemberPermissionOverwrite extends PermissionOverwrite { + get memberId() { return this.discordObject.id } + + get member() { + return GuildMember.fromId(this.memberId); + } +} + +export class GuildChannel extends Channel { + static get PermissionOverwrite() { return PermissionOverwrite } + + get guildId() { return this.discordObject.guild_id } + get parentId() { return this.discordObject.parent_id } // Channel category + get position() { return this.discordObject.position } + get nicks() { return this.discordObject.nicks } + + checkPermissions(perms) { + return Modules.PermissionUtils.can(perms, DiscordApi.currentUser, this.discordObject); + } + + assertPermissions(name, perms) { + if (!this.checkPermissions(perms)) throw new InsufficientPermissions(name); + } + + get category() { + return Channel.fromId(this.parentId); + } + + /** + * The current user's permissions on this channel. + */ + get permissions() { + return Modules.GuildPermissions.getChannelPermissions(this.id); + } + + get permissionOverwrites() { + return List.from(Object.entries(this.discordObject.permissionOverwrites), ([i, p]) => PermissionOverwrite.from(p, this.id)); + } + + get guild() { + return Guild.fromId(this.guildId); + } + + /** + * Whether this channel is the guild's default channel. + */ + get isDefaultChannel() { + return Modules.GuildChannelsStore.getDefaultChannel(this.guildId).id === this.id; + } + + /** + * Opens this channel's settings window. + * @param {String} section The section to open (see DiscordConstants.ChannelSettingsSections) + */ + openSettings(section = 'OVERVIEW') { + Modules.ChannelSettingsWindow.setSection(section); + Modules.ChannelSettingsWindow.open(this.id); + } + + /** + * Updates this channel's name. + * @param {String} name The channel's new name + * @return {Promise} + */ + updateName(name) { + return this.updateChannel({ name }); + } + + /** + * Changes the channel's position. + * @param {Number} position The channel's new position + * @return {Promise} + */ + changeSortLocation(position = 0) { + if (position instanceof GuildChannel) position = position.position; + return this.updateChannel({ position }); + } + + /** + * Updates this channel's permission overwrites. + * @param {Array} permissionOverwrites An array of permission overwrites + * @return {Promise} + */ + updatePermissionOverwrites(permission_overwrites) { + return this.updateChannel({ permission_overwrites }); + } + + /** + * Updates this channel's category. + * @param {ChannelCategory} category The new channel category + * @return {Promise} + */ + updateCategory(category) { + return this.updateChannel({ parent_id: category.id || category }); + } +} + +// Type 0 - GUILD_TEXT +export class GuildTextChannel extends GuildChannel { + get type() { return 'GUILD_TEXT' } + get topic() { return this.discordObject.topic } + get nsfw() { return this.discordObject.nsfw } + + /** + * Updates this channel's topic. + * @param {String} topc The new channel topic + * @return {Promise} + */ + updateTopic(topic) { + return this.updateChannel({ topic }); + } + + /** + * Updates this channel's not-safe-for-work flag. + * @param {Boolean} nsfw Whether the channel should be marked as NSFW + * @return {Promise} + */ + setNsfw(nsfw = true) { + return this.updateChannel({ nsfw }); + } + + setNotNsfw() { + return this.setNsfw(false); + } +} + +// Type 2 - GUILD_VOICE +export class GuildVoiceChannel extends GuildChannel { + get type() { return 'GUILD_VOICE' } + get userLimit() { return this.discordObject.userLimit } + get bitrate() { return this.discordObject.bitrate } + + sendMessage() { throw new Error('Cannot send messages in a voice channel.'); } + get messages() { return new List(); } + jumpToPresent() { throw new Error('Cannot select a voice channel.'); } + get hasMoreAfter() { return false; } + sendInvite() { throw new Error('Cannot invite someone to a voice channel.'); } + select() { throw new Error('Cannot select a voice channel.'); } + + /** + * Updates this channel's bitrate. + * @param {Number} bitrate The new bitrate + * @return {Promise} + */ + updateBitrate(bitrate) { + return this.updateChannel({ bitrate }); + } + + /** + * Updates this channel's user limit. + * @param {Number} userLimit The new user limit + * @return {Promise} + */ + updateUserLimit(user_limit) { + return this.updateChannel({ user_limit }); + } +} + +// Type 4 - GUILD_CATEGORY +export class ChannelCategory extends GuildChannel { + get type() { return 'GUILD_CATEGORY' } + get parentId() { return undefined } + get category() { return undefined } + + sendMessage() { throw new Error('Cannot send messages in a channel category.'); } + get messages() { return new List(); } + jumpToPresent() { throw new Error('Cannot select a channel category.'); } + get hasMoreAfter() { return false; } + sendInvite() { throw new Error('Cannot invite someone to a channel category.'); } + select() { throw new Error('Cannot select a channel category.'); } + updateCategory() { throw new Error('Cannot set a channel category on another channel category.'); } + + /** + * A list of channels in this category. + */ + get channels() { + return List.from(this.guild.channels, c => c.parentId === this.id); + } + + /** + * Opens the create channel modal for this guild. + * @param {Number} type The type of channel to create - either 0 (text), 2 (voice) or 4 (category) + * @param {GuildChannel} clone A channel to clone permissions of + */ + openCreateChannelModal(type, category, clone) { + this.guild.openCreateChannelModal(type, this.id, this, clone); + } + + /** + * Creates a channel in this category. + * @param {Number} type The type of channel to create - either 0 (text) or 2 (voice) + * @param {String} name A name for the new channel + * @param {Array} permission_overwrites An array of PermissionOverwrite-like objects - leave to use the permissions of the category + * @return {Promise => GuildChannel} + */ + createChannel(type, name, permission_overwrites) { + return this.guild.createChannel(type, name, this, permission_overwrites); + } +} + +export class PrivateChannel extends Channel { + get userLimit() { return this.discordObject.userLimit } + get bitrate() { return this.discordObject.bitrate } +} + +// Type 1 - DM +export class DirectMessageChannel extends PrivateChannel { + get type() { return 'DM' } + get recipientId() { return this.discordObject.recipients[0] } + + /** + * The other user of this direct message channel. + */ + get recipient() { + return User.fromId(this.recipientId); + } +} + +// Type 3 - GROUP_DM +export class GroupChannel extends PrivateChannel { + get ownerId() { return this.discordObject.ownerId } + get type() { return 'GROUP_DM' } + get name() { return this.discordObject.name } + get icon() { return this.discordObject.icon } + + /** + * A list of the other members of this group direct message channel. + */ + get members() { + return List.from(this.discordObject.recipients, id => User.fromId(id)); + } + + /** + * The owner of this group direct message channel. This is usually the person who created it. + */ + get owner() { + return User.fromId(this.ownerId); + } + + /** + * Updates this channel's name. + * @param {String} name The channel's new name + * @return {Promise} + */ + updateName(name) { + return this.updateChannel({ name }); + } +} diff --git a/client/src/structs/discord/guild.js b/client/src/structs/discord/guild.js new file mode 100644 index 00000000..e96e06b2 --- /dev/null +++ b/client/src/structs/discord/guild.js @@ -0,0 +1,483 @@ +/** + * BetterDiscord Guild Struct + * 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 { DiscordApi, DiscordApiModules as Modules } from 'modules'; +import { List, InsufficientPermissions } from 'structs'; +import { FileUtils } from 'common'; +import { Channel } from './channel'; +import { GuildMember } from './user'; + +const roles = new WeakMap(); + +export class Role { + constructor(data, guild_id) { + if (roles.has(data)) return roles.get(data); + roles.set(data, this); + + this.discordObject = data; + this.guildId = guild_id; + } + + get id() { return this.discordObject.id } + get name() { return this.discordObject.name } + get position() { return this.discordObject.position } + get originalPosition() { return this.discordObject.originalPosition } + get permissions() { return this.discordObject.permissions } + get managed() { return this.discordObject.managed } + get mentionable() { return this.discordObject.mentionable } + get hoist() { return this.discordObject.hoist } + get colour() { return this.discordObject.color } + get colourString() { return this.discordObject.colorString } + + get guild() { + return Guild.fromId(this.guildId); + } + + get members() { + return this.guild.members.filter(m => m.roles.includes(this)); + } +} + +const emojis = new WeakMap(); + +export class Emoji { + constructor(data) { + if (emojis.has(data)) return emojis.get(data); + emojis.set(data, this); + + this.discordObject = data; + } + + get id() { return this.discordObject.id } + get guildId() { return this.discordObject.guild_id } + get name() { return this.discordObject.name } + get managed() { return this.discordObject.managed } + get animated() { return this.discordObject.animated } + get allNamesString() { return this.discordObject.allNamesString } + get requireColons() { return this.discordObject.require_colons } + get url() { return this.discordObject.url } + get roles() { return this.discordObject.roles } + + get guild() { + return Guild.fromId(this.guildId); + } +} + +const guilds = new WeakMap(); + +export class Guild { + + constructor(data) { + if (guilds.has(data)) return guilds.get(data); + guilds.set(data, this); + + this.discordObject = data; + } + + static from(data) { + return new Guild(data); + } + + static fromId(id) { + const guild = Modules.GuildStore.getGuild(id); + if (guild) return Guild.from(guild); + } + + static get Role() { return Role } + static get Emoji() { return Emoji } + + get id() { return this.discordObject.id } + get ownerId() { return this.discordObject.ownerId } + get applicationId() { return this.discordObject.application_id } + get systemChannelId() { return this.discordObject.systemChannelId } + get name() { return this.discordObject.name } + get acronym() { return this.discordObject.acronym } + get icon() { return this.discordObject.icon } + get joinedAt() { return this.discordObject.joinedAt } + get verificationLevel() { return this.discordObject.verificationLevel } + get mfaLevel() { return this.discordObject.mfaLevel } + get large() { return this.discordObject.large } + get lazy() { return this.discordObject.lazy } + get voiceRegion() { return this.discordObject.region } + get afkChannelId() { return this.discordObject.afkChannelId } + get afkTimeout() { return this.discordObject.afkTimeout } + get explicitContentFilter() { return this.discordObject.explicitContentFilter } + get defaultMessageNotifications() { return this.discordObject.defaultMessageNotifications } + get splash() { return this.discordObject.splash } + get features() { return this.discordObject.features } + + get owner() { + return this.members.find(m => m.userId === this.ownerId); + } + + get roles() { + return List.from(Object.entries(this.discordObject.roles), ([i, r]) => new Role(r, this.id)) + .sort((r1, r2) => r1.position === r2.position ? 0 : r1.position > r2.position ? 1 : -1); + } + + get channels() { + const channels = Modules.GuildChannelsStore.getChannels(this.id); + const returnChannels = new List(); + for (const category in channels) { + if (channels.hasOwnProperty(category)) { + if (!Array.isArray(channels[category])) continue; + const channelList = channels[category]; + for (const channel of channelList) { + // For some reason Discord adds a new category with the ID "null" and name "Uncategorized" + if (channel.channel.id === 'null') continue; + returnChannels.push(Channel.from(channel.channel)); + } + } + } + return returnChannels; + } + + /** + * Channels that don't have a parent. (Channel categories and any text/voice channel not in one.) + */ + get mainChannels() { + return this.channels.filter(c => !c.parentId); + } + + /** + * The guild's default channel. (Usually the first in the list.) + */ + get defaultChannel() { + return Channel.from(Modules.GuildChannelsStore.getDefaultChannel(this.id)); + } + + /** + * The guild's AFK channel. + */ + get afkChannel() { + if (this.afkChannelId) return Channel.fromId(this.afkChannelId); + } + + /** + * The channel system messages are sent to. + */ + get systemChannel() { + if (this.systemChannelId) return Channel.fromId(this.systemChannelId); + } + + /** + * A list of GuildMember objects. + */ + get members() { + const members = Modules.GuildMemberStore.getMembers(this.id); + return List.from(members, m => new GuildMember(m, this.id)); + } + + /** + * The current user as a GuildMember of this guild. + */ + get currentUser() { + return this.members.find(m => m.user === DiscordApi.currentUser); + } + + /** + * The total number of members in the guild. + */ + get memberCount() { + return Modules.MemberCountStore.getMemberCount(this.id); + } + + /** + * An array of the guild's custom emojis. + */ + get emojis() { + return List.from(Modules.EmojiUtils.getGuildEmoji(this.id), e => new Emoji(e, this.id)); + } + + checkPermissions(perms) { + return Modules.PermissionUtils.can(perms, DiscordApi.currentUser, this.discordObject); + } + + assertPermissions(name, perms) { + if (!this.checkPermissions(perms)) throw new InsufficientPermissions(name); + } + + /** + * The current user's permissions on this guild. + */ + get permissions() { + return Modules.GuildPermissions.getGuildPermissions(this.id); + } + + /** + * Returns the GuildMember object for a user. + * @param {User|GuildMember|Number} user A User or GuildMember object or a user ID + * @return {GuildMember} + */ + getMember(user) { + const member = Modules.GuildMemberStore.getMember(this.id, user.userId || user.id || user); + if (member) return new GuildMember(member, this.id); + } + + /** + * Checks if a user is a member of this guild. + * @param {User|GuildMember|Number} user A User or GuildMember object or a user ID + * @return {Boolean} + */ + isMember(user) { + return Modules.GuildMemberStore.isMember(this.id, user.userId || user.id || user); + } + + /** + * Whether the user has not restricted direct messages from members of this guild. + */ + get allowPrivateMessages() { + return !DiscordApi.UserSettings.restrictedGuildIds.includes(this.id); + } + + /** + * Marks all messages in the guild as read. + */ + markAsRead() { + Modules.GuildActions.markGuildAsRead(this.id); + } + + /** + * Selects the guild in the UI. + */ + select() { + Modules.GuildActions.selectGuild(this.id); + } + + /** + * Whether this guild is currently selected. + */ + get isSelected() { + return DiscordApi.currentGuild === this; + } + + /** + * Opens this guild's settings window. + * @param {String} section The section to open (see DiscordConstants.GuildSettingsSections) + */ + openSettings(section = 'OVERVIEW') { + Modules.GuildSettingsWindow.setSection(section); + Modules.GuildSettingsWindow.open(this.id); + } + + /** + * Kicks members who don't have any roles and haven't been seen in the number of days passed. + * @param {Number} days + */ + pruneMembers(days) { + this.assertPermissions('KICK_MEMBERS', Modules.DiscordPermissions.KICK_MEMBERS); + Modules.PruneMembersModal.prune(this.id, days); + } + + openPruneMumbersModal() { + this.assertPermissions('KICK_MEMBERS', Modules.DiscordPermissions.KICK_MEMBERS); + Modules.PruneMembersModal.open(this.id); + } + + /** + * Opens the create channel modal for this guild. + * @param {Number} type The type of channel to create - either 0 (text), 2 (voice) or 4 (category) + * @param {ChannelCategory} category The category to create the channel in + * @param {GuildChannel} clone A channel to clone permissions, topic, bitrate and user limit of + */ + openCreateChannelModal(type, category, clone) { + this.assertPermissions('MANAGE_CHANNELS', Modules.DiscordPermissions.MANAGE_CHANNELS); + Modules.CreateChannelModal.open(type, this.id, category ? category.id : undefined, clone ? clone.id : undefined); + } + + /** + * Creates a channel in this guild. + * @param {Number} type The type of channel to create - either 0 (text), 2 (voice) or 4 (category) + * @param {String} name A name for the new channel + * @param {ChannelCategory} category The category to create the channel in + * @param {Array} permission_overwrites An array of PermissionOverwrite-like objects - leave to use the permissions of the category + * @return {Promise => GuildChannel} + */ + async createChannel(type, name, category, permission_overwrites) { + this.assertPermissions('MANAGE_CHANNELS', Modules.DiscordPermissions.MANAGE_CHANNELS); + const response = await Modules.APIModule.post({ + url: Modules.DiscordConstants.Endpoints.GUILD_CHANNELS(this.id), + body: { + type, name, + parent_id: category ? category.id : undefined, + permission_overwrites: permission_overwrites ? permission_overwrites.map(p => ({ + type: p.type, + id: (p.type === 'user' ? p.userId : p.roleId) || p.id, + allow: p.allow, + deny: p.deny + })) : undefined + } + }); + + return Channel.fromId(response.body.id); + } + + openNotificationSettingsModal() { + Modules.NotificationSettingsModal.open(this.id); + } + + openPrivacySettingsModal() { + Modules.PrivacySettingsModal.open(this.id); + } + + nsfwAgree() { + Modules.GuildActions.nsfwAgree(this.id); + } + + nsfwDisagree() { + Modules.GuildActions.nsfwDisagree(this.id); + } + + /** + * Changes the guild's position in the list. + * @param {Number} index The new position + */ + changeSortLocation(index) { + Modules.GuildActions.move(DiscordApi.guildPositions.indexOf(this.id), index); + } + + /** + * Updates this guild. + * @return {Promise} + */ + async updateGuild(body) { + this.assertPermissions('MANAGE_GUILD', Modules.DiscordPermissions.MANAGE_GUILD); + const response = await Modules.APIModule.patch({ + url: Modules.DiscordConstants.Endpoints.GUILD(this.id), + body + }); + + this.discordObject = Modules.GuildStore.getGuild(this.id); + guilds.set(this.discordObject, this); + } + + /** + * Updates this guild's name. + * @param {String} name The new name + * @return {Promise} + */ + updateName(name) { + return this.updateGuild({ name }); + } + + /** + * Updates this guild's voice region. + * @param {String} region The ID of the new voice region (obtainable via the API - see https://discordapp.com/developers/docs/resources/voice#list-voice-regions) + * @return {Promise} + */ + updateVoiceRegion(region) { + return this.updateGuild({ region }); + } + + /** + * Updates this guild's verification level. + * @param {Number} verificationLevel The new verification level (see https://discordapp.com/developers/docs/resources/guild#guild-object-verification-level) + * @return {Promise} + */ + updateVerificationLevel(verification_level) { + return this.updateGuild({ verification_level }); + } + + /** + * Updates this guild's default message notification level. + * @param {Number} defaultMessageNotifications The new default notification level (0: all messages, 1: only mentions) + * @return {Promise} + */ + updateDefaultMessageNotifications(default_message_notifications) { + return this.updateGuild({ default_message_notifications }); + } + + /** + * Updates this guild's explicit content filter level. + * @param {Number} explicitContentFilter The new explicit content filter level (0: disabled, 1: members without roles, 2: everyone) + * @return {Promise} + */ + updateExplicitContentFilter(explicit_content_filter) { + return this.updateGuild({ explicit_content_filter }); + } + + /** + * Updates this guild's AFK channel. + * @param {GuildVoiceChannel} afkChannel The new AFK channel + * @return {Promise} + */ + updateAfkChannel(afk_channel) { + return this.updateGuild({ afk_channel_id: afk_channel.id || afk_channel }); + } + + /** + * Updates this guild's AFK timeout. + * @param {Number} afkTimeout The new AFK timeout + * @return {Promise} + */ + updateAfkTimeout(afk_timeout) { + return this.updateGuild({ afk_timeout }); + } + + /** + * Updates this guild's icon. + * @param {Buffer|String} icon A buffer/base 64 encoded 128x128 JPEG image + * @return {Promise} + */ + updateIcon(icon) { + return this.updateGuild({ icon: typeof icon === 'string' ? icon : icon.toString('base64') }); + } + + /** + * Updates this guild's icon using a local file. + * TODO + * @param {String} icon_path The path to the new icon + * @return {Promise} + */ + async updateIconFromFile(icon_path) { + const buffer = await FileUtils.readFileBuffer(icon_path); + return this.updateIcon(buffer); + } + + /** + * Updates this guild's owner. (Should plugins really ever need to do this?) + * @param {User|GuildMember} owner The user/guild member to transfer ownership to + * @return {Promise} + */ + updateOwner(owner) { + return this.updateGuild({ owner_id: owner.user ? owner.user.id : owner.id || owner }); + } + + /** + * Updates this guild's splash image. + * (I don't know what this is actually used for. The API documentation says it's VIP-only.) + * @param {Buffer|String} icon A buffer/base 64 encoded 128x128 JPEG image + * @return {Promise} + */ + updateSplash(splash) { + return this.updateGuild({ splash: typeof splash === 'string' ? splash : splash.toString('base64') }); + } + + /** + * Updates this guild's splash image using a local file. + * TODO + * @param {String} splash_path The path to the new splash + * @return {Promise} + */ + async updateSplashFromFile(splash_path) { + const buffer = await FileUtils.readFileBuffer(splash_path); + return this.updateSplash(buffer); + } + + /** + * Updates this guild's system channel. + * @param {GuildTextChannel} systemChannel The new system channel + * @return {Promise} + */ + updateSystemChannel(system_channel) { + return this.updateGuild({ system_channel_id: system_channel.id || system_channel }); + } + +} diff --git a/client/src/structs/discord/message.js b/client/src/structs/discord/message.js new file mode 100644 index 00000000..7c2206b8 --- /dev/null +++ b/client/src/structs/discord/message.js @@ -0,0 +1,306 @@ +/** + * BetterDiscord Message Struct + * 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 { DiscordApi, DiscordApiModules as Modules } from 'modules'; +import { List, InsufficientPermissions } from 'structs'; +import { Channel } from './channel'; +import { User } from './user'; + +const reactions = new WeakMap(); + +export class Reaction { + constructor(data, message_id, channel_id) { + if (reactions.has(data)) return reactions.get(data); + reactions.set(data, this); + + this.discordObject = data; + this.messageId = message_id; + this.channelId = channel_id; + } + + get emoji() { + const id = this.discordObject.emoji.id; + if (!id || !this.guild) return this.discordObject.emoji; + return this.guild.emojis.find(e => e.id === id); + } + + get count() { return this.discordObject.count } + get me() { return this.discordObject.me } + + get channel() { + return Channel.fromId(this.channel_id); + } + + get message() { + if (this.channel) return this.channel.messages.find(m => m.id === this.messageId); + } + + get guild() { + if (this.channel) return this.channel.guild; + } +} + +const embeds = new WeakMap(); + +export class Embed { + constructor(data, message_id, channel_id) { + if (embeds.has(data)) return embeds.get(data); + embeds.set(data, this); + + this.discordObject = data; + this.messageId = message_id; + this.channelId = channel_id; + } + + get title() { return this.discordObject.title } + get type() { return this.discordObject.type } + get description() { return this.discordObject.description } + get url() { return this.discordObject.url } + get timestamp() { return this.discordObject.timestamp } + get colour() { return this.discordObject.color } + get footer() { return this.discordObject.footer } + get image() { return this.discordObject.image } + get thumbnail() { return this.discordObject.thumbnail } + get video() { return this.discordObject.video } + get provider() { return this.discordObject.provider } + get author() { return this.discordObject.author } + get fields() { return this.discordObject.fields } + + get channel() { + return Channel.fromId(this.channelId); + } + + get message() { + if (this.channel) return this.channel.messages.find(m => m.id === this.messageId); + } + + get guild() { + if (this.channel) return this.channel.guild; + } +} + +const messages = new WeakMap(); + +export class Message { + + constructor(data) { + if (messages.has(data)) return messages.get(data); + messages.set(data, this); + + this.discordObject = data; + } + + static from(data) { + switch (data.type) { + default: return new Message(data); + case 0: return new DefaultMessage(data); + case 1: return new RecipientAddMessage(data); + case 2: return new RecipientRemoveMessage(data); + case 3: return new CallMessage(data); + case 4: return new GroupChannelNameChangeMessage(data); + case 5: return new GroupChannelIconChangeMessage(data); + case 6: return new MessagePinnedMessage(data); + case 7: return new GuildMemberJoinMessage(data); + } + } + + static get DefaultMessage() { return DefaultMessage } + static get RecipientAddMessage() { return RecipientAddMessage } + static get RecipientRemoveMessage() { return RecipientRemoveMessage } + static get CallMessage() { return CallMessage } + static get GroupChannelNameChangeMessage() { return GroupChannelNameChangeMessage } + static get GroupChannelIconChangeMessage() { return GroupChannelIconChangeMessage } + static get MessagePinnedMessage() { return MessagePinnedMessage } + static get GuildMemberJoinMessage() { return GuildMemberJoinMessage } + + static get Reaction() { return Reaction } + static get Embed() { return Embed } + + get id() { return this.discordObject.id } + get channelId() { return this.discordObject.channel_id } + get nonce() { return this.discordObject.nonce } + get type() { return this.discordObject.type } + get timestamp() { return this.discordObject.timestamp } + get state() { return this.discordObject.state } + get nick() { return this.discordObject.nick } + get colourString() { return this.discordObject.colorString } + + get author() { + if (this.discordObject.author && !this.webhookId) return User.from(this.discordObject.author); + } + + get channel() { + return Channel.fromId(this.channelId); + } + + get guild() { + if (this.channel) return this.channel.guild; + } + + /** + * Deletes the message. + * @return {Promise} + */ + delete() { + if (!this.isDeletable) throw new Error(`Message type ${this.type} is not deletable.`); + if (this.author === DiscordApi.currentUser) {} + else if (this.channel.assertPermissions) this.channel.assertPermissions('MANAGE_MESSAGES', Modules.DiscordPermissions.MANAGE_MESSAGES); + else if (!this.channel.owner === DiscordApi.currentUser) throw new InsufficientPermissions('MANAGE_MESSAGES'); + + return Modules.APIModule.delete(`${Modules.DiscordConstants.Endpoints.MESSAGES(this.channelId)}/${this.id}`); + } + + get isDeletable() { + return this.type === 'DEFAULT' || this.type === 'CHANNEL_PINNED_MESSAGE' || this.type === 'GUILD_MEMBER_JOIN'; + } + + /** + * Jumps to the message. + */ + jumpTo(flash = true) { + Modules.MessageActions.jumpToMessage(this.channelId, this.id, flash); + } + +} + +export class DefaultMessage extends Message { + get webhookId() { return this.discordObject.webhookId } + get type() { return 'DEFAULT' } + get content() { return this.discordObject.content } + get contentParsed() { return this.discordObject.contentParsed } + get inviteCodes() { return this.discordObject.invites } + get attachments() { return this.discordObject.attachments } + get mentionIds() { return this.discordObject.mentions } + get mentionRoleIds() { return this.discordObject.mentionRoles } + get mentionEveryone() { return this.discordObject.mentionEveryone } + get editedTimestamp() { return this.discordObject.editedTimestamp } + get tts() { return this.discordObject.tts } + get mentioned() { return this.discordObject.mentioned } + get bot() { return this.discordObject.bot } + get blocked() { return this.discordObject.blocked } + get pinned() { return this.discordObject.pinned } + get activity() { return this.discordObject.activity } + get application() { return this.discordObject.application } + + get webhook() { + if (this.webhookId) return this.discordObject.author; + } + + get mentions() { + return List.from(this.mentionIds, id => User.fromId(id)); + } + + get mention_roles() { + return List.from(this.mentionRoleIds, id => this.guild.roles.find(r => r.id === id)); + } + + get embeds() { + return List.from(this.discordObject.embeds, r => new Embed(r, this.id, this.channelId)); + } + + get reactions() { + return List.from(this.discordObject.reactions, r => new Reaction(r, this.id, this.channelId)); + } + + get edited() { + return !!this.editedTimestamp; + } + + /** + * Programmatically update the message's content. + * @param {String} content The message's new content + * @param {Boolean} parse Whether to parse the message or update it as it is + * @return {Promise} + */ + async edit(content, parse = false) { + if (this.author !== DiscordApi.currentUser) throw new Error('Cannot edit messages sent by other users.'); + if (parse) content = Modules.MessageParser.parse(this.discordObject, content); + else content = {content}; + + const response = await Modules.APIModule.patch({ + url: `${Modules.DiscordConstants.Endpoints.MESSAGES(this.channelId)}/${this.id}`, + body: content + }); + + this.discordObject = Modules.MessageStore.getMessage(this.id, response.body.id); + messages.set(this.discordObject, this); + } + + /** + * Start the edit mode of the UI. + * @param {String} content A string to show in the message text area - if empty the message's current content will be used + */ + startEdit(content) { + if (this.author !== DiscordApi.currentUser) throw new Error('Cannot edit messages sent by other users.'); + Modules.MessageActions.startEditMessage(this.channelId, this.id, content || this.content); + } + + /** + * Exit the edit mode of the UI. + */ + endEdit() { + Modules.MessageActions.endEditMessage(); + } +} + +export class RecipientAddMessage extends Message { + get type() { return 'RECIPIENT_ADD' } + get addedUserId() { return this.discordObject.mentions[0] } + + get addedUser() { + return User.fromId(this.addedUserId); + } +} + +export class RecipientRemoveMessage extends Message { + get type() { return 'RECIPIENT_REMOVE' } + get removedUserId() { return this.discordObject.mentions[0] } + + get removedUser() { + return User.fromId(this.removedUserId); + } + + get userLeft() { + return this.author === this.removedUser; + } +} + +export class CallMessage extends Message { + get type() { return 'CALL' } + get mentionIds() { return this.discordObject.mentions } + get call() { return this.discordObject.call } + + get endedTimestamp() { return this.call.endedTimestamp } + + get mentions() { + return List.from(this.mentionIds, id => User.fromId(id)); + } + + get participants() { + return List.from(this.call.participants, id => User.fromId(id)); + } +} + +export class GroupChannelNameChangeMessage extends Message { + get type() { return 'CHANNEL_NAME_CHANGE' } + get newName() { return this.discordObject.content } +} + +export class GroupChannelIconChangeMessage extends Message { + get type() { return 'CHANNEL_ICON_CHANGE' } +} + +export class MessagePinnedMessage extends Message { + get type() { return 'CHANNEL_PINNED_MESSAGE' } +} + +export class GuildMemberJoinMessage extends Message { + get type() { return 'GUILD_MEMBER_JOIN' } +} diff --git a/client/src/structs/discord/user.js b/client/src/structs/discord/user.js new file mode 100644 index 00000000..1254bdaf --- /dev/null +++ b/client/src/structs/discord/user.js @@ -0,0 +1,330 @@ +/** + * BetterDiscord User Struct + * 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 { DiscordApi, DiscordApiModules as Modules } from 'modules'; +import { List, InsufficientPermissions } from 'structs'; +import { Utils } from 'common'; +import { Guild } from './guild'; +import { Channel } from './channel'; + +const users = new WeakMap(); + +export class User { + + constructor(data) { + if (users.has(data)) return users.get(data); + users.set(data, this); + + this.discordObject = data; + } + + static from(data) { + return new User(data); + } + + static fromId(id) { + const user = Modules.UserStore.getUser(id); + if (user) return User.from(user); + } + + static get GuildMember() { return GuildMember } + + get id() { return this.discordObject.id } + get username() { return this.discordObject.username } + get usernameLowerCase() { return this.discordObject.usernameLowerCase } + get discriminator() { return this.discordObject.discriminator } + get avatar() { return this.discordObject.avatar } + get email() { return undefined } + get phone() { return undefined } + get flags() { return this.discordObject.flags } + get isBot() { return this.discordObject.bot } + get premium() { return this.discordObject.premium } + get verified() { return this.discordObject.verified } + get mfaEnabled() { return this.discordObject.mfaEnabled } + get mobile() { return this.discordObject.mobile } + + get tag() { return this.discordObject.tag } + get avatarUrl() { return this.discordObject.avatarURL } + get createdAt() { return this.discordObject.createdAt } + + get isClamied() { return this.discordObject.isClaimed() } + get isLocalBot() { return this.discordObject.isLocalBot() } + get isPhoneVerified() { return this.discordObject.isPhoneVerified() } + + get guilds() { + return DiscordApi.guilds.filter(g => g.members.find(m => m.user === this)); + } + + get status() { + return Modules.UserStatusStore.getStatus(this.id); + } + + get activity() { + // type can be either 0 (normal/rich presence game), 1 (streaming) or 2 (listening to Spotify) + // (3 appears as watching but is undocumented) + return Modules.UserStatusStore.getActivity(this.id); + } + + get note() { + const note = Modules.UserNoteStore.getNote(this.id); + if (note) return note; + } + + /** + * Updates the note for this user. + * @param {String} note The new note + * @return {Promise} + */ + updateNote(note) { + return Modules.APIModule.put({ + url: `${Modules.DiscordConstants.Endpoints.NOTES}/${this.id}`, + body: { note } + }); + } + + get privateChannel() { + return DiscordApi.channels.find(c => c.type === 'DM' && c.recipientId === this.id); + } + + async ensurePrivateChannel() { + if (DiscordApi.currentUser === this) + throw new Error('Cannot create a direct message channel to the current user.'); + return Channel.fromId(await Modules.PrivateChannelActions.ensurePrivateChannel(DiscordApi.currentUser.id, this.id)); + } + + async sendMessage(content, parse = true) { + const channel = await this.ensurePrivateChannel(); + return channel.sendMessage(content, parse); + } + + get isFriend() { + return Modules.RelationshipStore.isFriend(this.id); + } + + get isBlocked() { + return Modules.RelationshipStore.isBlocked(this.id); + } + + addFriend() { + Modules.RelationshipManager.addRelationship(this.id, {location: 'Context Menu'}); + } + + removeFriend() { + Modules.RelationshipManager.removeRelationship(this.id, {location: 'Context Menu'}); + } + + block() { + Modules.RelationshipManager.addRelationship(this.id, {location: 'Context Menu'}, Modules.DiscordConstants.RelationshipTypes.BLOCKED); + } + + unblock() { + Modules.RelationshipManager.removeRelationship(this.id, {location: 'Context Menu'}); + } + + /** + * Opens the profile modal for this user. + * @param {String} section The section to open (see DiscordConstants.UserProfileSections) + */ + openUserProfileModal(section = 'USER_INFO') { + Modules.UserProfileModal.open(this.id); + Modules.UserProfileModal.setSection(section); + } + +} + +const guild_members = new WeakMap(); + +export class GuildMember { + constructor(data, guild_id) { + if (guild_members.has(data)) return guild_members.get(data); + guild_members.set(data, this); + + this.discordObject = data; + this.guildId = guild_id; + } + + get userId() { return this.discordObject.userId } + get nickname() { return this.discordObject.nick } + get colourString() { return this.discordObject.colorString } + get hoistRoleId() { return this.discordObject.hoistRoleId } + get roleIds() { return this.discordObject.roles } + + get user() { + return User.fromId(this.userId); + } + + get name() { + return this.nickname || this.user.username; + } + + get guild() { + return Guild.fromId(this.guildId); + } + + get roles() { + return List.from(this.roleIds, id => this.guild.roles.find(r => r.id === id)) + .sort((r1, r2) => r1.position === r2.position ? 0 : r1.position > r2.position ? 1 : -1); + } + + get hoistRole() { + return this.guild.roles.find(r => r.id === this.hoistRoleId); + } + + checkPermissions(perms) { + return Modules.PermissionUtils.can(perms, DiscordApi.currentUser.discordObject, this.guild.discordObject); + } + + assertPermissions(name, perms) { + if (!this.checkPermissions(perms)) throw new InsufficientPermissions(name); + } + + /** + * Opens the modal to change this user's nickname. + */ + openChangeNicknameModal() { + if (DiscordApi.currentUser === this.user) + this.assertPermissions('CHANGE_NICKNAME', Modules.DiscordPermissions.CHANGE_NICKNAME); + else this.assertPermissions('MANAGE_NICKNAMES', Modules.DiscordPermissions.MANAGE_NICKNAMES); + + Modules.ChangeNicknameModal.open(this.guildId, this.userId); + } + + /** + * Changes the user's nickname on this guild. + * @param {String} nickname The user's new nickname + * @return {Promise} + */ + changeNickname(nick) { + if (DiscordApi.currentUser === this.user) + this.assertPermissions('CHANGE_NICKNAME', Modules.DiscordPermissions.CHANGE_NICKNAME); + else this.assertPermissions('MANAGE_NICKNAMES', Modules.DiscordPermissions.MANAGE_NICKNAMES); + + return Modules.APIModule.patch({ + url: `${Modules.DiscordConstants.Endpoints.GUILD_MEMBERS(this.guild_id)}/${DiscordApi.currentUser === this.user ? '@me/nick' : this.userId}`, + body: { nick } + }); + } + + /** + * Kicks this user from the guild. + * @param {String} reason A reason to attach to the audit log entry + * @return {Promise} + */ + kick(reason = '') { + this.assertPermissions('KICK_MEMBERS', Modules.DiscordPermissions.KICK_MEMBERS); + return Modules.GuildActions.kickUser(this.guildId, this.userId, reason); + } + + /** + * Bans this user from the guild. + * @param {Number} daysToDelete The number of days of the user's recent message history to delete + * @param {String} reason A reason to attach to the audit log entry + * @return {Promise} + */ + ban(daysToDelete = 1, reason = '') { + this.assertPermissions('BAN_MEMBERS', Modules.DiscordPermissions.BAN_MEMBERS); + return Modules.GuildActions.banUser(this.guildId, this.userId, daysToDelete, reason); + } + + /** + * Removes the ban for this user. + * @return {Promise} + */ + unban() { + this.assertPermissions('BAN_MEMBERS', Modules.DiscordPermissions.BAN_MEMBERS); + return Modules.GuildActions.unbanUser(this.guildId, this.userId); + } + + /** + * Moves this user to another voice channel. + * @param {GuildVoiceChannel} channel The channel to move this user to + */ + move(channel) { + this.assertPermissions('MOVE_MEMBERS', Modules.DiscordPermissions.MOVE_MEMBERS); + Modules.GuildActions.setChannel(this.guildId, this.userId, channel.id); + } + + /** + * Mutes this user for everyone in the guild. + */ + mute(active = true) { + this.assertPermissions('MUTE_MEMBERS', Modules.DiscordPermissions.MUTE_MEMBERS); + Modules.GuildActions.setServerMute(this.guildId, this.userId, active); + } + + /** + * Unmutes this user. + */ + unmute() { + this.mute(false); + } + + /** + * Deafens this user. + */ + deafen(active = true) { + this.assertPermissions('DEAFEN_MEMBERS', Modules.DiscordPermissions.DEAFEN_MEMBERS); + Modules.GuildActions.setServerDeaf(this.guildId, this.userId, active); + } + + /** + * Undeafens this user. + */ + undeafen() { + this.deafen(false); + } + + /** + * Gives this user a role. + * @param {Role} role The role to add + * @return {Promise} + */ + addRole(...roles) { + const newRoles = this.roleIds.concat([]); + let changed = false; + for (let role of roles) { + if (newRoles.includes(role.id || role)) continue; + newRoles.push(role.id || role); + changed = true; + } + if (!changed) return; + return this.updateRoles(newRoles); + } + + /** + * Removes a role from this user. + * @param {Role} role The role to remove + * @return {Promise} + */ + removeRole(...roles) { + const newRoles = this.roleIds.concat([]); + let changed = false; + for (let role of roles) { + if (!newRoles.includes(role.id || role)) continue; + Utils.removeFromArray(newRoles, role.id || role); + changed = true; + } + if (!changed) return; + return this.updateRoles(newRoles); + } + + /** + * Updates this user's roles. + * @param {Array} roles An array of Role objects or role IDs + * @return {Promise} + */ + updateRoles(roles) { + roles = roles.map(r => r.id || r); + return Modules.APIModule.patch({ + url: `${Modules.DiscordConstants.Endpoints.GUILD_MEMBERS(this.guildId)}/${this.userId}`, + body: { roles } + }); + } +} diff --git a/client/src/structs/discordstructs.js b/client/src/structs/discordstructs.js new file mode 100644 index 00000000..1341ca20 --- /dev/null +++ b/client/src/structs/discordstructs.js @@ -0,0 +1,4 @@ +export * from './discord/user'; +export * from './discord/guild'; +export * from './discord/channel'; +export * from './discord/message'; diff --git a/client/src/structs/events/index.js b/client/src/structs/events/index.js index f10d1e35..5b9d7eb7 100644 --- a/client/src/structs/events/index.js +++ b/client/src/structs/events/index.js @@ -1,3 +1,4 @@ export { default as SettingUpdatedEvent } from './settingupdated'; export { default as SettingsUpdatedEvent } from './settingsupdated'; export { default as ErrorEvent } from './error'; +export { PermissionsError, InsufficientPermissions } from './permissionserror'; diff --git a/client/src/structs/events/permissionserror.js b/client/src/structs/events/permissionserror.js new file mode 100644 index 00000000..0ff95192 --- /dev/null +++ b/client/src/structs/events/permissionserror.js @@ -0,0 +1,25 @@ +/** + * BetterDiscord Permissions Error Struct + * 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 ErrorEvent from './error'; + +export class PermissionsError extends ErrorEvent { + constructor(message) { + super(message); + this.name = 'PermissionsError'; + } +} + +export class InsufficientPermissions extends PermissionsError { + constructor(message) { + super(`Missing Permission — ${message}`); + this.name = 'InsufficientPermissions'; + } +} diff --git a/client/src/structs/list.js b/client/src/structs/list.js new file mode 100644 index 00000000..157f1e9f --- /dev/null +++ b/client/src/structs/list.js @@ -0,0 +1,30 @@ +/** + * BetterDiscord List + * 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. +*/ + +export default class List extends Array { + + constructor() { + super(...arguments); + } + + get(...filters) { + return this.find(item => { + for (let filter of filters) { + for (let key in filter) { + if (filter.hasOwnProperty(key)) { + if (item[key] !== filter[key]) return false; + } + } + } + return true; + }); + } + +} diff --git a/client/src/structs/settings/multiplechoiceoption.js b/client/src/structs/settings/multiplechoiceoption.js index 3e583c91..217ab430 100644 --- a/client/src/structs/settings/multiplechoiceoption.js +++ b/client/src/structs/settings/multiplechoiceoption.js @@ -8,8 +8,6 @@ * LICENSE file in the root directory of this source tree. */ -import { Utils } from 'common'; - export default class MultipleChoiceOption { constructor(args) { diff --git a/client/src/structs/settings/setting.js b/client/src/structs/settings/setting.js index de2ffcc4..286706f0 100644 --- a/client/src/structs/settings/setting.js +++ b/client/src/structs/settings/setting.js @@ -8,8 +8,6 @@ * LICENSE file in the root directory of this source tree. */ -import { Utils } from 'common'; - import BoolSetting from './types/bool'; import StringSetting from './types/text'; import NumberSetting from './types/number'; diff --git a/client/src/structs/settings/settingscategory.js b/client/src/structs/settings/settingscategory.js index cf1c9453..33f8402d 100644 --- a/client/src/structs/settings/settingscategory.js +++ b/client/src/structs/settings/settingscategory.js @@ -8,10 +8,11 @@ * LICENSE file in the root directory of this source tree. */ +import { SettingUpdatedEvent, SettingsUpdatedEvent } from 'structs'; +import { ClientLogger as Logger, AsyncEventEmitter } from 'common'; import Setting from './setting'; import BaseSetting from './types/basesetting'; -import { ClientLogger as Logger, AsyncEventEmitter } from 'common'; -import { SettingUpdatedEvent, SettingsUpdatedEvent } from 'structs'; +import SettingsProxy from './settingsproxy'; export default class SettingsCategory extends AsyncEventEmitter { @@ -173,6 +174,14 @@ export default class SettingsCategory extends AsyncEventEmitter { await this.emit('removed-category', event); } + /** + * Returns a proxy which can be used to access the category's values like a normal object. + * @return {SettingsCategoryProxy} + */ + get proxy() { + return this._proxy || (this._proxy = SettingsProxy.createProxy(this)); + } + /** * Returns the first setting where calling {function} returns true. * @param {Function} function A function to call to filter settings diff --git a/client/src/structs/settings/settingsproxy.js b/client/src/structs/settings/settingsproxy.js new file mode 100644 index 00000000..d8de7720 --- /dev/null +++ b/client/src/structs/settings/settingsproxy.js @@ -0,0 +1,109 @@ +/** + * BetterDiscord Settings Proxy + * 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 SettingsSet from './settingsset'; +import SettingsCategory from './settingscategory'; + +const setHandler = { + get({ set }, category_id) { + const category = set.getCategory(category_id); + if (category) return SettingsProxy.createProxy(category); + + const setting = set.getSetting(category_id); + if (setting) return setting.value; + }, + set({ set }, category_id, values) { + const category = set.getCategory(category_id); + if (category) return category.merge(values); + + const setting = set.getSetting(category_id); + if (setting) return setting.value = values; + }, + has({ set }, category_id) { + const category = set.getCategory(category_id); + if (category) return true; + + const setting = set.getSetting(category_id); + if (setting) return true; + }, + getPrototypeOf({ set }) { + return SettingsSetProxy.prototype; + }, + setPrototypeOf({ set }) {}, + isExtensible({ set }) { + return false; + }, + preventExtensions({ set }) {}, + getOwnPropertyDescriptor({ set }, category_id) { + return { + value: setHandler.get({ set }, category_id), + writable: true, + enumerable: true, + configurable: true + }; + }, + defineProperty({ set }) {}, + deleteProperty({ set }) {}, + ownKeys({ set }) { + return set.categories.map(c => c.id); + } +}; + +const categoryHandler = { + get({ category }, setting_id) { + const setting = category.getSetting(setting_id); + if (setting) return setting.value; + }, + set({ category }, setting_id, value) { + const setting = category.getSetting(setting_id); + if (setting) return setting.value = value; + }, + has({ category }, setting_id) { + const setting = category.getSetting(setting_id); + if (setting) return true; + }, + getPrototypeOf({ category }) { + return SettingsCategoryProxy.prototype; + }, + setPrototypeOf({ category }) {}, + isExtensible({ category }) { + return false; + }, + preventExtensions({ category }) {}, + getOwnPropertyDescriptor({ category }, setting_id) { + return { + value: categoryHandler.get({ category }, setting_id), + writable: true, + enumerable: true, + configurable: true + }; + }, + defineProperty({ category }) {}, + deleteProperty({ category }) {}, + ownKeys({ category }) { + return category.settings.map(s => s.id); + } +}; + +export default class SettingsProxy { + + constructor(args) { + Object.assign(this, args); + } + + static createProxy(set) { + if (set instanceof SettingsSet) return new Proxy(new SettingsSetProxy({ set }), setHandler); + if (set instanceof SettingsCategory) return new Proxy(new SettingsCategoryProxy({ category: set }), categoryHandler); + } + +} + +export class SettingsSetProxy extends SettingsProxy {} +export class SettingsCategoryProxy extends SettingsProxy {} diff --git a/client/src/structs/settings/settingsset.js b/client/src/structs/settings/settingsset.js index fce98e9c..51aaba2c 100644 --- a/client/src/structs/settings/settingsset.js +++ b/client/src/structs/settings/settingsset.js @@ -8,11 +8,12 @@ * LICENSE file in the root directory of this source tree. */ -import SettingsCategory from './settingscategory'; -import SettingsScheme from './settingsscheme'; -import { ClientLogger as Logger, AsyncEventEmitter } from 'common'; import { SettingUpdatedEvent, SettingsUpdatedEvent } from 'structs'; import { Modals } from 'ui'; +import { ClientLogger as Logger, AsyncEventEmitter } from 'common'; +import SettingsCategory from './settingscategory'; +import SettingsScheme from './settingsscheme'; +import SettingsProxy from './settingsproxy'; export default class SettingsSet extends AsyncEventEmitter { @@ -245,6 +246,14 @@ export default class SettingsSet extends AsyncEventEmitter { }); } + /** + * Returns a proxy which can be used to access the set's categories like a normal object. + * @return {SettingsSetProxy} + */ + get proxy() { + return this._proxy || (this._proxy = SettingsProxy.createProxy(this)); + } + /** * Returns the first category where calling {function} returns true. * @param {Function} function A function to call to filter categories @@ -356,7 +365,7 @@ export default class SettingsSet extends AsyncEventEmitter { * Merges a set into this set without emitting events (and therefore synchronously). * This only exists for use by the constructor. */ - _merge(newSet, emit_multi = true) { + _merge(newSet) { let updatedSettings = []; // const categories = newSet instanceof Array ? newSet : newSet.settings; const categories = newSet && newSet.args ? newSet.args.settings : newSet ? newSet.settings : newSet; diff --git a/client/src/structs/settings/types/custom.js b/client/src/structs/settings/types/custom.js index a44bdb54..c632d53b 100644 --- a/client/src/structs/settings/types/custom.js +++ b/client/src/structs/settings/types/custom.js @@ -8,10 +8,9 @@ * LICENSE file in the root directory of this source tree. */ -import Setting from './basesetting'; -import SettingsCategory from '../settingscategory'; -import SettingsScheme from '../settingsscheme'; +import { Globals } from 'modules'; import path from 'path'; +import Setting from './basesetting'; export default class CustomSetting extends Setting { @@ -68,7 +67,7 @@ export default class CustomSetting extends Setting { * @param {String} classExport The name of a property of the file's exports that will be used (optional) */ setClass(class_file, class_export) { - const component = window.require(path.join(this.path, class_file)); + const component = Globals.require(path.join(this.path, class_file)); const setting_class = class_export ? component[class_export](CustomSetting) : component.default ? component.default(CustomSetting) : component(CustomSetting); if (!(setting_class.prototype instanceof CustomSetting)) diff --git a/client/src/structs/settings/types/keybind.js b/client/src/structs/settings/types/keybind.js index f98371c4..1a7984da 100644 --- a/client/src/structs/settings/types/keybind.js +++ b/client/src/structs/settings/types/keybind.js @@ -8,9 +8,11 @@ * LICENSE file in the root directory of this source tree. */ -import Setting from './basesetting'; import Combokeys from 'combokeys'; +import CombokeysGlobalBind from 'combokeys/plugins/global-bind'; +import Setting from './basesetting'; +const instances = new Set(); let keybindsPaused = false; export default class KeybindSetting extends Setting { @@ -18,10 +20,23 @@ export default class KeybindSetting extends Setting { constructor(args, ...merge) { super(args, ...merge); + // When adding a keybind-activated listener, add the keybind setting to the set of active keybind settings + // This creates a reference to the keybind setting, which may cause memory leaks + this.on('newListener', ({event: [event, listener]}) => { + if (event === 'keybind-activated') instances.add(this); + }); + + // When there are no more keybind-activated listeners, remove the keybind setting from the set of active keybind settings + // Always remember to unbind keybind-activated listeners! + this.on('removeListener', ({event: [event, listener]}) => { + if (!this.listenerCount('keybind-activated')) instances.delete(this); + }); + this.__keybind_activated = this.__keybind_activated.bind(this); - this.combokeys = new Combokeys(document); - this.combokeys.bind(this.value, this.__keybind_activated); + this.combokeys = new Combokeys(this); + CombokeysGlobalBind(this.combokeys); + this.combokeys.bindGlobal(this.value, this.__keybind_activated); } /** @@ -33,7 +48,7 @@ export default class KeybindSetting extends Setting { setValueHook() { this.combokeys.reset(); - this.combokeys.bind(this.value, this.__keybind_activated); + this.combokeys.bindGlobal(this.value, this.__keybind_activated); } __keybind_activated(event) { @@ -41,6 +56,22 @@ export default class KeybindSetting extends Setting { this.emit('keybind-activated', event); } + // Event function aliases for Combokeys + get addEventListener() { return this.on } + get removeEventListener() { return this.removeListener } + + static _init() { + document.addEventListener('keydown', this.__event_handler.bind(this, 'keydown')); + document.addEventListener('keyup', this.__event_handler.bind(this, 'keyup')); + document.addEventListener('keypress', this.__event_handler.bind(this, 'keypress')); + } + + static __event_handler(event, data) { + for (let keybindSetting of instances) { + keybindSetting.emit(event, data); + } + } + static get paused() { return keybindsPaused; } @@ -50,3 +81,5 @@ export default class KeybindSetting extends Setting { } } + +KeybindSetting._init(); diff --git a/client/src/structs/settings/types/radio.js b/client/src/structs/settings/types/radio.js index 1438de4e..cb4021a4 100644 --- a/client/src/structs/settings/types/radio.js +++ b/client/src/structs/settings/types/radio.js @@ -52,6 +52,28 @@ export default class RadioSetting extends Setting { this.args.value = selected_option.id; } + /** + * Whether the user should be allowed to choose multiple options. + */ + get multi() { + return this.args.multi; + } + + /** + * The minimum number of options the user may select if a multi select group. + * This only restricts deselecting options when there is less or equal options selected than this, and does not ensure that this number of options are actually selected. + */ + get min() { + return this.multi ? this.args.min || 0 : 1; + } + + /** + * The maximum number of options the user may select if a multi select group. + */ + get max() { + return this.multi ? this.args.max || 0 : 1; + } + /** * Returns a representation of this setting's value in SCSS. * @return {String} diff --git a/client/src/structs/structs.js b/client/src/structs/structs.js index b368dc11..22a0b84b 100644 --- a/client/src/structs/structs.js +++ b/client/src/structs/structs.js @@ -1,2 +1,4 @@ +export { default as List } from './list'; + export * from './events/index'; export * from './settings/index'; diff --git a/client/src/styles/index.scss b/client/src/styles/index.scss index a5a02b66..1bce97fa 100644 --- a/client/src/styles/index.scss +++ b/client/src/styles/index.scss @@ -1 +1 @@ -@import './partials/index.scss'; \ No newline at end of file +@import './partials/index.scss'; diff --git a/client/src/styles/partials/animations.scss b/client/src/styles/partials/animations.scss index e4597d42..1287340a 100644 --- a/client/src/styles/partials/animations.scss +++ b/client/src/styles/partials/animations.scss @@ -49,6 +49,30 @@ } } +@keyframes bd-toast-up { + 0% { + transform: translateY(10px); + opacity: 0; + } + + 100% { + transform: translateY(0%); + opacity: 1; + } +} + +@keyframes bd-toast-down { + 0% { + transform: translateY(0%); + opacity: 1; + } + + 100% { + transform: translateY(10px); + opacity: 0; + } +} + @keyframes bd-fade-out { 0% { opacity: 1; diff --git a/client/src/styles/partials/badges.scss b/client/src/styles/partials/badges.scss index 585fef64..5326fce7 100644 --- a/client/src/styles/partials/badges.scss +++ b/client/src/styles/partials/badges.scss @@ -18,50 +18,49 @@ background-size: cover; cursor: pointer; height: 16px; + width: 16px; margin-right: 6px; } .bd-profile-badge-developer, -.bd-profile-badge-contributor, -.bd-message-badge-developer, -.bd-message-badge-contributor { +.bd-profile-badge-webdev, +.bd-profile-badge-contributor { background-image: $logoSmallBw; - width: 16px; filter: brightness(10); - cursor: pointer; + + .theme-light [class*="topSectionNormal-"] .bd-profile-badges-profile-modal > &, + .theme-light :not(.bd-profile-badges-profile-modal) > & { + background-image: $logoSmallLight; + filter: none; + } } -.theme-light [class*="topSectionNormal-"] .bd-profile-badge-developer, -.theme-light [class*="topSectionNormal-"] .bd-profile-badge-contributor, -.theme-light .bd-message-badge-developer, -.theme-light .bd-message-badge-contributor { - background-image: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz48IURPQ1RZUEUgc3ZnIFBVQkxJQyAiLS8vVzNDLy9EVEQgU1ZHIDEuMS8vRU4iICJodHRwOi8vd3d3LnczLm9yZy9HcmFwaGljcy9TVkcvMS4xL0RURC9zdmcxMS5kdGQiPjxzdmcgdmVyc2lvbj0iMS4xIiBpZD0iQ2FscXVlXzEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4IiB2aWV3Qm94PSIwIDAgMjAwMCAyMDAwIiBlbmFibGUtYmFja2dyb3VuZD0ibmV3IDAgMCAyMDAwIDIwMDAiIHhtbDpzcGFjZT0icHJlc2VydmUiPjxnPjxwYXRoIGZpbGw9IiMzRTgyRTUiIGQ9Ik0xNDAyLjIsNjMxLjdjLTkuNy0zNTMuNC0yODYuMi00OTYtNjQyLjYtNDk2SDY4LjR2NzE0LjFsNDQyLDM5OFY0OTAuN2gyNTdjMjc0LjUsMCwyNzQuNSwzNDQuOSwwLDM0NC45SDU5Ny42djMyOS41aDE2OS44YzI3NC41LDAsMjc0LjUsMzQ0LjgsMCwzNDQuOGgtNjk5djM1NC45aDY5MS4yYzM1Ni4zLDAsNjMyLjgtMTQyLjYsNjQyLjYtNDk2YzAtMTYyLjYtNDQuNS0yODQuMS0xMjIuOS0zNjguNkMxMzU3LjcsOTE1LjgsMTQwMi4yLDc5NC4zLDE0MDIuMiw2MzEuN3oiLz48cGF0aCBmaWxsPSIjYmJiYmJiIiBkPSJNMTI2Mi41LDEzNS4yTDEyNjIuNSwxMzUuMmwtNzYuOCwwYzI2LjYsMTMuMyw1MS43LDI4LjEsNzUsNDQuM2M3MC43LDQ5LjEsMTI2LjEsMTExLjUsMTY0LjYsMTg1LjNjMzkuOSw3Ni42LDYxLjUsMTY1LjYsNjQuMywyNjQuNmwwLDEuMnYxLjJjMCwxNDEuMSwwLDU5Ni4xLDAsNzM3LjF2MS4ybDAsMS4yYy0yLjcsOTktMjQuMywxODgtNjQuMywyNjQuNmMtMzguNSw3My44LTkzLjgsMTM2LjItMTY0LjYsMTg1LjNjLTIyLjYsMTUuNy00Ni45LDMwLjEtNzIuNiw0My4xaDcyLjVjMzQ2LjIsMS45LDY3MS0xNzEuMiw2NzEtNTY3LjlWNzE2LjdDMTkzMy41LDMxMi4yLDE2MDguNywxMzUuMiwxMjYyLjUsMTM1LjJ6Ii8+PC9nPjwvc3ZnPg=='); - filter: none; -} - -.bd-message-badges-wrap { +.bd-profile-badges.bd-profile-badges-nametag { display: inline-block; margin-left: 6px; height: 11px; - .bd-message-badge-developer, - .bd-message-badge-contributor { + .bd-profile-badge { width: 12px; height: 12px; + + &:last-child { + margin-right: 0; + } } } -.member-username .bd-message-badges-wrap { - display: inline-block; - height: 17px; - width: 14px; - - .bd-message-badge-developer, - .bd-message-badge-contributor { - width: 14px; - height: 16px; - background-position: center; - background-size: 12px 12px; - background-repeat: no-repeat; - } -} +// .member-username .bd-profile-badges { +// display: inline-block; +// height: 17px; +// width: 14px; +// +// .bd-badge, +// .bd-badge-c { +// width: 14px; +// height: 16px; +// background-position: center; +// background-size: 12px 12px; +// background-repeat: no-repeat; +// } +// } diff --git a/client/src/styles/partials/bdsettings/card.scss b/client/src/styles/partials/bdsettings/card.scss index e997aa5f..aad5ac82 100644 --- a/client/src/styles/partials/bdsettings/card.scss +++ b/client/src/styles/partials/bdsettings/card.scss @@ -19,6 +19,7 @@ .bd-card-icon { width: 30px; height: 30px; + background-size: cover; } > span { diff --git a/client/src/styles/partials/bdsettings/index.scss b/client/src/styles/partials/bdsettings/index.scss index 8d498189..605c4b92 100644 --- a/client/src/styles/partials/bdsettings/index.scss +++ b/client/src/styles/partials/bdsettings/index.scss @@ -5,3 +5,4 @@ @import './tooltips.scss'; @import './settings-schemes.scss'; @import './updater.scss'; +@import './window-preferences'; diff --git a/client/src/styles/partials/bdsettings/window-preferences.scss b/client/src/styles/partials/bdsettings/window-preferences.scss new file mode 100644 index 00000000..67483288 --- /dev/null +++ b/client/src/styles/partials/bdsettings/window-preferences.scss @@ -0,0 +1,5 @@ +.bd-window-preferences { + .bd-window-preferences-disabled p { + color: #f6f6f7; + } +} diff --git a/client/src/styles/partials/discordoverrides.scss b/client/src/styles/partials/discordoverrides.scss index 43c0f3b2..6d051e20 100644 --- a/client/src/styles/partials/discordoverrides.scss +++ b/client/src/styles/partials/discordoverrides.scss @@ -1,4 +1,4 @@ -.guilds-wrapper { +[class*="guildsWrapper-"] { padding-top: 49px !important; .platform-osx & { @@ -6,11 +6,11 @@ } } -[class*="guilds-wrapper"] + [class*="flex"] { +[class*="guildsWrapper-"] + [class*="flex"] { border-radius: 0 0 0 5px; } -.unread-mentions-indicator-top { +[class*="unreadMentionsIndicatorTop-"] { top: 49px; .platform-osx & { @@ -19,6 +19,10 @@ } // Any layers need to be above the main layer (where the BD button is placed) -.layer-kosS71 + .layer-kosS71 { +[class*="layers-"] > * + * { z-index: 900; } + +.bd-settings-wrapper.platform-linux { + transform: none; +} diff --git a/client/src/styles/partials/emotes.scss b/client/src/styles/partials/emotes.scss index 1bfe491e..61559446 100644 --- a/client/src/styles/partials/emotes.scss +++ b/client/src/styles/partials/emotes.scss @@ -9,7 +9,7 @@ } .bd-emotewrapper { - display: flex; + display: inline-flex; max-height: 32px; img { diff --git a/client/src/styles/partials/generic/buttons.scss b/client/src/styles/partials/generic/buttons.scss index ceb9d6fc..cffdf1ce 100644 --- a/client/src/styles/partials/generic/buttons.scss +++ b/client/src/styles/partials/generic/buttons.scss @@ -61,13 +61,14 @@ .bd-button, .bd-material-button { - &:first-of-type { - border-radius: 6px 0 0 6px; + border-top-left-radius: 6px; + border-bottom-left-radius: 6px; } &:last-of-type { - border-radius: 0 6px 6px 0; + border-top-right-radius: 6px; + border-bottom-right-radius: 6px; } &:not(:last-of-type) { diff --git a/client/src/styles/partials/generic/drawers.scss b/client/src/styles/partials/generic/drawers.scss index cfe4fb94..d35cd5c6 100644 --- a/client/src/styles/partials/generic/drawers.scss +++ b/client/src/styles/partials/generic/drawers.scss @@ -33,7 +33,6 @@ } .bd-drawer-contents-wrap { - overflow: hidden; min-height: 5px; } @@ -50,7 +49,8 @@ } } - &.bd-animating { + &.bd-animating, + &:not(.bd-drawer-open) { > .bd-drawer-contents-wrap { overflow: hidden; } diff --git a/client/src/styles/partials/index.scss b/client/src/styles/partials/index.scss index 0adc254d..c45015c8 100644 --- a/client/src/styles/partials/index.scss +++ b/client/src/styles/partials/index.scss @@ -13,3 +13,4 @@ @import './helpers.scss'; @import './misc.scss'; @import './emotes.scss'; +@import './toasts.scss'; diff --git a/client/src/styles/partials/toasts.scss b/client/src/styles/partials/toasts.scss new file mode 100644 index 00000000..0281240b --- /dev/null +++ b/client/src/styles/partials/toasts.scss @@ -0,0 +1,68 @@ +.bd-toasts { + display: flex; + position: fixed; + top: 0; + width: 700px; + left: 50%; + transform: translateX(-50%); + bottom: 100px; + flex-direction: column; + align-items: center; + justify-content: flex-end; + pointer-events: none; + z-index: 4000; + + .bd-toast { + position: relative; + animation: bd-toast-up 300ms ease; + background: #36393F; + padding: 10px; + border-radius: 5px; + box-shadow: 0 0 0 1px rgba(32,34,37,.6), 0 2px 10px 0 rgba(0,0,0,.2); + font-weight: 500; + color: #fff; + user-select: text; + font-size: 14px; + margin-top: 10px; + + &.bd-toast-error { + background: #f04747; + } + + &.bd-toast-info { + background: #4a90e2; + } + + &.bd-toast-warning { + background: #FFA600; + } + + &.bd-toast-success { + background: #43b581; + } + + &.bd-toast-has-icon { + padding-left: 30px; + } + } + + .bd-toast-icon { + position: absolute; + left: 5px; + top: 50%; + transform: translateY(-50%); + bottom: 0; + height: 20px; + width: 20px; + border-radius: 50%; + overflow: hidden; + + svg { + fill: white; + } + } + + .bd-toast.bd-toast-closing { + animation: bd-toast-down 300ms ease; + } +} diff --git a/client/src/styles/partials/variables/images.scss b/client/src/styles/partials/variables/images.scss index 25e043fd..4a2f0b3f 100644 --- a/client/src/styles/partials/variables/images.scss +++ b/client/src/styles/partials/variables/images.scss @@ -1,3 +1,5 @@ $logoSmallBw: url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz48IURPQ1RZUEUgc3ZnIFBVQkxJQyAiLS8vVzNDLy9EVEQgU1ZHIDEuMS8vRU4iICJodHRwOi8vd3d3LnczLm9yZy9HcmFwaGljcy9TVkcvMS4xL0RURC9zdmcxMS5kdGQiPjxzdmcgdmVyc2lvbj0iMS4xIiBpZD0iQ2FscXVlXzEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4IiB2aWV3Qm94PSIwIDAgMjAwMCAyMDAwIiBlbmFibGUtYmFja2dyb3VuZD0ibmV3IDAgMCAyMDAwIDIwMDAiIHhtbDpzcGFjZT0icHJlc2VydmUiPjxnPjxwYXRoIGZpbGw9IiMzRTgyRTUiIGQ9Ik0xNDAyLjIsNjMxLjdjLTkuNy0zNTMuNC0yODYuMi00OTYtNjQyLjYtNDk2SDY4LjR2NzE0LjFsNDQyLDM5OFY0OTAuN2gyNTdjMjc0LjUsMCwyNzQuNSwzNDQuOSwwLDM0NC45SDU5Ny42djMyOS41aDE2OS44YzI3NC41LDAsMjc0LjUsMzQ0LjgsMCwzNDQuOGgtNjk5djM1NC45aDY5MS4yYzM1Ni4zLDAsNjMyLjgtMTQyLjYsNjQyLjYtNDk2YzAtMTYyLjYtNDQuNS0yODQuMS0xMjIuOS0zNjguNkMxMzU3LjcsOTE1LjgsMTQwMi4yLDc5NC4zLDE0MDIuMiw2MzEuN3oiLz48cGF0aCBmaWxsPSIjRkZGRkZGIiBkPSJNMTI2Mi41LDEzNS4yTDEyNjIuNSwxMzUuMmwtNzYuOCwwYzI2LjYsMTMuMyw1MS43LDI4LjEsNzUsNDQuM2M3MC43LDQ5LjEsMTI2LjEsMTExLjUsMTY0LjYsMTg1LjNjMzkuOSw3Ni42LDYxLjUsMTY1LjYsNjQuMywyNjQuNmwwLDEuMnYxLjJjMCwxNDEuMSwwLDU5Ni4xLDAsNzM3LjF2MS4ybDAsMS4yYy0yLjcsOTktMjQuMywxODgtNjQuMywyNjQuNmMtMzguNSw3My44LTkzLjgsMTM2LjItMTY0LjYsMTg1LjNjLTIyLjYsMTUuNy00Ni45LDMwLjEtNzIuNiw0My4xaDcyLjVjMzQ2LjIsMS45LDY3MS0xNzEuMiw2NzEtNTY3LjlWNzE2LjdDMTkzMy41LDMxMi4yLDE2MDguNywxMzUuMiwxMjYyLjUsMTM1LjJ6Ii8+PC9nPjwvc3ZnPg==); +$logoSmallLight: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz48IURPQ1RZUEUgc3ZnIFBVQkxJQyAiLS8vVzNDLy9EVEQgU1ZHIDEuMS8vRU4iICJodHRwOi8vd3d3LnczLm9yZy9HcmFwaGljcy9TVkcvMS4xL0RURC9zdmcxMS5kdGQiPjxzdmcgdmVyc2lvbj0iMS4xIiBpZD0iQ2FscXVlXzEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4IiB2aWV3Qm94PSIwIDAgMjAwMCAyMDAwIiBlbmFibGUtYmFja2dyb3VuZD0ibmV3IDAgMCAyMDAwIDIwMDAiIHhtbDpzcGFjZT0icHJlc2VydmUiPjxnPjxwYXRoIGZpbGw9IiMzRTgyRTUiIGQ9Ik0xNDAyLjIsNjMxLjdjLTkuNy0zNTMuNC0yODYuMi00OTYtNjQyLjYtNDk2SDY4LjR2NzE0LjFsNDQyLDM5OFY0OTAuN2gyNTdjMjc0LjUsMCwyNzQuNSwzNDQuOSwwLDM0NC45SDU5Ny42djMyOS41aDE2OS44YzI3NC41LDAsMjc0LjUsMzQ0LjgsMCwzNDQuOGgtNjk5djM1NC45aDY5MS4yYzM1Ni4zLDAsNjMyLjgtMTQyLjYsNjQyLjYtNDk2YzAtMTYyLjYtNDQuNS0yODQuMS0xMjIuOS0zNjguNkMxMzU3LjcsOTE1LjgsMTQwMi4yLDc5NC4zLDE0MDIuMiw2MzEuN3oiLz48cGF0aCBmaWxsPSIjYmJiYmJiIiBkPSJNMTI2Mi41LDEzNS4yTDEyNjIuNSwxMzUuMmwtNzYuOCwwYzI2LjYsMTMuMyw1MS43LDI4LjEsNzUsNDQuM2M3MC43LDQ5LjEsMTI2LjEsMTExLjUsMTY0LjYsMTg1LjNjMzkuOSw3Ni42LDYxLjUsMTY1LjYsNjQuMywyNjQuNmwwLDEuMnYxLjJjMCwxNDEuMSwwLDU5Ni4xLDAsNzM3LjF2MS4ybDAsMS4yYy0yLjcsOTktMjQuMywxODgtNjQuMywyNjQuNmMtMzguNSw3My44LTkzLjgsMTM2LjItMTY0LjYsMTg1LjNjLTIyLjYsMTUuNy00Ni45LDMwLjEtNzIuNiw0My4xaDcyLjVjMzQ2LjIsMS45LDY3MS0xNzEuMiw2NzEtNTY3LjlWNzE2LjdDMTkzMy41LDMxMi4yLDE2MDguNywxMzUuMiwxMjYyLjUsMTM1LjJ6Ii8+PC9nPjwvc3ZnPg=='); + $logoBigBw: url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz48IURPQ1RZUEUgc3ZnIFBVQkxJQyAiLS8vVzNDLy9EVEQgU1ZHIDEuMS8vRU4iICJodHRwOi8vd3d3LnczLm9yZy9HcmFwaGljcy9TVkcvMS4xL0RURC9zdmcxMS5kdGQiPjxzdmcgdmVyc2lvbj0iMS4xIiBpZD0iQ2FscXVlXzEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4IiB2aWV3Qm94PSIwIDAgMTk0MCA2NDAiIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgMCAwIDE5NDAgNjQwIiB4bWw6c3BhY2U9InByZXNlcnZlIj48Zz48cGF0aCBmaWxsPSIjRkZGRkZGIiBkPSJNMTU2LjEsMjczLjlIMjl2MTQyLjdsODQuNiw3Ni4yVjM1NC4yaDQ1LjJjMjguNywwLDQyLjksMTMuOCw0Mi45LDM2djEwNmMwLDIyLjItMTMuNCwzNy4xLTQyLjksMzcuMUgyOC42djgwLjdoMTI3YzY4LjEsMC40LDEzMi0zMy43LDEzMi0xMTEuN3YtMTE0QzI4OC4xLDMwOC43LDIyNC4yLDI3My45LDE1Ni4xLDI3My45TDE1Ni4xLDI3My45eiIvPjxwYXRoIGZpbGw9IiNGRkZGRkYiIGQ9Ik04MjEuOSw1MDIuM1YzODUuMmMwLTQyLjEsNzUuOC01MS43LDk4LjctOS42bDcwLTI4LjNjLTI3LjYtNjAuNS03Ny43LTc4LjEtMTE5LjQtNzguMWMtNjguMSwwLTEzNS41LDM5LjQtMTM1LjUsMTE1Ljl2MTE3LjFjMCw3Ny4zLDY3LjMsMTE1LjksMTMzLjksMTE1LjljNDIuOSwwLDk0LjEtMjEsMTIyLjUtNzYuMmwtNzUtMzQuNEM4OTguOCw1NTQuOCw4MjEuOSw1NDMuMyw4MjEuOSw1MDIuM0w4MjEuOSw1MDIuM3oiLz48cGF0aCBmaWxsPSIjRkZGRkZGIiBkPSJNNTkwLjQsNDAxLjNjLTI2LjQtNS43LTQ0LTE1LjMtNDUuMi0zMS44YzEuNS0zOS40LDYyLjQtNDAuOSw5OC0zLjFsNTYuMy00My4yYy0zNS4yLTQyLjktNzUtNTQuMy0xMTUuOS01NC4zYy02Mi40LDAtMTIyLjgsMzUuMi0xMjIuOCwxMDEuOGMwLDY0LjcsNDkuNyw5OS41LDEwNC41LDEwNy45YzI3LjksMy44LDU4LjksMTQuOSw1OC4yLDM0LjFjLTIuMywzNi40LTc3LjMsMzQuNC0xMTEuNC02LjlsLTU0LjMsNTAuOWMzMS44LDQwLjksNzUsNjEuNiwxMTUuNiw2MS42YzYyLjQsMCwxMzEuNi0zNiwxMzQuMy0xMDEuOEM3MTEuMyw0MzMuNSw2NTAuOSw0MTIuNCw1OTAuNCw0MDEuM0w1OTAuNCw0MDEuM3oiLz48cmVjdCB4PSIzMzQiIHk9IjI3My45IiBmaWxsPSIjRkZGRkZGIiB3aWR0aD0iODUuNyIgaGVpZ2h0PSIzMzkuOCIvPjxwYXRoIGZpbGw9IiNGRkZGRkYiIGQ9Ik0xNzc5LjMsMjczLjloLTEyN3YxNDIuN2w4NC42LDc2LjJWMzU0LjJoNDUuMmMyOC43LDAsNDIuOSwxMy44LDQyLjksMzZ2MTA2YzAsMjIuMi0xMy40LDM3LjEtNDIuOSwzNy4xaC0xMzAuMXY4MC43aDEyNy40YzY4LjEsMC40LDEzMi0zMy43LDEzMi0xMTEuN3YtMTE0QzE5MTEuNCwzMDguNywxODQ3LjUsMjczLjksMTc3OS4zLDI3My45TDE3NzkuMywyNzMuOXoiLz48cGF0aCBmaWxsPSIjRkZGRkZGIiBkPSJNMTE1NiwyNjkuM2MtNzAuNCwwLTE0MC40LDM4LjMtMTQwLjQsMTE2LjdWNTAyYzAsNzcuNyw3MC40LDExNi43LDE0MS4yLDExNi43YzcwLjQsMCwxNDAuNC0zOSwxNDAuNC0xMTYuN1YzODZDMTI5Ny4yLDMwNy45LDEyMjYuNCwyNjkuMywxMTU2LDI2OS4zeiBNMTIxMS4xLDUwMmMwLDI0LjUtMjcuNiwzNy4xLTU0LjcsMzcuMWMtMjcuNiwwLTU1LjEtMTEuOS01NS4xLTM3LjFWMzg2YzAtMjQuOSwyNi44LTM4LjMsNTMuNi0zOC4zYzI3LjksMCw1Ni4zLDExLjksNTYuMywzOC4zVjUwMnoiLz48cGF0aCBmaWxsPSIjRkZGRkZGIiBkPSJNMTUzOC4zLDQ5Ny40YzQwLjktMTMsNjYuNi00OC42LDY2LjYtMTExLjRjLTEuOS03OS42LTU2LjMtMTExLjctMTI2LjMtMTExLjdoLTEzNS44djMzOS44aDg2LjlWNTA2LjJoMTUuM2w3OC44LDEwNy45aDEwNy4xTDE1MzguMyw0OTcuNHogTTE0ODAuMSw0MzEuOWgtNTAuNXYtNzcuN2g1MC41QzE1MzQuMSwzNTQuMiwxNTM0LjEsNDMxLjksMTQ4MC4xLDQzMS45eiIvPjwvZz48Zz48cGF0aCBmaWxsPSIjM0U4MkU1IiBkPSJNMTAzLjMsNDMuM0gyOC42djc3LjFsNDcuNyw0M1Y4MS43aDI3LjhjMjkuNiwwLDI5LjYsMzcuMiwwLDM3LjJIODUuOHYzNS42aDE4LjNjMjkuNiwwLDI5LjYsMzcuMiwwLDM3LjJIMjguNlYyMzBoNzQuNmMzOC41LDAsNjguMy0xNS40LDY5LjQtNTMuNmMwLTE3LjYtNC44LTMwLjctMTMuMy0zOS44YzguNS05LjEsMTMuMy0yMi4yLDEzLjMtMzkuOEMxNzEuNiw1OC43LDE0MS44LDQzLjMsMTAzLjMsNDMuM3oiLz48cG9seWdvbiBmaWxsPSIjM0U4MkU1IiBwb2ludHM9IjM1OC43LDQzLjMgMzU4LjcsODEuNyA0MDYuOCw4MS43IDQwNi44LDIzMCA0NTQuNSwyMzAgNDU0LjUsODEuNyA1MjkuOCw4MS43IDUyOS44LDIzMCA1NzcuNiwyMzAgNTc3LjYsODEuNyA2MjUuNyw4MS43IDYyNS43LDQzLjMgIi8+PHBvbHlnb24gZmlsbD0iIzNFODJFNSIgcG9pbnRzPSIxOTcuNiw0My4zIDE5Ny42LDIzMCAzNDEuNywyMzAgMzQxLjcsMTkxLjcgMjQ1LjQsMTkxLjcgMjQ1LjQsMTYwLjUgMjk2LjUsMTYwLjUgMjk2LjUsMTEyLjggMjQ1LjQsMTEyLjggMjQ1LjQsODEuNyAzNDEuNyw4MS43IDM0MS43LDQzLjMgIi8+PHBvbHlnb24gZmlsbD0iIzNFODJFNSIgcG9pbnRzPSI2NDIuNCw0My4zIDY0Mi40LDIzMCA3ODYuNCwyMzAgNzg2LjQsMTkxLjcgNjkwLjEsMTkxLjcgNjkwLjEsMTYwLjUgNzQxLjMsMTYwLjUgNzQxLjMsMTEyLjggNjkwLjEsMTEyLjggNjkwLjEsODEuNyA3ODYuNCw4MS43IDc4Ni40LDQzLjMgIi8+PHBhdGggZmlsbD0iIzNFODJFNSIgZD0iTTkxOC42LDE2NmMyMi41LTcuMSwzNi41LTI2LjYsMzYuNS02MS4xYy0xLTQzLjYtMzAuOC02MS43LTY5LjItNjEuN2gtNzQuNVYyMzBIODU5di01OS4yaDguNGw0My4yLDU5LjJoNTguN0w5MTguNiwxNjZ6IE04ODYuNywxMzAuMkg4NTlWODEuN2gyNy43QzkxNi4zLDgxLjcsOTE2LjMsMTMwLjIsODg2LjcsMTMwLjJ6Ii8+PC9nPjwvc3ZnPg==); diff --git a/client/src/ui/automanip.js b/client/src/ui/automanip.js deleted file mode 100644 index b24e6784..00000000 --- a/client/src/ui/automanip.js +++ /dev/null @@ -1,196 +0,0 @@ -/** - * BetterDiscord Automated DOM Manipulations - * 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 { Events, WebpackModules, EventListener, DiscordApi, ReactComponents, Renderer } from 'modules'; -import { ClientLogger as Logger } from 'common'; -import Reflection from './reflection'; -import DOM from './dom'; -import VueInjector from './vueinjector'; -import EditedTimeStamp from './components/common/EditedTimestamp.vue'; -import Autocomplete from './components/common/Autocomplete.vue'; - -export default class extends EventListener { - - constructor(args) { - super(args); - } - - bindings() { - this.manipAll = this.manipAll.bind(this); - this.markupInjector = this.markupInjector.bind(this); - this.setIds = this.setIds.bind(this); - this.setMessageIds = this.setMessageIds.bind(this); - this.setUserIds = this.setUserIds.bind(this); - } - - get eventBindings() { - return [ - // { id: 'server-switch', callback: this.manipAll }, - // { id: 'channel-switch', callback: this.manipAll }, - // { id: 'discord:MESSAGE_CREATE', callback: this.markupInjector }, - // { id: 'discord:MESSAGE_UPDATE', callback: this.markupInjector }, - { id: 'gkh:keyup', callback: this.injectAutocomplete } - ]; - } - - manipAll() { - try { - this.appMount.setAttribute('guild-id', DiscordApi.currentGuild.id); - this.appMount.setAttribute('channel-id', DiscordApi.currentChannel.id); - this.setIds(); - this.makeMutable(); - } catch (err) { - Logger.err('AutoManip', err); - } - } - - markupInjector(e) { - if (!e.element) return; - this.setId(e.element); - const markup = e.element.querySelector('.markup:not(.mutable)'); - if (markup) this.injectMarkup(markup, this.cloneMarkup(markup), false); - } - - getEts(node) { - try { - const reh = Object.keys(node).find(k => k.startsWith('__reactInternalInstance')); - return node[reh].memoizedProps.children[node[reh].memoizedProps.children.length - 1].props.text; - } catch (err) { - return null; - } - } - - makeMutable() { - for (const el of document.querySelectorAll('.markup:not(.mutable)')) { - this.injectMarkup(el, this.cloneMarkup(el), false); - } - } - - cloneMarkup(node) { - const childNodes = [...node.childNodes]; - const clone = document.createElement('div'); - clone.className = 'markup mutable'; - const ets = this.getEts(node); - for (const [cni, cn] of childNodes.entries()) { - if (cn.nodeType !== Node.TEXT_NODE) { - if (cn.className.includes('edited')) continue; - } - clone.appendChild(cn.cloneNode(true)); - } - return { clone, ets } - } - - injectMarkup(sibling, markup, reinject) { - if (sibling.className && sibling.className.includes('mutable')) return; // Ignore trying to make mutable again - let cc = null; - for (const cn of sibling.parentElement.childNodes) { - if (cn.className && cn.className.includes('mutable')) cc = cn; - } - if (cc) sibling.parentElement.removeChild(cc); - if (markup === true) markup = this.cloneMarkup(sibling); - - sibling.parentElement.insertBefore(markup.clone, sibling); - sibling.classList.add('shadow'); - sibling.style.display = 'none'; - if (markup.ets) { - const etsRoot = document.createElement('span'); - markup.clone.appendChild(etsRoot); - VueInjector.inject(etsRoot, { - components: { EditedTimeStamp }, - data: { ets: markup.ets }, - template: '' - }); - } - - Events.emit('ui:mutable:.markup', markup.clone); - } - - setIds() { - this.setMessageIds(); - this.setUserIds(); - this.setChannelIds(); - } - - setMessageIds() { - for (let msg of document.querySelectorAll('.message')) { - this.setId(msg); - } - } - - setUserIds() { - for (let user of document.querySelectorAll('.channel-members-wrap .member, .channel-members-wrap .member-2FrNV0')) { - this.setUserId(user); - } - } - - setChannelIds() { - for (let channel of document.querySelectorAll('[class*=channels] [class*=containerDefault]')) { - this.setChannelId(channel); - } - } - - setId(msg) { - if (msg.hasAttribute('message-id')) return; - const messageid = Reflection(msg).prop('message.id'); - const authorid = Reflection(msg).prop('message.author.id'); - if (!messageid || !authorid) { - const msgGroup = msg.closest('.message-group'); - if (!msgGroup) return; - const userTest = Reflection(msgGroup).prop('user'); - if (!userTest) return; - msgGroup.setAttribute('data-author-id', userTest.id); - if (userTest.id === DiscordApi.currentUserId) msgGroup.setAttribute('data-currentuser', true); - return; - } - msg.setAttribute('data-message-id', messageid); - const msgGroup = msg.closest('.message-group'); - if (!msgGroup) return; - msgGroup.setAttribute('data-author-id', authorid); - if (authorid === DiscordApi.currentUser.id) msgGroup.setAttribute('data-currentuser', true); - } - - setUserId(user) { - if (user.hasAttribute('data-user-id')) return; - const userid = Reflection(user).prop('user.id'); - if (!userid) return; - user.setAttribute('data-user-id', userid); - const currentUser = userid === DiscordApi.currentUser.id; - if (currentUser) user.setAttribute('data-currentuser', true); - Events.emit('ui:useridset', user); - } - - setChannelId(channel) { - if (channel.hasAttribute('data-channel-id')) return; - const channelObj = Reflection(channel).prop('channel'); - if (!channelObj) return; - channel.setAttribute('data-channel-id', channelObj.id); - if (channelObj.nsfw) channel.setAttribute('data-channel-nsfw', true); - if (channelObj.type && channelObj.type === 2) channel.setAttribute('data-channel-voice', true); - } - - get appMount() { - return document.getElementById('app-mount'); - } - - injectAutocomplete(e) { - if (document.querySelector('.bd-autocomplete')) return; - if (!e.target.closest('[class*=channelTextArea]')) return; - const root = document.createElement('span'); - const parent = document.querySelector('[class*="channelTextArea"] > [class*="inner"]'); - if (!parent) return; - parent.parentElement.insertBefore(root, parent); - VueInjector.inject(root, { - components: { Autocomplete }, - data: { initial: e.target.value }, - template: '' - }); - } - -} diff --git a/client/src/ui/bdmenu.js b/client/src/ui/bdmenu.js index 01bee667..0d58adfb 100644 --- a/client/src/ui/bdmenu.js +++ b/client/src/ui/bdmenu.js @@ -31,8 +31,6 @@ let items = 0; export const BdMenuItems = new class { constructor() { - window.bdmenu = this; - this.items = []; const updater = this.add({category: 'Updates', contentid: 'updater', text: 'Updates available!', hidden: true}); diff --git a/client/src/ui/bdui.js b/client/src/ui/bdui.js index df3ff80a..4cf25833 100644 --- a/client/src/ui/bdui.js +++ b/client/src/ui/bdui.js @@ -8,13 +8,11 @@ * LICENSE file in the root directory of this source tree. */ -import { Events, WebpackModules, DiscordApi, MonkeyPatch } from 'modules'; -import { Utils } from 'common'; +import { Events, DiscordApi } from 'modules'; import { remote } from 'electron'; import DOM from './dom'; import Vue from './vue'; -import AutoManip from './automanip'; -import { BdSettingsWrapper, BdModals } from './components'; +import { BdSettingsWrapper, BdModals, BdToasts } from './components'; export default class { @@ -25,50 +23,35 @@ export default class { channel: DiscordApi.currentChannel }; - window.addEventListener('keyup', e => Events.emit('gkh:keyup', e)); - this.autoManip = new AutoManip(); + remote.getCurrentWindow().webContents.on('did-navigate-in-page', (e, url, isMainFrame) => { + const { currentGuild, currentChannel } = DiscordApi; - const ehookInterval = setInterval(() => { - if (!remote.BrowserWindow.getFocusedWindow()) return; - clearInterval(ehookInterval); - remote.BrowserWindow.getFocusedWindow().webContents.on('did-navigate-in-page', (e, url, isMainFrame) => { - const { currentGuild, currentChannel } = DiscordApi; + if (!this.pathCache.server) + Events.emit('server-switch', { server: currentGuild, channel: currentChannel }); + else if (!this.pathCache.channel) + Events.emit('channel-switch', currentChannel); + else if (currentGuild && currentGuild.id && this.pathCache.server && this.pathCache.server.id !== currentGuild.id) + Events.emit('server-switch', { server: currentGuild, channel: currentChannel }); + else if (currentChannel && currentChannel.id && this.pathCache.channel && this.pathCache.channel.id !== currentChannel.id) + Events.emit('channel-switch', currentChannel); - if (!this.pathCache.server) { - Events.emit('server-switch', { server: currentGuild, channel: currentChannel }); - this.pathCache.server = currentGuild; - this.pathCache.channel = currentChannel; - return; - } - - if (!this.pathCache.channel) { - Events.emit('channel-switch', currentChannel); - this.pathCache.server = currentGuild; - this.pathCache.channel = currentChannel; - return; - } - - if (currentGuild && currentGuild.id && this.pathCache.server && this.pathCache.server.id !== currentGuild.id) { - Events.emit('server-switch', { server: currentGuild, channel: currentChannel }); - this.pathCache.server = currentGuild; - this.pathCache.channel = currentChannel; - return; - } - - if (currentChannel && currentChannel.id && this.pathCache.channel && this.pathCache.channel.id !== currentChannel.id) - Events.emit('channel-switch', currentChannel); - - this.pathCache.server = currentGuild; - this.pathCache.channel = currentChannel; - }); - }, 100); + this.pathCache.server = currentGuild; + this.pathCache.channel = currentChannel; + }); } static injectUi() { DOM.createElement('div', null, 'bd-settings').appendTo(DOM.bdBody); DOM.createElement('div', null, 'bd-modals').appendTo(DOM.bdModals); + DOM.createElement('div', null, 'bd-toasts').appendTo(DOM.bdToasts); DOM.createElement('bd-tooltips').appendTo(DOM.bdBody); + this.toasts = new Vue({ + el: '#bd-toasts', + components: { BdToasts }, + template: '' + }); + this.modals = new Vue({ el: '#bd-modals', components: { BdModals }, diff --git a/client/src/ui/classnormaliser.js b/client/src/ui/classnormaliser.js new file mode 100644 index 00000000..a49263b4 --- /dev/null +++ b/client/src/ui/classnormaliser.js @@ -0,0 +1,47 @@ +/** + * BetterDiscord Class Normaliser + * 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 { Module, WebpackModules } from 'modules'; + +export default class ClassNormaliser extends Module { + + init() { + this.patchClassModules(WebpackModules.getModule(this.moduleFilter, false)); + } + + patchClassModules(modules) { + for (let module of modules) { + this.patchClassModule('da', module); + } + } + + moduleFilter(module) { + if (typeof module !== 'object' || Array.isArray(module)) return false; + if (Array.isArray(module)) return false; + if (module.__esModule) return false; + if (!Object.keys(module).length) return false; + for (let baseClassName in module) { + if (typeof module[baseClassName] !== 'string') return false; + if (module[baseClassName].split('-').length === 1) return false; + const alphaNumeric = module[baseClassName].split(/-(.+)/)[1].split(' ')[0]; + if (alphaNumeric.length !== 6) return false; + } + + return true; + } + + patchClassModule(componentName, classNames) { + for (let baseClassName in classNames) { + const normalised = baseClassName.split('-')[0].replace(/[A-Z]/g, m => `-${m}`).toLowerCase(); + classNames[baseClassName] += ` ${componentName}-${normalised}`; + } + } + +} diff --git a/client/src/ui/commoncomponents.js b/client/src/ui/commoncomponents.js new file mode 100644 index 00000000..0bb9ee28 --- /dev/null +++ b/client/src/ui/commoncomponents.js @@ -0,0 +1,10 @@ +export { ReactComponent } from './vue'; + +export * from './components/common'; + +export { default as SettingsWrapper } from './components/bd/SettingsWrapper.vue'; +export { default as SettingsPanel } from './components/bd/SettingsPanel.vue'; +export { default as Setting } from './components/bd/setting/Setting.vue'; +export { default as Card } from './components/bd/Card.vue'; +export { default as ContentAuthor } from './components/bd/ContentAuthor.vue'; +export { default as BdBadge } from './components/bd/BdBadge.vue'; diff --git a/client/src/ui/components/BdModals.vue b/client/src/ui/components/BdModals.vue index 78e04f3b..d7c76192 100644 --- a/client/src/ui/components/BdModals.vue +++ b/client/src/ui/components/BdModals.vue @@ -47,9 +47,12 @@ Events.on('bd-refresh-modals', this.eventListener = () => { this.$forceUpdate(); }); + + window.addEventListener('keyup', this.keyupListener); }, destroyed() { if (this.eventListener) Events.off('bd-refresh-modals', this.eventListener); + window.removeEventListener('keyup', this.keyupListener); }, methods: { closeModal(modal) { @@ -57,6 +60,10 @@ }, downscale(index, times) { return 1 - ((this.modals.stack.filter(m => !m.closing).length - index) * times); + }, + keyupListener(e) { + if (this.modals.stack.length && e.which === 27) + this.modals.closeLast(e.shiftKey); } } } diff --git a/client/src/ui/components/BdSettings.vue b/client/src/ui/components/BdSettings.vue index 5f09d7d6..1c190585 100644 --- a/client/src/ui/components/BdSettings.vue +++ b/client/src/ui/components/BdSettings.vue @@ -75,7 +75,8 @@ first: true, Settings, timeout: null, - SettingsWrapper + SettingsWrapper, + openMenuHandler: null }; }, props: ['active'], @@ -101,7 +102,8 @@ methods: { itemOnClick(id) { if (this.animating || id === this.activeIndex) return; - if (this.activeIndex >= 0) this.sidebarItems.find(item => item.id === this.activeIndex).active = false; + const activeItem = this.sidebarItems.find(item => item.id === this.activeIndex); + if (activeItem) activeItem.active = false; this.sidebarItems.find(item => item.id === id).active = true; this.animating = true; this.lastActiveIndex = this.activeIndex; @@ -153,7 +155,10 @@ } }, created() { - Events.on('bd-open-menu', item => item && this.itemOnClick(this.sidebarItems.find(i => i === item || i.id === item || i.contentid === item || i.set === item).id)); + Events.on('bd-open-menu', this.openMenuHandler = item => item && this.itemOnClick(this.sidebarItems.find(i => i === item || i.id === item || i.contentid === item || i.set === item).id)); + }, + destroyed() { + if (this.openMenuHandler) Events.off('bd-open-menu', this.openMenuHandler); } } diff --git a/client/src/ui/components/BdSettingsWrapper.vue b/client/src/ui/components/BdSettingsWrapper.vue index 1b636769..196639a4 100644 --- a/client/src/ui/components/BdSettingsWrapper.vue +++ b/client/src/ui/components/BdSettingsWrapper.vue @@ -23,6 +23,7 @@ // Imports import { Events, Settings } from 'modules'; import { Modals } from 'ui'; + import process from 'process'; import BdSettings from './BdSettings.vue'; export default { @@ -33,7 +34,9 @@ active: false, animating: false, timeout: null, - platform: global.process.platform + platform: process.platform, + eventHandlers: {}, + keybindHandler: null }; }, components: { @@ -65,21 +68,29 @@ } }, created() { - Events.on('ready', e => this.loaded = true); - Events.on('bd-open-menu', item => this.active = true); - Events.on('bd-close-menu', () => this.active = false); - Events.on('update-check-start', e => this.updating = 0); - Events.on('update-check-end', e => this.updating = 1); - Events.on('updates-available', e => this.updating = 2); + Events.on('ready', this.eventHandlers.ready = e => this.loaded = true); + Events.on('bd-open-menu', this.eventHandlers['bd-open-menu'] = item => this.active = true); + Events.on('bd-close-menu', this.eventHandlers['bd-close-menu'] = () => this.active = false); + Events.on('update-check-start', this.eventHandlers['update-check-start'] = e => this.updating = 0); + Events.on('update-check-end', this.eventHandlers['update-check-end'] = e => this.updating = 1); + Events.on('updates-available', this.eventHandlers['updates-available'] = e => this.updating = 2); + window.addEventListener('keyup', this.keyupListener); window.addEventListener('keydown', this.prevent, true); const menuKeybind = Settings.getSetting('core', 'default', 'menu-keybind'); - menuKeybind.on('keybind-activated', () => this.active = !this.active); + menuKeybind.on('keybind-activated', this.keybindHandler = () => this.active = !this.active); }, destroyed() { + for (let event in this.eventHandlers) Events.off(event, this.eventHandlers[event]); + window.removeEventListener('keyup', this.keyupListener); window.removeEventListener('keydown', this.prevent); + + if (this.keybindHandler) { + const menuKeybind = Settings.getSetting('core', 'default', 'menu-keybind'); + menuKeybind.removeListener('keybind-activated', this.keybindHandler = () => this.active = !this.active); + } } } diff --git a/client/src/ui/components/BdToasts.vue b/client/src/ui/components/BdToasts.vue new file mode 100644 index 00000000..87397ea9 --- /dev/null +++ b/client/src/ui/components/BdToasts.vue @@ -0,0 +1,32 @@ +/** + * BetterDiscord Toasts 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. +*/ + + + + diff --git a/client/src/ui/components/bd/BdBadge.vue b/client/src/ui/components/bd/BdBadge.vue index 1d71284f..266ee6de 100644 --- a/client/src/ui/components/bd/BdBadge.vue +++ b/client/src/ui/components/bd/BdBadge.vue @@ -9,12 +9,10 @@ */ @@ -23,12 +21,12 @@ import { shell } from 'electron'; export default { - props: ['webdev', 'developer', 'contributor'], + props: ['contributor', 'type'], methods: { click() { - if (this.developer) return shell.openExternal('https://github.com/JsSucks/BetterDiscordApp'); - if (this.webdev) return shell.openExternal('https://betterdiscord.net'); - if (this.contributor) return shell.openExternal('https://github.com/JsSucks/BetterDiscordApp/graphs/contributors'); + if (this.contributor.developer) return shell.openExternal('https://github.com/JsSucks/BetterDiscordApp'); + if (this.contributor.webdev) return shell.openExternal('https://betterdiscord.net'); + if (this.contributor.contributor) return shell.openExternal('https://github.com/JsSucks/BetterDiscordApp/graphs/contributors'); } } } diff --git a/client/src/ui/components/bd/BdMessageBadge.vue b/client/src/ui/components/bd/BdMessageBadge.vue deleted file mode 100644 index 1344529b..00000000 --- a/client/src/ui/components/bd/BdMessageBadge.vue +++ /dev/null @@ -1,33 +0,0 @@ -/** - * BetterDiscord BD Message Badge 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. -*/ - - - - diff --git a/client/src/ui/components/bd/Card.vue b/client/src/ui/components/bd/Card.vue index 5fbe7ede..7645d3df 100644 --- a/client/src/ui/components/bd/Card.vue +++ b/client/src/ui/components/bd/Card.vue @@ -11,7 +11,7 @@