From 1ec28a345bad401efade1af734db59ea1cd6dc1a Mon Sep 17 00:00:00 2001 From: V Date: Sat, 13 May 2023 18:47:46 +0200 Subject: [PATCH] EmoteCloner: Add Sticker cloning (#1118) --- src/plugins/emoteCloner.tsx | 258 ++++++++++++++++++++++++++---------- 1 file changed, 191 insertions(+), 67 deletions(-) diff --git a/src/plugins/emoteCloner.tsx b/src/plugins/emoteCloner.tsx index 0d65148..521ed86 100644 --- a/src/plugins/emoteCloner.tsx +++ b/src/plugins/emoteCloner.tsx @@ -21,17 +21,102 @@ import { CheckedTextInput } from "@components/CheckedTextInput"; import { Devs } from "@utils/constants"; import { Logger } from "@utils/Logger"; import { Margins } from "@utils/margins"; -import { ModalContent, ModalHeader, ModalRoot, openModal } from "@utils/modal"; +import { ModalContent, ModalHeader, ModalRoot, openModalLazy } from "@utils/modal"; import definePlugin from "@utils/types"; -import { findByCodeLazy, findByPropsLazy } from "@webpack"; -import { Forms, GuildStore, Menu, PermissionStore, React, Toasts, Tooltip, UserStore } from "@webpack/common"; +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 = findByPropsLazy("getGuilds", "getGuildEmoji"); +const GuildEmojiStore = findStoreLazy("EmojiStore"); +const StickersStore = findStoreLazy("StickersStore"); const uploadEmoji = findByCodeLazy('"EMOJI_UPLOAD_START"', "GUILD_EMOJIS("); -function getGuildCandidates(isAnimated: boolean) { +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 => { @@ -39,6 +124,10 @@ function getGuildCandidates(isAnimated: boolean) { 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]; @@ -49,33 +138,34 @@ function getGuildCandidates(isAnimated: boolean) { }).sort((a, b) => a.name.localeCompare(b.name)); } -async function doClone(guildId: string, id: string, name: string, isAnimated: boolean) { - const data = await fetch(`${location.protocol}//${window.GLOBAL_ENV.CDN_HOST}/emojis/${id}.${isAnimated ? "gif" : "png"}`) - .then(r => r.blob()); - const reader = new FileReader(); - - reader.onload = () => { - uploadEmoji({ - guildId, - name: name.split("~")[0], - image: reader.result - }).then(() => { - Toasts.show({ - message: `Successfully cloned ${name}!`, - type: Toasts.Type.SUCCESS, - id: Toasts.genId() - }); - }).catch((e: any) => { - new Logger("EmoteCloner").error("Failed to upload emoji", e); - Toasts.show({ - message: "Oopsie something went wrong :( Check console!!!", - type: Toasts.Type.FAILURE, - id: Toasts.genId() - }); - }); - }; +async function fetchBlob(url: string) { + const res = await fetch(url); + if (!res.ok) + throw new Error(`Failed to fetch ${url} - ${res.status}`); - reader.readAsDataURL(data); + 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) => { @@ -86,20 +176,23 @@ const getFontSize = (s: string) => { const nameValidator = /^\w+$/i; -function CloneModal({ id, name: emojiName, isAnimated }: { id: string; name: string; isAnimated: boolean; }) { +function CloneModal({ data }: { data: Sticker | Emoji; }) { const [isCloning, setIsCloning] = React.useState(false); - const [name, setName] = React.useState(emojiName); + const [name, setName] = React.useState(data.name); const [x, invalidateMemo] = React.useReducer(x => x + 1, 0); - const guilds = React.useMemo(() => getGuildCandidates(isAnimated), [isAnimated, x]); + const guilds = React.useMemo(() => getGuildCandidates(data), [data.id, x]); return ( <> Custom Name { + data.name = v; + setName(v); + }} validate={v => (v.length > 1 && v.length < 32 && nameValidator.test(v)) || "Name must be between 2 and 32 characters and only contain alphanumeric characters" @@ -135,7 +228,7 @@ function CloneModal({ id, name: emojiName, isAnimated }: { id: string; name: str }} onClick={isCloning ? void 0 : async () => { setIsCloning(true); - doClone(g.id, id, name, isAnimated).finally(() => { + doClone(g.id, data).finally(() => { invalidateMemo(); setIsCloning(false); }); @@ -175,32 +268,38 @@ function CloneModal({ id, name: emojiName, isAnimated }: { id: string; name: str ); } -function buildMenuItem(id: string, name: string, isAnimated: boolean) { +function buildMenuItem(type: "Emoji" | "Sticker", fetchData: () => Promisable>) { return ( - openModal(modalProps => ( - - - - Clone {name} - - - - - - )) + openModalLazy(async () => { + const res = await fetchData(); + const data = { t: type, ...res } as Sticker | Emoji; + const url = getUrl(data); + + return modalProps => ( + + + + Clone {data.name} + + + + + + ); + }) } /> ); @@ -213,28 +312,53 @@ function isGifUrl(url: string) { const messageContextMenuPatch: NavContextMenuPatchCallback = (children, props) => () => { const { favoriteableId, itemHref, itemSrc, favoriteableType } = props ?? {}; - if (!favoriteableId || favoriteableType !== "emoji") return; + 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"; - 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; - const group = findGroupChildrenByChildId("copy-link", children); - if (group) group.push(buildMenuItem(favoriteableId, name, isGifUrl(itemHref ?? itemSrc))); + 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 || !name || type !== "emoji") return; + if (!id) return; - const firstChild = props.target.firstChild as HTMLImageElement; + if (type === "emoji" && name) { + const firstChild = props.target.firstChild as HTMLImageElement; - children.push(buildMenuItem(id, name, firstChild && isGifUrl(firstChild.src))); + 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: "Adds a Clone context menu item to emotes to clone them your own server", + description: "Allows you to clone Emotes & Stickers to your own server (right click them)", + tags: ["StickerCloner"], authors: [Devs.Ven, Devs.Nuckyz], start() {