diff --git a/src/equicordplugins/downloadify/index.tsx b/src/equicordplugins/downloadify/index.tsx new file mode 100644 index 000000000..6c4216f9b --- /dev/null +++ b/src/equicordplugins/downloadify/index.tsx @@ -0,0 +1,115 @@ +/* + * 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], + hidden: IS_WEB, + 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: "animate}):null", + 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}?)(await \i.\i.saveImage\(\i,\i.contentType,\i.\i\)),/, + replace: "$1await $self.handleExpandedModalDownloadButtonClicked($2,async()=>{$3});", + }, + { + // Disable default success toast. + match: /(\(0,.{0,85}?ToastType.SUCCESS\)\))/, + replace: "", + }, + { + // Disable default failure toast. + match: /(\(0,.{0,85}?ToastType.FAILURE\)\))/, + replace: "", + }, + { + // Prevent videos from opening in browser. + match: /(let{item:\i}=\i.{0,350}?)(SAVE_MEDIA_PRESSED\)).{0,100}?(\i\(!0\);)/, + replace: "$1$2,true){$3" + } + ], + } + ] +}); diff --git a/src/equicordplugins/downloadify/native.ts b/src/equicordplugins/downloadify/native.ts new file mode 100644 index 000000000..1cc48e27a --- /dev/null +++ b/src/equicordplugins/downloadify/native.ts @@ -0,0 +1,94 @@ +/* + * 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 { + 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 { + 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 { + 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 { + 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 { + 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; +} diff --git a/src/equicordplugins/downloadify/settings.tsx b/src/equicordplugins/downloadify/settings.tsx new file mode 100644 index 000000000..da21af8d8 --- /dev/null +++ b/src/equicordplugins/downloadify/settings.tsx @@ -0,0 +1,109 @@ +/* + * 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 { OptionType } from "@utils/types"; +import { Button, Forms, 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 ( + + + + Default Directory + + + 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. + +
+ + {defaultDirectory || "No Directory Set"} + +
+ + +
+
+
+
+ ); +} + +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: "", + }, +}); diff --git a/src/equicordplugins/downloadify/style.css b/src/equicordplugins/downloadify/style.css new file mode 100644 index 000000000..2b7f3df5e --- /dev/null +++ b/src/equicordplugins/downloadify/style.css @@ -0,0 +1,77 @@ +.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; +} \ No newline at end of file diff --git a/src/equicordplugins/downloadify/utils/definitions.tsx b/src/equicordplugins/downloadify/utils/definitions.tsx new file mode 100644 index 000000000..8f65a964a --- /dev/null +++ b/src/equicordplugins/downloadify/utils/definitions.tsx @@ -0,0 +1,371 @@ +/* + * 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; +export const defaultAssets = findByPropsLazy("DEFAULT_GROUP_DM_AVATARS") as DefaultAssets; + +interface DefaultAssets { + BOT_AVATARS: Record; + 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 ( + + + + ); +} + +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, other: Record; }; + 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 { + 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 +}; diff --git a/src/equicordplugins/downloadify/utils/handlers.tsx b/src/equicordplugins/downloadify/utils/handlers.tsx new file mode 100644 index 000000000..42dd32fc7 --- /dev/null +++ b/src/equicordplugins/downloadify/utils/handlers.tsx @@ -0,0 +1,1006 @@ +/* + * 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, 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( + 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( + 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( + 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( + 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( + 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( + 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( + 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, 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 ? ( + ImageIcon({ width: 20, height: 20 })} + action={async () => { + await handleDownload( + channelIconURL, + channel.name ? `${channel.name}-group-icon` : null, + assetAvailability["guild-icon"], + false + ); + }} + /> + ) : null, + defaultChannelIconURL ? ( + 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, 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 ? ( + ImageIcon({ width: 20, height: 20 })} + action={async () => await handleDownload( + guildIconURL, + guild.name ? `${guild.name}-icon` : null, + assetAvailability["guild-icon"], + !!guildIconHash?.startsWith("a_") + )} + /> + ) : null, + guildBannerURL ? ( + ImageIcon({ width: 20, height: 20 })} + action={async () => await handleDownload( + guildBannerURL, + guild.name ? `${guild.name}-banner` : null, + assetAvailability["guild-banner"], + !!guildBannerHash?.startsWith("a_") + )} + /> + ) : null, + guildInviteURL ? ( + 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 ? ( + 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, 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 ? ( + ImageIcon({ width: 20, height: 20 })} + action={async () => await handleDownload( + userAvatarURL, + `${user.username}-avatar`, + assetAvailability["user-avatar"], + !!userAvatarHash?.startsWith("a_") + )} + /> + ) : null, + userBannerURL ? ( + ImageIcon({ width: 20, height: 20 })} + action={async () => await handleDownload( + userBannerURL, + `${user.username}-banner`, + assetAvailability["user-banner"], + !!userBannerHash?.startsWith("a_") + )} + /> + ) : null, + userAvatarDecorationURL ? ( + ImageIcon({ width: 20, height: 20 })} + action={async () => await handleDownload( + userAvatarDecorationURL, + `${user.username}-avatar-decoration`, + assetAvailability["avatar-decoration"], + true + )} + /> + ) : null, + memberAvatarURL ? ( + 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 ? ( + 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 ? ( + 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 ? ( + ImageIcon({ width: 20, height: 20 })} + action={async () => await handleDownload( + defaultUserAvatarURL, + `${user.username}-default-avatar`, + assetAvailability["default-user-avatar"], + false + )} + /> + ) : null, + userNameplateURL ? ( + 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 { + 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("File Opened in Browser", Toasts.Type.MESSAGE, { duration: statusDuration * 1000 }); + 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): Promise { + 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("Download Started", Toasts.Type.MESSAGE, { duration: statusDuration * 1000 }); + + try { + await fallback(); + displayStatus && showToast("Download Success", Toasts.Type.SUCCESS, { duration: statusDuration * 1000 }); + } catch (error) { + DownloadifyLogger.info(`[${getFormattedNow()}] [FALLBACK DOWNLOAD FAILED]`, error); + displayStatus && showToast("Download Failed", Toasts.Type.FAILURE, { duration: statusDuration * 1000 }); + } + } 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) { + 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 &&
+ +
} + + ); +} + +/** + * 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 }); + } +} diff --git a/src/equicordplugins/downloadify/utils/misc.tsx b/src/equicordplugins/downloadify/utils/misc.tsx new file mode 100644 index 000000000..ab1119fb4 --- /dev/null +++ b/src/equicordplugins/downloadify/utils/misc.tsx @@ -0,0 +1,253 @@ +/* + * 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, other: Record; } + ); + + 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, + items: Array, + 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 = ( + + {[...items]} + + ); + + 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 = ( + + {[...items]} + + ); + } else { + existingGroup = ({[]}); + 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); + } +}