Plugin
ExpressionCloner
Allows you to clone Emotes & Stickers to your own server (right click them)
1
import { findGroupChildrenByChildId, NavContextMenuPatchCallback } from "@api/ContextMenu";2
import { migratePluginSettings } from "@api/Settings";3
import { BaseText } from "@components/BaseText";4
import { CheckedTextInput } from "@components/CheckedTextInput";5
import { Flex } from "@components/Flex";6
import { Devs } from "@utils/constants";7
import { getGuildAcronym } from "@utils/discord";8
import { Logger } from "@utils/Logger";9
import definePlugin from "@utils/types";10
import { Guild, GuildSticker } from "@vencord/discord-types";11
import { StickerFormatType } from "@vencord/discord-types/enums";12
import { findByCodeLazy } from "@webpack";13
import { Constants, EmojiStore, FluxDispatcher, Forms, GuildStore, IconUtils, Menu, Modal, openModalLazy, PermissionsBits, PermissionStore, React, RestAPI, StickersStore, Toasts, Tooltip, UserStore } from "@webpack/common";14
import { Promisable } from "type-fest";15
16
const uploadEmoji = findByCodeLazy(".GUILD_EMOJIS(", "EMOJI_UPLOAD_START");17
18
const getGuildMaxEmojiSlots = findByCodeLazy(".additionalEmojiSlots") as (guild: Guild) => number;19
20
interface Sticker extends GuildSticker {21
t: "Sticker";22
}23
24
interface Emoji {25
t: "Emoji";26
id: string;27
name: string;28
isAnimated: boolean;29
}30
31
type Data = Emoji | Sticker;32
33
const StickerExtMap = {34
[StickerFormatType.PNG]: "png",35
[StickerFormatType.APNG]: "png",36
[StickerFormatType.LOTTIE]: "json",37
[StickerFormatType.GIF]: "gif"38
} as const;39
40
const PremiumTierStickerLimitMap = {41
0: 5,42
1: 15,43
2: 30,44
3: 6045
} as const;46
47
const MAX_EMOJI_SIZE_BYTES = 256 * 1024;48
const MAX_STICKER_SIZE_BYTES = 512 * 1024;49
50
function 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
57
function 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
64
async 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: body75
});76
77
return body as Sticker;78
}79
80
async 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
102
async 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: dataUrl115
});116
}117
118
function 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
149
async function fetchBlob(data: Data) {150
const MAX_SIZE = data.t === "Sticker"151
? MAX_STICKER_SIZE_BYTES152
: 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
168
async function doClone(guildId: string, data: Sticker | Emoji) {169
try {170
if (data.t === "Sticker")171
await cloneSticker(guildId, data);172
else173
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
195
const 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
201
const nameValidator = /^\w+$/i;202
203
function 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
<CheckedTextInput215
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
<div238
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
<img264
aria-hidden265
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: 512275
})}276
alt={g.name}277
/>278
) : (279
<Forms.FormText280
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
301
function buildMenuItem(type: "Emoji" | "Sticker", fetchData: () => Promisable<Omit<Sticker | Emoji, "t">>) {302
return (303
<Menu.MenuItem304
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
<Modal315
{...modalProps}316
title={317
<Flex gap="0.5em" alignItems="center">318
<img319
role="presentation"320
aria-hidden321
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
339
function isGifUrl(url: string) {340
const u = new URL(url);341
return u.pathname.endsWith(".gif") || u.searchParams.get("animated") === "true";342
}343
344
const 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
374
const 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
391
migratePluginSettings("ExpressionCloner", "EmoteCloner");392
export 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": expressionPickerPatch401
}402
});403