diff --git a/client/src/modules/permissionmanager.js b/client/src/modules/permissionmanager.js index b052207c..7a50e0bf 100644 --- a/client/src/modules/permissionmanager.js +++ b/client/src/modules/permissionmanager.js @@ -8,11 +8,53 @@ * LICENSE file in the root directory of this source tree. */ +/* These are Discord's permissions as of March 13th 2019 +{ + "CREATE_INSTANT_INVITE": 1, + "KICK_MEMBERS": 2, + "BAN_MEMBERS": 4, + "ADMINISTRATOR": 8, + "MANAGE_CHANNELS": 16, + "MANAGE_GUILD": 32, + "CHANGE_NICKNAME": 67108864, + "MANAGE_NICKNAMES": 134217728, + "MANAGE_ROLES": 268435456, + "MANAGE_WEBHOOKS": 536870912, + "MANAGE_EMOJIS": 1073741824, + "VIEW_AUDIT_LOG": 128, + "VIEW_CHANNEL": 1024, + "SEND_MESSAGES": 2048, + "SEND_TSS_MESSAGES": 4096, + "MANAGE_MESSAGES": 8192, + "EMBED_LINKS": 16384, + "ATTACH_FILES": 32768, + "READ_MESSAGE_HISTORY": 65536, + "MENTION_EVERYONE": 131072, + "USE_EXTERNAL_EMOJIS": 262144, + "ADD_REACTIONS": 64, + "CONNECT": 1048576, + "SPEAK": 2097152, + "MUTE_MEMBERS": 4194304, + "DEAFEN_MEMBERS": 8388608, + "MOVE_MEMBERS": 16777216, + "USE_VAD": 33554432, + "PRIORITY_SPEAKER": 256 +} +*/ + const PermissionMap = { IDENTIFY: { HEADER: 'Access your account information', BODY: 'Allows :NAME: to read your account information (excluding user token).' }, + NAVIGATION: { + HEADER: 'Navigate within Discord', + BODY: 'Allows :NAME: to navigate Discord and open things like user settings.' + }, + MANAGE_SETTINGS: { + HEADER: 'Change user settings', + BODY: 'Allows :NAME: to change Discord setting like light/dark theme and cozy/compact mode.' + }, READ_MESSAGES: { HEADER: 'Read all messages', BODY: 'Allows :NAME: to read all messages accessible through your Discord account.' @@ -29,9 +71,37 @@ const PermissionMap = { HEADER: 'Edit messages', BODY: 'Allows :NAME: to edit messages on your behalf.' }, - JOIN_SERVERS: { - HEADER: 'Join servers for you', - BODY: 'Allows :NAME: to join servers on your behalf.' + ATTACH_FILES: { + HEADER: 'Attach files', + BODY: 'Allows :NAME: to upload files on your behalf.' + }, + ADD_REACTIONS: { + HEADER: 'Add reactions', + BODY: 'Allows :NAME: to add reactions on your behalf.' + }, + VIEW_SERVERS: { + HEADER: 'View servers', + BODY: 'Allows :NAME: to see what servers you are a member of.' + }, + MANAGE_SERVERS: { + HEADER: 'Join and leave servers', + BODY: 'Allows :NAME: to join and leave servers on your behalf.' + }, + VIEW_CHANNELS: { + HEADER: 'View channels', + BODY: 'Allows :NAME: to see what channels you have available.' + }, + FS_ACCESS: { + HEADER: 'Access your filesystem', + BODY: 'Allows :NAME: to read and write files to your computer.' + }, + VIEW_RELATIONSHIPS: { + HEADER: 'VIEW your relationships', + BODY: 'Allows :NAME: to view your friends and blocked users.' + }, + MANAGE_RELATIONSHIPS: { + HEADER: 'Manage your relationships', + BODY: 'Allows :NAME: to add/remove friends, and block/unblock users on your behalf.' } } diff --git a/client/src/modules/pluginapi.js b/client/src/modules/pluginapi.js index ac5bfae7..1a95e4ce 100644 --- a/client/src/modules/pluginapi.js +++ b/client/src/modules/pluginapi.js @@ -9,8 +9,8 @@ */ import { EmoteModule } from 'builtin'; -import { SettingsSet, SettingsCategory, Setting, SettingsScheme } from 'structs'; -import { BdMenu, Modals, DOM, DOMObserver, VueInjector, Toasts, Notifications, BdContextMenu, DiscordContextMenu } from 'ui'; +import { SettingsSet, SettingsCategory, Setting, SettingsScheme, Screen } from 'structs'; +import { BdMenu, Modals, DOM, DOMObserver, VueInjector, Toasts, Notifications, BdContextMenu, DiscordContextMenu, Tooltip } from 'ui'; import * as CommonComponents from 'commoncomponents'; import { default as Components } from '../ui/components/generic'; import { Utils, Filters, ClientLogger as Logger, ClientIPC, AsyncEventEmitter } from 'common'; @@ -28,6 +28,7 @@ import GlobalAc from '../ui/autocomplete'; import Vue from 'vue'; import path from 'path'; import Globals from './globals'; +import { remote } from 'electron'; export default class PluginApi { @@ -74,6 +75,7 @@ export default class PluginApi { get Reflection() { return Reflection } get DOM() { return DOM } get VueInjector() { return VueInjector } + get Screen() { return Screen } get observer() { return this._observer || (this._observer = new DOMObserver()); @@ -112,7 +114,12 @@ export default class PluginApi { wait: (...args) => Utils.wait.apply(Utils, args), until: (...args) => Utils.until.apply(Utils, args), findInTree: (...args) => Utils.findInTree.apply(Utils, args), - findInReactTree: (...args) => Utils.findInReactTree.apply(Utils, args) + findInReactTree: (...args) => Utils.findInReactTree.apply(Utils, args), + formatString: (...args) => Utils.formatString.apply(Utils, args), + getNestedProp: (...args) => Utils.getNestedProp.apply(Utils, args), + extend: (...args) => Utils.extend.apply(Utils, args), + memoizeObject: (...args) => Utils.memoizeObject.apply(Utils, args), + stableSort: (...args) => Utils.stableSort.apply(Utils, args) }; } @@ -355,6 +362,17 @@ export default class PluginApi { }; } + /** + * Tooltips + */ + + addTooltip(node, text, options = {}) { + return new Tooltip(node, text, options); + } + get Tooltip() { + return Tooltip; + } + /** * Notifications */ @@ -549,6 +567,26 @@ export default class PluginApi { }; } + /** + * NodeModules + */ + + getNodeModule(module_id) { + return {}; // Don't give open access, when plugin permissions available remove this line + // This should require extra permissions + //return require(module_id); + } + listNodeModules() { + return []; // Don't give open access, when plugin permissions available implement this properly + } + get NodeModules() { + return { + getModule: this.getNodeModule.bind(this), + listModules: this.listNodeModules.bind(this) + // path: path (Perhaps we add this stuff here as well) + }; + } + /** * Patcher */ @@ -616,6 +654,22 @@ export default class PluginApi { return VueInjector.createReactElement(Vue.component(id, component), props); } + /** + * Adds a callback to a set of listeners for onSwitch. + * @param {callable} callback - basic callback to happen on channel switch + */ + static addOnSwitchListener(callback) { + remote.getCurrentWebContents().on('did-navigate-in-page', callback); + } + + /** + * Removes the listener added by {@link addOnSwitchListener}. + * @param {callable} callback - callback to remove from the listener list + */ + static removeOnSwitchListener(callback) { + remote.getCurrentWebContents().removeListener('did-navigate-in-page', callback); + } + } // Stop plugins from modifying the plugin API for all plugins diff --git a/client/src/modules/reflection/modules.js b/client/src/modules/reflection/modules.js index 2ba57675..a4ea8efb 100644 --- a/client/src/modules/reflection/modules.js +++ b/client/src/modules/reflection/modules.js @@ -155,6 +155,7 @@ const KnownModules = { /* DOM/React Components */ /* ==================== */ LayerManager: Filters.byProperties(['popLayer', 'pushLayer']), + Tooltips: m => m.hide && m.show && !m.search && !m.submit && !m.search && !m.activateRagingDemon && !m.dismiss, UserSettingsWindow: Filters.byProperties(['open', 'updateAccount']), ChannelSettingsWindow: Filters.byProperties(['open', 'updateChannel']), GuildSettingsWindow: Filters.byProperties(['open', 'updateGuild']), diff --git a/client/src/structs/screen.js b/client/src/structs/screen.js new file mode 100644 index 00000000..cf849d74 --- /dev/null +++ b/client/src/structs/screen.js @@ -0,0 +1,13 @@ +/** + * Representation of the screen such as width and height. + * + * Note: This comes from Zerebos' library. + */ +class Screen { + /** Document/window width */ + static get width() { return Math.max(document.documentElement.clientWidth, window.innerWidth || 0); } + /** Document/window height */ + static get height() { return Math.max(document.documentElement.clientHeight, window.innerHeight || 0); } +} + +export default Screen; \ No newline at end of file diff --git a/client/src/structs/structs.js b/client/src/structs/structs.js index 22a0b84b..572f3271 100644 --- a/client/src/structs/structs.js +++ b/client/src/structs/structs.js @@ -1,4 +1,5 @@ export { default as List } from './list'; +export { default as Screen } from './screen'; export * from './events/index'; export * from './settings/index'; diff --git a/client/src/ui/dom.js b/client/src/ui/dom.js index e7b0ae43..6c3d29a1 100644 --- a/client/src/ui/dom.js +++ b/client/src/ui/dom.js @@ -11,25 +11,68 @@ import { Utils, ClientLogger as Logger } from 'common'; class BdNode { - constructor(tag, className, id) { - this.element = document.createElement(tag); - if (className) this.element.className = className; - if (id) this.element.id = id; + constructor(node, attributes) { + this.element = node; + DOM.setAttributes(node, attributes); } - appendTo(e) { - const el = DOM.getElement(e); - if (!el) return null; - el.append(this.element); - return this.element; - } + get index() { return DOM.index(this.element); } + get innerWidth() { return DOM.innerWidth(this.element); } + get innerHeight() { return DOM.innerHeight(this.element); } + get outerWidth() { return DOM.outerWidth(this.element); } + get outerHeight() { return DOM.outerHeight(this.element); } + get offset() { return DOM.offset(this.element); } - prependTo(e) { - const el = DOM.getElement(e); - if (!el) return null; - el.prepend(this.element); - return this.element; - } + get width() { return DOM.width(this.element); } + set width(value) { return DOM.width(this.element, value); } + get height() { return DOM.height(this.element); } + set height(value) { return DOM.height(this.element, value); } + get text() { return DOM.text(this.element); } + set text(value) { return DOM.text(this.element, value); } + + css(attribute, value) { return DOM.css(this.element, attribute, value); } + + addClass(...classes) { return DOM.addClass(this.element, ...classes); } + removeClass(...classes) { return DOM.removeClass(this.element, ...classes); } + toggleClass(className, indicator) { return DOM.toggleClass(this.element, className, indicator); } + replaceClass(oldClass, newClass) { return DOM.replaceClass(this.element, oldClass, newClass); } + hasClass(className) { return DOM.hasClass(this.element, className); } + + insertAfter(otherNode) { return DOM.insertAfter(this.element, otherNode); } + after(newNode) { return DOM.after(this.element, newNode); } + + next(selector = '') { return DOM.next(this.element, selector); } + nextAll() { return DOM.nextAll(this.element); } + nextUntil(selector) { return DOM.nextUntil(this.element, selector); } + + previous(selector = '') { return DOM.previous(this.element, selector); } + previousAll() { return DOM.previousAll(this.element); } + previousUntil(selector) { return DOM.previousUntil(this.element, selector); } + + parent(selector = '') { return DOM.parent(this.element, selector); } + parents(selector ='') { return DOM.parents(this.element, selector); } + parentsUntil(selector) { return DOM.parentsUntil(this.element, selector); } + + findChild(selector) { return DOM.findChild(this.element, selector); } + findChildren(selector) { return DOM.findChildren(this.element, selector); } + + siblings(selector = '*') { return DOM.siblings(this.element, selector); } + + on(event, delegate, callback) { return DOM.on(this.element, event, delegate, callback); } + once(event, delegate, callback) { return DOM.once(this.element, event, delegate, callback); } + off(event, delegate, callback) { return DOM.off(this.element, event, delegate, callback); } + + find(selector) { return DOM.find(this.element, selector); } + findAll(selector) { return DOM.findAll(this.element, selector); } + + appendTo(otherNode) { return DOM.appendTo(this.element, otherNode); } + prependTo(otherNode) { return DOM.prependTo(this.element, otherNode); } + + onMountChange(callback, onMount = true) { return DOM.onMountChange(this.element, callback, onMount); } + onMount(callback) { return DOM.onMount(this.element, callback); } + onAdded(callback) { return DOM.onAdded(this.element, callback); } + onUnmount(callback) { return DOM.onUnmount(this.element, callback); } + onRemoved(callback) { return DOM.onRemoved(this.element, callback); } } export class DOMObserver { @@ -188,19 +231,43 @@ export default class DOM { static get bdNotifications() { return this.getElement('bd-notifications') || this.createElement('bd-notifications').appendTo(this.bdBody) } static get bdContextMenu() { return this.getElement('bd-contextmenu') || this.createElement('bd-contextmenu').appendTo(this.bdBody) } - static getElement(e) { + /** + * Essentially a shorthand for `document.querySelector`. If the `baseElement` is not provided + * `document` is used by default. + * @param {string} selector - Selector to query + * @param {Element} [baseElement] - Element to base the query from + * @returns {(Element|null)} - The found element or null if not found + */ + static getElement(e, baseElement = document) { if (e instanceof BdNode) return e.element; if (e instanceof window.Node) return e; if ('string' !== typeof e) return null; - return document.querySelector(e); + return baseElement.querySelector(e); } - static getElements(e) { - return document.querySelectorAll(e); + /** + * Alias for {@link module:DOM.getElement}. + */ + static query(e, baseElement = document) {return this.getElement(e, baseElement);} + + /** + * Essentially a shorthand for `document.querySelectorAll`. If the `baseElement` is not provided + * `document` is used by default. + * @param {string} selector - Selector to query + * @param {Element} [baseElement] - Element to base the query from + * @returns {Array} - Array of all found elements + */ + static getElements(e, baseElement = document) { + return baseElement.querySelectorAll(e); } - static createElement(tag = 'div', className = null, id = null) { - return new BdNode(tag, className, id); + /** + * Alias for {@link module:DOM.getElements}. + */ + static queryAll(e, baseElement = document) {return this.getElements(e, baseElement);} + + static createElement(tag = 'div', attributes = {}) { + return new BdNode(document.createElement(tag), attributes); } static deleteStyle(id) { @@ -244,4 +311,636 @@ export default class DOM { } } + /** + * Builds a classname string from any number of arguments. This includes arrays and objects. + * When given an array all values from the array are added to the list. + * When given an object they keys are added as the classnames if the value is truthy. + * Copyright (c) 2018 Jed Watson https://github.com/JedWatson/classnames MIT License + * @param {...Any} argument - anything that should be used to add classnames. + */ + static className() { + const classes = []; + const hasOwnProp = {}.hasOwnProperty; + + for (let i = 0; i < arguments.length; i++) { + const arg = arguments[i]; + if (!arg) continue; + + const argType = typeof arg; + + if (argType === 'string' || argType === 'number') { + classes.push(arg); + } + else if (Array.isArray(arg) && arg.length) { + const inner = this.classNames.apply(null, arg); + if (inner) classes.push(inner); + } + else if (argType === 'object') { + for (const key in arg) { + if (hasOwnProp.call(arg, key) && arg[key]) classes.push(key); + } + } + } + + return classes.join(' '); + } + + /** + * Functions below come from Zerebos' library module DOMTools. + */ + + /** + * Parses a string of HTML and returns the results. If the second parameter is true, + * the parsed HTML will be returned as a document fragment {@see https://developer.mozilla.org/en-US/docs/Web/API/DocumentFragment}. + * This is extremely useful if you have a list of elements at the top level, they can then be appended all at once to another node. + * + * If the second parameter is false, then the return value will be the list of parsed + * nodes and there were multiple top level nodes, otherwise the single node is returned. + * @param {string} html - HTML to be parsed + * @param {boolean} [fragment=false] - Whether or not the return should be the raw `DocumentFragment` + * @returns {(DocumentFragment|NodeList|HTMLElement)} - The result of HTML parsing + */ + static parseHTML(html, fragment = false) { + const template = document.createElement('template'); + template.innerHTML = html; + const node = template.content.cloneNode(true); + if (fragment) return node; + return node.childNodes.length > 1 ? node.childNodes : node.childNodes[0]; + } + + /** + * Takes a string of html and escapes it using the brower's own escaping mechanism. + * @param {String} html - html to be escaped + */ + static escapeHTML(html) { + const textNode = document.createTextNode(''); + const spanElement = document.createElement('span'); + spanElement.append(textNode); + textNode.nodeValue = html; + return spanElement.innerHTML; + } + + /** + * This is my shit version of not having to use `$` from jQuery. Meaning + * that you can pass a selector and it will automatically run {@link module:DOM.query}. + * It also means that you can pass a string of html and it will perform and return `parseHTML`. + * @see module:DOM.parseHTML + * @see module:DOM.query + * @param {string} selector - Selector to query or HTML to parse + * @returns {(DocumentFragment|NodeList|HTMLElement)} - Either the result of `parseHTML` or `query` + */ + static Q(selector) { + const element = this.parseHTML(selector); + const isHTML = element instanceof NodeList ? Array.from(element).some(n => n.nodeType === 1) : element.nodeType === 1; + if (isHTML) return element; + return this.query(selector); + } + + /** + * Adds a list of classes from the target element. + * @param {Element} element - Element to edit classes of + * @param {...string} classes - Names of classes to add + * @returns {Element} - `element` to allow for chaining + */ + static addClass(element, ...classes) { + for (let c = 0; c < classes.length; c++) classes[c] = classes[c].toString().split(' '); + classes = classes.flatten().filter(c => c); + element.classList.add(...classes); + return element; + } + + /** + * Removes a list of classes from the target element. + * @param {Element} element - Element to edit classes of + * @param {...string} classes - Names of classes to remove + * @returns {Element} - `element` to allow for chaining + */ + static removeClass(element, ...classes) { + for (let c = 0; c < classes.length; c++) classes[c] = classes[c].toString().split(' '); + classes = classes.flatten().filter(c => c); + element.classList.remove(...classes); + return element; + } + + /** + * When only one argument is present: Toggle class value; + * i.e., if class exists then remove it and return false, if not, then add it and return true. + * When a second argument is present: + * If the second argument evaluates to true, add specified class value, and if it evaluates to false, remove it. + * @param {Element} element - Element to edit classes of + * @param {string} classname - Name of class to toggle + * @param {boolean} [indicator] - Optional indicator for if the class should be toggled + * @returns {Element} - `element` to allow for chaining + */ + static toggleClass(element, classname, indicator) { + classname = classname.toString().split(' ').filter(c => c); + if (typeof(indicator) !== 'undefined') classname.forEach(c => element.classList.toggle(c, indicator)); + else classname.forEach(c => element.classList.toggle(c)); + return element; + } + + /** + * Checks if an element has a specific class + * @param {Element} element - Element to edit classes of + * @param {string} classname - Name of class to check + * @returns {boolean} - `true` if the element has the class, `false` otherwise. + */ + static hasClass(element, classname) { + return classname.toString().split(' ').filter(c => c).every(c => element.classList.contains(c)); + } + + /** + * Replaces one class with another + * @param {Element} element - Element to edit classes of + * @param {string} oldName - Name of class to replace + * @param {string} newName - New name for the class + * @returns {Element} - `element` to allow for chaining + */ + static replaceClass(element, oldName, newName) { + element.classList.replace(oldName, newName); + return element; + } + + /** + * Appends `thisNode` to `thatNode` + * @param {Node} thisNode - Node to be appended to another node + * @param {Node} thatNode - Node for `thisNode` to be appended to + * @returns {Node} - `thisNode` to allow for chaining + */ + static appendTo(thisNode, thatNode) { + if (typeof(thatNode) == 'string') thatNode = this.query(thatNode); + if (!thatNode) return null; + thatNode.append(thisNode); + return thisNode; + } + + /** + * Prepends `thisNode` to `thatNode` + * @param {Node} thisNode - Node to be prepended to another node + * @param {Node} thatNode - Node for `thisNode` to be prepended to + * @returns {Node} - `thisNode` to allow for chaining + */ + static prependTo(thisNode, thatNode) { + if (typeof(thatNode) == 'string') thatNode = this.query(thatNode); + if (!thatNode) return null; + thatNode.prepend(thisNode); + return thisNode; + } + + /** + * Insert after a specific element, similar to jQuery's `thisElement.insertAfter(otherElement)`. + * @param {Node} thisNode - The node to insert + * @param {Node} targetNode - Node to insert after in the tree + * @returns {Node} - `thisNode` to allow for chaining + */ + static insertAfter(thisNode, targetNode) { + targetNode.parentNode.insertBefore(thisNode, targetNode.nextSibling); + return thisNode; + } + + /** + * Insert after a specific element, similar to jQuery's `thisElement.after(newElement)`. + * @param {Node} thisNode - The node to insert + * @param {Node} newNode - Node to insert after in the tree + * @returns {Node} - `thisNode` to allow for chaining + */ + static after(thisNode, newNode) { + thisNode.parentNode.insertBefore(newNode, thisNode.nextSibling); + return thisNode; + } + + /** + * Gets the next sibling element that matches the selector. + * @param {Element} element - Element to get the next sibling of + * @param {string} [selector=''] - Optional selector + * @returns {Element} - The sibling element + */ + static next(element, selector = '') { + return selector ? element.querySelector(`+ ${selector}`) : element.nextElementSibling; + } + + /** + * Gets all subsequent siblings. + * @param {Element} element - Element to get next siblings of + * @returns {NodeList} - The list of siblings + */ + static nextAll(element) { + return element.querySelectorAll('~ *'); + } + + /** + * Gets the subsequent siblings until an element matches the selector. + * @param {Element} element - Element to get the following siblings of + * @param {string} selector - Selector to stop at + * @returns {Array} - The list of siblings + */ + static nextUntil(element, selector) { + const next = []; + while (element.nextElementSibling && !element.nextElementSibling.matches(selector)) next.push(element = element.nextElementSibling); + return next; + } + + /** + * Gets the previous sibling element that matches the selector. + * @param {Element} element - Element to get the previous sibling of + * @param {string} [selector=''] - Optional selector + * @returns {Element} - The sibling element + */ + static previous(element, selector = '') { + const previous = element.previousElementSibling; + if (selector) return previous && previous.matches(selector) ? previous : null; + return previous; + } + + /** + * Gets all preceeding siblings. + * @param {Element} element - Element to get preceeding siblings of + * @returns {NodeList} - The list of siblings + */ + static previousAll(element) { + const previous = []; + while (element.previousElementSibling) previous.push(element = element.previousElementSibling); + return previous; + } + + /** + * Gets the preceeding siblings until an element matches the selector. + * @param {Element} element - Element to get the preceeding siblings of + * @param {string} selector - Selector to stop at + * @returns {Array} - The list of siblings + */ + static previousUntil(element, selector) { + const previous = []; + while (element.previousElementSibling && !element.previousElementSibling.matches(selector)) previous.push(element = element.previousElementSibling); + return previous; + } + + /** Shorthand for {@link module:DOM.indexInParent} */ + static index(node) {return this.indexInParent(node);} + + /** + * Find which index in children a certain node is. Similar to jQuery's `$.index()` + * @param {HTMLElement} node - The node to find its index in parent + * @returns {number} Index of the node + */ + static indexInParent(node) { + const children = node.parentNode.childNodes; + let num = 0; + for (let i = 0; i < children.length; i++) { + if (children[i] == node) return num; + if (children[i].nodeType == 1) num++; + } + return -1; + } + + /** + * Gets the parent of the element if it matches the selector, + * otherwise returns null. + * @param {Element} element - Element to get parent of + * @param {string} [selector=''] - Selector to match parent + * @returns {(Element|null)} - The sibling element or null + */ + static parent(element, selector = '') { + return !selector || element.parentElement.matches(selector) ? element.parentElement : null; + } + + /** + * Gets all ancestors of Element that match the selector if provided. + * @param {Element} element - Element to get all parents of + * @param {string} [selector=''] - Selector to match the parents to + * @returns {Array} - The list of parents + */ + static parents(element, selector = '') { + const parents = []; + if (selector) while (element.parentElement && element.parentElement.closest(selector)) parents.push(element = element.parentElement.closest(selector)); + else while (element.parentElement) parents.push(element = element.parentElement); + return parents; + } + + /** + * Gets the ancestors until an element matches the selector. + * @param {Element} element - Element to get the ancestors of + * @param {string} selector - Selector to stop at + * @returns {Array} - The list of parents + */ + static parentsUntil(element, selector) { + const parents = []; + while (element.parentElement && !element.parentElement.matches(selector)) parents.push(element = element.parentElement); + return parents; + } + + /** + * Gets all children of Element that match the selector if provided. + * @param {Element} element - Element to get all children of + * @param {string} selector - Selector to match the children to + * @returns {Array} - The list of children + */ + static findChild(element, selector) { + return element.querySelector(`:scope > ${selector}`); + } + + /** + * Gets all children of Element that match the selector if provided. + * @param {Element} element - Element to get all children of + * @param {string} selector - Selector to match the children to + * @returns {Array} - The list of children + */ + static findChildren(element, selector) { + return element.querySelectorAll(`:scope > ${selector}`); + } + + /** + * Gets all siblings of the element that match the selector. + * @param {Element} element - Element to get all siblings of + * @param {string} [selector='*'] - Selector to match the siblings to + * @returns {Array} - The list of siblings + */ + static siblings(element, selector = '*') { + return Array.from(element.parentElement.children).filter(e => e != element && e.matches(selector)); + } + + /** + * Sets or gets css styles for a specific element. If `value` is provided + * then it sets the style and returns the element to allow for chaining, + * otherwise returns the style. + * @param {Element} element - Element to set the CSS of + * @param {string} attribute - Attribute to get or set + * @param {string} [value] - Value to set for attribute + * @returns {Element|string} - When setting a value, element is returned for chaining, otherwise the value is returned. + */ + static css(element, attribute, value) { + if (typeof(value) == 'undefined') return global.getComputedStyle(element)[attribute]; + element.style[attribute] = value; + return element; + } + + /** + * Sets or gets the width for a specific element. If `value` is provided + * then it sets the width and returns the element to allow for chaining, + * otherwise returns the width. + * @param {Element} element - Element to set the CSS of + * @param {string} [value] - Width to set + * @returns {Element|string} - When setting a value, element is returned for chaining, otherwise the value is returned. + */ + static width(element, value) { + if (typeof(value) == 'undefined') return parseInt(getComputedStyle(element).width); + element.style.width = value; + return element; + } + + /** + * Sets or gets the height for a specific element. If `value` is provided + * then it sets the height and returns the element to allow for chaining, + * otherwise returns the height. + * @param {Element} element - Element to set the CSS of + * @param {string} [value] - Height to set + * @returns {Element|string} - When setting a value, element is returned for chaining, otherwise the value is returned. + */ + static height(element, value) { + if (typeof(value) == 'undefined') return parseInt(getComputedStyle(element).height); + element.style.height = value; + return element; + } + + /** + * Sets the inner text of an element if given a value, otherwise returns it. + * @param {Element} element - Element to set the text of + * @param {string} [text] - Content to set + * @returns {string} - Either the string set by this call or the current text content of the node. + */ + static text(element, text) { + if (typeof(text) == 'undefined') return element.textContent; + return element.textContent = text; + } + + /** + * Returns the innerWidth of the element. + * @param {Element} element - Element to retrieve inner width of + * @return {number} - The inner width of the element. + */ + static innerWidth(element) { + return element.clientWidth; + } + + /** + * Returns the innerHeight of the element. + * @param {Element} element - Element to retrieve inner height of + * @return {number} - The inner height of the element. + */ + static innerHeight(element) { + return element.clientHeight; + } + + /** + * Returns the outerWidth of the element. + * @param {Element} element - Element to retrieve outer width of + * @return {number} - The outer width of the element. + */ + static outerWidth(element) { + return element.offsetWidth; + } + + /** + * Returns the outerHeight of the element. + * @param {Element} element - Element to retrieve outer height of + * @return {number} - The outer height of the element. + */ + static outerHeight(element) { + return element.offsetHeight; + } + + /** + * Gets the offset of the element in the page. + * @param {Element} element - Element to get offset of + * @return {Offset} - The offset of the element + */ + static offset(element) { + return element.getBoundingClientRect(); + } + + static get listeners() { return this._listeners || (this._listeners = {}); } + + /** + * This is similar to jQuery's `on` function and can *hopefully* be used in the same way. + * + * Rather than attempt to explain, I'll show some example usages. + * + * The following will add a click listener (in the `myPlugin` namespace) to `element`. + * `DOM.on(element, 'click.myPlugin', () => {console.log('clicked!');});` + * + * The following will add a click listener (in the `myPlugin` namespace) to `element` that only fires when the target is a `.block` element. + * `DOM.on(element, 'click.myPlugin', '.block', () => {console.log('clicked!');});` + * + * The following will add a click listener (without namespace) to `element`. + * `DOM.on(element, 'click', () => {console.log('clicked!');});` + * + * The following will add a click listener (without namespace) to `element` that only fires once. + * `const cancel = DOM.on(element, 'click', () => {console.log('fired!'); cancel();});` + * + * @param {Element} element - Element to add listener to + * @param {string} event - Event to listen to with option namespace (e.g. 'event.namespace') + * @param {(string|callable)} delegate - Selector to run on element to listen to + * @param {callable} [callback] - Function to fire on event + * @returns {module:DOM~CancelListener} - A function that will undo the listener + */ + static on(element, event, delegate, callback) { + const [type, namespace] = event.split('.'); + const hasDelegate = delegate && callback; + if (!callback) callback = delegate; + const eventFunc = !hasDelegate ? callback : function(event) { + if (event.target.matches(delegate)) { + callback(event); + } + }; + + element.addEventListener(type, eventFunc); + const cancel = () => { + element.removeEventListener(type, eventFunc); + }; + if (namespace) { + if (!this.listeners[namespace]) this.listeners[namespace] = []; + const newCancel = () => { + cancel(); + this.listeners[namespace].splice(this.listeners[namespace].findIndex(l => l.event == type && l.element == element), 1); + }; + this.listeners[namespace].push({ + event: type, + element: element, + cancel: newCancel + }); + return newCancel; + } + return cancel; + } + + /** + * Functionality for this method matches {@link module:DOM.on} but automatically cancels itself + * and removes the listener upon the first firing of the desired event. + * + * @param {Element} element - Element to add listener to + * @param {string} event - Event to listen to with option namespace (e.g. 'event.namespace') + * @param {(string|callable)} delegate - Selector to run on element to listen to + * @param {callable} [callback] - Function to fire on event + * @returns {module:DOM~CancelListener} - A function that will undo the listener + */ + static once(element, event, delegate, callback) { + const [type, namespace] = event.split('.'); + const hasDelegate = delegate && callback; + if (!callback) callback = delegate; + const eventFunc = !hasDelegate ? function(event) { + callback(event); + element.removeEventListener(type, eventFunc); + } : function(event) { + if (!event.target.matches(delegate)) return; + callback(event); + element.removeEventListener(type, eventFunc); + }; + + element.addEventListener(type, eventFunc); + const cancel = () => { + element.removeEventListener(type, eventFunc); + }; + if (namespace) { + if (!this.listeners[namespace]) this.listeners[namespace] = []; + const newCancel = () => { + cancel(); + this.listeners[namespace].splice(this.listeners[namespace].findIndex(l => l.event == type && l.element == element), 1); + }; + this.listeners[namespace].push({ + event: type, + element: element, + cancel: newCancel + }); + return newCancel; + } + return cancel; + } + + static __offAll(event, element) { + const [type, namespace] = event.split('.'); + let matchFilter = listener => listener.event == type, defaultFilter = _ => _; + if (element) matchFilter = l => l.event == type && l.element == element, defaultFilter = l => l.element == element; + const listeners = this.listeners[namespace] || []; + const list = type ? listeners.filter(matchFilter) : listeners.filter(defaultFilter); + for (let c = 0; c < list.length; c++) list[c].cancel(); + } + + /** + * This is similar to jQuery's `off` function and can *hopefully* be used in the same way. + * + * Rather than attempt to explain, I'll show some example usages. + * + * The following will remove a click listener called `onClick` (in the `myPlugin` namespace) from `element`. + * `DOM.off(element, 'click.myPlugin', onClick);` + * + * The following will remove a click listener called `onClick` (in the `myPlugin` namespace) from `element` that only fired when the target is a `.block` element. + * `DOM.off(element, 'click.myPlugin', '.block', onClick);` + * + * The following will remove a click listener (without namespace) from `element`. + * `DOM.off(element, 'click', onClick);` + * + * The following will remove all listeners in namespace `myPlugin` from `element`. + * `DOM.off(element, '.myPlugin');` + * + * The following will remove all click listeners in namespace `myPlugin` from *all elements*. + * `DOM.off('click.myPlugin');` + * + * The following will remove all listeners in namespace `myPlugin` from *all elements*. + * `DOM.off('.myPlugin');` + * + * @param {(Element|string)} element - Element to remove listener from + * @param {string} [event] - Event to listen to with option namespace (e.g. 'event.namespace') + * @param {(string|callable)} [delegate] - Selector to run on element to listen to + * @param {callable} [callback] - Function to fire on event + * @returns {Element} - The original element to allow for chaining + */ + static off(element, event, delegate, callback) { + if (typeof(element) == 'string') return this.__offAll(element); + const [type, namespace] = event.split('.'); + if (namespace) return this.__offAll(event, element); + + const hasDelegate = delegate && callback; + if (!callback) callback = delegate; + const eventFunc = !hasDelegate ? callback : function(event) { + if (event.target.matches(delegate)) { + callback(event); + } + }; + + element.removeEventListener(type, eventFunc); + return element; + } + + /** + * Adds a listener for when the node is added/removed from the document body. + * The listener is automatically removed upon firing. + * @param {HTMLElement} node - node to wait for + * @param {callable} callback - function to be performed on event + * @param {boolean} onMount - determines if it should fire on Mount or on Unmount + */ + static onMountChange(node, callback, onMount = true) { + const wrappedCallback = () => { + this.observer.unsubscribe(wrappedCallback); + callback(); + }; + return this.observer.subscribe(wrappedCallback, mutation => { + const nodes = Array.from(onMount ? mutation.addedNode : mutation.removedNodes); + const directMatch = nodes.indexOf(node) > -1; + const parentMatch = nodes.some(parent => parent.contains(node)); + return directMatch || parentMatch; + }); + } + + /** Shorthand for {@link module:DOM.onMountChange} with third parameter `true` */ + static onMount(node, callback) { return this.onMountChange(node, callback); } + + /** Shorthand for {@link module:DOM.onMountChange} with third parameter `false` */ + static onUnmount(node, callback) { return this.onMountChange(node, callback, false); } + + /** Alias for {@link module:DOM.onMount} */ + static onAdded(node, callback) { return this.onMount(node, callback); } + + /** Alias for {@link module:DOM.onUnmount} */ + static onRemoved(node, callback) { return this.onUnmount(node, callback); } + } diff --git a/client/src/ui/reflection.js b/client/src/ui/reflection.js index 653cb72c..a82f55ca 100644 --- a/client/src/ui/reflection.js +++ b/client/src/ui/reflection.js @@ -12,6 +12,8 @@ import { Filters, ClientLogger as Logger } from 'common'; import { ReactComponents } from 'modules'; class Reflection { + static get reactRootInstance() {return document.getElementById('app-mount')._reactRootContainer._internalRoot.current;} + static reactInternalInstance(node) { if (!node) return null; if (!Object.keys(node) || !Object.keys(node).length) return null; diff --git a/client/src/ui/tooltip.js b/client/src/ui/tooltip.js new file mode 100644 index 00000000..c340bb12 --- /dev/null +++ b/client/src/ui/tooltip.js @@ -0,0 +1,83 @@ +/** + * Tooltips that automatically show and hide themselves on mouseenter and mouseleave events. + * Will also remove themselves if the node to watch is removed from DOM through + * a DOMObserver. + * + * Note: This comes from Zerebos' library. + * + * @module Tooltip + * @version 0.0.1 + */ +import { Reflection } from 'modules'; +import { Screen } from 'structs'; +import DOM from './dom'; + +export default class Tooltip { + /** + * + * @constructor + * @param {HTMLElement} node - DOM node to monitor and show the tooltip on + * @param {string} tip - string to show in the tooltip + * @param {object} options - additional options for the tooltip + * @param {string} [options.style=black] - correlates to the discord styling + * @param {string} [options.side=top] - can be any of top, right, bottom, left + * @param {boolean} [options.preventFlip=false] - prevents moving the tooltip to the opposite side if it is too big or goes offscreen + * @param {boolean} [options.disabled=false] - whether the tooltip should be disabled from showing on hover + */ + constructor(node, text, options = {}) { + this.node = node; + const {style = 'black', side = 'top', disabled = false} = options; + this.label = text; + this.style = style; + this.side = side; + this.disabled = disabled; + this.id = Reflection.modules.KeyGenerator(); + this.hide = this.hide.bind(this); + + this.node.addEventListener('mouseenter', () => { + if (this.disabled) return; + this.show(); + DOM.onUnmount(this.node, this.hide); + }); + + this.node.addEventListener('mouseleave', () => { + this.hide(); + DOM.observer.unsubscribe(this.hide); + }); + } + + /** + * Disabled the tooltip and prevents it from showing on hover. + */ + disable() { + this.disabled = true; + } + + /** + * Enables the tooltip and allows it to show on hover. + */ + enable() { + this.disabled = false; + } + + /** Hides the tooltip. Automatically called on mouseleave. */ + hide() { + Reflection.modules.Tooltips.hide(this.id); + } + + /** Shows the tooltip. Automatically called on mouseenter. */ + show() { + const {left, top, width, height} = this.node.getBoundingClientRect(); + Reflection.modules.Tooltips.show(this.id, { + position: this.side, + text: this.label, + color: this.style, + targetWidth: width, + targetHeight: height, + windowWidth: Screen.width, + windowHeight: Screen.height, + x: left, + y: top + }); + } +} \ No newline at end of file diff --git a/client/src/ui/ui.js b/client/src/ui/ui.js index f8ecde5c..0534befa 100644 --- a/client/src/ui/ui.js +++ b/client/src/ui/ui.js @@ -3,6 +3,7 @@ export { default as BdUI } from './bdui'; export { default as BdMenu, BdMenuItems } from './bdmenu'; export { default as Modals } from './modals'; export { default as Toasts } from './toasts'; +export { default as Tooltip } from './tooltip'; export { default as Notifications } from './notifications'; export * from './contextmenus'; diff --git a/common/modules/utils.js b/common/modules/utils.js index fa49f23a..eea971b8 100644 --- a/common/modules/utils.js +++ b/common/modules/utils.js @@ -54,6 +54,24 @@ export class Utils { return camelCased; } + /** + * Format strings with placeholders (`{{placeholder}}`) into full strings. + * Quick example: `formatString("Hello, {{user}}", {user: "Zerebos"})` + * would return "Hello, Zerebos". + * @param {string} string - string to format + * @param {object} values - object literal of placeholders to replacements + * @returns {string} the properly formatted string + */ + static formatString(string, values) { + for (const val in values) { + let replacement = values[val]; + if (Array.isArray(replacement)) replacement = JSON.stringify(replacement); + if (typeof(replacement) === 'object' && replacement !== null) replacement = replacement.toString(); + string = string.replace(new RegExp(`{{${val}}}`, 'g'), replacement); + } + return string; + } + /** * Finds a value, subobject, or array from a tree that matches a specific filter. Great for patching render functions. * @param {object} tree React tree to look through. Can be a rendered object or an internal instance. @@ -96,6 +114,18 @@ export class Utils { return tempReturn; } + /** + * Gets a nested property (if it exists) safely. Path should be something like `prop.prop2.prop3`. + * Numbers can be used for arrays as well like `prop.prop2.array.0.id`. + * @param {Object} obj - object to get nested property of + * @param {string} path - representation of the property to obtain + */ + static getNestedProp(obj, path) { + return path.split(/\s?\.\s?/).reduce(function(obj, prop) { + return obj && obj[prop]; + }, obj); + } + /** * Checks if two or more values contain the same data. * @param {Any} ...value The value to compare @@ -126,6 +156,28 @@ export class Utils { return true; } + /** + * Deep extends an object with a set of other objects. Objects later in the list + * of `extenders` have priority, that is to say if one sets a key to be a primitive, + * it will be overwritten with the next one with the same key. If it is an object, + * and the keys match, the object is extended. This happens recursively. + * @param {object} extendee - Object to be extended + * @param {...object} extenders - Objects to extend with + * @returns {object} - A reference to `extendee` + */ + static extend(extendee, ...extenders) { + for (let i = 0; i < extenders.length; i++) { + for (const key in extenders[i]) { + if (extenders[i].hasOwnProperty(key)) { + if (typeof extendee[key] === 'object' && typeof extenders[i][key] === 'object') this.extend(extendee[key], extenders[i][key]); + else if (typeof extenders[i][key] === 'object') extendee[key] = {}, this.extend(extendee[key], extenders[i][key]); + else extendee[key] = extenders[i][key]; + } + } + } + return extendee; + } + /** * Clones an object and all it's properties. * @param {Any} value The value to clone @@ -212,6 +264,36 @@ export class Utils { }); } + /** + * Generates an automatically memoizing version of an object. + * @param {Object} object - object to memoize + * @returns {Proxy} the proxy to the object that memoizes properties + */ + static memoizeObject(object) { + const proxy = new Proxy(object, { + get: function(obj, mod) { + if (!obj.hasOwnProperty(mod)) return undefined; + if (Object.getOwnPropertyDescriptor(obj, mod).get) { + const value = obj[mod]; + delete obj[mod]; + obj[mod] = value; + } + return obj[mod]; + }, + set: function(obj, mod, value) { + if (obj.hasOwnProperty(mod)) return; + obj[mod] = value; + return obj[mod]; + } + }); + + Object.defineProperty(proxy, 'hasOwnProperty', {value: function(prop) { + return this[prop] !== undefined; + }}); + + return proxy; + } + static wait(time = 0) { return new Promise(resolve => setTimeout(resolve, time)); } @@ -227,6 +309,33 @@ export class Utils { return value; } + /** + * Stably sorts arrays since `.sort()` has issues. + * @param {Array} list - array to sort + * @param {function} comparator - comparator to sort by + */ + static stableSort(list, comparator) { + const length = list.length; + const entries = Array(length); + let index; + + // wrap values with initial indices + for (index = 0; index < length; index++) { + entries[index] = [index, list[index]]; + } + + // sort with fallback based on initial indices + entries.sort(function (a, b) { + const comparison = Number(this(a[1], b[1])); + return comparison || a[0] - b[0]; + }.bind(comparator)); + + // re-map original array to stable sorted values + for (index = 0; index < length; index++) { + list[index] = entries[index][1]; + } + } + /** * Finds the index of array of bytes in another array * @param {Array} haystack The array to find aob in diff --git a/package-lock.json b/package-lock.json index e198080c..2a8babef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "betterdiscord", - "version": "2.0.0-beta.4", + "version": "2.0.0-beta.5", "lockfileVersion": 1, "requires": true, "dependencies": {