Downloadify (#349)
* PR. * Update definitions.tsx * Typo + cleanup. * Revert breaking change. * Fix image extension flaw + embed context flaw. * Fix typo. * Label. * Video + label fixes. * Label improvement. * Content type improvements. --------- Co-authored-by: thororen <78185467+thororen1234@users.noreply.github.com>
This commit is contained in:
parent
1f6dc116a0
commit
7ed241f861
7 changed files with 2025 additions and 0 deletions
115
src/equicordplugins/downloadify/index.tsx
Normal file
115
src/equicordplugins/downloadify/index.tsx
Normal file
|
|
@ -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"
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
});
|
||||
94
src/equicordplugins/downloadify/native.ts
Normal file
94
src/equicordplugins/downloadify/native.ts
Normal file
|
|
@ -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<boolean> {
|
||||
try {
|
||||
await fs.promises.access(filePath, fs.constants.F_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the user to select a directory to save files.
|
||||
*/
|
||||
export async function getDirectory(_: Electron.IpcMainInvokeEvent): Promise<string | null> {
|
||||
const result = await dialog.showOpenDialog({
|
||||
properties: ["openDirectory"]
|
||||
});
|
||||
|
||||
return result.filePaths[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the user to select a file name and location to save a file.
|
||||
*/
|
||||
export async function getFilePath(_: Electron.IpcMainInvokeEvent, fileName: string, fileTypes: string[] | null): Promise<string | null> {
|
||||
const filters = fileTypes?.map(ext => ({
|
||||
name: ext.toUpperCase(),
|
||||
extensions: [ext]
|
||||
})) ?? [];
|
||||
|
||||
if (!fileTypes?.length) {
|
||||
filters.push({ name: "All Files", extensions: ["*"] });
|
||||
}
|
||||
|
||||
const result = await dialog.showSaveDialog({
|
||||
title: "Save File",
|
||||
defaultPath: fileName,
|
||||
buttonLabel: "Save",
|
||||
properties: ["createDirectory"],
|
||||
filters
|
||||
});
|
||||
|
||||
return result.filePath ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the content type of a file at a URL.
|
||||
*
|
||||
* @returns The content type of the file if it exists, undefined if no content type is available, or null if the file doesn't exist.
|
||||
*/
|
||||
export async function queryURL(_: Electron.IpcMainInvokeEvent, url: string): Promise<string | null | undefined> {
|
||||
const response = await fetch(url, { method: "HEAD" });
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return response.headers.get("content-type") ?? undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a file from a URL to a local file path.
|
||||
*/
|
||||
export async function downloadURL(_: Electron.IpcMainInvokeEvent, url: string, filePath: string): Promise<boolean> {
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const readableStream = Readable.fromWeb(response.body as any);
|
||||
const fileWriteStream = fs.createWriteStream(filePath);
|
||||
readableStream.pipe(fileWriteStream);
|
||||
await finished(fileWriteStream);
|
||||
|
||||
return true;
|
||||
}
|
||||
109
src/equicordplugins/downloadify/settings.tsx
Normal file
109
src/equicordplugins/downloadify/settings.tsx
Normal file
|
|
@ -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 (
|
||||
<ErrorBoundary>
|
||||
<Forms.FormSection>
|
||||
<Forms.FormTitle className={d("form-title")}>
|
||||
Default Directory
|
||||
</Forms.FormTitle>
|
||||
<Forms.FormText className={d("form-description")}>
|
||||
Default download location. If set, the file will always be downloaded in its original format even if alternatives are available. Leave empty to pick a folder and file type each time.
|
||||
</Forms.FormText>
|
||||
<div className={d("directory-container")}>
|
||||
<Forms.FormText className={d("directory-display")}>
|
||||
{defaultDirectory || "No Directory Set"}
|
||||
</Forms.FormText>
|
||||
<div className={d("directory-buttons")}>
|
||||
<Button
|
||||
disabled={isDialogueOpen}
|
||||
className={d("directory-button", "browse-button", { "disabled": isDialogueOpen })}
|
||||
onClick={handlePickDirectory}
|
||||
color={Button.Colors.CUSTOM}
|
||||
>
|
||||
{isDialogueOpen ? "Browsing..." : "Browse"}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!defaultDirectory || isDialogueOpen}
|
||||
className={d("directory-button", "clear-button", { "disabled": !defaultDirectory || isDialogueOpen })}
|
||||
onClick={handleClearDirectory}
|
||||
color={Button.Colors.CUSTOM}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Forms.FormSection>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
export const settings = definePluginSettings({
|
||||
displayStatus: {
|
||||
description: "Display a status notification when downloads start, finish, or error.",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: true,
|
||||
},
|
||||
statusDuration: {
|
||||
type: OptionType.SLIDER,
|
||||
description: "The number of seconds to display status notifications.",
|
||||
markers: [1, 3, 5],
|
||||
default: 2.5,
|
||||
stickToMarkers: false,
|
||||
},
|
||||
voiceMessages: {
|
||||
description: "Add a download button to voice messages.",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: true,
|
||||
},
|
||||
allowUnicode: {
|
||||
description: "Allow non-ASCII characters in file names.",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: true,
|
||||
},
|
||||
overwriteFiles: {
|
||||
description: "If a default directory is set and a download file name matches an existing file, overwrite the file. If disabled, a number will be appended to the file name.",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: false,
|
||||
},
|
||||
defaultDirectory: {
|
||||
component: DefaultDirectorySetting,
|
||||
type: OptionType.COMPONENT,
|
||||
default: "",
|
||||
},
|
||||
});
|
||||
77
src/equicordplugins/downloadify/style.css
Normal file
77
src/equicordplugins/downloadify/style.css
Normal file
|
|
@ -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;
|
||||
}
|
||||
371
src/equicordplugins/downloadify/utils/definitions.tsx
Normal file
371
src/equicordplugins/downloadify/utils/definitions.tsx
Normal file
|
|
@ -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<typeof import("../native")>;
|
||||
export const defaultAssets = findByPropsLazy("DEFAULT_GROUP_DM_AVATARS") as DefaultAssets;
|
||||
|
||||
interface DefaultAssets {
|
||||
BOT_AVATARS: Record<string, string>;
|
||||
DEFAULT_AVATARS: string[];
|
||||
DEFAULT_CHANNEL_ICON: string;
|
||||
DEFAULT_GROUP_DM_AVATARS: string[];
|
||||
DEFAULT_PROVISIONAL_AVATARS: string[];
|
||||
}
|
||||
|
||||
export interface AssetInfo {
|
||||
source: AssetSource;
|
||||
static: string[];
|
||||
animated: string[];
|
||||
type?: string;
|
||||
forceAnimated?: boolean;
|
||||
}
|
||||
|
||||
export enum AssetSource {
|
||||
CDN = "cdn",
|
||||
EXTERNAL_IMAGE_PROXY = "external_image_proxy",
|
||||
ATTACHMENT_MEDIA_PROXY = "attachment_media_proxy",
|
||||
ASSET_MEDIA_PROXY = "asset_media_proxy",
|
||||
PRIMARY_DOMAIN = "primary_domain",
|
||||
EXTERNAL = "external",
|
||||
TENOR = "tenor"
|
||||
}
|
||||
|
||||
export const unknownExternal = {
|
||||
source: AssetSource.EXTERNAL,
|
||||
static: [],
|
||||
animated: []
|
||||
};
|
||||
|
||||
export const unknownPrimaryDomain = {
|
||||
source: AssetSource.PRIMARY_DOMAIN,
|
||||
static: [],
|
||||
animated: []
|
||||
};
|
||||
|
||||
export const unknownCDN = {
|
||||
source: AssetSource.CDN,
|
||||
static: [],
|
||||
animated: []
|
||||
};
|
||||
|
||||
export const unknownAssetMediaProxy = {
|
||||
source: AssetSource.ASSET_MEDIA_PROXY,
|
||||
static: [],
|
||||
animated: []
|
||||
};
|
||||
|
||||
export const unknownAttachmentMediaProxy = {
|
||||
source: AssetSource.ATTACHMENT_MEDIA_PROXY,
|
||||
static: [],
|
||||
animated: []
|
||||
};
|
||||
|
||||
export const unknownExternalImageProxy = {
|
||||
source: AssetSource.EXTERNAL_IMAGE_PROXY,
|
||||
static: [],
|
||||
animated: []
|
||||
};
|
||||
|
||||
export const defaultResourceAvailability = {
|
||||
source: AssetSource.PRIMARY_DOMAIN,
|
||||
static: ["png"],
|
||||
animated: []
|
||||
};
|
||||
|
||||
export const APNGAvailability = {
|
||||
source: AssetSource.ASSET_MEDIA_PROXY,
|
||||
static: ["png", "webp", "jpg"],
|
||||
animated: ["apng", "png", "webp", "jpg"]
|
||||
};
|
||||
|
||||
export const hashableAvailability = {
|
||||
source: AssetSource.ASSET_MEDIA_PROXY,
|
||||
static: ["png", "webp", "jpg"],
|
||||
animated: ["gif", "awebp", "png", "webp", "jpg"]
|
||||
};
|
||||
|
||||
// Defines known asset type availability. Unknown types which may be
|
||||
// encountered are AssetSource.EXTERNAL which are present in some embeds,
|
||||
// as well as OTHER attachment types.
|
||||
export const assetAvailability = {
|
||||
"user-avatar": hashableAvailability,
|
||||
"user-banner": hashableAvailability,
|
||||
"avatar-decoration": APNGAvailability,
|
||||
"clan-badge": {
|
||||
source: AssetSource.ASSET_MEDIA_PROXY,
|
||||
static: ["png"],
|
||||
animated: []
|
||||
},
|
||||
"nameplate": {
|
||||
source: AssetSource.CDN,
|
||||
static: ["png"],
|
||||
animated: ["webm", "apng", "png"],
|
||||
type: "nameplate"
|
||||
},
|
||||
"default-user-avatar": defaultResourceAvailability,
|
||||
"default-group-icon": defaultResourceAvailability,
|
||||
"role-icon-custom": {
|
||||
source: AssetSource.ASSET_MEDIA_PROXY,
|
||||
static: ["png", "webp", "jpg"],
|
||||
animated: [],
|
||||
},
|
||||
"role-icon-unicode-emoji": {
|
||||
source: AssetSource.PRIMARY_DOMAIN,
|
||||
static: ["svg"],
|
||||
animated: [],
|
||||
},
|
||||
"guild-icon": hashableAvailability,
|
||||
"guild-banner": hashableAvailability,
|
||||
"guild-invite-splash": hashableAvailability,
|
||||
"guild-discovery-splash": hashableAvailability,
|
||||
"custom-emoji": hashableAvailability,
|
||||
"unicode-emoji": {
|
||||
source: AssetSource.PRIMARY_DOMAIN,
|
||||
static: ["svg"],
|
||||
animated: []
|
||||
},
|
||||
"sticker": {
|
||||
"APNG": APNGAvailability,
|
||||
"PNG": {
|
||||
source: AssetSource.ASSET_MEDIA_PROXY,
|
||||
static: ["png", "webp", "jpg"],
|
||||
animated: []
|
||||
},
|
||||
"GIF": {
|
||||
source: AssetSource.ASSET_MEDIA_PROXY,
|
||||
static: [],
|
||||
animated: ["gif", "awebp", "png", "webp", "jpg"],
|
||||
forceAnimated: true
|
||||
},
|
||||
"LOTTIE": {
|
||||
source: AssetSource.CDN,
|
||||
static: ["json"],
|
||||
animated: ["json"]
|
||||
},
|
||||
},
|
||||
"tenor": {
|
||||
source: AssetSource.TENOR,
|
||||
static: [],
|
||||
animated: ["gif", "mp4"],
|
||||
forceAnimated: true
|
||||
},
|
||||
"external": {
|
||||
"image/png": {
|
||||
source: AssetSource.EXTERNAL_IMAGE_PROXY,
|
||||
static: ["png", "webp", "jpg"],
|
||||
animated: []
|
||||
},
|
||||
"image/webp": {
|
||||
source: AssetSource.EXTERNAL_IMAGE_PROXY,
|
||||
static: ["png", "webp", "jpg"],
|
||||
animated: []
|
||||
},
|
||||
"image/jpeg": {
|
||||
source: AssetSource.EXTERNAL_IMAGE_PROXY,
|
||||
static: ["png", "webp", "jpg"],
|
||||
animated: []
|
||||
},
|
||||
"video/mp4": {
|
||||
source: AssetSource.EXTERNAL,
|
||||
static: [],
|
||||
animated: ["mp4"],
|
||||
forceAnimated: true
|
||||
},
|
||||
},
|
||||
"attachment": {
|
||||
// Image attachments over a certain size and/or resolution are treated as
|
||||
// non-image files and are therefore not available in alternative formats.
|
||||
// This is designated by an attachment type of OTHER instead of IMAGE.
|
||||
"image/png": {
|
||||
source: AssetSource.ATTACHMENT_MEDIA_PROXY,
|
||||
static: ["png", "webp", "jpg"],
|
||||
// APNG attachments are technically supported, but appear to get
|
||||
// broken by Discord squashing them into static PNGs. APNG is kept
|
||||
// here in case it is fixed some day.
|
||||
animated: ["apng", "png", "webp", "jpg"]
|
||||
},
|
||||
"image/webp": {
|
||||
source: AssetSource.ATTACHMENT_MEDIA_PROXY,
|
||||
static: ["webp", "png", "jpg"],
|
||||
animated: ["awebp", "webp", "png", "jpg"]
|
||||
},
|
||||
"image/avif": {
|
||||
source: AssetSource.ATTACHMENT_MEDIA_PROXY,
|
||||
static: ["avif", "png", "webp"],
|
||||
animated: ["avif", "awebp", "png", "webp"]
|
||||
},
|
||||
"image/jpeg": {
|
||||
source: AssetSource.ATTACHMENT_MEDIA_PROXY,
|
||||
static: ["jpg", "png", "webp"],
|
||||
animated: []
|
||||
},
|
||||
"image/gif": {
|
||||
source: AssetSource.ATTACHMENT_MEDIA_PROXY,
|
||||
static: [],
|
||||
animated: ["gif", "awebp", "png", "webp", "jpg"],
|
||||
forceAnimated: true
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
export function DownloadIcon({ width, height }: { width: number; height: number; }): JSX.Element {
|
||||
return (
|
||||
<svg
|
||||
className={d("-download-icon")}
|
||||
role="img"
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M12 2a1 1 0 0 1 1 1v10.59l3.3-3.3a1 1 0 1 1 1.4 1.42l-5 5a1 1 0 0 1-1.4 0l-5-5a1 1 0 1 1 1.4-1.42l3.3 3.3V3a1 1 0 0 1 1-1ZM3 20a1 1 0 1 0 0 2h18a1 1 0 1 0 0-2H3Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ParsedFile {
|
||||
path: string;
|
||||
baseName: string;
|
||||
extension: string | null;
|
||||
twitterExtra: string | null;
|
||||
}
|
||||
|
||||
export interface ParsedURL {
|
||||
url: string;
|
||||
host: string;
|
||||
path: string;
|
||||
baseName: string;
|
||||
extension: string | null;
|
||||
params: { expiry: Record<string, string>, other: Record<string, string>; };
|
||||
twitterExtra: string | null;
|
||||
}
|
||||
|
||||
export interface InsertGroup {
|
||||
id: { group?: string, child?: string; };
|
||||
type: "WITH_GROUP" | "BEFORE_GROUP" | "AFTER_GROUP";
|
||||
position?: "START" | "END";
|
||||
}
|
||||
|
||||
export interface VoiceMessageDownloadButtonProps {
|
||||
message: Message;
|
||||
item: {
|
||||
type: "AUDIO";
|
||||
originalItem: {
|
||||
filename: string;
|
||||
proxy_url: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface HoverDownloadProps {
|
||||
item: {
|
||||
type: "AUDIO" | "IMAGE" | "VIDEO" | "OTHER";
|
||||
downloadUrl: string;
|
||||
contentType: string;
|
||||
srcIsAnimated: boolean;
|
||||
originalItem: {
|
||||
title?: string;
|
||||
filename: string;
|
||||
proxy_url: string;
|
||||
url: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface ExpandedModalDownloadProps {
|
||||
item: {
|
||||
type: "AUDIO" | "IMAGE" | "VIDEO" | "OTHER";
|
||||
url: string;
|
||||
original: string;
|
||||
proxyUrl?: string;
|
||||
contentType?: string;
|
||||
srcIsAnimated: boolean;
|
||||
sourceMetadata?: {
|
||||
identifier: {
|
||||
type: "attachment" | "embed";
|
||||
title?: string;
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface MessageContextMenuProps {
|
||||
message: Message;
|
||||
channel: Channel;
|
||||
itemSrc: string;
|
||||
itemSafeSrc?: string;
|
||||
favoriteableType: "sticker" | "emoji" | null;
|
||||
favoriteableName: string | null;
|
||||
favoriteableId: string | null;
|
||||
contextMenuAPIArguments: any[];
|
||||
mediaItem?: {
|
||||
contentType: string;
|
||||
proxyUrl: string;
|
||||
url: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface GDMContextMenuProps {
|
||||
channel: Channel & {
|
||||
icon: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface GuildContextMenuProps {
|
||||
guild: Guild;
|
||||
}
|
||||
|
||||
export interface UserContextMenuProps {
|
||||
user: User;
|
||||
guildId?: string;
|
||||
}
|
||||
|
||||
export interface CustomizedUser extends User {
|
||||
avatarDecorationData: null | {
|
||||
asset: string;
|
||||
};
|
||||
collectibles: null | Record<"nameplate", {
|
||||
asset: string;
|
||||
label: string;
|
||||
palette: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface CustomizedMember extends Omit<GuildMember, "avatarDecoration"> {
|
||||
avatarDecoration: null | {
|
||||
asset: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const AttachmentFlags = {
|
||||
IS_CLIP: 1 << 0,
|
||||
IS_THUMBNAIL: 1 << 1,
|
||||
IS_REMIX: 1 << 2,
|
||||
IS_SPOILER: 1 << 3,
|
||||
CONTAINS_EXPLICIT_MEDIA: 1 << 4,
|
||||
IS_ANIMATED: 1 << 5,
|
||||
CONTAINS_GORE_CONTENT: 1 << 6
|
||||
};
|
||||
1006
src/equicordplugins/downloadify/utils/handlers.tsx
Normal file
1006
src/equicordplugins/downloadify/utils/handlers.tsx
Normal file
File diff suppressed because it is too large
Load diff
253
src/equicordplugins/downloadify/utils/misc.tsx
Normal file
253
src/equicordplugins/downloadify/utils/misc.tsx
Normal file
|
|
@ -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<string, string>, other: Record<string, string>; }
|
||||
);
|
||||
|
||||
return {
|
||||
url: url,
|
||||
host: parsed.origin.toLowerCase(),
|
||||
path,
|
||||
baseName,
|
||||
extension,
|
||||
params,
|
||||
twitterExtra
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a file name by replacing invalid characters with underscores.
|
||||
*/
|
||||
export function sanitizeFilename(filename: string, allowUnicode: boolean, fallback?: string): string {
|
||||
let sanitized = filename.replace(/[<>:"/\\|?*\x00-\x1F]/g, "_") // windows-reserved
|
||||
.replace(/^\./, "_") // dot files
|
||||
.replace(/(\.\.)+/g, "_") // relative paths
|
||||
.replace(/[ \t\n\r]+/g, "_"); // spaces
|
||||
|
||||
if (!allowUnicode) {
|
||||
sanitized = sanitized.replace(/[^\x00-\x7F]/g, "_"); // non-ASCII characters
|
||||
} else {
|
||||
sanitized = sanitized.normalize("NFD"); // normalize unicode
|
||||
}
|
||||
|
||||
sanitized = sanitized.replace(/^_+|_+$/g, ""); // leading and trailing underscores
|
||||
return sanitized || fallback || "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current date and time formatted as a string.
|
||||
*/
|
||||
export function getFormattedNow(): string {
|
||||
return new Date().toLocaleString(undefined, {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: true
|
||||
}).replace(",", "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Join or create a group in the guild context menu.
|
||||
*/
|
||||
export function joinOrCreateContextMenuGroup(
|
||||
children: Array<any>,
|
||||
items: Array<any>,
|
||||
groupId: string,
|
||||
submenuId?: string,
|
||||
submenuLabel?: string,
|
||||
insertOrder?: InsertGroup[]
|
||||
) {
|
||||
function joinOrCreateSubmenu() {
|
||||
const existingItemShouldJoinSubmenu = existingGroup.props.children.find(child => child?.props?.submenuId === submenuId);
|
||||
let existingSubmenu = existingGroup.props.children.find(child => child?.props?.id === submenuId);
|
||||
let numitems = items.length;
|
||||
|
||||
if (existingItemShouldJoinSubmenu) {
|
||||
existingSubmenu = null;
|
||||
items.unshift(existingItemShouldJoinSubmenu);
|
||||
numitems++;
|
||||
const indexOfExistingItemShouldJoinSubmenu = existingGroup.props.children.findIndex(child => child === existingItemShouldJoinSubmenu);
|
||||
existingGroup.props.children.splice(indexOfExistingItemShouldJoinSubmenu, 1);
|
||||
}
|
||||
|
||||
if (existingSubmenu) {
|
||||
if (!Array.isArray(existingSubmenu.props.children)) {
|
||||
existingSubmenu.props.children = [existingSubmenu.props.children];
|
||||
}
|
||||
|
||||
items.forEach(member => member.props.label = member.props.submenuItemLabel ?? member.props.label);
|
||||
existingSubmenu.props.children.push(...items);
|
||||
} else if (numitems === 1) {
|
||||
if (!chosenInsertOption?.position || chosenInsertOption.position === "END") {
|
||||
existingGroup.props.children.push(items[0]);
|
||||
} else {
|
||||
existingGroup.props.children.unshift(items[0]);
|
||||
}
|
||||
} else {
|
||||
existingSubmenu = (
|
||||
<Menu.MenuItem
|
||||
id={submenuId as string}
|
||||
label={submenuLabel}
|
||||
>
|
||||
{[...items]}
|
||||
</Menu.MenuItem>
|
||||
);
|
||||
|
||||
items.forEach(member => member.props.label = member.props.submenuItemLabel ?? member.props.label);
|
||||
|
||||
if (!chosenInsertOption?.position || chosenInsertOption.position === "END") {
|
||||
existingGroup.props.children.push(existingSubmenu);
|
||||
} else {
|
||||
existingGroup.props.children.unshift(existingSubmenu);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items.forEach(member => {
|
||||
member.props.groupId = groupId;
|
||||
member.props.submenuId = submenuId;
|
||||
member.props.submenuLabel = submenuLabel;
|
||||
});
|
||||
|
||||
const insertWithOptions = insertOrder?.filter(item => item.type === "WITH_GROUP");
|
||||
let existingGroup = children.find(child => child?.props?.id === groupId);
|
||||
let chosenInsertOption;
|
||||
|
||||
if (!existingGroup && insertWithOptions) {
|
||||
let targetItem;
|
||||
|
||||
for (const item of insertWithOptions) {
|
||||
if (item.id.group) {
|
||||
targetItem = children.find(child => child?.props?.id === item.id.group);
|
||||
}
|
||||
|
||||
if (!targetItem && item.id.child) {
|
||||
targetItem = findGroupChildrenByChildId(item.id.child, children, true);
|
||||
}
|
||||
|
||||
if (targetItem) {
|
||||
existingGroup = children.find(child => child?.props?.children === targetItem);
|
||||
chosenInsertOption = item;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (existingGroup) {
|
||||
if (!Array.isArray(existingGroup.props.children)) {
|
||||
existingGroup.props.children = [existingGroup.props.children];
|
||||
}
|
||||
|
||||
if (!submenuId) {
|
||||
if (!chosenInsertOption?.position || chosenInsertOption.position === "END") {
|
||||
existingGroup.props.children.push(...items);
|
||||
} else {
|
||||
existingGroup.props.children.unshift(...items);
|
||||
}
|
||||
|
||||
return;
|
||||
} else {
|
||||
joinOrCreateSubmenu();
|
||||
}
|
||||
} else {
|
||||
if (!submenuId) {
|
||||
existingGroup = (
|
||||
<Menu.MenuGroup id={groupId}>
|
||||
{[...items]}
|
||||
</Menu.MenuGroup>
|
||||
);
|
||||
} else {
|
||||
existingGroup = (<Menu.MenuGroup id={groupId}>{[]}</Menu.MenuGroup>);
|
||||
joinOrCreateSubmenu();
|
||||
}
|
||||
|
||||
const insertAfterBeforeOptions = insertOrder?.filter(item => item.type !== "WITH_GROUP") || [];
|
||||
let targetIndex = children.length;
|
||||
let targetItem;
|
||||
|
||||
for (const item of insertAfterBeforeOptions) {
|
||||
if (item.id.group) {
|
||||
targetItem = children.find(child => child?.props?.id === item.id.group);
|
||||
}
|
||||
|
||||
if (!targetItem && item.id.child) {
|
||||
targetItem = findGroupChildrenByChildId(item.id.child, children, true);
|
||||
}
|
||||
|
||||
if (targetItem) {
|
||||
targetIndex = children.findIndex(child => child?.props?.children === targetItem);
|
||||
|
||||
if (item.type === "AFTER_GROUP") {
|
||||
targetIndex++;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
children.splice(targetIndex, 0, existingGroup);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue