diff --git a/src/plugins/svgEmbed/README.md b/src/plugins/svgEmbed/README.md new file mode 100644 index 000000000..f790dc0ad --- /dev/null +++ b/src/plugins/svgEmbed/README.md @@ -0,0 +1,3 @@ +# SVGEmbed + +Makes SVG files which are uploaded directly to Discord or linked via `discord.com`/`cdn.discordapp.com` embed like normal images. diff --git a/src/plugins/svgEmbed/index.ts b/src/plugins/svgEmbed/index.ts new file mode 100644 index 000000000..9e07da6ea --- /dev/null +++ b/src/plugins/svgEmbed/index.ts @@ -0,0 +1,205 @@ +/* + * Vencord, a modification for Discord's desktop app + * Copyright (c) 2023 Vendicated and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . +*/ + +import { definePluginSettings } from "@api/Settings"; +import { Devs } from "@utils/constants"; +import definePlugin, { OptionType } from "@utils/types"; +import { FluxDispatcher } from "@webpack/common"; +import Message from "discord-types/general/Message"; + +const EMBED_SUPPRESSED = 1 << 2; + +const MAX_EMBEDS_PER_MESSAGE = 5; +const MIN_SVG_WIDTH = 400; +const MIN_SVG_HEIGHT = 350; +// Limit the size to prevent lag when parsing big files +const MAX_SVG_SIZE_MB = 10; + +const URL_REGEX = new RegExp( + /(?)/g, +); + +// Cache to avoid excessive requests in component updates +const FileSizeCache: Map = new Map(); +async function checkFileSize(url: string) { + if (FileSizeCache.has(url)) { + return FileSizeCache.get(url); + } + + let belowMaxSize = false; + try { + // We cannot check Content-Length due to Access-Control-Expose-Headers. + // Instead, we request 1 byte past the size limit, and see if it succeeds. + const res = await fetch(url, { + method: "HEAD", + headers: { + Range: `bytes=${MAX_SVG_SIZE_MB * 1024 * 1024}-${MAX_SVG_SIZE_MB * 1024 * 1024 + 1}` + } + }); + + if (res.status === 416 /* Range Not Satisfiable */) { + belowMaxSize = true; + } + } catch (ex) { } + + FileSizeCache.set(url, belowMaxSize); + return belowMaxSize; +} + +async function getSVGDimensions(svgUrl: string) { + let width = 0, height = 0; + let svgData: string; + + try { + const res = await fetch(svgUrl); + svgData = await res.text(); + } catch (ex) { + return { width, height }; + } + + const svgElement = new DOMParser().parseFromString(svgData, "image/svg+xml").documentElement as unknown as SVGSVGElement; + + // Return 0,0 on error, so that the renderer falls back to displaying the raw content + const errorNode = svgElement.querySelector("parsererror"); + if (errorNode) { + return { width, height }; + } + + if (svgElement.width && svgElement.height && svgElement.width.baseVal.unitType === 1 && svgElement.height.baseVal.unitType === 1) { + width = svgElement.width.baseVal.value; + height = svgElement.height.baseVal.value; + } else { + width = svgElement.viewBox.baseVal.width; + height = svgElement.viewBox.baseVal.height; + } + + // If the dimensions are below the minimum values, + // scale them up by the smallest integer which makes at least 1 of them exceed it + if (width < MIN_SVG_WIDTH && height < MIN_SVG_HEIGHT) { + const multiplier = Math.ceil(Math.min(MIN_SVG_WIDTH / width, MIN_SVG_HEIGHT / height)); + width *= multiplier; + height *= multiplier; + } + + return { width, height }; +} + +const settings = definePluginSettings({ + embedLinks: { + type: OptionType.BOOLEAN, + description: "Embed SVG links hosted under discord.com and cdn.discordapp.com", + default: true, + restartNeeded: true + }, +}); + +export default definePlugin({ + name: "SVGEmbed", + description: "Makes SVG files embed as images.", + authors: [Devs.amia, Devs.nakoyasha], + settings: settings, + + patches: [ + { + find: "png|jpe?g|webp|gif|", + replacement: { + match: /(?<=png\|jpe\?g\|webp\|gif\|)/, + replace: "svg|" + } + }, + { + find: ".Messages.REMOVE_ATTACHMENT_BODY", + replacement: [ + { + match: /(?<=renderAttachments\(\i\){)/, + replace: "$self.processAttachments(arguments[0]);" + }, + { + match: /(?<=renderEmbeds\(\i\){)/, + predicate: () => settings.store.embedLinks, + replace: "$self.processEmbeds(arguments[0]);" + } + ] + } + ], + + async processAttachments(message: Message) { + let shouldUpdateMessage = false; + + const toProcess = message.attachments.filter(x => x.content_type?.startsWith("image/svg+xml") && x.width == null && x.height == null); + for (const attachment of toProcess) { + if (attachment.size / 1024 / 1024 > MAX_SVG_SIZE_MB) continue; + + const { width, height } = await getSVGDimensions(attachment.url); + attachment.width = width; + attachment.height = height; + + // Change the media.discordapp.net url to use cdn.discordapp.com + // since the media one will return http 415 for svgs + attachment.proxy_url = attachment.url; + + shouldUpdateMessage = true; + } + + if (shouldUpdateMessage) { + FluxDispatcher.dispatch({ type: "MESSAGE_UPDATE", message }); + } + }, + + currentlyProcessing: new Set(), + async processEmbeds(message: Message) { + if (message.state !== "SENT") return; + if (message.hasFlag(EMBED_SUPPRESSED)) return; + + if (this.currentlyProcessing.has(message.id)) return; + this.currentlyProcessing.add(message.id); + + let shouldUpdateMessage = false; + + const svgUrls = new Set(message.content.match(URL_REGEX)); + const existingUrls = new Set(message.embeds.filter(e => e.type === "image").map(e => e.image?.url)); + + let imageEmbedsCount = existingUrls.size; + for (const url of [...svgUrls.values()]) { + if (imageEmbedsCount >= MAX_EMBEDS_PER_MESSAGE) break; + if (existingUrls.has(url)) continue; + + const belowMaxSize = await checkFileSize(url); + if (!belowMaxSize) continue; + + const { width, height } = await getSVGDimensions(url); + // @ts-ignore ~ bad types + message.embeds.push({ + id: "embed_1", // The id can be anything as it seems to be changed by the client anyways + url, + type: "image", + image: { url, proxyURL: url, width, height }, + fields: [] + }); + + imageEmbedsCount++; + shouldUpdateMessage = true; + } + + if (shouldUpdateMessage) { + FluxDispatcher.dispatch({ type: "MESSAGE_UPDATE", message }); + } + + this.currentlyProcessing.delete(message.id); + } +});