Plugin

Quoter

Adds the ability to create a quote image from a message

index.tsx
Download

Source

src/plugins/quoter/index.tsx
1/*
2 * FiveCord — a Discord client mod
3 * Copyright (c) 2025 FiveCord
4 * SPDX-License-Identifier: GPL-3.0-or-later
5 */
6
7import { findGroupChildrenByChildId, NavContextMenuPatchCallback } from "@api/ContextMenu";
8import { Devs } from "@utils/constants";
9import { getCurrentChannel } from "@utils/discord";
10import { ModalCloseButton, ModalContent, ModalHeader, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal";
11import definePlugin from "@utils/types";
12import { Button, Menu, Switch, Text, UploadHandler, useEffect, useState } from "@webpack/common";
13import { Message } from "discord-types/general";
14
15let recentmessage: Message;
16let grayscale;
17
18const messagePatch: NavContextMenuPatchCallback = (children, { message }) => () => {
19 recentmessage = message;
20 if (!message.content) return;
21
22 const group = findGroupChildrenByChildId("copy-text", children);
23 if (!group) return;
24
25 group.splice(
26 group.findIndex(c => c?.props?.id === "copy-text") + 1,
27 0,
28 <Menu.MenuItem
29 id="vc-quote"
30 label="Quote"
31 icon={QuoteIcon}
32 action={async () => {
33 openModal(props => <QuoteModal {...props} />);
34 }}
35 />
36 );
37};
38
39export default definePlugin({
40 name: "Quoter",
41 description: "Adds the ability to create a quote image from a message",
42 authors: [Devs.FiveCord],
43 contextMenus: {
44 "message": messagePatch
45 }
46});
47
48
49export function QuoteIcon({
50 height = 24,
51 width = 24,
52 className
53}: {
54 height?: number;
55 width?: number;
56 className?: string;
57}) {
58 return (
59 <svg xmlns="http:class="ts-cmt">//www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
60 <path
61 d="M21 3C21.5523 3 22 3.44772 22 4V18C22 18.5523 21.5523 19 21 19H6.455L2 22.5V4C2 3.44772 2.44772 3 3 3H21ZM20 5H4V18.385L5.76333 17H20V5ZM10.5153 7.4116L10.9616 8.1004C9.29402 9.0027 9.32317 10.4519 9.32317 10.7645C9.47827 10.7431 9.64107 10.7403 9.80236 10.7553C10.7045 10.8389 11.4156 11.5795 11.4156 12.5C11.4156 13.4665 10.6321 14.25 9.66558 14.25C9.12905 14.25 8.61598 14.0048 8.29171 13.6605C7.77658 13.1137 7.5 12.5 7.5 11.5052C7.5 9.75543 8.72825 8.18684 10.5153 7.4116ZM15.5153 7.4116L15.9616 8.1004C14.294 9.0027 14.3232 10.4519 14.3232 10.7645C14.4783 10.7431 14.6411 10.7403 14.8024 10.7553C15.7045 10.8389 16.4156 11.5795 16.4156 12.5C16.4156 13.4665 15.6321 14.25 14.6656 14.25C14.1291 14.25 13.616 14.0048 13.2917 13.6605C12.7766 13.1137 12.5 12.5 12.5 11.5052C12.5 9.75543 13.7283 8.18684 15.5153 7.4116Z"
62 ></path>
63 </svg>
64 );
65}
66
67function sizeUpgrade(url) {
68 const u = new URL(url);
69 u.searchParams.set("size", "1024");
70 return u.toString();
71}
72
73
74let preparingSentence: string[] = [];
75const lines: string[] = [];
76
77
78async function createQuoteImage(avatarUrl: string, name: string, quoteOld: string, grayScale: boolean): Promise<Blob> {
79 const quote = removeCustomEmojis(quoteOld);
80 const canvas = document.createElement("canvas");
81 const ctx = canvas.getContext("2d");
82
83 if (!ctx) {
84 throw new Error("Cant get 2d rendering context :(");
85 }
86
87 const cardWidth = 1200;
88 const cardHeight = 600;
89
90 canvas.width = cardWidth;
91 canvas.height = cardHeight;
92
93 ctx.fillStyle = "#000";
94 ctx.fillRect(0, 0, canvas.width, canvas.height);
95
96 const avatarBlob = await fetchImageAsBlob(avatarUrl);
97 const fadeBlob = await fetchImageAsBlob("https:class="ts-cmt">//files.catbox.moe/54e96l.png");
98
99 const avatar = new Image();
100 const fade = new Image();
101
102 const avatarPromise = new Promise<void>(resolve => {
103 avatar.onload = () => resolve();
104 avatar.src = URL.createObjectURL(avatarBlob);
105 });
106
107 const fadePromise = new Promise<void>(resolve => {
108 fade.onload = () => resolve();
109 fade.src = URL.createObjectURL(fadeBlob);
110 });
111
112 await Promise.all([avatarPromise, fadePromise]);
113
114 if (grayScale) {
115 ctx.drawImage(avatar, 0, 0, cardHeight, cardHeight);
116 ctx.globalCompositeOperation = "saturation";
117 ctx.fillStyle = "#fff";
118 ctx.fillRect(0, 0, cardWidth, cardHeight);
119 ctx.globalCompositeOperation = "source-over";
120 } else {
121 ctx.drawImage(avatar, 0, 0, cardHeight, cardHeight);
122 }
123 ctx.drawImage(fade, cardHeight - 400, 0, 400, cardHeight);
124
125 ctx.fillStyle = "#fff";
126 ctx.font = "italic 20px Georgia";
127 const quoteWidth = cardWidth / 2 - 50;
128 const quoteX = ((cardWidth - cardHeight));
129 const quoteY = cardHeight / 2 - 10;
130 wrapText(ctx, quote, quoteX, quoteY, quoteWidth, 20);
131
132 const wrappedTextHeight = lines.length * 25;
133
134 ctx.font = "bold 16px Georgia";
135 const authorNameX = (cardHeight * 1.5) - (ctx.measureText(`- ${name}`).width / 2) - 30;
136 const authorNameY = quoteY + wrappedTextHeight + 30;
137
138 ctx.fillText(`- ${name}`, authorNameX, authorNameY);
139 preparingSentence.length = 0;
140 lines.length = 0;
141 return new Promise<Blob>(resolve => {
142 canvas.toBlob(blob => {
143 if (blob) {
144
145 resolve(blob);
146 } else {
147 throw new Error("Failed to create Blob");
148 }
149 }, "image/png");
150 });
151
152 function wrapText(
153 context: CanvasRenderingContext2D,
154 text: string,
155 x: number,
156 y: number,
157 maxWidth: number,
158 lineHeight: number
159 ) {
160 const words = text.split(" ");
161 for (let i = 0; i < words.length; i++) {
162 const workSentence = preparingSentence.join(" ") + " " + words[i];
163
164 if (context.measureText(workSentence).width > maxWidth) {
165 lines.push(preparingSentence.join(" "));
166 preparingSentence = [words[i]];
167 } else {
168 preparingSentence.push(words[i]);
169 }
170 }
171
172 lines.push(preparingSentence.join(" "));
173
174 lines.forEach(element => {
175 const lineWidth = context.measureText(element).width;
176 const xOffset = (maxWidth - lineWidth) / 2;
177
178 y += lineHeight;
179 context.fillText(element, x + xOffset, y);
180 });
181 }
182
183 async function fetchImageAsBlob(url: string): Promise<Blob> {
184 const response = await fetch(url);
185 const blob = await response.blob();
186 return blob;
187 }
188
189 function removeCustomEmojis(quote) {
190 const emojiRegex = /<a?:(\w+):(\d+)>/g;
191 return quote.replace(emojiRegex, "");
192 }
193
194}
195
196function QuoteModal(props: ModalProps) {
197 const [gray, setGray] = useState(true);
198 useEffect(() => {
199 grayscale = gray;
200 GeneratePreview();
201
202 }, [gray]);
203 return (
204
205 <ModalRoot {...props} size={ModalSize.MEDIUM}>
206 <ModalHeader separator={false}>
207 <Text color="header-primary" variant="heading-lg/semibold" tag="h1" style={{ flexGrow: 1 }}>
208 Catch Them In 4K.
209 </Text>
210 <ModalCloseButton onClick={props.onClose} />
211 </ModalHeader>
212 <ModalContent scrollbarType="none">
213 <img src={""} id={"quoterPreview"} style={{ borderRadius: "20px", width: "100%" }}></img>
214 <br></br><br></br>
215 <Switch value={gray} onChange={setGray}>Grayscale</Switch>
216 <Button color={Button.Colors.BRAND_NEW} size={Button.Sizes.SMALL} onClick={() => Export()} style={{ display: "inline-block", marginRight: "5px" }}>Export</Button>
217 <Button color={Button.Colors.BRAND_NEW} size={Button.Sizes.SMALL} onClick={() => SendInChat(props.onClose)} style={{ display: "inline-block" }}>Send</Button>
218
219 </ModalContent>
220 <br></br>
221 </ModalRoot>
222 );
223}
224
225async function SendInChat(onClose) {
226
227 const image = await createQuoteImage(sizeUpgrade(recentmessage.author.getAvatarURL()), recentmessage.author.username, recentmessage.content, grayscale);
228 const preview = generateFileNamePreview(recentmessage.content);
229 const imageName = `${preview} - ${recentmessage.author.username}`;
230 const file = new File([image], `${imageName}.png`, { type: "image/png" });
231 UploadHandler.promptToUpload([file], getCurrentChannel(), 0);
232 onClose();
233
234}
235
236
237async function Export() {
238 const image = await createQuoteImage(sizeUpgrade(recentmessage.author.getAvatarURL()), recentmessage.author.username, recentmessage.content, grayscale);
239 const link = document.createElement("a");
240 link.href = URL.createObjectURL(image);
241 const preview = generateFileNamePreview(recentmessage.content);
242
243 const imageName = `${preview} - ${recentmessage.author.username}`;
244 link.download = `${imageName}.png`;
245 link.click();
246 link.remove();
247}
248
249
250
251async function GeneratePreview() {
252 const image = await createQuoteImage(sizeUpgrade(recentmessage.author.getAvatarURL()), recentmessage.author.username, recentmessage.content, grayscale);
253 document.getElementById("quoterPreview")?.setAttribute("src", URL.createObjectURL(image));
254}
255
256
257function generateFileNamePreview(message) {
258 const words = message.split(" ");
259 let preview;
260 if (words.length >= 6) {
261 preview = words.slice(0, 6).join(" ");
262 }
263 else {
264 preview = words.slice(0, words.length).join(" ");
265 }
266 return preview;
267}
268