Discord: resizable, high quality emojis

Enlarges and loads higher resolution versions of emojis on Discord.

  1. /** == BetterDiscord plugin ==
  2. * @name ResizableEmojis
  3. * @version 1.3.1
  4. * @description Enlarges and loads higher resolution versions of emojis.
  5. * @author Corrodias
  6. * @icon https://www.google.com/s2/favicons?sz=64&domain=discord.com
  7. * @license MIT
  8. */
  9.  
  10. // ==UserScript==
  11. // @name Discord: resizable, high quality emojis
  12. // @namespace http://tampermonkey.net/
  13. // @version 1.3.1
  14. // @description Enlarges and loads higher resolution versions of emojis on Discord.
  15. // @author Corrodias
  16. // @match https://discord.com/*
  17. // @icon https://www.google.com/s2/favicons?sz=64&domain=discord.com
  18. // @grant GM.setValue
  19. // @grant GM.getValue
  20. // @license MIT
  21. // ==/UserScript==
  22.  
  23. /*jshint esversion: 11 */
  24.  
  25. (async function () {
  26. 'use strict';
  27.  
  28. const validEmojiSizes = [16, 32, 48, 64, 80, 96, 128];
  29.  
  30. var emojiUrlSize = '48';
  31. var css = '';
  32.  
  33. const defaultSettings = {
  34. newEmojiSize: 64,
  35. newPickerWidth: 1010,
  36. newPickerHeight: 600
  37. };
  38. var settings = {};
  39.  
  40. function loadAndOverwriteSettingsValue(key) {
  41. let value = settings[key]
  42. ? isFinite(settings[key])
  43. ? settings[key]
  44. : parseInt(settings[key])
  45. : NaN;
  46. if (isNaN(value)) value = defaultSettings[key];
  47. settings[key] = value;
  48. return value;
  49. }
  50.  
  51. function configure() {
  52. let newEmojiSize = loadAndOverwriteSettingsValue('newEmojiSize');
  53. let newPickerWidth = loadAndOverwriteSettingsValue('newPickerWidth');
  54. let newPickerHeight = loadAndOverwriteSettingsValue('newPickerHeight');
  55.  
  56. let emojiRowWidth = newPickerWidth - 48 - 16; // category panel is 48 wide. there is a margin.
  57. let emojiCountPerRow = Math.floor(emojiRowWidth / (newEmojiSize + 8)); // each emoji button has an 8px margin.
  58. // this is how many emojis we want to fit in the row. now, calculate the desired width of the panel so that this number of 48px buttons fit (used to be 40px).
  59. // this is because the grid layout code for the panel chooses how many buttons to put on each row based on its width, assuming original-sized buttons.
  60. let initialPanelWidth = (emojiCountPerRow * 48) + 48 + 16;
  61.  
  62. // Here's where stuff gets persisted.
  63.  
  64. // Find the smallest, valid URL size we can get away with for the desired emoji size.
  65. emojiUrlSize = (validEmojiSizes.find(e => e >= newEmojiSize) ?? 128).toString();
  66.  
  67. // .emojiItem-277VFM.emojiItemMedium-2stgkv is the reaction picker buttons
  68. // .emojiListRowMediumSize-2_-xbz is the grid
  69. // .emojiItemMedium-2stgkv .image-3tDi44 is the image
  70. // .emojiImage-1mTIfi is the inline picker when you type a colon
  71.  
  72. css = `
  73. main[class*="chatContent_"] .emoji, main[class*="chatContent_"] .emote {
  74. height: ${newEmojiSize}px !important;
  75. width: ${newEmojiSize}px !important;
  76. max-height: none !important;
  77. max-width: none !important;
  78. vertical-align: bottom;
  79. }
  80.  
  81. div[class*="emojiPicker_"] {
  82. height: ${newPickerHeight}px;
  83. width: ${initialPanelWidth}px;
  84. }
  85.  
  86. section[class*="positionContainer_"] {
  87. height: ${newPickerHeight}px;
  88. }
  89.  
  90. button[class*="emojiItemMedium_"] {
  91. height: ${newEmojiSize + 8}px;
  92. width: ${newEmojiSize + 8}px;
  93. }
  94.  
  95. ul[class*="emojiListRowMediumSize_"] {
  96. height: ${newEmojiSize + 8}px;
  97. grid-template-columns: repeat(auto-fill, ${newEmojiSize + 8}px);
  98. }
  99.  
  100. button[class*="emojiItemMedium_"] img[class*="image_"] {
  101. height: ${newEmojiSize}px !important;
  102. width: ${newEmojiSize}px !important;
  103. }
  104.  
  105. img[class*="emojiImage_"] {
  106. height: ${newEmojiSize}px !important;
  107. width: ${newEmojiSize}px !important;
  108. }
  109. `;
  110.  
  111. applyCss();
  112. }
  113.  
  114. const mutationObserver = new MutationObserver(async mutations => {
  115. for (const mutation of mutations) {
  116. processMutation(mutation);
  117. }
  118. });
  119.  
  120. function processMutation(mutation) {
  121. // deal with the webp/gif emojis, which swap URLs when discord gains/loses focus.
  122. if (mutation.type === 'attributes' && mutation.target.tagName === 'IMG') {
  123. replaceImageSource(mutation.target);
  124. }
  125. for (const node of mutation.addedNodes) {
  126. if (node.nodeType !== Node.ELEMENT_NODE) continue;
  127. replaceAllImageSources(node);
  128. resizeReactionPicker(node);
  129. if (shouldAddSettingsMenu) addSettingsMenu(node);
  130. }
  131. }
  132.  
  133. function resizeReactionPicker(element) {
  134. // let the CSS give it a default size calculated to insert the correct number of emojis per row.
  135. // then resize it after it's added, which does NOT alter the number of emojis per row.
  136.  
  137. let panel = null;
  138. if (hasClassPrefix(element, 'emojiPicker_')) panel = element;
  139. else panel = element.querySelector('div[class*="emojiPicker_"]');
  140.  
  141. if (panel === null) return;
  142. if (hasClassPrefix(panel.parentNode, 'emojiPickerInExpressionPicker_')) return; // only resize the reaction picker. the chat picker is more complex than this.
  143. if (hasClassPrefix(panel.parentNode, 'emojiPickerHasTabWrapper_')) return; // only resize the reaction picker. the chat picker is more complex than this.
  144.  
  145. panel.style.width = settings.newPickerWidth.toString() + 'px';
  146. }
  147.  
  148. function hasClassPrefix(element, prefix) {
  149. for (let clazz of element.classList.values()) {
  150. if (clazz.startsWith(prefix)) return true;
  151. }
  152. return false;
  153. }
  154.  
  155. function replaceAllImageSources(element) {
  156. element.querySelectorAll('img').forEach(replaceImageSource);
  157. }
  158.  
  159. const emojiRegex = /^(https:\/\/cdn.discordapp.com\/emojis\/.*?\?.*?size=)(\d+)(.*)$/;
  160. function replaceImageSource(img) {
  161. // Only replace emojis.
  162. // This will recursively cause another mutation, so also only replace if the target size doesn't already match!
  163. let match = img.src.match(emojiRegex);
  164. if (match === null || match[2] === emojiUrlSize) return;
  165. img.src = match[1] + emojiUrlSize + match[3];
  166. }
  167.  
  168. function addSettingsMenu(element) {
  169. // Only act on the settings menu.
  170. let sideBar = element.querySelector('*[class*="side_"]');
  171. if (sideBar === null) return;
  172. // Find the last menu item in the App Settings section.
  173. let appSettingsHeader = Array.from(sideBar.querySelectorAll('*[class*="header_"]')).find(e => e.textContent === 'App Settings');
  174. let finalItem = appSettingsHeader.nextElementSibling;
  175. while (hasClassPrefix(finalItem.nextElementSibling, 'item_')) {
  176. finalItem = finalItem.nextElementSibling;
  177. }
  178. // Add a new menu item at the end.
  179. let newItem = finalItem.cloneNode();
  180. newItem.textContent = 'Resizable Emojis';
  181. finalItem.after(newItem);
  182.  
  183. newItem.onclick = () => {
  184. let dialog = document.body.appendChild(createSettingsPanel());
  185. dialog.style.display = 'block';
  186. dialog.style.position = 'fixed';
  187. dialog.style.zIndex = '1000';
  188. dialog.style.left = '0';
  189. dialog.style.top = '0';
  190. dialog.style.width = '100%';
  191. dialog.style.height = '100%';
  192. dialog.style.overflow = 'auto';
  193. dialog.style.backgroundColor = 'rgba(0,0,0,0.4)';
  194.  
  195. dialog.querySelector('button[name="save"]').addEventListener('click',
  196. () => {
  197. dialog.remove();
  198. });
  199. dialog.querySelector('button[name="cancel"]').addEventListener('click',
  200. () => {
  201. dialog.remove();
  202. });
  203. };
  204. }
  205.  
  206. function createSettingsPanel() {
  207. let html = `
  208. <div style="margin: 15% auto; padding: 20px; border: 1px solid; width: 80%; background: black;">
  209. <p style="color: white;">Resizable Emojis Configuration</p>
  210. <textarea style="width: 100%; height: 100px;"></textarea>
  211. <button type="button" name="save">Save</input>
  212. <button type="button" name="cancel">Cancel</input>
  213. </div>
  214. `;
  215. let div = document.createElement('div');
  216. div.innerHTML = html;
  217.  
  218. div.querySelector('textarea').value = JSON.stringify(settings, null, 2);
  219.  
  220. div.querySelector('button[name="save"]').onclick = async () => {
  221. let settings_text = div.querySelector('textarea').value;
  222. settings = await saveSettingsToStorage(settings_text);
  223. configure();
  224. };
  225.  
  226. div.querySelector('button[name="cancel"]').onclick = () => {
  227. div.querySelector('textarea').value = JSON.stringify(settings, null, 2);
  228. };
  229.  
  230. return div;
  231. }
  232.  
  233. // Platform-dependent functions
  234. var applyCss; // is called by configure()
  235. var loadSettingsFromStorage; // async
  236. var saveSettingsToStorage; // async
  237. var shouldAddSettingsMenu;
  238.  
  239. if (typeof BdApi === 'function') { // BetterDiscord plugin
  240. const pluginName = 'ResizableEmojis';
  241.  
  242. const start = async () => {
  243. await loadSettingsFromStorage();
  244. configure();
  245. // BD's observer does not observe attribute changes, which we need, so we must make our own.
  246. mutationObserver.observe(document.body, { attributes: true, attributesFilter: ['src'], childList: true, subtree: true });
  247. };
  248. const stop = () => {
  249. BdApi.clearCSS(pluginName);
  250. mutationObserver.disconnect();
  251. };
  252.  
  253. module.exports = () => ({
  254. start: start,
  255. stop: stop,
  256. getSettingsPanel: createSettingsPanel
  257. });
  258.  
  259. applyCss = () => {
  260. BdApi.clearCSS(pluginName);
  261. BdApi.injectCSS(pluginName, css);
  262. };
  263.  
  264. loadSettingsFromStorage = async () => {
  265. settings = Object.assign({}, defaultSettings, BdApi.loadData(pluginName, "settings"));
  266. };
  267.  
  268. saveSettingsToStorage = async (jsonString) => {
  269. try {
  270. let jobj = JSON.parse(jsonString);
  271. BdApi.saveData(pluginName, "settings", jobj);
  272. BdApi.showToast('Settings saved!', { type: 'success' });
  273. return jobj;
  274. } catch (e) {
  275. BdApi.showToast('Settings are invalid. Not saving.', { type: 'error' });
  276. return settings;
  277. }
  278. };
  279.  
  280. shouldAddSettingsMenu = false;
  281. } else { // userscript
  282. var cssElement;
  283. var addStyle = () => {
  284. cssElement = document.createElement('style');
  285. cssElement.setAttribute('type', 'text/css');
  286.  
  287. if ('textContent' in cssElement) {
  288. cssElement.textContent = css;
  289. } else {
  290. cssElement.styleSheet.cssText = css;
  291. }
  292.  
  293. document.head.appendChild(cssElement);
  294. };
  295.  
  296. applyCss = () => {
  297. if (typeof cssElement !== 'undefined') cssElement.remove();
  298. addStyle();
  299. };
  300.  
  301. loadSettingsFromStorage = async () => {
  302. try {
  303. settings = JSON.parse(await GM.getValue('settings'));
  304. } catch (e) { }
  305. };
  306.  
  307. saveSettingsToStorage = async (jsonString) => {
  308. try {
  309. let jobj = JSON.parse(jsonString);
  310. await GM.setValue('settings', jsonString);
  311. return jobj;
  312. } catch (e) { }
  313. };
  314.  
  315. shouldAddSettingsMenu = true;
  316.  
  317. await loadSettingsFromStorage();
  318. configure();
  319. mutationObserver.observe(document.body, { attributes: true, attributesFilter: ['src'], childList: true, subtree: true });
  320. }
  321. })();