New plugin CopyStickerLinks: adds Copy/Open Link option to stickers (#3191)

Co-authored-by: V <vendicated@riseup.net>
This commit is contained in:
byeoon 2025-08-07 16:24:13 -04:00 committed by GitHub
parent 7e028267f1
commit 4403aee3c1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 188 additions and 69 deletions

View file

@ -1 +1,2 @@
export * from "./commands"; export * from "./commands";
export * from "./messages";

View file

@ -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
}

View file

@ -2,6 +2,7 @@ import { CommandOption } from './Commands';
import { User, UserJSON } from '../User'; import { User, UserJSON } from '../User';
import { Embed, EmbedJSON } from './Embed'; import { Embed, EmbedJSON } from './Embed';
import { DiscordRecord } from "../Record"; import { DiscordRecord } from "../Record";
import { StickerFormatType } from "../../../enums";
/** /**
* TODO: looks like discord has moved over to Date instead of Moment; * TODO: looks like discord has moved over to Date instead of Moment;
@ -92,7 +93,7 @@ export class Message extends DiscordRecord {
reactions: MessageReaction[]; reactions: MessageReaction[];
state: string; state: string;
stickerItems: { stickerItems: {
format_type: number; format_type: StickerFormatType;
id: string; id: string;
name: string; name: string;
}[]; }[];

View file

@ -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[];
}

View file

@ -2,3 +2,4 @@ export * from "./Commands";
export * from "./Message"; export * from "./Message";
export * from "./Embed"; export * from "./Embed";
export * from "./Emoji"; export * from "./Emoji";
export * from "./Sticker";

View file

@ -0,0 +1,15 @@
import { FluxStore, GuildSticker, PremiumStickerPack, Sticker } from "..";
export type StickerGuildMap = Map<string, GuildSticker[]>;
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;
}

View file

@ -11,6 +11,7 @@ export * from "./MessageStore";
export * from "./RelationshipStore"; export * from "./RelationshipStore";
export * from "./SelectedChannelStore"; export * from "./SelectedChannelStore";
export * from "./SelectedGuildStore"; export * from "./SelectedGuildStore";
export * from "./StickersStore";
export * from "./ThemeStore"; export * from "./ThemeStore";
export * from "./TypingStore"; export * from "./TypingStore";
export * from "./UserProfileStore"; export * from "./UserProfileStore";

View file

@ -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)

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<Sticker, "id" | "format_type">;
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 (
<>
<Menu.MenuGroup>
<Menu.MenuItem
id="vc-copy-sticker-link"
key="vc-copy-sticker-link"
label="Copy Link"
action={() => copyWithToast(getUrl(sticker), "Link copied!")}
/>
<Menu.MenuItem
id="vc-open-sticker-link"
key="vc-open-sticker-link"
label="Open Link"
action={() => VencordNative.native.openExternal(getUrl(sticker))}
/>
</Menu.MenuGroup>
{addBottomSeparator && <Menu.MenuSeparator />}
</>
);
}
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
}
});

View file

@ -25,25 +25,17 @@ import { Logger } from "@utils/Logger";
import { Margins } from "@utils/margins"; import { Margins } from "@utils/margins";
import { ModalContent, ModalHeader, ModalRoot, openModalLazy } from "@utils/modal"; import { ModalContent, ModalHeader, ModalRoot, openModalLazy } from "@utils/modal";
import definePlugin from "@utils/types"; import definePlugin from "@utils/types";
import { Guild } from "@vencord/discord-types"; import { Guild, GuildSticker } from "@vencord/discord-types";
import { findByCodeLazy, findStoreLazy } from "@webpack"; import { findByCodeLazy } from "@webpack";
import { Constants, EmojiStore, FluxDispatcher, Forms, GuildStore, IconUtils, Menu, PermissionsBits, PermissionStore, React, RestAPI, Toasts, Tooltip, UserStore } from "@webpack/common"; import { Constants, EmojiStore, FluxDispatcher, Forms, GuildStore, IconUtils, Menu, PermissionsBits, PermissionStore, React, RestAPI, StickersStore, Toasts, Tooltip, UserStore } from "@webpack/common";
import { Promisable } from "type-fest"; import { Promisable } from "type-fest";
const StickersStore = findStoreLazy("StickersStore");
const uploadEmoji = findByCodeLazy(".GUILD_EMOJIS(", "EMOJI_UPLOAD_START"); const uploadEmoji = findByCodeLazy(".GUILD_EMOJIS(", "EMOJI_UPLOAD_START");
const getGuildMaxEmojiSlots = findByCodeLazy(".additionalEmojiSlots") as (guild: Guild) => number; const getGuildMaxEmojiSlots = findByCodeLazy(".additionalEmojiSlots") as (guild: Guild) => number;
interface Sticker { interface Sticker extends GuildSticker {
t: "Sticker"; t: "Sticker";
description: string;
format_type: number;
guild_id: string;
id: string;
name: string;
tags: string;
type: number;
} }
interface Emoji { interface Emoji {

View file

@ -24,17 +24,12 @@ import { getCurrentGuild, getEmojiURL } from "@utils/discord";
import { Logger } from "@utils/Logger"; import { Logger } from "@utils/Logger";
import definePlugin, { OptionType, Patch } from "@utils/types"; import definePlugin, { OptionType, Patch } from "@utils/types";
import type { Emoji, Message } from "@vencord/discord-types"; import type { Emoji, Message } from "@vencord/discord-types";
import { StickerFormatType } from "@vencord/discord-types/enums";
import { findByCodeLazy, findByPropsLazy, findStoreLazy, proxyLazyWebpack } from "@webpack"; 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 { applyPalette, GIFEncoder, quantize } from "gifenc";
import type { ReactElement, ReactNode } from "react"; import type { ReactElement, ReactNode } from "react";
const StickerStore = findStoreLazy("StickersStore") as {
getPremiumPacks(): StickerPack[];
getAllGuildStickers(): Map<string, Sticker[]>;
getStickerById(id: string): Sticker | undefined;
};
const UserSettingsProtoStore = findStoreLazy("UserSettingsProtoStore"); const UserSettingsProtoStore = findStoreLazy("UserSettingsProtoStore");
const BINARY_READ_OPTIONS = findByPropsLazy("readerFactory"); 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 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 { const enum FakeNoticeType {
Sticker, Sticker,
Emoji Emoji
@ -549,8 +509,8 @@ export default definePlugin({
const gifMatch = child.props.href.match(fakeNitroGifStickerRegex); const gifMatch = child.props.href.match(fakeNitroGifStickerRegex);
if (gifMatch) { 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 // 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 (StickerStore.getStickerById(gifMatch[1])) return null; if (StickersStore.getStickerById(gifMatch[1])) return null;
} }
} }
@ -638,7 +598,7 @@ export default definePlugin({
url = new URL(item); url = new URL(item);
} catch { } } 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({ stickers.push({
format_type: 1, format_type: 1,
id: imgMatch[1], id: imgMatch[1],
@ -651,9 +611,9 @@ export default definePlugin({
const gifMatch = item.match(fakeNitroGifStickerRegex); const gifMatch = item.match(fakeNitroGifStickerRegex);
if (gifMatch) { 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({ stickers.push({
format_type: 2, format_type: 2,
id: gifMatch[1], id: gifMatch[1],
@ -689,8 +649,8 @@ export default definePlugin({
const gifMatch = url.match(fakeNitroGifStickerRegex); const gifMatch = url.match(fakeNitroGifStickerRegex);
if (gifMatch) { 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 // 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 (StickerStore.getStickerById(gifMatch[1])) return true; if (StickersStore.getStickerById(gifMatch[1])) return true;
} }
} }
@ -710,8 +670,8 @@ export default definePlugin({
const match = attachment.url.match(fakeNitroGifStickerRegex); const match = attachment.url.match(fakeNitroGifStickerRegex);
if (match) { 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 // 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 (StickerStore.getStickerById(match[1])) return false; if (StickersStore.getStickerById(match[1])) return false;
} }
return true; return true;
@ -866,7 +826,7 @@ export default definePlugin({
if (!s.enableStickerBypass) if (!s.enableStickerBypass)
break stickerBypass; break stickerBypass;
const sticker = StickerStore.getStickerById(extra.stickers?.[0]!); const sticker = StickersStore.getStickerById(extra.stickers?.[0]!);
if (!sticker) if (!sticker)
break stickerBypass; break stickerBypass;
@ -883,11 +843,11 @@ export default definePlugin({
// but will give us a normal non animated png for no reason // but will give us a normal non animated png for no reason
// TODO: Remove this workaround when it's not needed anymore // TODO: Remove this workaround when it's not needed anymore
let link = this.getStickerLink(sticker.id); 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"); link = link.replace(".png", ".gif");
} }
if (sticker.format_type === StickerType.APNG) { if (sticker.format_type === StickerFormatType.APNG) {
if (!hasAttachmentPerms(channelId)) { if (!hasAttachmentPerms(channelId)) {
Alerts.show({ Alerts.show({
title: "Hold on!", title: "Hold on!",

View file

@ -482,7 +482,7 @@ export const Devs = /* #__PURE__*/ Object.freeze({
name: "Sqaaakoi", name: "Sqaaakoi",
id: 259558259491340288n id: 259558259491340288n
}, },
Byron: { Byeoon: {
name: "byeoon", name: "byeoon",
id: 1167275288036655133n id: 1167275288036655133n
}, },

View file

@ -49,11 +49,11 @@ export let TypingStore: t.TypingStore;
export let RelationshipStore: t.RelationshipStore; export let RelationshipStore: t.RelationshipStore;
export let EmojiStore: t.EmojiStore; export let EmojiStore: t.EmojiStore;
export let StickersStore: t.StickersStore;
export let ThemeStore: t.ThemeStore; export let ThemeStore: t.ThemeStore;
export let WindowStore: t.WindowStore; export let WindowStore: t.WindowStore;
export let DraftStore: t.DraftStore; export let DraftStore: t.DraftStore;
/** /**
* @see jsdoc of {@link t.useStateFromStores} * @see jsdoc of {@link t.useStateFromStores}
*/ */
@ -77,6 +77,7 @@ waitForStore("GuildRoleStore", m => GuildRoleStore = m);
waitForStore("MessageStore", m => MessageStore = m); waitForStore("MessageStore", m => MessageStore = m);
waitForStore("WindowStore", m => WindowStore = m); waitForStore("WindowStore", m => WindowStore = m);
waitForStore("EmojiStore", m => EmojiStore = m); waitForStore("EmojiStore", m => EmojiStore = m);
waitForStore("StickersStore", m => StickersStore = m);
waitForStore("TypingStore", m => TypingStore = m); waitForStore("TypingStore", m => TypingStore = m);
waitForStore("ThemeStore", m => { waitForStore("ThemeStore", m => {
ThemeStore = m; ThemeStore = m;