diff --git a/src/equicordplugins/downloadify.desktop/index.tsx b/src/equicordplugins/downloadify.desktop/index.tsx deleted file mode 100644 index 880e39e13..000000000 --- a/src/equicordplugins/downloadify.desktop/index.tsx +++ /dev/null @@ -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){" - } - ], - } - ] -}); diff --git a/src/equicordplugins/downloadify.desktop/native.ts b/src/equicordplugins/downloadify.desktop/native.ts deleted file mode 100644 index 1cc48e27a..000000000 --- a/src/equicordplugins/downloadify.desktop/native.ts +++ /dev/null @@ -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 { - 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.desktop/settings.tsx b/src/equicordplugins/downloadify.desktop/settings.tsx deleted file mode 100644 index 97a74d9d8..000000000 --- a/src/equicordplugins/downloadify.desktop/settings.tsx +++ /dev/null @@ -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 ( - -
- - 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.desktop/style.css b/src/equicordplugins/downloadify.desktop/style.css deleted file mode 100644 index 2b7f3df5e..000000000 --- a/src/equicordplugins/downloadify.desktop/style.css +++ /dev/null @@ -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; -} \ No newline at end of file diff --git a/src/equicordplugins/downloadify.desktop/utils/definitions.tsx b/src/equicordplugins/downloadify.desktop/utils/definitions.tsx deleted file mode 100644 index 8f65a964a..000000000 --- a/src/equicordplugins/downloadify.desktop/utils/definitions.tsx +++ /dev/null @@ -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; -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.desktop/utils/handlers.tsx b/src/equicordplugins/downloadify.desktop/utils/handlers.tsx deleted file mode 100644 index 43f336578..000000000 --- a/src/equicordplugins/downloadify.desktop/utils/handlers.tsx +++ /dev/null @@ -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, 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("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): 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("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) { - 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.desktop/utils/misc.tsx b/src/equicordplugins/downloadify.desktop/utils/misc.tsx deleted file mode 100644 index ab1119fb4..000000000 --- a/src/equicordplugins/downloadify.desktop/utils/misc.tsx +++ /dev/null @@ -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, 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); - } -}