diff options
author | Vendicated <vendicated@riseup.net> | 2022-11-13 03:46:46 +0100 |
---|---|---|
committer | Vendicated <vendicated@riseup.net> | 2022-11-13 03:46:46 +0100 |
commit | 9110d1f9bde60b92df548551d60dc2b004e7afc3 (patch) | |
tree | b78566acee5bfa91af4c66eba7b6ed91866b414d | |
parent | 81edc1407071c6c0328f40a1ee487ea0388b9a7e (diff) | |
download | Vencord-9110d1f9bde60b92df548551d60dc2b004e7afc3.tar.gz Vencord-9110d1f9bde60b92df548551d60dc2b004e7afc3.tar.bz2 Vencord-9110d1f9bde60b92df548551d60dc2b004e7afc3.zip |
Emote Clone plugin
-rw-r--r-- | src/plugins/EmoteYoink.tsx | 231 | ||||
-rw-r--r-- | src/plugins/reverseImageSearch.tsx | 2 | ||||
-rw-r--r-- | src/webpack/common.tsx | 1 |
3 files changed, 233 insertions, 1 deletions
diff --git a/src/plugins/EmoteYoink.tsx b/src/plugins/EmoteYoink.tsx new file mode 100644 index 0000000..d9b579b --- /dev/null +++ b/src/plugins/EmoteYoink.tsx @@ -0,0 +1,231 @@ +/* + * 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 { CheckedTextInput } from "../components/CheckedTextInput"; +import { Devs } from "../utils/constants"; +import { lazyWebpack } 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 yoinked ${name}!`, + type: Toasts.Type.SUCCESS, + id: Toasts.genId() + }); + }).catch(e => { + console.error("[EmoteYoink] 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> + </> + ); +} + +export default definePlugin({ + name: "EmoteYoink", + description: "Clone emotes to 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.EmoteYoink.makeMenu(arguments[2])" + } + }], + + 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="yoink" + key="yoink" + label="Yoink" + 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>; + }, +}); diff --git a/src/plugins/reverseImageSearch.tsx b/src/plugins/reverseImageSearch.tsx index cbe72d8..2d66636 100644 --- a/src/plugins/reverseImageSearch.tsx +++ b/src/plugins/reverseImageSearch.tsx @@ -56,7 +56,7 @@ export default definePlugin({ }], makeMenu(src: string, target: HTMLElement) { - if (target && target.attributes["data-role"]?.value !== "img") + if (target && !(target instanceof HTMLImageElement) && target.attributes["data-role"]?.value !== "img") return null; return ( diff --git a/src/webpack/common.tsx b/src/webpack/common.tsx index 497dfe4..9a15011 100644 --- a/src/webpack/common.tsx +++ b/src/webpack/common.tsx @@ -33,6 +33,7 @@ export let React: typeof import("react"); export const ReactDOM: typeof import("react-dom") = lazyWebpack(filters.byProps("createPortal", "render")); export const MessageStore = lazyWebpack(filters.byProps("getRawMessages")) as Omit<Stores.MessageStore, "getMessages"> & { getMessages(chanId: string): any; }; +export const PermissionStore = lazyWebpack(filters.byProps("can", "getGuildPermissions")); export let GuildStore: Stores.GuildStore; export let UserStore: Stores.UserStore; export let SelectedChannelStore: Stores.SelectedChannelStore; |