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