Plugin

ExpressionCloner

Allows you to clone Emotes & Stickers to your own server (right click them)

Emotes Servers
index.tsx
Download

Source

src/plugins/expressionCloner/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 { migratePluginSettings } from "@api/Settings";
9import { BaseText } from "@components/BaseText";
10import { CheckedTextInput } from "@components/CheckedTextInput";
11import { Flex } from "@components/Flex";
12import { Devs } from "@utils/constants";
13import { getGuildAcronym } from "@utils/discord";
14import { Logger } from "@utils/Logger";
15import definePlugin from "@utils/types";
16import { Guild, GuildSticker } from "@vencord/discord-types";
17import { StickerFormatType } from "@vencord/discord-types/enums";
18import { findByCodeLazy } from "@webpack";
19import { Constants, EmojiStore, FluxDispatcher, Forms, GuildStore, IconUtils, Menu, Modal, openModalLazy, PermissionsBits, PermissionStore, React, RestAPI, StickersStore, Toasts, Tooltip, UserStore } from "@webpack/common";
20import { Promisable } from "type-fest";
21
22const uploadEmoji = findByCodeLazy(".GUILD_EMOJIS(", "EMOJI_UPLOAD_START");
23
24const getGuildMaxEmojiSlots = findByCodeLazy(".additionalEmojiSlots") as (guild: Guild) => number;
25
26interface Sticker extends GuildSticker {
27 t: "Sticker";
28}
29
30interface Emoji {
31 t: "Emoji";
32 id: string;
33 name: string;
34 isAnimated: boolean;
35}
36
37type Data = Emoji | Sticker;
38
39const StickerExtMap = {
40 [StickerFormatType.PNG]: "png",
41 [StickerFormatType.APNG]: "png",
42 [StickerFormatType.LOTTIE]: "json",
43 [StickerFormatType.GIF]: "gif"
44} as const;
45
46const PremiumTierStickerLimitMap = {
47 0: 5,
48 1: 15,
49 2: 30,
50 3: 60
51} as const;
52
53const MAX_EMOJI_SIZE_BYTES = 256 * 1024;
54const MAX_STICKER_SIZE_BYTES = 512 * 1024;
55
56function getGuildMaxStickerSlots(guild: Guild) {
57 if (guild.features.has("MORE_STICKERS") && guild.premiumTier === 3)
58 return 120;
59
60 return PremiumTierStickerLimitMap[guild.premiumTier] ?? PremiumTierStickerLimitMap[0];
61}
62
63function getUrl(data: Data, size: number) {
64 if (data.t === "Emoji")
65 return `${location.protocol}class="ts-cmt">//${window.GLOBAL_ENV.CDN_HOST}/emojis/${data.id}.webp?size=${size}&lossless=true&animated=true`;
66
67 return `${window.GLOBAL_ENV.MEDIA_PROXY_ENDPOINT}/stickers/${data.id}.${StickerExtMap[data.format_type]}?size=${size}&lossless=true&animated=true`;
68}
69
70async function fetchSticker(id: string) {
71 const cached = StickersStore.getStickerById(id);
72 if (cached) return cached;
73
74 const { body } = await RestAPI.get({
75 url: Constants.Endpoints.STICKER(id)
76 });
77
78 FluxDispatcher.dispatch({
79 type: "STICKER_FETCH_SUCCESS",
80 sticker: body
81 });
82
83 return body as Sticker;
84}
85
86async function cloneSticker(guildId: string, sticker: Sticker) {
87 const data = new FormData();
88 data.append("name", sticker.name);
89 data.append("tags", sticker.tags);
90 data.append("description", sticker.description);
91 data.append("file", await fetchBlob(sticker));
92
93 const { body } = await RestAPI.post({
94 url: Constants.Endpoints.GUILD_STICKER_PACKS(guildId),
95 body: data,
96 });
97
98 FluxDispatcher.dispatch({
99 type: "GUILD_STICKERS_CREATE_SUCCESS",
100 guildId,
101 sticker: {
102 ...body,
103 user: UserStore.getCurrentUser()
104 }
105 });
106}
107
108async function cloneEmoji(guildId: string, emoji: Emoji) {
109 const data = await fetchBlob(emoji);
110
111 const dataUrl = await new Promise<string>(resolve => {
112 const reader = new FileReader();
113 reader.onload = () => resolve(reader.result as string);
114 reader.readAsDataURL(data);
115 });
116
117 return uploadEmoji({
118 guildId,
119 name: emoji.name.split("~")[0],
120 image: dataUrl
121 });
122}
123
124function getGuildCandidates(data: Data) {
125 const meId = UserStore.getCurrentUser().id;
126
127 return Object.values(GuildStore.getGuilds()).filter(g => {
128 const canCreate = g.ownerId === meId ||
129 (PermissionStore.getGuildPermissions({ id: g.id }) & PermissionsBits.CREATE_GUILD_EXPRESSIONS) === PermissionsBits.CREATE_GUILD_EXPRESSIONS;
130 if (!canCreate) return false;
131
132 if (data.t === "Sticker") {
133 const stickerSlots = getGuildMaxStickerSlots(g);
134 const stickers = StickersStore.getStickersByGuildId(g.id);
135
136 return !stickers || stickers.length < stickerSlots;
137 }
138
139 const { isAnimated } = data as Emoji;
140
141 const emojiSlots = getGuildMaxEmojiSlots(g);
142 const emojis = EmojiStore.getGuildEmoji(g.id);
143
144 let count = 0;
145 for (const emoji of emojis) {
146 if (emoji.animated === isAnimated && !emoji.managed) {
147 count++;
148 }
149 }
150
151 return count < emojiSlots;
152 }).sort((a, b) => a.name.localeCompare(b.name));
153}
154
155async function fetchBlob(data: Data) {
156 const MAX_SIZE = data.t === "Sticker"
157 ? MAX_STICKER_SIZE_BYTES
158 : MAX_EMOJI_SIZE_BYTES;
159
160 for (let size = 4096; size >= 16; size /= 2) {
161 const url = getUrl(data, size);
162 const res = await fetch(url);
163 if (!res.ok)
164 throw new Error(`Failed to fetch ${url} - ${res.status}`);
165
166 const blob = await res.blob();
167 if (blob.size <= MAX_SIZE)
168 return blob;
169 }
170
171 throw new Error(`Failed to fetch ${data.t} within size limit of ${MAX_SIZE / 1000}kB`);
172}
173
174async function doClone(guildId: string, data: Sticker | Emoji) {
175 try {
176 if (data.t === "Sticker")
177 await cloneSticker(guildId, data);
178 else
179 await cloneEmoji(guildId, data);
180
181 Toasts.show({
182 message: `Successfully cloned ${data.name} to ${GuildStore.getGuild(guildId)?.name ?? "your server"}!`,
183 type: Toasts.Type.SUCCESS,
184 id: Toasts.genId()
185 });
186 } catch (e: any) {
187 let message = "Something went wrong (check console!)";
188 try {
189 message = JSON.parse(e.text).message;
190 } catch { }
191
192 new Logger("ExpressionCloner").error("Failed to clone", data.name, "to", guildId, e);
193 Toasts.show({
194 message: "Failed to clone: " + message,
195 type: Toasts.Type.FAILURE,
196 id: Toasts.genId()
197 });
198 }
199}
200
201const getFontSize = (s: string) => {
202 // [18, 18, 16, 16, 14, 12, 10]
203 const sizes = [20, 20, 18, 18, 16, 14, 12];
204 return sizes[s.length] ?? 4;
205};
206
207const nameValidator = /^\w+$/i;
208
209function CloneModal({ data }: { data: Sticker | Emoji; }) {
210 const [isCloning, setIsCloning] = React.useState(false);
211 const [name, setName] = React.useState(data.name);
212
213 const [x, invalidateMemo] = React.useReducer(x => x + 1, 0);
214
215 const guilds = React.useMemo(() => getGuildCandidates(data), [data.id, x]);
216
217 return (
218 <>
219 <Forms.FormTitle>Custom Name</Forms.FormTitle>
220 <CheckedTextInput
221 initialValue={name}
222 onChange={v => {
223 data.name = v;
224 setName(v);
225 }}
226 validate={v =>
227 (data.t === "Emoji" && v.length > 2 && v.length < 32 && nameValidator.test(v))
228 || (data.t === "Sticker" && v.length > 2 && v.length < 30)
229 || "Name must be between 2 and 32 characters and only contain alphanumeric characters"
230 }
231 />
232 <div style={{
233 display: "flex",
234 flexWrap: "wrap",
235 gap: "1em",
236 padding: "1em 0.5em",
237 justifyContent: "center",
238 alignItems: "center"
239 }}>
240 {guilds.map(g => (
241 <Tooltip key={g.id} text={g.name}>
242 {({ onMouseLeave, onMouseEnter }) => (
243 <div
244 onMouseLeave={onMouseLeave}
245 onMouseEnter={onMouseEnter}
246 role="button"
247 aria-label={"Clone to " + g.name}
248 aria-disabled={isCloning}
249 style={{
250 borderRadius: "50%",
251 backgroundColor: "var(--background-base-lower)",
252 display: "inline-flex",
253 justifyContent: "center",
254 alignItems: "center",
255 width: "4em",
256 height: "4em",
257 cursor: isCloning ? "not-allowed" : "pointer",
258 filter: isCloning ? "brightness(50%)" : "none"
259 }}
260 onClick={isCloning ? void 0 : async () => {
261 setIsCloning(true);
262 doClone(g.id, data).finally(() => {
263 invalidateMemo();
264 setIsCloning(false);
265 });
266 }}
267 >
268 {g.icon ? (
269 <img
270 aria-hidden
271 style={{
272 borderRadius: "50%",
273 width: "100%",
274 height: "100%",
275 }}
276 src={IconUtils.getGuildIconURL({
277 id: g.id,
278 icon: g.icon,
279 canAnimate: true,
280 size: 512
281 })}
282 alt={g.name}
283 />
284 ) : (
285 <Forms.FormText
286 style={{
287 fontSize: getFontSize(getGuildAcronym(g)),
288 width: "100%",
289 overflow: "hidden",
290 whiteSpace: "nowrap",
291 textAlign: "center",
292 cursor: isCloning ? "not-allowed" : "pointer",
293 }}
294 >
295 {getGuildAcronym(g)}
296 </Forms.FormText>
297 )}
298 </div>
299 )}
300 </Tooltip>
301 ))}
302 </div>
303 </>
304 );
305}
306
307function buildMenuItem(type: "Emoji" | "Sticker", fetchData: () => Promisable<Omit<Sticker | Emoji, "t">>) {
308 return (
309 <Menu.MenuItem
310 id="emote-cloner"
311 key="emote-cloner"
312 label={`Clone ${type}`}
313 action={() =>
314 openModalLazy(async () => {
315 const res = await fetchData();
316 const data = { t: type, ...res } as Sticker | Emoji;
317 const url = getUrl(data, 128);
318
319 return modalProps => (
320 <Modal
321 {...modalProps}
322 title={
323 <Flex gap="0.5em" alignItems="center">
324 <img
325 role="presentation"
326 aria-hidden
327 src={url}
328 alt=""
329 height={24}
330 width={24}
331 />
332 <BaseText tag="h3" size="md" weight="medium">Clone {data.name}</BaseText>
333 </Flex>
334 }
335 >
336 <CloneModal data={data} />
337 </Modal>
338 );
339 })
340 }
341 />
342 );
343}
344
345function isGifUrl(url: string) {
346 const u = new URL(url);
347 return u.pathname.endsWith(".gif") || u.searchParams.get("animated") === "true";
348}
349
350const messageContextMenuPatch: NavContextMenuPatchCallback = (children, props) => {
351 const { favoriteableId, itemHref, itemSrc, favoriteableType } = props ?? {};
352
353 if (!favoriteableId) return;
354
355 const menuItem = (() => {
356 switch (favoriteableType) {
357 case "emoji":
358 const match = props.message.content.match(RegExp(`<a?:(\\w+)(?:~\\d+)?:${favoriteableId}>|https:class="ts-cmt">//cdn\\.discordapp\\.com/emojis/${favoriteableId}\\.`));
359 const reaction = props.message.reactions.find(reaction => reaction.emoji.id === favoriteableId);
360 if (!match && !reaction) return;
361 const name = (match && match[1]) ?? reaction?.emoji.name ?? "FakeNitroEmoji";
362
363 return buildMenuItem("Emoji", () => ({
364 id: favoriteableId,
365 name,
366 isAnimated: isGifUrl(itemHref ?? itemSrc)
367 }));
368 case "sticker":
369 const sticker = props.message.stickerItems.find(s => s.id === favoriteableId);
370 if (sticker?.format_type === 3 /* LOTTIE */) return;
371
372 return buildMenuItem("Sticker", () => fetchSticker(favoriteableId));
373 }
374 })();
375
376 if (menuItem)
377 findGroupChildrenByChildId("copy-link", children)?.push(menuItem);
378};
379
380const expressionPickerPatch: NavContextMenuPatchCallback = (children, props: { target: HTMLElement; }) => {
381 const { id, name, type } = props?.target?.dataset ?? {};
382 if (!id) return;
383
384 if (type === "emoji" && name) {
385 const firstChild = props.target.firstChild as HTMLImageElement;
386
387 children.push(buildMenuItem("Emoji", () => ({
388 id,
389 name,
390 isAnimated: firstChild && isGifUrl(firstChild.src)
391 })));
392 } else if (type === "sticker" && !props.target.className?.includes("lottieCanvas")) {
393 children.push(buildMenuItem("Sticker", () => fetchSticker(id)));
394 }
395};
396
397migratePluginSettings("ExpressionCloner", "EmoteCloner");
398export default definePlugin({
399 name: "ExpressionCloner",
400 description: "Allows you to clone Emotes & Stickers to your own server (right click them)",
401 tags: ["Emotes", "Servers"],
402 searchTerms: ["StickerCloner", "EmoteCloner", "EmojiCloner"],
403 authors: [Devs.Ven, Devs.Nuckyz],
404 contextMenus: {
405 "message": messageContextMenuPatch,
406 "expression-picker": expressionPickerPatch
407 }
408});
409