EmoteCloner: Add Sticker cloning (#1118)
This commit is contained in:
parent
2fdc00b11e
commit
1ec28a345b
|
@ -21,17 +21,102 @@ import { CheckedTextInput } from "@components/CheckedTextInput";
|
||||||
import { Devs } from "@utils/constants";
|
import { Devs } from "@utils/constants";
|
||||||
import { Logger } from "@utils/Logger";
|
import { Logger } from "@utils/Logger";
|
||||||
import { Margins } from "@utils/margins";
|
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 definePlugin from "@utils/types";
|
||||||
import { findByCodeLazy, findByPropsLazy } from "@webpack";
|
import { findByCodeLazy, findStoreLazy } from "@webpack";
|
||||||
import { Forms, GuildStore, Menu, PermissionStore, React, Toasts, Tooltip, UserStore } from "@webpack/common";
|
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 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(");
|
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<string>(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;
|
const meId = UserStore.getCurrentUser().id;
|
||||||
|
|
||||||
return Object.values(GuildStore.getGuilds()).filter(g => {
|
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;
|
BigInt(PermissionStore.getGuildPermissions({ id: g.id }) & MANAGE_EMOJIS_AND_STICKERS) === MANAGE_EMOJIS_AND_STICKERS;
|
||||||
if (!canCreate) return false;
|
if (!canCreate) return false;
|
||||||
|
|
||||||
|
if (data.t === "Sticker") return true;
|
||||||
|
|
||||||
|
const { isAnimated } = data as Emoji;
|
||||||
|
|
||||||
const emojiSlots = g.getMaxEmojiSlots();
|
const emojiSlots = g.getMaxEmojiSlots();
|
||||||
const { emojis } = GuildEmojiStore.getGuilds()[g.id];
|
const { emojis } = GuildEmojiStore.getGuilds()[g.id];
|
||||||
|
|
||||||
|
@ -49,33 +138,34 @@ function getGuildCandidates(isAnimated: boolean) {
|
||||||
}).sort((a, b) => a.name.localeCompare(b.name));
|
}).sort((a, b) => a.name.localeCompare(b.name));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doClone(guildId: string, id: string, name: string, isAnimated: boolean) {
|
async function fetchBlob(url: string) {
|
||||||
const data = await fetch(`${location.protocol}//${window.GLOBAL_ENV.CDN_HOST}/emojis/${id}.${isAnimated ? "gif" : "png"}`)
|
const res = await fetch(url);
|
||||||
.then(r => r.blob());
|
if (!res.ok)
|
||||||
const reader = new FileReader();
|
throw new Error(`Failed to fetch ${url} - ${res.status}`);
|
||||||
|
|
||||||
reader.onload = () => {
|
return res.blob();
|
||||||
uploadEmoji({
|
}
|
||||||
guildId,
|
|
||||||
name: name.split("~")[0],
|
async function doClone(guildId: string, data: Sticker | Emoji) {
|
||||||
image: reader.result
|
try {
|
||||||
}).then(() => {
|
if (data.t === "Sticker")
|
||||||
Toasts.show({
|
await cloneSticker(guildId, data);
|
||||||
message: `Successfully cloned ${name}!`,
|
else
|
||||||
type: Toasts.Type.SUCCESS,
|
await cloneEmoji(guildId, data);
|
||||||
id: Toasts.genId()
|
|
||||||
});
|
Toasts.show({
|
||||||
}).catch((e: any) => {
|
message: `Successfully cloned ${data.name} to ${GuildStore.getGuild(guildId)?.name ?? "your server"}!`,
|
||||||
new Logger("EmoteCloner").error("Failed to upload emoji", e);
|
type: Toasts.Type.SUCCESS,
|
||||||
Toasts.show({
|
id: Toasts.genId()
|
||||||
message: "Oopsie something went wrong :( Check console!!!",
|
|
||||||
type: Toasts.Type.FAILURE,
|
|
||||||
id: Toasts.genId()
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
};
|
} catch (e) {
|
||||||
|
new Logger("EmoteCloner").error("Failed to clone", data.name, "to", guildId, e);
|
||||||
reader.readAsDataURL(data);
|
Toasts.show({
|
||||||
|
message: "Oopsie something went wrong :( Check console!!!",
|
||||||
|
type: Toasts.Type.FAILURE,
|
||||||
|
id: Toasts.genId()
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getFontSize = (s: string) => {
|
const getFontSize = (s: string) => {
|
||||||
|
@ -86,20 +176,23 @@ const getFontSize = (s: string) => {
|
||||||
|
|
||||||
const nameValidator = /^\w+$/i;
|
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 [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 [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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Forms.FormTitle className={Margins.top20}>Custom Name</Forms.FormTitle>
|
<Forms.FormTitle className={Margins.top20}>Custom Name</Forms.FormTitle>
|
||||||
<CheckedTextInput
|
<CheckedTextInput
|
||||||
value={name}
|
value={name}
|
||||||
onChange={setName}
|
onChange={v => {
|
||||||
|
data.name = v;
|
||||||
|
setName(v);
|
||||||
|
}}
|
||||||
validate={v =>
|
validate={v =>
|
||||||
(v.length > 1 && v.length < 32 && nameValidator.test(v))
|
(v.length > 1 && v.length < 32 && nameValidator.test(v))
|
||||||
|| "Name must be between 2 and 32 characters and only contain alphanumeric characters"
|
|| "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 () => {
|
onClick={isCloning ? void 0 : async () => {
|
||||||
setIsCloning(true);
|
setIsCloning(true);
|
||||||
doClone(g.id, id, name, isAnimated).finally(() => {
|
doClone(g.id, data).finally(() => {
|
||||||
invalidateMemo();
|
invalidateMemo();
|
||||||
setIsCloning(false);
|
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<Omit<Sticker | Emoji, "t">>) {
|
||||||
return (
|
return (
|
||||||
<Menu.MenuItem
|
<Menu.MenuItem
|
||||||
id="emote-cloner"
|
id="emote-cloner"
|
||||||
key="emote-cloner"
|
key="emote-cloner"
|
||||||
label="Clone Emote"
|
label={`Clone ${type}`}
|
||||||
action={() =>
|
action={() =>
|
||||||
openModal(modalProps => (
|
openModalLazy(async () => {
|
||||||
<ModalRoot {...modalProps}>
|
const res = await fetchData();
|
||||||
<ModalHeader>
|
const data = { t: type, ...res } as Sticker | Emoji;
|
||||||
<img
|
const url = getUrl(data);
|
||||||
role="presentation"
|
|
||||||
aria-hidden
|
return modalProps => (
|
||||||
src={`${location.protocol}//${window.GLOBAL_ENV.CDN_HOST}/emojis/${id}.${isAnimated ? "gif" : "png"}`}
|
<ModalRoot {...modalProps}>
|
||||||
alt=""
|
<ModalHeader>
|
||||||
height={24}
|
<img
|
||||||
width={24}
|
role="presentation"
|
||||||
style={{ marginRight: "0.5em" }}
|
aria-hidden
|
||||||
/>
|
src={url}
|
||||||
<Forms.FormText>Clone {name}</Forms.FormText>
|
alt=""
|
||||||
</ModalHeader>
|
height={24}
|
||||||
<ModalContent>
|
width={24}
|
||||||
<CloneModal id={id} name={name} isAnimated={isAnimated} />
|
style={{ marginRight: "0.5em" }}
|
||||||
</ModalContent>
|
/>
|
||||||
</ModalRoot>
|
<Forms.FormText>Clone {data.name}</Forms.FormText>
|
||||||
))
|
</ModalHeader>
|
||||||
|
<ModalContent>
|
||||||
|
<CloneModal data={data} />
|
||||||
|
</ModalContent>
|
||||||
|
</ModalRoot>
|
||||||
|
);
|
||||||
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
@ -213,28 +312,53 @@ function isGifUrl(url: string) {
|
||||||
const messageContextMenuPatch: NavContextMenuPatchCallback = (children, props) => () => {
|
const messageContextMenuPatch: NavContextMenuPatchCallback = (children, props) => () => {
|
||||||
const { favoriteableId, itemHref, itemSrc, favoriteableType } = props ?? {};
|
const { favoriteableId, itemHref, itemSrc, favoriteableType } = props ?? {};
|
||||||
|
|
||||||
if (!favoriteableId || favoriteableType !== "emoji") return;
|
if (!favoriteableId) return;
|
||||||
|
|
||||||
const match = props.message.content.match(RegExp(`<a?:(\\w+)(?:~\\d+)?:${favoriteableId}>|https://cdn\\.discordapp\\.com/emojis/${favoriteableId}\\.`));
|
const menuItem = (() => {
|
||||||
if (!match) return;
|
switch (favoriteableType) {
|
||||||
const name = match[1] ?? "FakeNitroEmoji";
|
case "emoji":
|
||||||
|
const match = props.message.content.match(RegExp(`<a?:(\\w+)(?:~\\d+)?:${favoriteableId}>|https://cdn\\.discordapp\\.com/emojis/${favoriteableId}\\.`));
|
||||||
|
if (!match) return;
|
||||||
|
const name = match[1] ?? "FakeNitroEmoji";
|
||||||
|
|
||||||
const group = findGroupChildrenByChildId("copy-link", children);
|
return buildMenuItem("Emoji", () => ({
|
||||||
if (group) group.push(buildMenuItem(favoriteableId, name, isGifUrl(itemHref ?? itemSrc)));
|
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 expressionPickerPatch: NavContextMenuPatchCallback = (children, props: { target: HTMLElement; }) => () => {
|
||||||
const { id, name, type } = props?.target?.dataset ?? {};
|
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({
|
export default definePlugin({
|
||||||
name: "EmoteCloner",
|
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],
|
authors: [Devs.Ven, Devs.Nuckyz],
|
||||||
|
|
||||||
start() {
|
start() {
|
||||||
|
|
Loading…
Reference in a new issue