/* * 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 <https://www.gnu.org/licenses/>. */ import { migratePluginSettings, Settings } from "../api/settings"; import { CheckedTextInput } from "../components/CheckedTextInput"; import { Devs } from "../utils/constants"; import Logger from "../utils/Logger"; import { lazyWebpack, makeLazy } from "../utils/misc"; import { ModalContent, ModalHeader, ModalRoot, openModal } from "../utils/modal"; import definePlugin from "../utils/types"; import { filters } from "../webpack"; import { Forms, GuildStore, Margins, Menu, PermissionStore, React, Toasts, Tooltip, UserStore } from "../webpack/common"; const MANAGE_EMOJIS_AND_STICKERS = 1n << 30n; const GuildEmojiStore = lazyWebpack(filters.byProps("getGuilds", "getGuildEmoji")); const uploadEmoji = lazyWebpack(filters.byCode('"EMOJI_UPLOAD_START"', "GUILD_EMOJIS(")); function getGuildCandidates(isAnimated: boolean) { 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; 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 doClone(guildId: string, id: string, name: string, isAnimated: boolean) { const data = await fetch(`https://cdn.discordapp.com/emojis/${id}.${isAnimated ? "gif" : "png"}`) .then(r => r.blob()); const reader = new FileReader(); reader.onload = () => { uploadEmoji({ guildId, name, 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() }); }); }; reader.readAsDataURL(data); } 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({ id, name: emojiName, isAnimated }: { id: string; name: string; isAnimated: boolean; }) { const [isCloning, setIsCloning] = React.useState(false); const [name, setName] = React.useState(emojiName); const [x, invalidateMemo] = React.useReducer(x => x + 1, 0); const guilds = React.useMemo(() => getGuildCandidates(isAnimated), [isAnimated, x]); return ( <> <Forms.FormTitle className={Margins.marginTop20}>Custom Name</Forms.FormTitle> <CheckedTextInput value={name} onChange={setName} validate={v => (v.length > 1 && v.length < 32 && nameValidator.test(v)) || "Name must be between 2 and 32 characters and only contain alphanumeric characters" } /> <div style={{ display: "flex", flexWrap: "wrap", gap: "1em", padding: "1em 0.5em", justifyContent: "center", alignItems: "center" }}> {guilds.map(g => ( <Tooltip text={g.name}> {({ onMouseLeave, onMouseEnter }) => ( <div onMouseLeave={onMouseLeave} onMouseEnter={onMouseEnter} role="button" aria-label={"Clone to " + g.name} aria-disabled={isCloning} style={{ borderRadius: "50%", backgroundColor: "var(--background-secondary)", display: "inline-flex", justifyContent: "center", alignItems: "center", width: "4em", height: "4em", cursor: isCloning ? "not-allowed" : "pointer", filter: isCloning ? "brightness(50%)" : "none" }} onClick={isCloning ? void 0 : async () => { setIsCloning(true); doClone(g.id, id, name, isAnimated).finally(() => { invalidateMemo(); setIsCloning(false); }); }} > {g.icon ? ( <img aria-hidden style={{ borderRadius: "50%", width: "100%", height: "100%", }} src={g.getIconURL(512, true)} alt={g.name} /> ) : ( <Forms.FormText style={{ fontSize: getFontSize(g.acronym), width: "100%", overflow: "hidden", whiteSpace: "nowrap", textAlign: "center", cursor: isCloning ? "not-allowed" : "pointer", }} > {g.acronym} </Forms.FormText> )} </div> )} </Tooltip> ))} </div> </> ); } migratePluginSettings("EmoteCloner", "EmoteYoink"); export default definePlugin({ name: "EmoteCloner", description: "Adds a Clone context menu item to emotes to clone them your own server", authors: [Devs.Ven], dependencies: ["MenuItemDeobfuscatorAPI"], patches: [{ // Literally copy pasted from ReverseImageSearch lol find: "open-native-link", replacement: { match: /id:"open-native-link".{0,200}\(\{href:(.{0,3}),.{0,200}\},"open-native-link"\)/, replace: "$&,Vencord.Plugins.plugins.EmoteCloner.makeMenu(arguments[2])" }, }, // Also copy pasted from Reverse Image Search { // pass the target to the open link menu so we can grab its data find: "REMOVE_ALL_REACTIONS_CONFIRM_BODY,", predicate: makeLazy(() => !Settings.plugins.ReverseImageSearch.enabled), noWarn: true, replacement: { match: /(?<props>.).onHeightUpdate.{0,200}(.)=(.)=.\.url;.+?\(null!=\3\?\3:\2[^)]+/, replace: "$&,$<props>.target" } }], makeMenu(htmlElement: HTMLImageElement) { if (htmlElement?.dataset.type !== "emoji") return null; const { id } = htmlElement.dataset; const name = htmlElement.alt.match(/:(.*)(?:~\d+)?:/)?.[1]; if (!name || !id) return null; const isAnimated = new URL(htmlElement.src).pathname.endsWith(".gif"); return <Menu.MenuItem id="emote-cloner" key="emote-cloner" label="Clone" action={() => openModal(modalProps => ( <ModalRoot {...modalProps}> <ModalHeader> <img role="presentation" aria-hidden src={`https://cdn.discordapp.com/emojis/${id}.${isAnimated ? "gif" : "png"}`} alt="" height={24} width={24} style={{ marginRight: "0.5em" }} /> <Forms.FormText>Clone {name}</Forms.FormText> </ModalHeader> <ModalContent> <CloneModal id={id} name={name} isAnimated={isAnimated} /> </ModalContent> </ModalRoot> )) } > </Menu.MenuItem>; }, });