Greasy Fork is available in English.

Discord Context Menu Extender

Adding Discord desktop action buttons in context menu in discord web version

// ==UserScript==
// @name         Discord Context Menu Extender
// @namespace    http://tampermonkey.net/
// @version      1.0.2
// @description  Adding Discord desktop action buttons in context menu in discord web version
// @author       DoctorDeathDDracula & Sticky
// @supportURL   https://discord.gg/sHj5UauJZ4
// @license      MIT
// @match        *://discord.com/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=discord.com
// @grant        unsafeWindow
// @grant        GM_log
// @grant        GM_download
// @grant        GM_xmlhttpRequest
// @grant        GM_setClipboard
// @connect      discord.com
// @connect      cdn.discordapp.com
// @connect      media.discordapp.net
// @connect      images-ext-1.discordapp.net
// @connect      images-ext-2.discordapp.net
// @run-at       document-start
// ==/UserScript==


(function() {
	'use strict';

	const console = {
		log: GM_log
	};

	class DiscordContextMenuExtender {

		constructor() {
			this.compatibilityItems = ['ClipboardItem', 'MutationObserver'];
			this.currentElement = null;
			this.style = `.my-focused{background-color:var(--brand-experiment-560);color:#fff;} .my-focused > *{color:#fff !important;}`;
		}

		cc(tag, options = {}, parent = false, init = undefined) {
			const children = options.children || [];
			delete options.children;
			const element = Object.assign(document.createElement(tag), options);
			for (const child of children) element.appendChild(child);
			if (typeof init == 'function') init(element);
			return parent && parent.nodeType === Node.ELEMENT_NODE ? parent.appendChild(element) : element;
		}

		ss(selector, searchIn = document, all = false) {
			return all ? searchIn.querySelectorAll(selector) : searchIn.querySelector(selector);
		}

		checkForСompatibility() {
			for (let item of this.compatibilityItems) {
				if (!(item in unsafeWindow)) return false;
			}
			return true;
		}

		init() {
			if (!this.checkForСompatibility()) return alert('Sorry, your browser does not support this feature');
			document.addEventListener('DOMContentLoaded', this.onDOMContentLoaded.bind(this));
			unsafeWindow.addEventListener('pointerdown', this.onPointerDown.bind(this));
			this.shutDownConsole();
			this.setTriggerOnElement('#message', 'addedNodes', this.onContextMenu.bind(this));
		}

		onDOMContentLoaded() {
			this.addStyles();
		}

		addStyles() {
			this.cc('style', {
				textContent: this.style
			}, document.head);
		}

		shutDownConsole() {
			for (const key in Object.getOwnPropertyDescriptors(unsafeWindow.console)) unsafeWindow.console[key] = () => {};
		}

		onPointerDown(e) {
			if (e.which == 3) this.currentElement = e.target;
		}

		onContextMenu(menu) {
			const currentMessageBlock = this.currentElement.closest('[id*="chat-messages"]');
			const media = this.currentElement.closest('[class*=embedMedia]') || this.currentElement.closest('[class*="messageAttachment"]');
			const group = this.ss('[role="group"]', menu, true)[1];

			if (media !== null) {
				let mediaType;
				if (mediaType = this.ss('img', media)) {
					group.after(this.cc('div', {
						role: "group",
						className: this.ss('[role="group"]', menu, true).className,
						children: [
							this.generateSplitLine(),
							this.generateContextButton(mediaType, menu, group, 'Copy Image', async() => {
								await this.copyImage(mediaType);
								menu.remove();
							}),
							this.generateContextButton(mediaType, menu, group, 'Save Image', async() => {
								await this.downloadImage(mediaType);
								menu.remove();
							}),
							this.generateSplitLine(),
							this.generateContextButton(mediaType, menu, group, 'Copy Link', () => {
								GM_setClipboard(this.getFullOfDiscordImage(mediaType.src).split('?')[0]);
								menu.remove();
							}),
							this.generateContextButton(mediaType, menu, group, 'Open Link', async() => {
								window.open(this.getFullOfDiscordImage(mediaType.src).split('?')[0], '_blank');
								menu.remove();
							})
						]
					}));
					this.fixMenuPosition(menu);
				} else if ((mediaType = this.ss('video:not([aria-label="GIF"])', media))) {
					group.after(this.cc('div', {
						role: "group",
						className: this.ss('[role="group"]', menu, true).className,
						children: [
							this.generateSplitLine(),
							this.generateContextButton(mediaType, menu, group, 'Copy Link', () => {
								GM_setClipboard(this.getFullOfDiscordImage(mediaType.src).split('?')[0]);
								menu.remove();
							}),
							this.generateContextButton(mediaType, menu, group, 'Open Link', async() => {
								window.open(this.getFullOfDiscordImage(mediaType.src).split('?')[0], '_blank');
								menu.remove();
							})
						]
					}));
					this.fixMenuPosition(menu);
				} else if ((mediaType = this.ss('video[aria-label="GIF"]', media))) {
					group.after(this.cc('div', {
						role: "group",
						className: this.ss('[role="group"]', menu, true).className,
						children: [
							this.generateSplitLine(),
							this.generateContextButton(mediaType, menu, group, 'Copy Link', () => {
								GM_setClipboard(this.getFullOfDiscordImage(mediaType.src).split('?')[0]);
                                menu.remove();
							}),
							this.generateContextButton(mediaType, menu, group, 'Open Link', async() => {
								window.open(this.getFullOfDiscordImage(mediaType.src).split('?')[0], '_blank');
								menu.remove();
							})
						]
					}));
					this.fixMenuPosition(menu);
				} else {
					console.log('unknown type');
				}
			} else {
				console.log('null');
			}
		}

		fixMenuPosition(menu) {
			const rectM = menu.getBoundingClientRect();
			if (rectM.y + rectM.height > window.innerHeight) {
				const def = rectM.y + rectM.height - window.innerHeight;
				const top = menu.parentNode.style.top.replace('px', '');
				menu.parentNode.style.top = top - def + 'px';
			}
		}

		generateSplitLine() {
			return this.cc('div', {
				role: 'separator',
				style: 'box-sizing:border-box;margin:4px;border-bottom:1px solid var(--background-modifier-accent);'
			});
		}

		generateContextButton(mediaType, menu, group, text, onclick) {
			return this.cc('div', {
				role: "group",
				children: [
					this.cc('div', {
						className: group.children[1].className,
						children: [
							this.cc('div', {
								textContent: text,
								style: group.children[1].firstChild.className,
							})
						],
						onclick: onclick,
						onmouseenter: function() {
							const focused = menu.querySelector('[class*="focused"]');
							if (focused) focused.classList.remove(Array.from(focused.classList).find(_class => _class.startsWith('focused')));
							this.classList.add('my-focused');
						},
						onmouseleave: function() {
							this.classList.remove('my-focused');
						}
					})
				]
			})
		}

		setTriggerOnElement(selector, action, callback, once, searchIn) {
			const observer = new MutationObserver(function(mutations) {
				for (const mutation of mutations) {
					const nodes = mutation[action] || [];
					for (const node of nodes) {
						const element = node.matches && node.matches(selector) ? node : (node.querySelector ? node.querySelector(selector) : null);
						if (element) {
							if (once) {
								observer.disconnect();
								return callback(element);
							} else {
								callback(element);
							}
						}
					}
				}
			});

			observer.observe(searchIn || document, {
				attributes: false,
				childList: true,
				subtree: true
			});

			return observer;
		}

		async downloadImage(image) {
			const blob = await this.requestImage(this.getFullOfDiscordImage(image.src), 'blob');
			const dataURL = await this.blobToBase64(blob);
			this.cc('a', {
				href: dataURL,
				download: image.src.split('/').pop().split('?')[0]
			}).click();
		}

		async imageToPNGBlob(blob) {
			const dataURL = await this.blobToBase64(blob);
			console.log(dataURL);
		}

		blobToBase64(blob) {
			return new Promise((resolve) => {
				const reader = new FileReader();
				reader.onloadend = () => resolve(reader.result);
				reader.readAsDataURL(blob);
			});
		}

		async copyImage(image) {
			await (this.getExtension(image.src) != 'png' ? this.copyNonPNGImage(image) : this.copyPNGImage(image));
		}

		async copyNonPNGImage(image) {
			const blob = await this.requestImage(image.src, 'blob');
			const dataURL = await this.blobToBase64(blob);
			const pngBlob = await this.imageDataURLToPNGBlob(dataURL);
			this.copyImageFromBlob(pngBlob);
		}

		imageDataURLToPNGBlob(dataURL) {
			return new Promise(resolve => {
				const img = this.cc('img', {
					src: dataURL,
					onload: () => {
						const canvas = this.cc('canvas', {
							style: 'position:fixed;top:0px;left:0px;z-index:1000;',
							width: img.width,
							height: img.height
						});
						const context = canvas.getContext('2d');
						context.drawImage(img, 0, 0, img.width, img.height);
						canvas.toBlob(blob => resolve(blob));
					}
				});
			});
		}

		async copyPNGImage(image) {
			const imageBlob = await this.requestImage(this.getFullOfDiscordImage(image.src), 'blob');
			this.copyImageFromBlob(imageBlob);
		}

		copyImageFromBlob(blob) {
			const item = new unsafeWindow.ClipboardItem({
				"image/png": blob
			});
			navigator.clipboard.write([item]);
		}

		requestImage(imageURL, responseType) {
			return new Promise(function(resolve, reject) {
				GM_xmlhttpRequest({
					methods: "GET",
					responseType: responseType,
					url: imageURL,
					onload: function(data) {
						resolve(data.response);
					},
					onerror: function(e) {
						reject(e);
					}
				});
			});
		}

		getFullOfDiscordImage(imageURL) {
			return imageURL.replace('media.discordapp.net', 'cdn.discordapp.com');
		}

		getNoneDefaultStyle(node) {
			const styles = [];
			const supportElement = this.cc(node.tagName, {
				visible: false
			}, document.body);
			const elementStyles = window.getComputedStyle(node);
			const defaultStyles = window.getComputedStyle(supportElement);
			for (const key of elementStyles) {
				if (elementStyles[key] !== defaultStyles[key] && defaultStyles[key] !== '') styles.push([key, elementStyles[key]]);
			}
			supportElement.remove();
			return styles;
		}

		packInlineStyle(style) {
			return style.reduce(function(pre, cur) {
				return pre + ";" + cur[0] + ":" + cur[1];
			}, "") + ";";
		}

		getExtension(filename) {
			return filename.split('.').pop().split('?')[0];
		}
	}

	const DCME = new DiscordContextMenuExtender();
	DCME.init();

})();