Plugin
ExpressionCloner
Allows you to clone Emotes & Stickers to your own server (right click them)
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 { migratePluginSettings } from "@api/Settings";9
import { BaseText } from "@components/BaseText";10
import { CheckedTextInput } from "@components/CheckedTextInput";11
import { Flex } from "@components/Flex";12
import { Devs } from "@utils/constants";13
import { getGuildAcronym } from "@utils/discord";14
import { Logger } from "@utils/Logger";15
import definePlugin from "@utils/types";16
import { Guild, GuildSticker } from "@vencord/discord-types";17
import { StickerFormatType } from "@vencord/discord-types/enums";18
import { findByCodeLazy } from "@webpack";19
import { Constants, EmojiStore, FluxDispatcher, Forms, GuildStore, IconUtils, Menu, Modal, openModalLazy, PermissionsBits, PermissionStore, React, RestAPI, StickersStore, Toasts, Tooltip, UserStore } from "@webpack/common";20
import { Promisable } from "type-fest";21
22
const uploadEmoji = findByCodeLazy(".GUILD_EMOJIS(", "EMOJI_UPLOAD_START");23
24
const getGuildMaxEmojiSlots = findByCodeLazy(".additionalEmojiSlots") as (guild: Guild) => number;25
26
interface Sticker extends GuildSticker {27
t: "Sticker";28
}29
30
interface Emoji {31
t: "Emoji";32
id: string;33
name: string;34
isAnimated: boolean;35
}36
37
type Data = Emoji | Sticker;38
39
const StickerExtMap = {40
[StickerFormatType.PNG]: "png",41
[StickerFormatType.APNG]: "png",42
[StickerFormatType.LOTTIE]: "json",43
[StickerFormatType.GIF]: "gif"44
} as const;45
46
const PremiumTierStickerLimitMap = {47
0: 5,48
1: 15,49
2: 30,50
3: 6051
} as const;52
53
const MAX_EMOJI_SIZE_BYTES = 256 * 1024;54
const MAX_STICKER_SIZE_BYTES = 512 * 1024;55
56
function 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
63
function 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
70
async 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: body81
});82
83
return body as Sticker;84
}85
86
async 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
108
async 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: dataUrl121
});122
}123
124
function 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
155
async function fetchBlob(data: Data) {156
const MAX_SIZE = data.t === "Sticker"157
? MAX_STICKER_SIZE_BYTES158
: 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
174
async function doClone(guildId: string, data: Sticker | Emoji) {175
try {176
if (data.t === "Sticker")177
await cloneSticker(guildId, data);178
else179
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
201
const 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
207
const nameValidator = /^\w+$/i;208
209
function 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
<CheckedTextInput221
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
<div244
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
<img270
aria-hidden271
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: 512281
})}282
alt={g.name}283
/>284
) : (285
<Forms.FormText286
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
307
function buildMenuItem(type: "Emoji" | "Sticker", fetchData: () => Promisable<Omit<Sticker | Emoji, "t">>) {308
return (309
<Menu.MenuItem310
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
<Modal321
{...modalProps}322
title={323
<Flex gap="0.5em" alignItems="center">324
<img325
role="presentation"326
aria-hidden327
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
345
function isGifUrl(url: string) {346
const u = new URL(url);347
return u.pathname.endsWith(".gif") || u.searchParams.get("animated") === "true";348
}349
350
const 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
380
const 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
397
migratePluginSettings("ExpressionCloner", "EmoteCloner");398
export 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": expressionPickerPatch407
}408
});409