Plugin
Quoter
Adds the ability to create a quote image from a message
1
/*2
* FiveCord — a Discord client mod3
* Copyright (c) 2025 FiveCord4
* SPDX-License-Identifier: GPL-3.0-or-later5
*/6
7
import { findGroupChildrenByChildId, NavContextMenuPatchCallback } from "@api/ContextMenu";8
import { Devs } from "@utils/constants";9
import { getCurrentChannel } from "@utils/discord";10
import { ModalCloseButton, ModalContent, ModalHeader, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal";11
import definePlugin from "@utils/types";12
import { Button, Menu, Switch, Text, UploadHandler, useEffect, useState } from "@webpack/common";13
import { Message } from "discord-types/general";14
15
let recentmessage: Message;16
let grayscale;17
18
const 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.MenuItem29
id="vc-quote"30
label="Quote"31
icon={QuoteIcon}32
action={async () => {33
openModal(props => <QuoteModal {...props} />);34
}}35
/>36
);37
};38
39
export 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": messagePatch45
}46
});47
48
49
export function QuoteIcon({50
height = 24,51
width = 24,52
className53
}: {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
<path61
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
67
function sizeUpgrade(url) {68
const u = new URL(url);69
u.searchParams.set("size", "1024");70
return u.toString();71
}72
73
74
let preparingSentence: string[] = [];75
const lines: string[] = [];76
77
78
async 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: number159
) {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
196
function 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
225
async 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
237
async 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
251
async 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
257
function 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