/* * Vencord, a modification for Discord's desktop app * Copyright (c) 2022 Vendicated and contributors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ import { addContextMenuPatch, findGroupChildrenByChildId, NavContextMenuPatchCallback, removeContextMenuPatch } from "@api/ContextMenu"; import { CheckedTextInput } from "@components/CheckedTextInput"; import { Devs } from "@utils/constants"; import { Logger } from "@utils/Logger"; import { Margins } from "@utils/margins"; import { ModalContent, ModalHeader, ModalRoot, openModalLazy } from "@utils/modal"; import definePlugin from "@utils/types"; import { findByCodeLazy, findStoreLazy } from "@webpack"; import { FluxDispatcher, Forms, GuildStore, Menu, PermissionStore, React, RestAPI, Toasts, Tooltip, UserStore } from "@webpack/common"; import { Promisable } from "type-fest"; const MANAGE_EMOJIS_AND_STICKERS = 1n << 30n; const GuildEmojiStore = findStoreLazy("EmojiStore"); const StickersStore = findStoreLazy("StickersStore"); const uploadEmoji = findByCodeLazy('"EMOJI_UPLOAD_START"', "GUILD_EMOJIS("); interface Sticker { t: "Sticker"; description: string; format_type: number; guild_id: string; id: string; name: string; tags: string; type: number; } interface Emoji { t: "Emoji"; id: string; name: string; isAnimated: boolean; } type Data = Emoji | Sticker; const StickerExt = [, "png", "png", "json", "gif"] as const; function getUrl(data: Data) { if (data.t === "Emoji") return `${location.protocol}//${window.GLOBAL_ENV.CDN_HOST}/emojis/${data.id}.${data.isAnimated ? "gif" : "png"}`; return `${location.origin}/stickers/${data.id}.${StickerExt[data.format_type]}`; } async function fetchSticker(id: string) { const cached = StickersStore.getStickerById(id); if (cached) return cached; const { body } = await RestAPI.get({ url: `/stickers/${id}` }); FluxDispatcher.dispatch({ type: "STICKER_FETCH_SUCCESS", sticker: body }); return body as Sticker; } async function cloneSticker(guildId: string, sticker: Sticker) { const data = new FormData(); data.append("name", sticker.name); data.append("tags", sticker.tags); data.append("description", sticker.description); data.append("file", await fetchBlob(getUrl(sticker))); const { body } = await RestAPI.post({ url: `/guilds/${guildId}/stickers`, body: data, }); FluxDispatcher.dispatch({ type: "GUILD_STICKERS_CREATE_SUCCESS", guildId, sticker: { ...body, user: UserStore.getCurrentUser() } }); } async function cloneEmoji(guildId: string, emoji: Emoji) { const data = await fetchBlob(getUrl(emoji)); const dataUrl = await new Promise(resolve => { const reader = new FileReader(); reader.onload = () => resolve(reader.result as string); reader.readAsDataURL(data); }); return uploadEmoji({ guildId, name: emoji.name.split("~")[0], image: dataUrl }); } function getGuildCandidates(data: Data) { const meId = UserStore.getCurrentUser().id; return Object.values(GuildStore.getGuilds()).filter(g => { const canCreate = g.ownerId === meId || BigInt(PermissionStore.getGuildPermissions({ id: g.id }) & MANAGE_EMOJIS_AND_STICKERS) === MANAGE_EMOJIS_AND_STICKERS; if (!canCreate) return false; if (data.t === "Sticker") return true; const { isAnimated } = data as Emoji; const emojiSlots = g.getMaxEmojiSlots(); const { emojis } = GuildEmojiStore.getGuilds()[g.id]; let count = 0; for (const emoji of emojis) if (emoji.animated === isAnimated) count++; return count < emojiSlots; }).sort((a, b) => a.name.localeCompare(b.name)); } async function fetchBlob(url: string) { const res = await fetch(url); if (!res.ok) throw new Error(`Failed to fetch ${url} - ${res.status}`); return res.blob(); } async function doClone(guildId: string, data: Sticker | Emoji) { try { if (data.t === "Sticker") await cloneSticker(guildId, data); else await cloneEmoji(guildId, data); Toasts.show({ message: `Successfully cloned ${data.name} to ${GuildStore.getGuild(guildId)?.name ?? "your server"}!`, type: Toasts.Type.SUCCESS, id: Toasts.genId() }); } catch (e) { new Logger("EmoteCloner").error("Failed to clone", data.name, "to", guildId, e); Toasts.show({ message: "Oopsie something went wrong :( Check console!!!", type: Toasts.Type.FAILURE, id: Toasts.genId() }); } } const getFontSize = (s: string) => { // [18, 18, 16, 16, 14, 12, 10] const sizes = [20, 20, 18, 18, 16, 14, 12]; return sizes[s.length] ?? 4; }; const nameValidator = /^\w+$/i; function CloneModal({ data }: { data: Sticker | Emoji; }) { const [isCloning, setIsCloning] = React.useState(false); const [name, setName] = React.useState(data.name); const [x, invalidateMemo] = React.useReducer(x => x + 1, 0); const guilds = React.useMemo(() => getGuildCandidates(data), [data.id, x]); return ( <> Custom Name { data.name = v; setName(v); }} validate={v => (data.t === "Emoji" && v.length > 2 && v.length < 32 && nameValidator.test(v)) || (data.t === "Sticker" && v.length > 2 && v.length < 30) || "Name must be between 2 and 32 characters and only contain alphanumeric characters" } />
{guilds.map(g => ( {({ onMouseLeave, onMouseEnter }) => (
{ setIsCloning(true); doClone(g.id, data).finally(() => { invalidateMemo(); setIsCloning(false); }); }} > {g.icon ? ( {g.name} ) : ( {g.acronym} )}
)}
))}
); } function buildMenuItem(type: "Emoji" | "Sticker", fetchData: () => Promisable>) { return ( openModalLazy(async () => { const res = await fetchData(); const data = { t: type, ...res } as Sticker | Emoji; const url = getUrl(data); return modalProps => ( Clone {data.name} ); }) } /> ); } function isGifUrl(url: string) { return new URL(url).pathname.endsWith(".gif"); } const messageContextMenuPatch: NavContextMenuPatchCallback = (children, props) => () => { const { favoriteableId, itemHref, itemSrc, favoriteableType } = props ?? {}; if (!favoriteableId) return; const menuItem = (() => { switch (favoriteableType) { case "emoji": const match = props.message.content.match(RegExp(`|https://cdn\\.discordapp\\.com/emojis/${favoriteableId}\\.`)); if (!match) return; const name = match[1] ?? "FakeNitroEmoji"; return buildMenuItem("Emoji", () => ({ id: favoriteableId, name, isAnimated: isGifUrl(itemHref ?? itemSrc) })); case "sticker": const sticker = props.message.stickerItems.find(s => s.id === favoriteableId); if (sticker?.format_type === 3 /* LOTTIE */) return; return buildMenuItem("Sticker", () => fetchSticker(favoriteableId)); } })(); if (menuItem) findGroupChildrenByChildId("copy-link", children)?.push(menuItem); }; const expressionPickerPatch: NavContextMenuPatchCallback = (children, props: { target: HTMLElement; }) => () => { const { id, name, type } = props?.target?.dataset ?? {}; if (!id) return; if (type === "emoji" && name) { const firstChild = props.target.firstChild as HTMLImageElement; children.push(buildMenuItem("Emoji", () => ({ id, name, isAnimated: firstChild && isGifUrl(firstChild.src) }))); } else if (type === "sticker" && !props.target.className?.includes("lottieCanvas")) { children.push(buildMenuItem("Sticker", () => fetchSticker(id))); } }; export default definePlugin({ name: "EmoteCloner", description: "Allows you to clone Emotes & Stickers to your own server (right click them)", tags: ["StickerCloner"], authors: [Devs.Ven, Devs.Nuckyz], start() { addContextMenuPatch("message", messageContextMenuPatch); addContextMenuPatch("expression-picker", expressionPickerPatch); }, stop() { removeContextMenuPatch("message", messageContextMenuPatch); removeContextMenuPatch("expression-picker", expressionPickerPatch); } });