/* * Vencord, a Discord client mod * Copyright (c) 2025 Vendicated and contributors * SPDX-License-Identifier: GPL-3.0-or-later */ import { findGroupChildrenByChildId } from "@api/ContextMenu"; import { definePluginSettings } from "@api/Settings"; import { FormSwitch } from "@components/FormSwitch"; import { Devs } from "@utils/constants"; import { getCurrentChannel } from "@utils/discord"; import { ModalCloseButton, ModalContent, ModalHeader, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal"; import definePlugin, { OptionType } from "@utils/types"; import { Message } from "@vencord/discord-types"; import { Button, Menu, Select, Text, UploadHandler, useEffect, useState } from "@webpack/common"; import { QuoteIcon } from "./components"; import { canvasToBlob, fetchImageAsBlob, FixUpQuote, wrapText } from "./utils"; enum ImageStyle { inspirational } enum userIDOptions { displayName, userName, userId } interface QuoteImageOptions { avatarUrl: string; quoteOld: string; grayScale: boolean; imageStyle: ImageStyle; author: { username: string; globalName?: string; id: string; }; userIdentifier: userIDOptions; } const settings = definePluginSettings({ userIdentifier: { type: OptionType.SELECT, description: "What the author's name should be displayed as", options: [ { label: "Display Name", value: userIDOptions.displayName, default: true }, { label: "Username", value: userIDOptions.userName }, { label: "User ID", value: userIDOptions.userId } ] } }); export default definePlugin({ name: "Quoter", description: "Adds the ability to create an inspirational quote image from a message", authors: [Devs.Samwich, Devs.thororen], settings, contextMenus: { "message": (children, { message }) => { if (!message.content) return; const buttonElement = ( openModal(props => )} /> ); const group = findGroupChildrenByChildId("copy-text", children); if (!group) children.push(buttonElement); else group.splice(group.findIndex(c => c?.props?.id === "copy-text") + 1, 0, buttonElement); } } }); function sizeUpgrade(url: string) { const u = new URL(url); u.searchParams.set("size", "512"); return u.toString(); } const preparingSentence: string[] = []; const lines: string[] = []; async function createQuoteImage(options: QuoteImageOptions): Promise { const { avatarUrl, quoteOld, grayScale, imageStyle, author, userIdentifier } = options; const quote = FixUpQuote(quoteOld); const canvas = document.createElement("canvas"); const ctx = canvas.getContext("2d"); if (!ctx) throw new Error("Cant get 2d rendering context :("); let name = ""; switch (userIdentifier) { case userIDOptions.displayName: name = author.globalName || author.username; break; case userIDOptions.userName: name = author.username; break; case userIDOptions.userId: name = author.id; break; default: name = "Unknown"; } switch (imageStyle) { case ImageStyle.inspirational: const cardWidth = 1200; const cardHeight = 600; canvas.width = cardWidth; canvas.height = cardHeight; ctx.fillStyle = "#000"; ctx.fillRect(0, 0, cardWidth, cardHeight); const avatarBlob = await fetchImageAsBlob(avatarUrl); const fadeBlob = await fetchImageAsBlob("https://raw.githubusercontent.com/Equicord/Equibored/main/icons/quoter/quoter.png"); const avatar = new Image(); const fade = new Image(); await Promise.all([ new Promise(resolve => { avatar.onload = () => resolve(); avatar.src = URL.createObjectURL(avatarBlob); }), new Promise(resolve => { fade.onload = () => resolve(); fade.src = URL.createObjectURL(fadeBlob); }) ]); ctx.drawImage(avatar, 0, 0, cardHeight, cardHeight); if (grayScale) { ctx.globalCompositeOperation = "saturation"; ctx.fillStyle = "#fff"; ctx.fillRect(0, 0, cardWidth, cardHeight); ctx.globalCompositeOperation = "source-over"; } ctx.drawImage(fade, cardHeight - 400, 0, 400, cardHeight); ctx.fillStyle = "#fff"; ctx.font = "italic 20px Georgia"; const quoteWidth = cardWidth / 2 - 50; const quoteX = cardWidth - cardHeight; const quoteY = cardHeight / 2 - 10; wrapText(ctx, `"${quote}"`, quoteX, quoteY, quoteWidth, 20, preparingSentence, lines); const wrappedTextHeight = lines.length * 25; ctx.font = "bold 16px Georgia"; const authorNameX = cardHeight * 1.5 - ctx.measureText(`- ${name}`).width / 2 - 30; const authorNameY = quoteY + wrappedTextHeight + 30; ctx.fillText(`- ${name}`, authorNameX, authorNameY); preparingSentence.length = 0; lines.length = 0; return await canvasToBlob(canvas); } } function generateFileNamePreview(message: string) { const words = message.split(" "); return words.length >= 6 ? words.slice(0, 6).join(" ") : words.join(" "); } function QuoteModal({ message, ...props }: ModalProps & { message: Message; }) { const [gray, setGray] = useState(true); const [style, setStyle] = useState(ImageStyle.inspirational); const [quoteImage, setQuoteImage] = useState(null); const { userIdentifier } = settings.store; const safeContent = message.content ? message.content : ""; const generateImage = async () => { const image = await createQuoteImage({ avatarUrl: sizeUpgrade(message.author.getAvatarURL()), quoteOld: safeContent, grayScale: gray, imageStyle: style, author: message.author, userIdentifier }); setQuoteImage(image); document.getElementById("quoterPreview")?.setAttribute("src", URL.createObjectURL(image)); }; useEffect(() => { generateImage(); }, [gray, style, safeContent]); const Export = () => { if (!quoteImage) return; const link = document.createElement("a"); const preview = generateFileNamePreview(safeContent); link.href = URL.createObjectURL(quoteImage); link.download = `${preview} - ${message.author.username}.png`; link.click(); link.remove(); }; const SendInChat = () => { if (!quoteImage) return; const preview = generateFileNamePreview(safeContent); const file = new File([quoteImage], `${preview} - ${message.author.username}.png`, { type: "image/png" }); // @ts-expect-error typing issue UploadHandler.promptToUpload([file], getCurrentChannel(), 0); props.onClose?.(); }; return ( Catch Them In 4K.