From 4403aee3c1436b88d14b245e9d8e29d6b527eb84 Mon Sep 17 00:00:00 2001 From: byeoon <47872200+byeoon@users.noreply.github.com> Date: Thu, 7 Aug 2025 16:24:13 -0400 Subject: [PATCH] New plugin CopyStickerLinks: adds Copy/Open Link option to stickers (#3191) Co-authored-by: V --- packages/discord-types/enums/index.ts | 1 + packages/discord-types/enums/messages.ts | 13 +++ .../src/common/messages/Message.d.ts | 3 +- .../src/common/messages/Sticker.d.ts | 35 +++++++ .../src/common/messages/index.d.ts | 1 + .../src/stores/StickersStore.d.ts | 15 +++ packages/discord-types/src/stores/index.d.ts | 1 + src/plugins/copyStickerLinks/README.MD | 5 + src/plugins/copyStickerLinks/index.tsx | 94 +++++++++++++++++++ src/plugins/expressionCloner/index.tsx | 16 +--- src/plugins/fakeNitro/index.tsx | 68 +++----------- src/utils/constants.ts | 2 +- src/webpack/common/stores.ts | 3 +- 13 files changed, 188 insertions(+), 69 deletions(-) create mode 100644 packages/discord-types/enums/messages.ts create mode 100644 packages/discord-types/src/common/messages/Sticker.d.ts create mode 100644 packages/discord-types/src/stores/StickersStore.d.ts create mode 100644 src/plugins/copyStickerLinks/README.MD create mode 100644 src/plugins/copyStickerLinks/index.tsx diff --git a/packages/discord-types/enums/index.ts b/packages/discord-types/enums/index.ts index 62074d46..e2227d09 100644 --- a/packages/discord-types/enums/index.ts +++ b/packages/discord-types/enums/index.ts @@ -1 +1,2 @@ export * from "./commands"; +export * from "./messages"; diff --git a/packages/discord-types/enums/messages.ts b/packages/discord-types/enums/messages.ts new file mode 100644 index 00000000..9c0025b7 --- /dev/null +++ b/packages/discord-types/enums/messages.ts @@ -0,0 +1,13 @@ +export const enum StickerType { + /** an official sticker in a pack */ + STANDARD = 1, + /** a sticker uploaded to a guild for the guild's members */ + GUILD = 2 +} + +export const enum StickerFormatType { + PNG = 1, + APNG = 2, + LOTTIE = 3, + GIF = 4 +} diff --git a/packages/discord-types/src/common/messages/Message.d.ts b/packages/discord-types/src/common/messages/Message.d.ts index 41de8816..e3586255 100644 --- a/packages/discord-types/src/common/messages/Message.d.ts +++ b/packages/discord-types/src/common/messages/Message.d.ts @@ -2,6 +2,7 @@ import { CommandOption } from './Commands'; import { User, UserJSON } from '../User'; import { Embed, EmbedJSON } from './Embed'; import { DiscordRecord } from "../Record"; +import { StickerFormatType } from "../../../enums"; /** * TODO: looks like discord has moved over to Date instead of Moment; @@ -92,7 +93,7 @@ export class Message extends DiscordRecord { reactions: MessageReaction[]; state: string; stickerItems: { - format_type: number; + format_type: StickerFormatType; id: string; name: string; }[]; diff --git a/packages/discord-types/src/common/messages/Sticker.d.ts b/packages/discord-types/src/common/messages/Sticker.d.ts new file mode 100644 index 00000000..744b0623 --- /dev/null +++ b/packages/discord-types/src/common/messages/Sticker.d.ts @@ -0,0 +1,35 @@ +import { StickerFormatType, StickerType } from "../../../enums"; + +interface BaseSticker { + asset: string; + available: boolean; + description: string; + format_type: StickerFormatType; + id: string; + name: string; + sort_value?: number; + /** a comma separated string */ + tags: string; +} + +export interface PackSticker extends BaseSticker { + pack_id: string; + type: StickerType.STANDARD; +} + +export interface GuildSticker extends BaseSticker { + guild_id: string; + type: StickerType.GUILD; +} + +export type Sticker = PackSticker | GuildSticker; + +export interface PremiumStickerPack { + banner_asset_id?: string; + cover_sticker_id?: string; + description: string; + id: string; + name: string; + sku_id: string; + stickers: PackSticker[]; +} diff --git a/packages/discord-types/src/common/messages/index.d.ts b/packages/discord-types/src/common/messages/index.d.ts index 245e971e..23987546 100644 --- a/packages/discord-types/src/common/messages/index.d.ts +++ b/packages/discord-types/src/common/messages/index.d.ts @@ -2,3 +2,4 @@ export * from "./Commands"; export * from "./Message"; export * from "./Embed"; export * from "./Emoji"; +export * from "./Sticker"; diff --git a/packages/discord-types/src/stores/StickersStore.d.ts b/packages/discord-types/src/stores/StickersStore.d.ts new file mode 100644 index 00000000..fdfb012c --- /dev/null +++ b/packages/discord-types/src/stores/StickersStore.d.ts @@ -0,0 +1,15 @@ +import { FluxStore, GuildSticker, PremiumStickerPack, Sticker } from ".."; + +export type StickerGuildMap = Map; + +export class StickersStore extends FluxStore { + getAllGuildStickers(): StickerGuildMap; + getRawStickersByGuild(): StickerGuildMap; + getPremiumPacks(): PremiumStickerPack[]; + + getStickerById(id: string): Sticker | undefined; + getStickerPack(id: string): PremiumStickerPack | undefined; + getStickersByGuildId(guildId: string): Sticker[] | undefined; + + isPremiumPack(id: string): boolean; +} diff --git a/packages/discord-types/src/stores/index.d.ts b/packages/discord-types/src/stores/index.d.ts index 16a329c5..7a435ff5 100644 --- a/packages/discord-types/src/stores/index.d.ts +++ b/packages/discord-types/src/stores/index.d.ts @@ -11,6 +11,7 @@ export * from "./MessageStore"; export * from "./RelationshipStore"; export * from "./SelectedChannelStore"; export * from "./SelectedGuildStore"; +export * from "./StickersStore"; export * from "./ThemeStore"; export * from "./TypingStore"; export * from "./UserProfileStore"; diff --git a/src/plugins/copyStickerLinks/README.MD b/src/plugins/copyStickerLinks/README.MD new file mode 100644 index 00000000..40e63232 --- /dev/null +++ b/src/plugins/copyStickerLinks/README.MD @@ -0,0 +1,5 @@ +# CopyStickerLinks + +Adds "Copy Link" and "Open Link" options to the context menu of stickers! + +![](https://github.com/user-attachments/assets/a0982d5c-ab83-458b-9ca3-834803e0782e) diff --git a/src/plugins/copyStickerLinks/index.tsx b/src/plugins/copyStickerLinks/index.tsx new file mode 100644 index 00000000..eaccc9b7 --- /dev/null +++ b/src/plugins/copyStickerLinks/index.tsx @@ -0,0 +1,94 @@ +/* + * Vencord, a modification for Discord's desktop app + * Copyright (c) 2025 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 { findGroupChildrenByChildId, NavContextMenuPatchCallback } from "@api/ContextMenu"; +import { Devs } from "@utils/constants"; +import { copyWithToast } from "@utils/misc"; +import definePlugin from "@utils/types"; +import { Message, Sticker } from "@vencord/discord-types"; +import { Menu, React, StickersStore } from "@webpack/common"; +import ExpressionClonerPlugin from "plugins/expressionCloner"; + +const StickerExt = [, "png", "png", "json", "gif"] as const; + +type PartialSticker = Pick; + +function getUrl(data: PartialSticker): string { + if (data.format_type === 4) + return `https:${window.GLOBAL_ENV.MEDIA_PROXY_ENDPOINT}/stickers/${data.id}.gif?size=512&lossless=true`; + + return `https://${window.GLOBAL_ENV.CDN_HOST}/stickers/${data.id}.${StickerExt[data.format_type]}?size=512&lossless=true`; +} + +function buildMenuItem(sticker: PartialSticker, addBottomSeparator: boolean) { + return ( + <> + + copyWithToast(getUrl(sticker), "Link copied!")} + /> + + VencordNative.native.openExternal(getUrl(sticker))} + /> + + {addBottomSeparator && } + + ); +} + +const messageContextMenuPatch: NavContextMenuPatchCallback = ( + children, + { favoriteableId, favoriteableType, message }: { favoriteableId: string; favoriteableType: string; message: Message; } +) => { + if (!favoriteableId || favoriteableType !== "sticker") return; + + const sticker = message.stickerItems.find(s => s.id === favoriteableId); + if (!sticker?.format_type) return; + + const idx = children.findIndex(c => Array.isArray(c) && findGroupChildrenByChildId("vc-copy-sticker-url", c) != null); + + children.splice(idx, 0, buildMenuItem(sticker, idx !== -1)); +}; + +const expressionPickerPatch: NavContextMenuPatchCallback = (children, props: { target: HTMLElement; }) => { + const id = props?.target?.dataset?.id; + if (!id) return; + if (props.target.className?.includes("lottieCanvas")) return; + + const sticker = StickersStore.getStickerById(id); + if (sticker) { + children.push(buildMenuItem(sticker, Vencord.Plugins.isPluginEnabled(ExpressionClonerPlugin.name))); + } +}; + +export default definePlugin({ + name: "CopyStickerLinks", + description: "Adds the ability to copy & open Sticker links", + authors: [Devs.Ven, Devs.Byeoon], + contextMenus: { + "message": messageContextMenuPatch, + "expression-picker": expressionPickerPatch + } +}); diff --git a/src/plugins/expressionCloner/index.tsx b/src/plugins/expressionCloner/index.tsx index 60a95336..45022e60 100644 --- a/src/plugins/expressionCloner/index.tsx +++ b/src/plugins/expressionCloner/index.tsx @@ -25,25 +25,17 @@ import { Logger } from "@utils/Logger"; import { Margins } from "@utils/margins"; import { ModalContent, ModalHeader, ModalRoot, openModalLazy } from "@utils/modal"; import definePlugin from "@utils/types"; -import { Guild } from "@vencord/discord-types"; -import { findByCodeLazy, findStoreLazy } from "@webpack"; -import { Constants, EmojiStore, FluxDispatcher, Forms, GuildStore, IconUtils, Menu, PermissionsBits, PermissionStore, React, RestAPI, Toasts, Tooltip, UserStore } from "@webpack/common"; +import { Guild, GuildSticker } from "@vencord/discord-types"; +import { findByCodeLazy } from "@webpack"; +import { Constants, EmojiStore, FluxDispatcher, Forms, GuildStore, IconUtils, Menu, PermissionsBits, PermissionStore, React, RestAPI, StickersStore, Toasts, Tooltip, UserStore } from "@webpack/common"; import { Promisable } from "type-fest"; -const StickersStore = findStoreLazy("StickersStore"); const uploadEmoji = findByCodeLazy(".GUILD_EMOJIS(", "EMOJI_UPLOAD_START"); const getGuildMaxEmojiSlots = findByCodeLazy(".additionalEmojiSlots") as (guild: Guild) => number; -interface Sticker { +interface Sticker extends GuildSticker { t: "Sticker"; - description: string; - format_type: number; - guild_id: string; - id: string; - name: string; - tags: string; - type: number; } interface Emoji { diff --git a/src/plugins/fakeNitro/index.tsx b/src/plugins/fakeNitro/index.tsx index f697c3fd..981a7da8 100644 --- a/src/plugins/fakeNitro/index.tsx +++ b/src/plugins/fakeNitro/index.tsx @@ -24,17 +24,12 @@ import { getCurrentGuild, getEmojiURL } from "@utils/discord"; import { Logger } from "@utils/Logger"; import definePlugin, { OptionType, Patch } from "@utils/types"; import type { Emoji, Message } from "@vencord/discord-types"; +import { StickerFormatType } from "@vencord/discord-types/enums"; import { findByCodeLazy, findByPropsLazy, findStoreLazy, proxyLazyWebpack } from "@webpack"; -import { Alerts, ChannelStore, DraftType, EmojiStore, FluxDispatcher, Forms, GuildMemberStore, lodash, Parser, PermissionsBits, PermissionStore, UploadHandler, UserSettingsActionCreators, UserStore } from "@webpack/common"; +import { Alerts, ChannelStore, DraftType, EmojiStore, FluxDispatcher, Forms, GuildMemberStore, lodash, Parser, PermissionsBits, PermissionStore, StickersStore, UploadHandler, UserSettingsActionCreators, UserStore } from "@webpack/common"; import { applyPalette, GIFEncoder, quantize } from "gifenc"; import type { ReactElement, ReactNode } from "react"; -const StickerStore = findStoreLazy("StickersStore") as { - getPremiumPacks(): StickerPack[]; - getAllGuildStickers(): Map; - getStickerById(id: string): Sticker | undefined; -}; - const UserSettingsProtoStore = findStoreLazy("UserSettingsProtoStore"); const BINARY_READ_OPTIONS = findByPropsLazy("readerFactory"); @@ -70,41 +65,6 @@ const enum EmojiIntentions { const IS_BYPASSEABLE_INTENTION = `[${EmojiIntentions.CHAT},${EmojiIntentions.GUILD_STICKER_RELATED_EMOJI}].includes(fakeNitroIntention)`; -const enum StickerType { - PNG = 1, - APNG = 2, - LOTTIE = 3, - // don't think you can even have gif stickers but the docs have it - GIF = 4 -} - -interface BaseSticker { - available: boolean; - description: string; - format_type: number; - id: string; - name: string; - tags: string; - type: number; -} -interface GuildSticker extends BaseSticker { - guild_id: string; -} -interface DiscordSticker extends BaseSticker { - pack_id: string; -} -type Sticker = GuildSticker | DiscordSticker; - -interface StickerPack { - id: string; - name: string; - sku_id: string; - description: string; - cover_sticker_id: string; - banner_asset_id: string; - stickers: Sticker[]; -} - const enum FakeNoticeType { Sticker, Emoji @@ -549,8 +509,8 @@ export default definePlugin({ const gifMatch = child.props.href.match(fakeNitroGifStickerRegex); if (gifMatch) { - // There is no way to differentiate a regular gif attachment from a fake nitro animated sticker, so we check if the StickerStore contains the id of the fake sticker - if (StickerStore.getStickerById(gifMatch[1])) return null; + // There is no way to differentiate a regular gif attachment from a fake nitro animated sticker, so we check if the StickersStore contains the id of the fake sticker + if (StickersStore.getStickerById(gifMatch[1])) return null; } } @@ -638,7 +598,7 @@ export default definePlugin({ url = new URL(item); } catch { } - const stickerName = StickerStore.getStickerById(imgMatch[1])?.name ?? url?.searchParams.get("name") ?? "FakeNitroSticker"; + const stickerName = StickersStore.getStickerById(imgMatch[1])?.name ?? url?.searchParams.get("name") ?? "FakeNitroSticker"; stickers.push({ format_type: 1, id: imgMatch[1], @@ -651,9 +611,9 @@ export default definePlugin({ const gifMatch = item.match(fakeNitroGifStickerRegex); if (gifMatch) { - if (!StickerStore.getStickerById(gifMatch[1])) continue; + if (!StickersStore.getStickerById(gifMatch[1])) continue; - const stickerName = StickerStore.getStickerById(gifMatch[1])?.name ?? "FakeNitroSticker"; + const stickerName = StickersStore.getStickerById(gifMatch[1])?.name ?? "FakeNitroSticker"; stickers.push({ format_type: 2, id: gifMatch[1], @@ -689,8 +649,8 @@ export default definePlugin({ const gifMatch = url.match(fakeNitroGifStickerRegex); if (gifMatch) { - // There is no way to differentiate a regular gif attachment from a fake nitro animated sticker, so we check if the StickerStore contains the id of the fake sticker - if (StickerStore.getStickerById(gifMatch[1])) return true; + // There is no way to differentiate a regular gif attachment from a fake nitro animated sticker, so we check if the StickersStore contains the id of the fake sticker + if (StickersStore.getStickerById(gifMatch[1])) return true; } } @@ -710,8 +670,8 @@ export default definePlugin({ const match = attachment.url.match(fakeNitroGifStickerRegex); if (match) { - // There is no way to differentiate a regular gif attachment from a fake nitro animated sticker, so we check if the StickerStore contains the id of the fake sticker - if (StickerStore.getStickerById(match[1])) return false; + // There is no way to differentiate a regular gif attachment from a fake nitro animated sticker, so we check if the StickersStore contains the id of the fake sticker + if (StickersStore.getStickerById(match[1])) return false; } return true; @@ -866,7 +826,7 @@ export default definePlugin({ if (!s.enableStickerBypass) break stickerBypass; - const sticker = StickerStore.getStickerById(extra.stickers?.[0]!); + const sticker = StickersStore.getStickerById(extra.stickers?.[0]!); if (!sticker) break stickerBypass; @@ -883,11 +843,11 @@ export default definePlugin({ // but will give us a normal non animated png for no reason // TODO: Remove this workaround when it's not needed anymore let link = this.getStickerLink(sticker.id); - if (sticker.format_type === StickerType.GIF && link.includes(".png")) { + if (sticker.format_type === StickerFormatType.GIF && link.includes(".png")) { link = link.replace(".png", ".gif"); } - if (sticker.format_type === StickerType.APNG) { + if (sticker.format_type === StickerFormatType.APNG) { if (!hasAttachmentPerms(channelId)) { Alerts.show({ title: "Hold on!", diff --git a/src/utils/constants.ts b/src/utils/constants.ts index eb19bf86..7c7488b6 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -482,7 +482,7 @@ export const Devs = /* #__PURE__*/ Object.freeze({ name: "Sqaaakoi", id: 259558259491340288n }, - Byron: { + Byeoon: { name: "byeoon", id: 1167275288036655133n }, diff --git a/src/webpack/common/stores.ts b/src/webpack/common/stores.ts index 221218c6..472abd4e 100644 --- a/src/webpack/common/stores.ts +++ b/src/webpack/common/stores.ts @@ -49,11 +49,11 @@ export let TypingStore: t.TypingStore; export let RelationshipStore: t.RelationshipStore; export let EmojiStore: t.EmojiStore; +export let StickersStore: t.StickersStore; export let ThemeStore: t.ThemeStore; export let WindowStore: t.WindowStore; export let DraftStore: t.DraftStore; - /** * @see jsdoc of {@link t.useStateFromStores} */ @@ -77,6 +77,7 @@ waitForStore("GuildRoleStore", m => GuildRoleStore = m); waitForStore("MessageStore", m => MessageStore = m); waitForStore("WindowStore", m => WindowStore = m); waitForStore("EmojiStore", m => EmojiStore = m); +waitForStore("StickersStore", m => StickersStore = m); waitForStore("TypingStore", m => TypingStore = m); waitForStore("ThemeStore", m => { ThemeStore = m;