Downloadify: Security Drop

This commit is contained in:
thororen1234 2025-10-24 23:41:55 -04:00
parent 3633cc09e6
commit 59b5ba1837
No known key found for this signature in database
7 changed files with 0 additions and 2009 deletions

View file

@ -1,104 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import "./style.css";
import { EquicordDevs } from "@utils/constants";
import definePlugin from "@utils/types";
import { settings } from "./settings";
import { GDMContextMenu, GuildContextMenu, handleExpandedModalDownloadButtonClicked, handleHoverDownloadButtonClicked, MessageContextMenu, UserContextMenu, VoiceMessageDownloadButton } from "./utils/handlers";
export default definePlugin({
name: "Downloadify",
description: "Download various assets directly in Discord without having to open a browser or dig through HTML.",
authors: [EquicordDevs.Etorix],
settings,
VoiceMessageDownloadButton,
handleHoverDownloadButtonClicked,
handleExpandedModalDownloadButtonClicked,
contextMenus: {
"message": MessageContextMenu,
"guild-context": GuildContextMenu,
"user-context": UserContextMenu,
"gdm-context": GDMContextMenu
},
patches: [
{
// Pass the guild ID to the profile modal context. Used by the next patch.
find: "clanTagChiplet}),",
replacement: {
match: /("right",)(avatarUrl:null)/,
replace: "$1guildId:arguments[0]?.channel?.guild_id,$2"
}
},
{
// Pass the guild ID to the profile modal renderer. Allows guild specific profiles
// to load on the initial click instead of having to expand the profile first.
// Needed specifically for member banners.
find: '["children","userId","user"]',
replacement: {
match: /(\),{)(user:\i,currentUser:\i,children)/,
replace: "$1guildId:arguments[0].guildId,$2"
}
},
{
// Adds a download button to voice messages before the volume slider.
find: "volumeSlider,muted",
replacement: {
match: /(}\),)(\(0,\i.\i\)\(\i.\i,{className:\i.volumeButton)/,
replace: "$1$self.VoiceMessageDownloadButton(arguments[0]),$2"
}
},
{
// Hides the inline download button on text files in
// favor of the hover download button enabled below.
find: "formattedSize),children",
replacement: {
match: /(\(0,\i.\i\)\(\i.\i,{text:"".concat\(\i.intl.string)/,
replace: "false&&$1"
}
},
{
// Passes on the file information to be used by the hover download buttons.
find: "downloadUrl,showDownload",
replacement: {
match: /(showImageAppPicker:\i&&\i)/,
replace: "item:arguments[0].item,$1"
}
},
{
// Forces the hover download button to always be visible on supported media.
// Also overwrites the onClick function to use the custom download handling.
find: "downloadHoverButtonIcon,focusProps:{",
replacement: {
match: /((\i)=>{)(.{0,60}?)showDownload:(\i),(.{0,1250}?)href:\i,/,
replace: "$1const downloadifyHoverItem=$2;$3downloadifyShowDownload:$4=!0,$5onClick:()=>{$self.handleHoverDownloadButtonClicked(downloadifyHoverItem);},"
},
},
{
// Overwrites the default download button behavior for expanded image & video modals.
// This patch is lazy loaded. You must open an image or video modal for it to resolve.
find: "SAVE_MEDIA_PRESSED),",
group: true,
replacement: [
{
// Make use of the download function.
match: /(let{item:\i}=(\i).{0,450}?)(\i\(!0\);try{.{0,400}?finally{\i\(!1\)})/,
replace: "$1await $self.handleExpandedModalDownloadButtonClicked($2,async()=>{$3});",
},
{
// Prevent videos from opening in browser.
match: /(let{item:\i}=\i.{0,350}?)(SAVE_MEDIA_PRESSED\)).{0,100}?type\){/,
replace: "$1$2,true){"
}
],
}
]
});

View file

@ -1,94 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { dialog } from "electron";
import fs from "fs";
import { Readable } from "stream";
import { finished } from "stream/promises";
/**
* Check if a file exists at a given path.
*/
export async function fileExists(_: Electron.IpcMainInvokeEvent, filePath: string): Promise<boolean> {
try {
await fs.promises.access(filePath, fs.constants.F_OK);
return true;
} catch {
return false;
}
}
/**
* Ask the user to select a directory to save files.
*/
export async function getDirectory(_: Electron.IpcMainInvokeEvent): Promise<string | null> {
const result = await dialog.showOpenDialog({
properties: ["openDirectory"]
});
return result.filePaths[0] ?? null;
}
/**
* Ask the user to select a file name and location to save a file.
*/
export async function getFilePath(_: Electron.IpcMainInvokeEvent, fileName: string, fileTypes: string[] | null): Promise<string | null> {
const filters = fileTypes?.map(ext => ({
name: ext.toUpperCase(),
extensions: [ext]
})) ?? [];
if (!fileTypes?.length) {
filters.push({ name: "All Files", extensions: ["*"] });
}
const result = await dialog.showSaveDialog({
title: "Save File",
defaultPath: fileName,
buttonLabel: "Save",
properties: ["createDirectory"],
filters
});
return result.filePath ?? null;
}
/**
* Get the content type of a file at a URL.
*
* @returns The content type of the file if it exists, undefined if no content type is available, or null if the file doesn't exist.
*/
export async function queryURL(_: Electron.IpcMainInvokeEvent, url: string): Promise<string | null | undefined> {
const response = await fetch(url, { method: "HEAD" });
if (!response.ok) {
return null;
}
return response.headers.get("content-type") ?? undefined;
}
/**
* Download a file from a URL to a local file path.
*/
export async function downloadURL(_: Electron.IpcMainInvokeEvent, url: string, filePath: string): Promise<boolean> {
const response = await fetch(url);
if (!response.ok) {
return false;
}
if (!response.body) {
return false;
}
const readableStream = Readable.fromWeb(response.body as any);
const fileWriteStream = fs.createWriteStream(filePath);
readableStream.pipe(fileWriteStream);
await finished(fileWriteStream);
return true;
}

View file

@ -1,111 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import "./style.css";
import { definePluginSettings } from "@api/Settings";
import ErrorBoundary from "@components/ErrorBoundary";
import { Heading } from "@components/Heading";
import { Paragraph } from "@components/Paragraph";
import { OptionType } from "@utils/types";
import { Button, showToast, Toasts, useState } from "@webpack/common";
import { JSX } from "react";
import { d, DownloadifyLogger, DownloadifyNative } from "./utils/definitions";
import { getFormattedNow } from "./utils/misc";
function DefaultDirectorySetting(): JSX.Element {
const { defaultDirectory } = settings.use(["defaultDirectory"]);
const [isDialogueOpen, setDialogueOpen] = useState(false);
const handlePickDirectory = async () => {
try {
setDialogueOpen(true);
const directory = await DownloadifyNative.getDirectory();
directory && (settings.store.defaultDirectory = directory);
} catch (error) {
DownloadifyLogger.error(`[${getFormattedNow()}] [FAILED TO SET DOWNLOAD DIRECTORY]`, error);
showToast("Failed to set download directory.", Toasts.Type.FAILURE, { duration: 3000 });
} finally {
setDialogueOpen(false);
}
};
const handleClearDirectory = () => {
settings.store.defaultDirectory = "";
};
return (
<ErrorBoundary>
<section>
<Heading className={d("form-title")}>
Default Directory
</Heading>
<Paragraph className={d("form-description")}>
Default download location. If set, the file will always be downloaded in its original format even if alternatives are available. Leave empty to pick a folder and file type each time.
</Paragraph>
<div className={d("directory-container")}>
<Paragraph className={d("directory-display")}>
{defaultDirectory || "No Directory Set"}
</Paragraph>
<div className={d("directory-buttons")}>
<Button
disabled={isDialogueOpen}
className={d("directory-button", "browse-button", { "disabled": isDialogueOpen })}
onClick={handlePickDirectory}
color={Button.Colors.CUSTOM}
>
{isDialogueOpen ? "Browsing..." : "Browse"}
</Button>
<Button
disabled={!defaultDirectory || isDialogueOpen}
className={d("directory-button", "clear-button", { "disabled": !defaultDirectory || isDialogueOpen })}
onClick={handleClearDirectory}
color={Button.Colors.CUSTOM}
>
Clear
</Button>
</div>
</div>
</section>
</ErrorBoundary>
);
}
export const settings = definePluginSettings({
displayStatus: {
description: "Display a status notification when downloads start, finish, or error.",
type: OptionType.BOOLEAN,
default: true,
},
statusDuration: {
type: OptionType.SLIDER,
description: "The number of seconds to display status notifications.",
markers: [1, 3, 5],
default: 2.5,
stickToMarkers: false,
},
voiceMessages: {
description: "Add a download button to voice messages.",
type: OptionType.BOOLEAN,
default: true,
},
allowUnicode: {
description: "Allow non-ASCII characters in file names.",
type: OptionType.BOOLEAN,
default: true,
},
overwriteFiles: {
description: "If a default directory is set and a download file name matches an existing file, overwrite the file. If disabled, a number will be appended to the file name.",
type: OptionType.BOOLEAN,
default: false,
},
defaultDirectory: {
component: DefaultDirectorySetting,
type: OptionType.COMPONENT,
default: "",
},
});

View file

@ -1,77 +0,0 @@
.downloadify-voice-message-container {
flex-shrink: 0;
align-items: center;
display: flex;
flex-direction: column;
justify-content: flex-end;
position: relative;
margin: 0 -2px;
}
.downloadify-voice-message-button {
cursor: pointer;
line-height: 0;
border: none;
width: auto;
background: transparent;
color: var(--interactive-normal);
margin: 0;
padding: 0;
align-items: center;
border-radius: 8px;
box-sizing: border-box;
display: flex;
font-size: 14px;
font-weight: var(--font-weight-medium);
justify-content: center;
position: relative;
transition-duration: .2s;
user-select: none;
}
.downloadify-voice-message-button:hover {
color: var(--interactive-hover);
}
.downloadify-directory-container {
margin-top: 10px;
}
.downloadify-directory-buttons {
margin-top: 10px;
display: flex;
gap: 10px;
}
.downloadify-directory-button {
border-radius: 4px;
min-height: 34px;
color: white;
height: 34px;
flex: 1;
}
.downloadify-directory-button:not(.downloadify-disabled):hover {
filter: brightness(0.75);
transition: filter 0.15s ease-in-out;
}
.downloadify-clear-button {
background-color: rgb(180 71 78);
}
.downloadify-browse-button {
background-color: rgb(87 100 240);
}
.downloadify-directory-display {
background-color: var(--background-base-lower);
overflow-wrap: break-word;
font-family: monospace;
word-wrap: break-word;
white-space: pre-wrap;
border-radius: 4px;
user-select: text;
font-size: 14px;
padding: 8px;
}

View file

@ -1,371 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { classNameFactory } from "@api/Styles";
import { Logger } from "@utils/Logger";
import { PluginNative } from "@utils/types";
import { Channel, Guild, GuildMember, Message, User } from "@vencord/discord-types";
import { findByPropsLazy } from "@webpack";
import { JSX } from "react";
export const d = classNameFactory("downloadify-");
export const CDN_BASE = "https://cdn.discordapp.com";
export const MEDIA_PROXY_BASE = "https://media.discordapp.net";
export const PRIMARY_DOMAIN_BASE = "https://discord.com";
export const IMAGE_EXT_1_DOMAIN_BASE = "https://images-ext-1.discordapp.net";
export const IMAGE_EXT_2_DOMAIN_BASE = "https://images-ext-2.discordapp.net";
export const TENOR_BASE = "https://c.tenor.com";
export const TENOR_GIF_ID = "Ad";
export const TENOR_MP4_ID = "Po";
export const ASSET_TYPE_EXTRACTOR = new RegExp(/^https:\/\/(?:cdn\.discordapp\.com|media.discordapp.net)\/([a-z-]+(?:\/[a-z-]+){0,2})(?:(?:\/\d+\/[a-z-]+\/\d+\/([a-z-]+))|(?:\/([a-z-]+(?:\/[a-z-_]+)?)))?/i);
export const DownloadifyLogger = new Logger("Downloadify");
export const DownloadifyNative = VencordNative.pluginHelpers.Downloadify as PluginNative<typeof import("../native")>;
export const defaultAssets = findByPropsLazy("DEFAULT_GROUP_DM_AVATARS") as DefaultAssets;
interface DefaultAssets {
BOT_AVATARS: Record<string, string>;
DEFAULT_AVATARS: string[];
DEFAULT_CHANNEL_ICON: string;
DEFAULT_GROUP_DM_AVATARS: string[];
DEFAULT_PROVISIONAL_AVATARS: string[];
}
export interface AssetInfo {
source: AssetSource;
static: string[];
animated: string[];
type?: string;
forceAnimated?: boolean;
}
export enum AssetSource {
CDN = "cdn",
EXTERNAL_IMAGE_PROXY = "external_image_proxy",
ATTACHMENT_MEDIA_PROXY = "attachment_media_proxy",
ASSET_MEDIA_PROXY = "asset_media_proxy",
PRIMARY_DOMAIN = "primary_domain",
EXTERNAL = "external",
TENOR = "tenor"
}
export const unknownExternal = {
source: AssetSource.EXTERNAL,
static: [],
animated: []
};
export const unknownPrimaryDomain = {
source: AssetSource.PRIMARY_DOMAIN,
static: [],
animated: []
};
export const unknownCDN = {
source: AssetSource.CDN,
static: [],
animated: []
};
export const unknownAssetMediaProxy = {
source: AssetSource.ASSET_MEDIA_PROXY,
static: [],
animated: []
};
export const unknownAttachmentMediaProxy = {
source: AssetSource.ATTACHMENT_MEDIA_PROXY,
static: [],
animated: []
};
export const unknownExternalImageProxy = {
source: AssetSource.EXTERNAL_IMAGE_PROXY,
static: [],
animated: []
};
export const defaultResourceAvailability = {
source: AssetSource.PRIMARY_DOMAIN,
static: ["png"],
animated: []
};
export const APNGAvailability = {
source: AssetSource.ASSET_MEDIA_PROXY,
static: ["png", "webp", "jpg"],
animated: ["apng", "png", "webp", "jpg"]
};
export const hashableAvailability = {
source: AssetSource.ASSET_MEDIA_PROXY,
static: ["png", "webp", "jpg"],
animated: ["gif", "awebp", "png", "webp", "jpg"]
};
// Defines known asset type availability. Unknown types which may be
// encountered are AssetSource.EXTERNAL which are present in some embeds,
// as well as OTHER attachment types.
export const assetAvailability = {
"user-avatar": hashableAvailability,
"user-banner": hashableAvailability,
"avatar-decoration": APNGAvailability,
"clan-badge": {
source: AssetSource.ASSET_MEDIA_PROXY,
static: ["png"],
animated: []
},
"nameplate": {
source: AssetSource.CDN,
static: ["png"],
animated: ["webm", "apng", "png"],
type: "nameplate"
},
"default-user-avatar": defaultResourceAvailability,
"default-group-icon": defaultResourceAvailability,
"role-icon-custom": {
source: AssetSource.ASSET_MEDIA_PROXY,
static: ["png", "webp", "jpg"],
animated: [],
},
"role-icon-unicode-emoji": {
source: AssetSource.PRIMARY_DOMAIN,
static: ["svg"],
animated: [],
},
"guild-icon": hashableAvailability,
"guild-banner": hashableAvailability,
"guild-invite-splash": hashableAvailability,
"guild-discovery-splash": hashableAvailability,
"custom-emoji": hashableAvailability,
"unicode-emoji": {
source: AssetSource.PRIMARY_DOMAIN,
static: ["svg"],
animated: []
},
"sticker": {
"APNG": APNGAvailability,
"PNG": {
source: AssetSource.ASSET_MEDIA_PROXY,
static: ["png", "webp", "jpg"],
animated: []
},
"GIF": {
source: AssetSource.ASSET_MEDIA_PROXY,
static: [],
animated: ["gif", "awebp", "png", "webp", "jpg"],
forceAnimated: true
},
"LOTTIE": {
source: AssetSource.CDN,
static: ["json"],
animated: ["json"]
},
},
"tenor": {
source: AssetSource.TENOR,
static: [],
animated: ["gif", "mp4"],
forceAnimated: true
},
"external": {
"image/png": {
source: AssetSource.EXTERNAL_IMAGE_PROXY,
static: ["png", "webp", "jpg"],
animated: []
},
"image/webp": {
source: AssetSource.EXTERNAL_IMAGE_PROXY,
static: ["png", "webp", "jpg"],
animated: []
},
"image/jpeg": {
source: AssetSource.EXTERNAL_IMAGE_PROXY,
static: ["png", "webp", "jpg"],
animated: []
},
"video/mp4": {
source: AssetSource.EXTERNAL,
static: [],
animated: ["mp4"],
forceAnimated: true
},
},
"attachment": {
// Image attachments over a certain size and/or resolution are treated as
// non-image files and are therefore not available in alternative formats.
// This is designated by an attachment type of OTHER instead of IMAGE.
"image/png": {
source: AssetSource.ATTACHMENT_MEDIA_PROXY,
static: ["png", "webp", "jpg"],
// APNG attachments are technically supported, but appear to get
// broken by Discord squashing them into static PNGs. APNG is kept
// here in case it is fixed some day.
animated: ["apng", "png", "webp", "jpg"]
},
"image/webp": {
source: AssetSource.ATTACHMENT_MEDIA_PROXY,
static: ["webp", "png", "jpg"],
animated: ["awebp", "webp", "png", "jpg"]
},
"image/avif": {
source: AssetSource.ATTACHMENT_MEDIA_PROXY,
static: ["avif", "png", "webp"],
animated: ["avif", "awebp", "png", "webp"]
},
"image/jpeg": {
source: AssetSource.ATTACHMENT_MEDIA_PROXY,
static: ["jpg", "png", "webp"],
animated: []
},
"image/gif": {
source: AssetSource.ATTACHMENT_MEDIA_PROXY,
static: [],
animated: ["gif", "awebp", "png", "webp", "jpg"],
forceAnimated: true
},
}
};
export function DownloadIcon({ width, height }: { width: number; height: number; }): JSX.Element {
return (
<svg
className={d("-download-icon")}
role="img"
width={width}
height={height}
viewBox="0 0 24 24"
fill="currentColor"
>
<path
d="M12 2a1 1 0 0 1 1 1v10.59l3.3-3.3a1 1 0 1 1 1.4 1.42l-5 5a1 1 0 0 1-1.4 0l-5-5a1 1 0 1 1 1.4-1.42l3.3 3.3V3a1 1 0 0 1 1-1ZM3 20a1 1 0 1 0 0 2h18a1 1 0 1 0 0-2H3Z"
/>
</svg>
);
}
export interface ParsedFile {
path: string;
baseName: string;
extension: string | null;
twitterExtra: string | null;
}
export interface ParsedURL {
url: string;
host: string;
path: string;
baseName: string;
extension: string | null;
params: { expiry: Record<string, string>, other: Record<string, string>; };
twitterExtra: string | null;
}
export interface InsertGroup {
id: { group?: string, child?: string; };
type: "WITH_GROUP" | "BEFORE_GROUP" | "AFTER_GROUP";
position?: "START" | "END";
}
export interface VoiceMessageDownloadButtonProps {
message: Message;
item: {
type: "AUDIO";
originalItem: {
filename: string;
proxy_url: string;
};
};
}
export interface HoverDownloadProps {
item: {
type: "AUDIO" | "IMAGE" | "VIDEO" | "OTHER";
downloadUrl: string;
contentType: string;
srcIsAnimated: boolean;
originalItem: {
title?: string;
filename: string;
proxy_url: string;
url: string;
};
};
}
export interface ExpandedModalDownloadProps {
item: {
type: "AUDIO" | "IMAGE" | "VIDEO" | "OTHER";
url: string;
original: string;
proxyUrl?: string;
contentType?: string;
srcIsAnimated: boolean;
sourceMetadata?: {
identifier: {
type: "attachment" | "embed";
title?: string;
},
};
};
}
export interface MessageContextMenuProps {
message: Message;
channel: Channel;
itemSrc: string;
itemSafeSrc?: string;
favoriteableType: "sticker" | "emoji" | null;
favoriteableName: string | null;
favoriteableId: string | null;
contextMenuAPIArguments: any[];
mediaItem?: {
contentType: string;
proxyUrl: string;
url: string;
};
}
export interface GDMContextMenuProps {
channel: Channel & {
icon: string | null;
};
}
export interface GuildContextMenuProps {
guild: Guild;
}
export interface UserContextMenuProps {
user: User;
guildId?: string;
}
export interface CustomizedUser extends User {
avatarDecorationData: null | {
asset: string;
};
collectibles: null | Record<"nameplate", {
asset: string;
label: string;
palette: string;
}>;
}
export interface CustomizedMember extends Omit<GuildMember, "avatarDecoration"> {
avatarDecoration: null | {
asset: string;
};
}
export const AttachmentFlags = {
IS_CLIP: 1 << 0,
IS_THUMBNAIL: 1 << 1,
IS_REMIX: 1 << 2,
IS_SPOILER: 1 << 3,
CONTAINS_EXPLICIT_MEDIA: 1 << 4,
IS_ANIMATED: 1 << 5,
CONTAINS_GORE_CONTENT: 1 << 6
};

View file

@ -1,999 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { ImageIcon } from "@components/Icons";
import { StickerFormatType } from "@vencord/discord-types/enums";
import { GuildMemberStore, GuildRoleStore, GuildStore, Menu, showToast, Toasts, UserProfileStore, UserStore } from "@webpack/common";
import { JSX } from "react";
import { settings } from "../settings";
import { ASSET_TYPE_EXTRACTOR, assetAvailability, AssetInfo, AssetSource, AttachmentFlags, CDN_BASE, CustomizedMember, CustomizedUser, d, defaultAssets, DownloadIcon, DownloadifyLogger, DownloadifyNative, ExpandedModalDownloadProps, GDMContextMenuProps, GuildContextMenuProps, HoverDownloadProps, IMAGE_EXT_1_DOMAIN_BASE, IMAGE_EXT_2_DOMAIN_BASE, MEDIA_PROXY_BASE, MessageContextMenuProps, ParsedURL, PRIMARY_DOMAIN_BASE, TENOR_BASE, TENOR_GIF_ID, TENOR_MP4_ID, unknownAttachmentMediaProxy, unknownCDN, unknownExternal, unknownExternalImageProxy, unknownPrimaryDomain, UserContextMenuProps, VoiceMessageDownloadButtonProps } from "./definitions";
import { getFormattedNow, joinOrCreateContextMenuGroup, parseFile, parseURL, sanitizeFilename } from "./misc";
export function MessageContextMenu(children: Array<any>, props: MessageContextMenuProps): void {
if (!children?.length || !props?.message?.id) {
return;
}
const { allowUnicode } = settings.store;
let { favoriteableId, favoriteableName, favoriteableType } = props;
const { itemSrc, itemSafeSrc, message, channel, mediaItem, contextMenuAPIArguments } = props;
const target = contextMenuAPIArguments?.[0]?.target;
let targetURL = itemSafeSrc ?? itemSrc;
const emojiTarget = target.dataset?.type === "emoji"
? target
: target.children?.[1]?.firstChild?.dataset?.type === "emoji"
? target.children[1].firstChild
: target.parentElement?.children?.[1]?.firstChild?.dataset?.type === "emoji"
? target.parentElement.children[1].firstChild
: null;
if (!!emojiTarget?.dataset?.id) {
favoriteableType = "emoji";
favoriteableId = emojiTarget.dataset.id;
targetURL = emojiTarget.src;
} else if (!!emojiTarget?.dataset?.name) {
favoriteableType = "emoji";
favoriteableName = emojiTarget.alt ?? emojiTarget.dataset.name;
favoriteableId = null;
targetURL = emojiTarget.src;
}
const roleIconTarget = target.classList && (Array.from(target.classList) as string[]).some(cls => cls.includes("roleIcon"))
? target
: target.firstChild?.classList && (Array.from(target.firstChild.classList) as string[]).some(cls => cls.includes("roleIcon"))
? target.firstChild
: null;
const clanBadgeTarget = target.classList && (Array.from(target.classList) as string[]).some(cls => cls.includes("badge"))
? target
: target.parentElement?.firstChild?.classList && (Array.from(target.parentElement.firstChild.classList) as string[]).some(cls => cls.includes("badge"))
? target.parentElement.firstChild
: target.firstChild?.firstChild?.classList && (Array.from(target.firstChild.firstChild.classList) as string[]).some(cls => cls.includes("badge"))
? target.firstChild.firstChild
: null;
if (target.tagName === "VIDEO" && !!target.src) {
targetURL = contextMenuAPIArguments[0].target.src;
} else if (target.parentElement?.parentElement?.firstChild?.tagName === "VIDEO" && !!target.parentElement?.parentElement?.firstChild?.src) {
targetURL = contextMenuAPIArguments[0].target.parentElement.parentElement.firstChild.src;
} else if (target.parentElement?.parentElement?.parentElement?.firstChild?.tagName === "VIDEO" && !!target.parentElement?.parentElement?.parentElement?.firstChild?.src) {
targetURL = contextMenuAPIArguments[0].target.parentElement.parentElement.parentElement.firstChild.src;
} else if (target.parentElement?.parentElement?.parentElement?.parentElement?.parentElement?.firstChild?.tagName === "VIDEO" && !!target.parentElement?.parentElement?.parentElement?.parentElement?.parentElement?.firstChild?.src) {
targetURL = contextMenuAPIArguments[0].target.parentElement.parentElement.parentElement.parentElement.parentElement.firstChild.src;
}
const isRoleIcon = !!roleIconTarget;
const isCustomEmoji = favoriteableType === "emoji" && !!favoriteableId;
const isUnicodeEmoji = favoriteableType === "emoji" && !favoriteableId;
const isSticker = favoriteableType === "sticker";
const isAttachment = !!mediaItem;
const emojiRegex = favoriteableId ? new RegExp(`<(a)?:(\\w+):(${favoriteableId})>`) : null;
let emojiName = isUnicodeEmoji ? (favoriteableName?.replaceAll(":", "") || null) : (favoriteableName ?? (emojiRegex?.exec(message.content || "")?.[2] || null));
let emojiAnimated = isCustomEmoji ? emojiRegex?.exec(message.content || "")?.[1] === "a" : false;
const emojiURL = (isCustomEmoji || isUnicodeEmoji) ? parseURL(targetURL.replace(CDN_BASE, MEDIA_PROXY_BASE)) : null;
const sticker = isSticker ? message.stickerItems?.find(sticker => sticker.id === favoriteableId) : null;
const downloadifyItems: any[] = [];
function findNestedComponent(components: any[], checkFor: string): any {
for (const innerComponent of components) {
if (innerComponent.emoji?.id === checkFor || innerComponent.emoji?.name === checkFor) {
return innerComponent;
}
if (innerComponent.components?.length) {
const found = findNestedComponent(innerComponent.components, checkFor);
if (found) {
return found;
}
}
}
return null;
}
if (isRoleIcon) {
const parsedURL = parseURL(roleIconTarget.src.replace(CDN_BASE, MEDIA_PROXY_BASE));
const isUnicodeRoleIcon = itemSrc.startsWith(PRIMARY_DOMAIN_BASE);
const isCustomRoleIcon = !isUnicodeRoleIcon;
const roleID = GuildMemberStore.getMember(channel.guild_id, message.author.id)?.iconRoleId;
const role = roleID ? GuildRoleStore.getRole(channel.guild_id, roleID) : null;
const sanitizedRoleName = role ? sanitizeFilename(role.name, allowUnicode) : null;
downloadifyItems.push(
<Menu.MenuItem
id="downloadify-role-icon"
label={`Download ${role?.name ?? "Role"} Icon`}
submenuItemLabel={` ${role?.name ?? "Role"} Icon`}
icon={() => ImageIcon({ width: 20, height: 20 })}
action={async () => {
await handleDownload(
parsedURL,
sanitizedRoleName ? `${sanitizedRoleName}-icon` : null,
isCustomRoleIcon ? assetAvailability["role-icon-custom"] : assetAvailability["role-icon-unicode-emoji"],
false
);
}}
/>
);
} else if (clanBadgeTarget && !!(message.author as any).primaryGuild) {
const { primaryGuild } = (message.author as any);
const sanitizedGuildTag = sanitizeFilename(primaryGuild.tag, allowUnicode);
downloadifyItems.push(
<Menu.MenuItem
id="downloadify-clan-badge"
label={`Download ${primaryGuild.tag} Clan Badge`}
submenuItemLabel={`${primaryGuild.tag} Clan Badge`}
icon={() => ImageIcon({ width: 20, height: 20 })}
action={async () => {
await handleDownload(
parseURL(clanBadgeTarget.src.replace(CDN_BASE, MEDIA_PROXY_BASE)),
sanitizedGuildTag ? `${sanitizedGuildTag}-clan-badge` : null,
assetAvailability["clan-badge"],
false
);
}}
/>
);
} else if (isCustomEmoji) {
const reaction = message.reactions?.find(reaction => reaction.emoji.id === favoriteableId)?.emoji;
const component = message.components?.length ? findNestedComponent(message.components, favoriteableId as string) : null;
emojiAnimated = component?.emoji?.animated || reaction?.animated || emojiAnimated;
emojiName = component?.emoji?.name || reaction?.name || emojiName;
downloadifyItems.push(
<Menu.MenuItem
id="downloadify-custom-emoji"
label={`Download ${emojiName || "Custom Emoji"}`}
submenuItemLabel={`${emojiName || "Custom Emoji"}`}
icon={() => ImageIcon({ width: 20, height: 20 })}
action={async () => {
await handleDownload(
emojiURL as ParsedURL,
emojiName,
assetAvailability["custom-emoji"],
emojiAnimated
);
}}
/>
);
} else if (isUnicodeEmoji) {
const reaction = message.reactions?.find(reaction => reaction.emoji.name === favoriteableName)?.emoji;
const component = message.components?.length ? findNestedComponent(message.components, favoriteableName as string) : null;
emojiName = component?.emoji?.name || reaction?.name || emojiName;
emojiAnimated = false;
downloadifyItems.push(
<Menu.MenuItem
id="downloadify-unicode-emoji"
label={`Download ${emojiName || "Unicode Emoji"}`}
submenuItemLabel={`${emojiName || "Unicode Emoji"}`}
icon={() => ImageIcon({ width: 20, height: 20 })}
action={async () => {
await handleDownload(
emojiURL as ParsedURL,
emojiName,
assetAvailability["unicode-emoji"],
emojiAnimated
);
}}
/>
);
} else if (isSticker) {
let stickerURL;
let stickerSource;
let stickerAnimated;
if (sticker?.format_type === StickerFormatType.PNG) {
stickerURL = parseURL(`${MEDIA_PROXY_BASE}/stickers/${sticker.id}.png`);
stickerSource = assetAvailability.sticker.PNG;
stickerAnimated = false;
} else if (sticker?.format_type === StickerFormatType.GIF) {
stickerURL = parseURL(`${MEDIA_PROXY_BASE}/stickers/${sticker.id}.gif`);
stickerSource = assetAvailability.sticker.GIF;
stickerAnimated = true;
} else if (sticker?.format_type === StickerFormatType.APNG) {
stickerURL = parseURL(`${MEDIA_PROXY_BASE}/stickers/${sticker.id}.png`);
stickerSource = assetAvailability.sticker.APNG;
stickerAnimated = true;
} else if (sticker?.format_type === StickerFormatType.LOTTIE) {
stickerURL = parseURL(`${CDN_BASE}/stickers/${sticker.id}.json`);
stickerSource = assetAvailability.sticker.LOTTIE;
stickerAnimated = true;
} else {
return;
}
downloadifyItems.push(
<Menu.MenuItem
id="downloadify-sticker"
label={`Download ${sticker.name || "Sticker"}`}
submenuItemLabel={`${sticker.name || "Sticker"}`}
icon={() => ImageIcon({ width: 20, height: 20 })}
action={async () => {
await handleDownload(
stickerURL,
sticker.name,
stickerSource,
stickerAnimated
);
}}
/>
);
} else if (isAttachment) {
const assetInfo = assetAvailability.attachment[mediaItem.contentType] ?? unknownCDN;
const attachment = message.attachments?.find(attachment => attachment.proxy_url === mediaItem.proxyUrl || attachment.url === mediaItem.url);
if (!attachment) {
return;
}
downloadifyItems.push(
<Menu.MenuItem
id="downloadify-attachment"
label="Download Media"
submenuItemLabel="Media"
icon={() => ImageIcon({ width: 20, height: 20 })}
action={async () => {
await handleDownload(
parseURL(assetInfo.source === AssetSource.CDN ? mediaItem.url : mediaItem.proxyUrl),
(attachment as any)?.title || null,
assetInfo,
!!((attachment as any).flags & AttachmentFlags.IS_ANIMATED)
);
}}
/>
);
} else if (targetURL) {
let isTenor = false;
const embedImage = message.embeds?.find(embed => {
const images = (embed as any).images ? (embed as any).images : embed.image ? [embed.image] : [];
return images.some(image => targetURL.startsWith(image.url) || targetURL.startsWith(image.proxyURL));
});
const embedVideo = embedImage ? undefined : message.embeds?.find(embed => {
isTenor = embed.provider?.name === "Tenor";
const videos = (embed as any).videos ? (embed as any).videos : embed.video ? [embed.video] : [];
return videos.some(video => targetURL.startsWith(video.url) || targetURL.startsWith(video.proxyURL));
});
const embedThumbnail = message.embeds?.find(embed => {
return (embed.thumbnail?.url && targetURL.startsWith(embed.thumbnail.url)) || (embed.thumbnail?.proxyURL && targetURL.startsWith(embed.thumbnail.proxyURL));
});
const embedAuthor = message.embeds?.find(embed => {
return (embed.author?.iconURL && targetURL.startsWith(embed.author.iconURL)) || (embed.author?.iconProxyURL && targetURL.startsWith(embed.author.iconProxyURL));
});
const embedFooter = (message.embeds?.find(embed => {
return ((embed as any).footer?.iconURL && targetURL.startsWith((embed as any).footer.iconURL)) || ((embed as any).footer?.iconProxyURL && targetURL.startsWith((embed as any).footer.iconProxyURL));
}) as any);
const targetEmbed = (embedImage || embedVideo || embedThumbnail || embedAuthor || embedFooter);
const targetEmbedItem = (embedImage?.image || embedVideo?.video || embedThumbnail?.thumbnail || embedAuthor?.author || embedFooter?.footer);
const labelEmbedMedia = embedFooter?.footer
? "Footer Icon"
: embedAuthor?.author
? "Author Icon"
: embedThumbnail?.thumbnail
? "Thumbnail Image"
: targetEmbed?.type === "rich" || !!targetEmbed?.provider
? "Embed Media"
: "Media";
downloadifyItems.push(
<Menu.MenuItem
id="downloadify-attachment"
label={isTenor ? "Download Tenor GIF" : `Download ${labelEmbedMedia}`}
submenuItemLabel={isTenor ? "Tenor GIF" : labelEmbedMedia}
icon={() => ImageIcon({ width: 20, height: 20 })}
action={async () => {
let srcIsAnimated = !!(targetEmbedItem as any)?.srcIsAnimated;
let aliasBasename: string | null = null;
let assetInfo: any;
const contentType = ((targetEmbedItem as any)?.contentType || await DownloadifyNative.queryURL(itemSrc));
const isMediaProxy = targetURL?.startsWith(MEDIA_PROXY_BASE);
const isPrimaryDomain = targetURL?.startsWith(PRIMARY_DOMAIN_BASE);
const isCDN = targetURL?.startsWith(CDN_BASE);
const isImageExt1 = targetURL?.startsWith(IMAGE_EXT_1_DOMAIN_BASE);
const isImageExt2 = targetURL?.startsWith(IMAGE_EXT_2_DOMAIN_BASE);
const isImageExt = isImageExt1 || isImageExt2;
if (isTenor && embedVideo) {
targetURL = embedVideo.url ?? targetURL;
}
const parsedURL = parseURL(targetURL);
if (isTenor) {
assetInfo = assetAvailability.tenor;
srcIsAnimated = true;
} else if (isImageExt) {
assetInfo = assetAvailability.attachment[contentType]
?? assetAvailability.external[contentType]
?? unknownExternalImageProxy;
} else if (isMediaProxy || isCDN) {
const guestimate = guesstimateAsset(parsedURL, contentType);
assetInfo = guestimate.assetInfo ?? (isCDN ? unknownCDN : (isImageExt ? unknownExternalImageProxy : unknownExternal));
aliasBasename = guestimate.aliasBasename ?? aliasBasename;
srcIsAnimated = guestimate.srcIsAnimated ?? srcIsAnimated;
} else {
assetInfo = isPrimaryDomain ? unknownPrimaryDomain : unknownExternal;
}
await handleDownload(
parsedURL,
aliasBasename,
assetInfo,
srcIsAnimated
);
}}
/>
);
}
if (!downloadifyItems.length) {
return;
}
joinOrCreateContextMenuGroup(
children,
downloadifyItems,
"message-content-group",
"downloadify-submenu",
"Download",
[{
id: { child: "devmode-copy-id" },
type: "WITH_GROUP",
position: "START"
}]
);
}
export function GDMContextMenu(children: Array<any>, props: GDMContextMenuProps): void {
if (!children?.length || !props?.channel?.id) {
return;
}
const { channel } = props;
const channelIconHash = channel.icon || null;
const channelIconURL = channelIconHash ? parseURL(`${MEDIA_PROXY_BASE}/channel-icons/${channel.id}/${channelIconHash}.png`) : null;
const channelTimestamp = Number(BigInt(channel.id) >> 22n);
const defaultChannelIconURL = channelTimestamp ? parseURL(`${PRIMARY_DOMAIN_BASE}${defaultAssets.DEFAULT_GROUP_DM_AVATARS[channelTimestamp % 8]}`) : null;
const downloadifyItems = [
channelIconURL ? (
<Menu.MenuItem
id="downloadify-gdm-channel-icon"
label="Download Group Icon"
submenuItemLabel="Group Icon"
icon={() => ImageIcon({ width: 20, height: 20 })}
action={async () => {
await handleDownload(
channelIconURL,
channel.name ? `${channel.name}-group-icon` : null,
assetAvailability["guild-icon"],
false
);
}}
/>
) : null,
defaultChannelIconURL ? (
<Menu.MenuItem
id="downloadify-default-gdm-channel-icon"
label="Download Default Icon"
submenuItemLabel="Default Icon"
icon={() => ImageIcon({ width: 20, height: 20 })}
action={async () => {
await handleDownload(
defaultChannelIconURL,
channel.name ? `${channel.name}-default-group-icon` : null,
assetAvailability["default-group-icon"],
false
);
}}
/>
) : null
].filter(Boolean);
joinOrCreateContextMenuGroup(
children,
downloadifyItems,
"channel-content-group",
"downloadify-submenu",
"Download",
[{
id: { child: "devmode-copy-id" },
type: "WITH_GROUP",
position: "START"
}]
);
}
export function GuildContextMenu(children: Array<any>, props: GuildContextMenuProps): void {
if (!children?.length || !props?.guild?.id) {
return;
}
const { guild } = props;
const guildIconHash = guild.icon || null;
const guildBannerHash = guild.banner || null;
const guildInviteHash = guild.splash !== guildBannerHash ? guild.splash || null : null;
const guildDiscoveryHash = guild.discoverySplash !== guildInviteHash && guild.discoverySplash !== guildBannerHash ? guild.discoverySplash || null : null;
const guildIconHashAnimated = guildIconHash?.startsWith("a_");
const guildBannerHashAnimated = guildBannerHash?.startsWith("a_");
const guildIconURL = guildIconHash ? parseURL(`${MEDIA_PROXY_BASE}/icons/${guild.id}/${guildIconHash}.${guildIconHashAnimated ? "gif" : "png"}`) : null;
const guildBannerURL = guildBannerHash ? parseURL(`${MEDIA_PROXY_BASE}/banners/${guild.id}/${guildBannerHash}.${guildBannerHashAnimated ? "gif" : "png"}`) : null;
const guildInviteURL = guildInviteHash ? parseURL(`${MEDIA_PROXY_BASE}/splashes/${guild.id}/${guildInviteHash}.png`) : null;
const guildDiscoveryURL = guildDiscoveryHash ? parseURL(`${MEDIA_PROXY_BASE}/discovery-splashes/${guild.id}/${guildDiscoveryHash}.png`) : null;
if (!guildIconURL && !guildBannerURL && !guildInviteURL && !guildDiscoveryURL) {
return;
}
const downloadifyItems = [guildIconURL ? (
<Menu.MenuItem
id="downloadify-guild-icon"
label="Download Icon"
submenuItemLabel="Icon"
icon={() => ImageIcon({ width: 20, height: 20 })}
action={async () => await handleDownload(
guildIconURL,
guild.name ? `${guild.name}-icon` : null,
assetAvailability["guild-icon"],
!!guildIconHash?.startsWith("a_")
)}
/>
) : null,
guildBannerURL ? (
<Menu.MenuItem
id="downloadify-guild-banner"
label="Download Banner"
submenuItemLabel="Banner"
icon={() => ImageIcon({ width: 20, height: 20 })}
action={async () => await handleDownload(
guildBannerURL,
guild.name ? `${guild.name}-banner` : null,
assetAvailability["guild-banner"],
!!guildBannerHash?.startsWith("a_")
)}
/>
) : null,
guildInviteURL ? (
<Menu.MenuItem
id="downloadify-guild-invite-splash"
label="Download Invite Splash"
submenuItemLabel="Invite Splash"
icon={() => ImageIcon({ width: 20, height: 20 })}
action={async () => await handleDownload(
guildInviteURL,
guild.name ? `${guild.name}-invite-splash` : null,
assetAvailability["guild-invite-splash"],
!!guildInviteHash?.startsWith("a_")
)}
/>
) : null,
guildDiscoveryURL ? (
<Menu.MenuItem
id="downloadify-guild-discovery-splash"
label="Download Discovery Splash"
submenuItemLabel="Discovery Splash"
icon={() => ImageIcon({ width: 20, height: 20 })}
action={async () => await handleDownload(
guildDiscoveryURL,
guild.name ? `${guild.name}-discovery-splash` : null,
assetAvailability["guild-discovery-splash"],
!!guildDiscoveryHash?.startsWith("a_")
)}
/>
) : null].filter(Boolean);
joinOrCreateContextMenuGroup(
children,
downloadifyItems,
"guild-content-group",
"downloadify-submenu",
"Download",
[{
id: { child: "devmode-copy-id" },
type: "WITH_GROUP",
position: "START"
},
{
id: { child: "privacy" },
type: "WITH_GROUP",
position: "END"
}]
);
}
export function UserContextMenu(children: Array<any>, props: UserContextMenuProps): void {
if (!children?.length || !props.user?.id) {
return;
}
const user = UserStore.getUser(props.user.id) as CustomizedUser;
const member = (props.guildId ? GuildMemberStore.getMember(props.guildId, props.user.id) : null) as CustomizedMember | null;
const guild = props.guildId ? GuildStore.getGuild(props.guildId) : null;
const userAvatarHash = user.avatar || null;
const memberAvatarHash = member?.avatar || null;
const userBannerHash = UserProfileStore.getUserProfile(user.id)?.banner || null;
const memberBannerHash = UserProfileStore.getGuildMemberProfile(user.id, props.guildId)?.banner || null;
const userAvatarDecorationHash = user.avatarDecorationData?.asset || null;
const memberAvatarDecorationHash = member?.avatarDecoration?.asset || null;
const userNameplatePath = user.collectibles?.nameplate?.asset || null;
const userAvatarURL = userAvatarHash ? parseURL(`${MEDIA_PROXY_BASE}/avatars/${user.id}/${userAvatarHash}.png`) : null;
const memberAvatarURL = memberAvatarHash ? parseURL(`${MEDIA_PROXY_BASE}/guilds/${props.guildId}/users/${user.id}/avatars/${memberAvatarHash}.png`) : null;
const userBannerURL = userBannerHash ? parseURL(`${MEDIA_PROXY_BASE}/banners/${user.id}/${userBannerHash}.png`) : null;
const memberBannerURL = memberBannerHash ? parseURL(`${MEDIA_PROXY_BASE}/guilds/${props.guildId}/users/${user.id}/banners/${memberBannerHash}.png`) : null;
const userAvatarDecorationURL = userAvatarDecorationHash ? parseURL(`${MEDIA_PROXY_BASE}/avatar-decoration-presets/${userAvatarDecorationHash}.png`) : null;
const memberAvatarDecorationURL = memberAvatarDecorationHash ? parseURL(`${MEDIA_PROXY_BASE}/avatar-decoration-presets/${memberAvatarDecorationHash}.png`) : null;
const userNameplateURL = userNameplatePath ? parseURL(`${CDN_BASE}/assets/collectibles/${userNameplatePath}static.png`) : null;
const defaultUserAvatarURL = parseURL(`${PRIMARY_DOMAIN_BASE}${defaultAssets.DEFAULT_AVATARS[user.discriminator === "0" ? (Math.floor(Number(BigInt(user.id) >> 22n)) % 6) : (Number(user.discriminator) % 5)]}`);
const downloadifyItems = [userAvatarURL ? (
<Menu.MenuItem
id="downloadify-user-avatar"
label="Download User Avatar"
submenuItemLabel="User Avatar"
icon={() => ImageIcon({ width: 20, height: 20 })}
action={async () => await handleDownload(
userAvatarURL,
`${user.username}-avatar`,
assetAvailability["user-avatar"],
!!userAvatarHash?.startsWith("a_")
)}
/>
) : null,
userBannerURL ? (
<Menu.MenuItem
id="downloadify-user-banner"
label="Download User Banner"
submenuItemLabel="User Banner"
icon={() => ImageIcon({ width: 20, height: 20 })}
action={async () => await handleDownload(
userBannerURL,
`${user.username}-banner`,
assetAvailability["user-banner"],
!!userBannerHash?.startsWith("a_")
)}
/>
) : null,
userAvatarDecorationURL ? (
<Menu.MenuItem
id="downloadify-user-avatar-decoration"
label="Download User Avatar Decoration"
submenuItemLabel="User Avatar Decoration"
icon={() => ImageIcon({ width: 20, height: 20 })}
action={async () => await handleDownload(
userAvatarDecorationURL,
`${user.username}-avatar-decoration`,
assetAvailability["avatar-decoration"],
true
)}
/>
) : null,
memberAvatarURL ? (
<Menu.MenuItem
id="downloadify-member-avatar"
label="Download Member Avatar"
submenuItemLabel="Member Avatar"
icon={() => ImageIcon({ width: 20, height: 20 })}
action={async () => await handleDownload(
memberAvatarURL,
guild ? `${guild.name}-${user.username}-avatar` : `${user.username}-avatar`,
assetAvailability["user-avatar"],
!!memberAvatarHash?.startsWith("a_")
)}
/>
) : null,
memberBannerURL ? (
<Menu.MenuItem
id="downloadify-member-banner"
label="Download Member Banner"
submenuItemLabel="Member Banner"
icon={() => ImageIcon({ width: 20, height: 20 })}
action={async () => await handleDownload(
memberBannerURL,
guild ? `${guild.name}-${user.username}-banner` : `${user.username}-banner`,
assetAvailability["user-banner"],
!!memberBannerHash?.startsWith("a_")
)}
/>
) : null,
memberAvatarDecorationURL ? (
<Menu.MenuItem
id="downloadify-member-avatar-decoration"
label="Download Member Avatar Decoration"
submenuItemLabel="Member Avatar Decoration"
icon={() => ImageIcon({ width: 20, height: 20 })}
action={async () => await handleDownload(
memberAvatarDecorationURL,
guild ? `${guild.name}-${user.username}-avatar-decoration` : `${user.username}-avatar-decoration`,
assetAvailability["avatar-decoration"],
true
)}
/>
) : null,
defaultUserAvatarURL ? (
<Menu.MenuItem
id="downloadify-default-user-avatar"
label="Download Default Avatar"
submenuItemLabel="Default Avatar"
icon={() => ImageIcon({ width: 20, height: 20 })}
action={async () => await handleDownload(
defaultUserAvatarURL,
`${user.username}-default-avatar`,
assetAvailability["default-user-avatar"],
false
)}
/>
) : null,
userNameplateURL ? (
<Menu.MenuItem
id="downloadify-user-nameplate"
label="Download Nameplate"
submenuItemLabel="Nameplate"
icon={() => ImageIcon({ width: 20, height: 20 })}
action={async () => await handleDownload(
userNameplateURL,
`${user.username}-nameplate`,
assetAvailability.nameplate,
true
)}
/>
) : null].filter(Boolean);
joinOrCreateContextMenuGroup(
children,
downloadifyItems,
"user-content-group",
"downloadify-submenu",
"Download",
[{
id: { child: "devmode-copy-id" },
type: "WITH_GROUP",
position: "START"
},
{
id: { child: "user-profile" },
type: "WITH_GROUP",
position: "END"
}]
);
}
export async function handleHoverDownloadButtonClicked(props: HoverDownloadProps): Promise<void> {
let { contentType } = props.item;
const { type, downloadUrl, srcIsAnimated, originalItem } = props.item;
const { displayStatus, statusDuration } = settings.store;
if (!originalItem) {
// Fallback to default behavior.
DownloadifyLogger.info(`[${getFormattedNow()}] [UNRECOGNIZED HOVER DOWNLOAD ITEM]`, props.item);
displayStatus && showToast("Unknown Media Item. Falling Back to Default Behavior", Toasts.Type.MESSAGE, { duration: statusDuration * 1250 });
window.open(downloadUrl, "_blank");
} else {
contentType ??= (await DownloadifyNative.queryURL(downloadUrl) || "");
const assetInfo = (type === "IMAGE" ? (assetAvailability.attachment[contentType]) : null) ?? unknownCDN;
await handleDownload(
parseURL(assetInfo.source === AssetSource.CDN ? downloadUrl : originalItem.proxy_url),
originalItem.title || null,
assetInfo,
!!srcIsAnimated
);
}
}
export async function handleExpandedModalDownloadButtonClicked(props: ExpandedModalDownloadProps, fallback: () => Promise<void>): Promise<void> {
let { contentType, srcIsAnimated } = props.item;
const { url, original, proxyUrl, sourceMetadata } = props.item;
const { displayStatus, statusDuration } = settings.store;
if (!url && !original) {
// Fallback to default behavior.
DownloadifyLogger.info(`[${getFormattedNow()}] [UNRECOGNIZED EXPANDED MODAL DOWNLOAD ITEM]`, props.item);
displayStatus && showToast("Unknown Media Item. Falling Back to Default Behavior", Toasts.Type.MESSAGE, { duration: statusDuration * 1250 });
await fallback();
} else {
contentType ??= (await DownloadifyNative.queryURL(url) || "");
let aliasBasename = sourceMetadata?.identifier.title ?? null;
const isAttachment = sourceMetadata?.identifier.type === "attachment";
const urlIsMediaProxy = url?.startsWith(MEDIA_PROXY_BASE);
const originalIsMediaProxy = original?.startsWith(MEDIA_PROXY_BASE);
const isMediaProxy = urlIsMediaProxy || originalIsMediaProxy;
const isPrimaryDomain = original?.startsWith(PRIMARY_DOMAIN_BASE);
const isCDN = original?.startsWith(CDN_BASE);
const isImageExt1 = url?.startsWith(IMAGE_EXT_1_DOMAIN_BASE);
const isImageExt2 = url?.startsWith(IMAGE_EXT_2_DOMAIN_BASE);
const isImageExt = isImageExt1 || isImageExt2;
let parsedURL: ParsedURL;
let assetInfo: any;
if (isAttachment) {
parsedURL = parseURL(proxyUrl || url);
assetInfo = assetAvailability.attachment[contentType] ?? unknownAttachmentMediaProxy;
} else if (isImageExt) {
parsedURL = parseURL(url);
assetInfo = assetAvailability.attachment[contentType] ?? assetAvailability.external[contentType] ?? unknownExternalImageProxy;
} else if (isMediaProxy || isCDN) {
parsedURL = parseURL(urlIsMediaProxy ? url : original);
const guestimate = guesstimateAsset(parsedURL, contentType);
assetInfo = guestimate.assetInfo ?? (isCDN ? unknownCDN : unknownExternal);
aliasBasename = guestimate.aliasBasename ?? aliasBasename;
srcIsAnimated = guestimate.srcIsAnimated ?? srcIsAnimated;
} else {
parsedURL = parseURL(original ?? url);
assetInfo = isPrimaryDomain ? unknownPrimaryDomain : unknownExternal;
}
await handleDownload(
parsedURL,
aliasBasename,
assetInfo,
!!srcIsAnimated,
);
}
}
function guesstimateAsset(parsedURL: ParsedURL, contentType: string): {
assetInfo?: AssetInfo;
aliasBasename?: string;
srcIsAnimated?: boolean;
} {
// Check if the URL is a known asset type. The only defined member
// of assetAvailability that we can't completely check for here are stickers
// due to their format information not being present in the URL. We can make
// accurate guesses based on certain extensions, but not for every format.
let assetInfo: AssetInfo | undefined;
let aliasBasename: string | undefined;
let srcIsAnimated: boolean | undefined;
const detected = parsedURL.url.match(ASSET_TYPE_EXTRACTOR);
const detectedPrimary = detected?.[1] ?? "";
const detectedSecondary = detected?.[2] ?? "";
const detectedTertiary = detected?.[3] ?? "";
if (detectedPrimary === "attachments") {
assetInfo = assetAvailability.attachment[contentType];
} else if (detectedPrimary === "emojis") {
assetInfo = assetAvailability["custom-emoji"];
} else if (detectedPrimary === "clan-badges") {
assetInfo = assetAvailability["clan-badge"];
} else if (detectedPrimary === "role-icons") {
assetInfo = assetAvailability["role-icon-custom"];
} else if (detectedPrimary === "discovery-splashes") {
assetInfo = assetAvailability["guild-discovery-splash"];
} else if (detectedPrimary === "splashes") {
assetInfo = assetAvailability["guild-invite-splash"];
} else if (detectedPrimary === "banners") {
assetInfo = assetAvailability["guild-banner"];
} else if (detectedPrimary === "icons") {
assetInfo = assetAvailability["guild-icon"];
} else if (detectedPrimary === "avatar-decoration-presets") {
assetInfo = assetAvailability["avatar-decoration"];
srcIsAnimated = true; // Avatar decorations always have animated variants.
} else if (detectedPrimary === "avatars" || (detectedPrimary === "guilds" && detectedSecondary === "avatars")) {
assetInfo = assetAvailability["user-avatar"];
} else if (detectedPrimary === "banners" || (detectedPrimary === "guilds" && detectedSecondary === "banners")) {
assetInfo = assetAvailability["user-banner"];
} else if (detectedPrimary === "assets/collectibles/nameplates") {
assetInfo = assetAvailability.nameplate;
aliasBasename = detectedTertiary ?? "nameplate";
srcIsAnimated = true; // Nameplates always have animated variants.
} else if (detectedPrimary === "stickers") {
if (parsedURL.extension === "gif") {
assetInfo = assetAvailability.sticker.GIF;
} else if (parsedURL.extension === "webp") {
if (srcIsAnimated) {
assetInfo = assetAvailability.sticker.GIF;
} else {
// There is no way to know if a static WEBP sticker
// link is sourced from an APNG, PNG, or GIF sticker.
}
} else if (parsedURL.extension === "png") {
if (srcIsAnimated) {
assetInfo = assetAvailability.sticker.PNG;
} else {
// There is no way to know if a static PNG sticker
// link is sourced from an APNG, PNG, or GIF sticker.
}
} else if (parsedURL.extension === "jpg") {
// There is no way to know if a JPG sticker link
// is sourced from an APNG, PNG, or GIF sticker.
}
}
return {
assetInfo,
aliasBasename,
srcIsAnimated
};
}
export function VoiceMessageDownloadButton(props: VoiceMessageDownloadButtonProps): JSX.Element {
async function voiceMessageDownloadClicked(event: React.MouseEvent<HTMLButtonElement>) {
event.preventDefault();
const { item, message } = props;
const { proxy_url } = item.originalItem;
const baseName = `voice-message-${message.id}`;
const parsedURL = parseURL(proxy_url);
await handleDownload(
parsedURL,
baseName,
{ source: AssetSource.ATTACHMENT_MEDIA_PROXY, static: [], animated: [] },
false,
);
}
const { voiceMessages } = settings.use(["voiceMessages"]);
return (
<>
{voiceMessages && <div className={d("voice-message-container")}>
<button
onClick={voiceMessageDownloadClicked}
className={d("voice-message-button")}
aria-label="Download Voice Message"
rel="noreferrer noopener"
>
{DownloadIcon({ width: 20, height: 20 })}
</button>
</div>}
</>
);
}
/**
* Common download handler for all other handlers.
*/
async function handleDownload(
url: ParsedURL,
alias: string | null,
asset: AssetInfo,
animated: boolean,
) {
const extensions = animated || !!asset.forceAnimated ? asset.animated : asset.static;
extensions.length < 1 && url.extension && extensions.push(url.extension);
const { defaultDirectory, overwriteFiles, displayStatus, allowUnicode, statusDuration } = settings.store;
const baseName = alias
? sanitizeFilename(alias, allowUnicode, "discord-download")
: url.baseName
? sanitizeFilename(url.baseName, allowUnicode, "discord-download")
: "discord-download";
let chosenExtension = extensions[0] ?? "";
let resolvedExtension = (chosenExtension).replace("apng", "png").replace("awebp", "webp");
let resolvedPath: string | null = null;
let resolvedURL = url.url;
if (defaultDirectory) {
const resolvedDirectory = defaultDirectory.trim().replace(/^["']|["']$/g, "").replace(/[/\\]+$/, "").replace(/\\/g, "/");
resolvedPath = `${resolvedDirectory}/${baseName}${resolvedExtension ? `.${resolvedExtension}` : ""}`;
displayStatus && showToast("Download Started", Toasts.Type.MESSAGE, { duration: statusDuration * 1000 });
if (!overwriteFiles) {
for (let num = 1; await DownloadifyNative.fileExists(resolvedPath); num++) {
resolvedPath = `${resolvedDirectory}/${baseName}-${num}${resolvedExtension ? `.${resolvedExtension}` : ""}`;
}
}
} else {
try {
resolvedPath = await DownloadifyNative.getFilePath(baseName, extensions);
if (!resolvedPath) {
DownloadifyLogger.info(`[${getFormattedNow()}] [SAVE DIALOGUE CLOSED / DOWNLOAD CANCELED]`);
displayStatus && showToast("Download Canceled", Toasts.Type.MESSAGE, { duration: statusDuration * 1000 });
return;
}
const chosenFile = parseFile(resolvedPath);
chosenExtension = chosenFile.extension || "";
resolvedExtension = (chosenExtension || "").replace("apng", "png").replace("awebp", "webp");
if (url.extension) {
const extReplacer = new RegExp(`\\.${chosenExtension}(?!.*\\.${chosenExtension})`, "i");
resolvedPath = resolvedPath.replace(extReplacer, `.${resolvedExtension}`);
}
} catch (error) {
DownloadifyLogger.error(`[${getFormattedNow()}] [ERROR OPENING SAVE DIALOGUE]:`, error);
displayStatus && showToast("Error Opening Save Dialogue", Toasts.Type.FAILURE, { duration: statusDuration * 1000 });
return;
}
}
try {
let success = false;
if ([AssetSource.ASSET_MEDIA_PROXY, AssetSource.ATTACHMENT_MEDIA_PROXY, AssetSource.EXTERNAL_IMAGE_PROXY].includes(asset.source)) {
let baseURL;
if (asset.source === AssetSource.ASSET_MEDIA_PROXY) {
baseURL = url.host + url.path + url.baseName + (resolvedExtension ? `.${resolvedExtension}` : "");
} else {
// Attachments on the media proxy use the `format` query parameter (seen below) to retrieve alternate
// formats instead of changing the file extension directly like assets on the media proxy do.
baseURL = url.host + url.path + url.baseName +
(url.extension ? `.${url.extension}` : "") +
(url.twitterExtra ? encodeURIComponent(url.twitterExtra) : "");
}
const authParams = url.params.expiry;
const newURL = new URL(baseURL);
Object.entries(authParams).forEach(([key, value]) => {
newURL.searchParams.append(key, value);
});
if ([AssetSource.ATTACHMENT_MEDIA_PROXY, AssetSource.EXTERNAL_IMAGE_PROXY].includes(asset.source)) {
if (url.extension !== resolvedExtension) {
newURL.searchParams.append("format", resolvedExtension.replace("jpg", "jpeg"));
}
} else {
// Attachments on the media proxy do not support
// resizing but assets on the media proxy do.
newURL.searchParams.append("size", "4096");
}
if (chosenExtension === "awebp" && animated) {
newURL.searchParams.append("animated", "true");
} else if (chosenExtension === "png" && animated) {
newURL.searchParams.append("passthrough", "false");
}
if (resolvedExtension === "webp") {
newURL.searchParams.append("lossless", "true");
}
resolvedURL = newURL.href;
} else if (asset.source === AssetSource.CDN) {
if (asset.type === "nameplate") {
resolvedURL = url.host + url.path + (
chosenExtension === "apng"
? "img.png"
: chosenExtension === "webm"
? "asset.webm"
: "static.png"
);
} else {
// Currently only the LOTTIE sticker.
resolvedURL = url.url;
}
} else if (asset.source === AssetSource.TENOR) {
const tenorID = url.path.replaceAll("/", "").slice(0, -2);
if (chosenExtension === "gif") {
resolvedURL = `${TENOR_BASE}/${tenorID}${TENOR_GIF_ID}/tenor.gif`;
} else if (chosenExtension === "mp4") {
resolvedURL = `${TENOR_BASE}/${tenorID}${TENOR_MP4_ID}/tenor.mp4`;
} else {
DownloadifyLogger.warn(`[${getFormattedNow()}] [UNSUPPORTED TENOR EXTENSION]\n${chosenExtension}\n${resolvedPath}\n${resolvedURL}`);
displayStatus && showToast("Unsupported Tenor Extension", Toasts.Type.FAILURE, { duration: statusDuration * 1000 });
return;
}
} else if ([AssetSource.EXTERNAL, AssetSource.PRIMARY_DOMAIN].includes(asset.source)) {
resolvedURL = url.url;
}
DownloadifyLogger.info(`[${getFormattedNow()}] [STARTING DOWNLOAD]\n${resolvedPath}\n${resolvedURL}`);
success = await DownloadifyNative.downloadURL(resolvedURL, resolvedPath);
if (success) {
DownloadifyLogger.info(`[${getFormattedNow()}] [DOWNLOAD SUCCESS]\n${resolvedPath}\n${resolvedURL}`);
displayStatus && showToast("File Downloaded", Toasts.Type.SUCCESS, { duration: statusDuration * 1000 });
} else {
DownloadifyLogger.error(`[${getFormattedNow()}] [DOWNLOAD FAILED]\n${resolvedPath}\n${resolvedURL}`);
displayStatus && showToast("File Download Failed", Toasts.Type.FAILURE, { duration: statusDuration * 1000 });
}
} catch (error) {
DownloadifyLogger.error(`[${getFormattedNow()}] [DOWNLOAD ERRORED]\n${resolvedPath}\n${resolvedURL}`, error);
displayStatus && showToast("Error Downloading File", Toasts.Type.FAILURE, { duration: statusDuration * 1000 });
}
}

View file

@ -1,253 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { findGroupChildrenByChildId } from "@api/ContextMenu";
import { Menu } from "@webpack/common";
import { InsertGroup, ParsedFile, ParsedURL } from "./definitions";
/**
* Retrieve information about a file path by parsing its parts.
*/
export function parseFile(filePath: string): ParsedFile {
const parts = filePath.split(/[\\/]/);
let fileNameWithExtra = parts.pop() as string;
while (fileNameWithExtra.includes("%25")) {
fileNameWithExtra = decodeURIComponent(fileNameWithExtra);
}
fileNameWithExtra = decodeURIComponent(fileNameWithExtra);
const posColon = fileNameWithExtra.indexOf(":");
const fileNameWithoutExtra = posColon === -1 ? fileNameWithExtra : fileNameWithExtra.slice(0, posColon);
const twitterExtra = posColon === -1 ? null : fileNameWithExtra.slice(posColon);
const path = parts.join("/") + "/";
const posDot = fileNameWithoutExtra.lastIndexOf(".");
if (fileNameWithoutExtra === "" || posDot < 1) {
return {
path,
baseName: fileNameWithoutExtra,
extension: null,
twitterExtra: null
};
}
return {
path,
baseName: fileNameWithoutExtra.slice(0, posDot),
extension: fileNameWithoutExtra.slice(posDot + 1).toLowerCase(),
twitterExtra: twitterExtra ?? null
};
}
/**
* Retrieve information about a URL by parsing its parts.
*/
export function parseURL(url: string): ParsedURL {
const parsed = new URL(url);
const { path, baseName, extension, twitterExtra } = parseFile(parsed.pathname);
const params = Array.from(parsed.searchParams.entries()).reduce(
(acc, [key, value]) => {
if (["ex", "is", "hm"].includes(key)) {
acc.expiry[key] = value;
} else {
acc.other[key] = value;
}
return acc;
}, { expiry: {}, other: {} } as { expiry: Record<string, string>, other: Record<string, string>; }
);
return {
url: url,
host: parsed.origin.toLowerCase(),
path,
baseName,
extension,
params,
twitterExtra
};
}
/**
* Sanitize a file name by replacing invalid characters with underscores.
*/
export function sanitizeFilename(filename: string, allowUnicode: boolean, fallback?: string): string {
let sanitized = filename.replace(/[<>:"/\\|?*\x00-\x1F]/g, "_") // windows-reserved
.replace(/^\./, "_") // dot files
.replace(/(\.\.)+/g, "_") // relative paths
.replace(/[ \t\n\r]+/g, "_"); // spaces
if (!allowUnicode) {
sanitized = sanitized.replace(/[^\x00-\x7F]/g, "_"); // non-ASCII characters
} else {
sanitized = sanitized.normalize("NFD"); // normalize unicode
}
sanitized = sanitized.replace(/^_+|_+$/g, ""); // leading and trailing underscores
return sanitized || fallback || "";
}
/**
* Get the current date and time formatted as a string.
*/
export function getFormattedNow(): string {
return new Date().toLocaleString(undefined, {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: true
}).replace(",", "");
}
/**
* Join or create a group in the guild context menu.
*/
export function joinOrCreateContextMenuGroup(
children: Array<any>,
items: Array<any>,
groupId: string,
submenuId?: string,
submenuLabel?: string,
insertOrder?: InsertGroup[]
) {
function joinOrCreateSubmenu() {
const existingItemShouldJoinSubmenu = existingGroup.props.children.find(child => child?.props?.submenuId === submenuId);
let existingSubmenu = existingGroup.props.children.find(child => child?.props?.id === submenuId);
let numitems = items.length;
if (existingItemShouldJoinSubmenu) {
existingSubmenu = null;
items.unshift(existingItemShouldJoinSubmenu);
numitems++;
const indexOfExistingItemShouldJoinSubmenu = existingGroup.props.children.findIndex(child => child === existingItemShouldJoinSubmenu);
existingGroup.props.children.splice(indexOfExistingItemShouldJoinSubmenu, 1);
}
if (existingSubmenu) {
if (!Array.isArray(existingSubmenu.props.children)) {
existingSubmenu.props.children = [existingSubmenu.props.children];
}
items.forEach(member => member.props.label = member.props.submenuItemLabel ?? member.props.label);
existingSubmenu.props.children.push(...items);
} else if (numitems === 1) {
if (!chosenInsertOption?.position || chosenInsertOption.position === "END") {
existingGroup.props.children.push(items[0]);
} else {
existingGroup.props.children.unshift(items[0]);
}
} else {
existingSubmenu = (
<Menu.MenuItem
id={submenuId as string}
label={submenuLabel}
>
{[...items]}
</Menu.MenuItem>
);
items.forEach(member => member.props.label = member.props.submenuItemLabel ?? member.props.label);
if (!chosenInsertOption?.position || chosenInsertOption.position === "END") {
existingGroup.props.children.push(existingSubmenu);
} else {
existingGroup.props.children.unshift(existingSubmenu);
}
}
}
items.forEach(member => {
member.props.groupId = groupId;
member.props.submenuId = submenuId;
member.props.submenuLabel = submenuLabel;
});
const insertWithOptions = insertOrder?.filter(item => item.type === "WITH_GROUP");
let existingGroup = children.find(child => child?.props?.id === groupId);
let chosenInsertOption;
if (!existingGroup && insertWithOptions) {
let targetItem;
for (const item of insertWithOptions) {
if (item.id.group) {
targetItem = children.find(child => child?.props?.id === item.id.group);
}
if (!targetItem && item.id.child) {
targetItem = findGroupChildrenByChildId(item.id.child, children, true);
}
if (targetItem) {
existingGroup = children.find(child => child?.props?.children === targetItem);
chosenInsertOption = item;
break;
}
}
}
if (existingGroup) {
if (!Array.isArray(existingGroup.props.children)) {
existingGroup.props.children = [existingGroup.props.children];
}
if (!submenuId) {
if (!chosenInsertOption?.position || chosenInsertOption.position === "END") {
existingGroup.props.children.push(...items);
} else {
existingGroup.props.children.unshift(...items);
}
return;
} else {
joinOrCreateSubmenu();
}
} else {
if (!submenuId) {
existingGroup = (
<Menu.MenuGroup id={groupId}>
{[...items]}
</Menu.MenuGroup>
);
} else {
existingGroup = (<Menu.MenuGroup id={groupId}>{[]}</Menu.MenuGroup>);
joinOrCreateSubmenu();
}
const insertAfterBeforeOptions = insertOrder?.filter(item => item.type !== "WITH_GROUP") || [];
let targetIndex = children.length;
let targetItem;
for (const item of insertAfterBeforeOptions) {
if (item.id.group) {
targetItem = children.find(child => child?.props?.id === item.id.group);
}
if (!targetItem && item.id.child) {
targetItem = findGroupChildrenByChildId(item.id.child, children, true);
}
if (targetItem) {
targetIndex = children.findIndex(child => child?.props?.children === targetItem);
if (item.type === "AFTER_GROUP") {
targetIndex++;
}
break;
}
}
children.splice(targetIndex, 0, existingGroup);
}
}