Security: Drop A Lot Of Plugins

Thank you V
This commit is contained in:
thororen1234 2025-10-31 20:06:05 -04:00
parent 81a4ea94c9
commit 01c464e0c4
No known key found for this signature in database
29 changed files with 15 additions and 3251 deletions

View file

@ -1,298 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import "./styles.css";
import { ChatBarButton } from "@api/ChatButtons";
import { DataStore } from "@api/index";
import { definePluginSettings } from "@api/Settings";
import { FormSwitch } from "@components/FormSwitch";
import { Heading } from "@components/Heading";
import { Paragraph } from "@components/Paragraph";
import { EquicordDevs } from "@utils/constants";
import { getCurrentChannel, sendMessage } from "@utils/discord";
import { useForceUpdater } from "@utils/react";
import definePlugin, { OptionType } from "@utils/types";
import { Button, React, TextInput } from "@webpack/common";
type ButtonEntry = {
id: string;
label: string;
message: string;
svg: string;
enabled: boolean;
};
let buttonEntries: ButtonEntry[] = [];
const BUTTON_ENTRIES_KEY = "ChatButtonsPlus_buttonEntries";
function generateId(): string {
return Date.now().toString(36) + Math.random().toString(36).substring(2);
}
async function handleButtonClick(context: string) {
const currentChannel = getCurrentChannel();
if (!currentChannel) return;
sendMessage(currentChannel.id, { content: context });
}
async function addButtonEntry(forceUpdate: () => void) {
try {
buttonEntries.push({
id: generateId(),
label: "New Button",
message: "Hello!",
svg: '<path fill="currentColor" d="M20 2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h4l4 4 4-4h4c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2z"/>',
enabled: true
});
await DataStore.set(BUTTON_ENTRIES_KEY, buttonEntries);
forceUpdate();
} catch (error) {
console.error("ChatButtonsPlus: Failed to add button entry:", error);
}
}
async function removeButtonEntry(id: string, forceUpdate: () => void) {
try {
const index = buttonEntries.findIndex(entry => entry.id === id);
if (index !== -1) {
buttonEntries.splice(index, 1);
await DataStore.set(BUTTON_ENTRIES_KEY, buttonEntries);
forceUpdate();
}
} catch (error) {
console.error("ChatButtonsPlus: Failed to remove button entry:", error);
}
}
async function resetAllButtons(forceUpdate: () => void) {
try {
buttonEntries.length = 0;
await DataStore.set(BUTTON_ENTRIES_KEY, []);
forceUpdate();
} catch (error) {
console.error("ChatButtonsPlus: Failed to reset buttons:", error);
}
}
function ButtonEntries() {
const update = useForceUpdater();
React.useEffect(() => {
const loadEntries = async () => {
try {
const storedEntries = await DataStore.get(BUTTON_ENTRIES_KEY) ?? [];
buttonEntries = storedEntries;
update();
} catch (error) {
console.error("ChatButtonsPlus: Failed to load entries:", error);
}
};
loadEntries();
}, []);
async function setLabel(id: string, value: string) {
try {
const index = buttonEntries.findIndex(entry => entry.id === id);
if (index !== -1) {
buttonEntries[index].label = value.trim() || "Button";
await DataStore.set(BUTTON_ENTRIES_KEY, buttonEntries);
update();
}
} catch (error) {
console.error("ChatButtonsPlus: Failed to update label:", error);
}
}
async function setMessage(id: string, value: string) {
try {
const index = buttonEntries.findIndex(entry => entry.id === id);
if (index !== -1) {
buttonEntries[index].message = value;
await DataStore.set(BUTTON_ENTRIES_KEY, buttonEntries);
update();
}
} catch (error) {
console.error("ChatButtonsPlus: Failed to update message:", error);
}
}
async function setSvg(id: string, value: string) {
try {
const index = buttonEntries.findIndex(entry => entry.id === id);
if (index !== -1) {
if (value.trim() === "" || isValidSvg(value.trim())) {
buttonEntries[index].svg = value.trim();
await DataStore.set(BUTTON_ENTRIES_KEY, buttonEntries);
update();
}
}
} catch (error) {
console.error("ChatButtonsPlus: Failed to update SVG:", error);
}
}
async function setEnabled(id: string, value: boolean) {
try {
const index = buttonEntries.findIndex(entry => entry.id === id);
if (index !== -1) {
buttonEntries[index].enabled = value;
await DataStore.set(BUTTON_ENTRIES_KEY, buttonEntries);
update();
}
} catch (error) {
console.error("ChatButtonsPlus: Failed to update enabled state:", error);
}
}
function isValidSvg(svg: string): boolean {
if (!svg) return true;
const dangerousPatterns = [
/<script/i,
/javascript:/i,
/on\w+=/i,
/<iframe/i,
/<object/i,
/<embed/i,
/<link/i,
/<meta/i
];
if (dangerousPatterns.some(pattern => pattern.test(svg))) {
return false;
}
return svg.includes("<") && (
svg.includes("path") ||
svg.includes("svg") ||
svg.includes("circle") ||
svg.includes("rect") ||
svg.includes("polygon") ||
svg.includes("g")
);
}
const elements = buttonEntries.map((entry, i) => {
return (
<div key={entry.id} className="chatButtonsPlus-card">
<div className="chatButtonsPlus-header">
<Heading className="chatButtonsPlus-title">Button {i + 1}</Heading>
<div className="chatButtonsPlus-controls">
<FormSwitch
title="Enabled"
value={entry.enabled ?? true}
onChange={value => setEnabled(entry.id, value)}
className="chatButtonsPlus-toggle"
/>
<Button
onClick={() => removeButtonEntry(entry.id, update)}
look={Button.Looks.FILLED}
color={Button.Colors.RED}
size={Button.Sizes.SMALL}
>
Delete
</Button>
</div>
</div>
<div className="chatButtonsPlus-field">
<Paragraph className="chatButtonsPlus-label">Button Label (Tooltip)</Paragraph>
<TextInput
placeholder="Button label/tooltip"
value={entry.label}
onChange={e => setLabel(entry.id, e)}
/>
</div>
<div className="chatButtonsPlus-field">
<Paragraph className="chatButtonsPlus-label">Message to Send</Paragraph>
<textarea
className="chatButtonsPlus-textarea"
placeholder="Message to send when clicked"
value={entry.message}
onChange={e => setMessage(entry.id, e.target.value)}
rows={2}
/>
</div>
<div className="chatButtonsPlus-field">
<Paragraph className="chatButtonsPlus-label">Custom SVG Path (24x24 viewBox)</Paragraph>
<textarea
className="chatButtonsPlus-textarea"
placeholder='<path fill="currentColor" d="..."/>'
value={entry.svg}
onChange={e => setSvg(entry.id, e.target.value)}
rows={3}
/>
<Paragraph className="chatButtonsPlus-description">
Enter SVG path elements for a 24x24 viewBox. Use "currentColor" for the fill to match Discord's theme.
</Paragraph>
</div>
</div>
);
});
return (
<>
{elements}
<div className="chatButtonsPlus-actions">
<Button onClick={() => addButtonEntry(update)}>Add Button</Button>
<Button
onClick={() => resetAllButtons(update)}
look={Button.Looks.FILLED}
color={Button.Colors.RED}
>
Reset All
</Button>
</div>
</>
);
}
const settings = definePluginSettings({
buttons: {
type: OptionType.COMPONENT,
description: "Manage your custom chat buttons",
component: () => <ButtonEntries />
}
});
export default definePlugin({
name: "ChatButtonsPlus",
description: "Add custom chat buttons with personalized + messages and SVG icons",
authors: [EquicordDevs.creations],
settings,
renderChatBarButton: ({ isMainChat }) => {
if (!isMainChat) return null;
return (
<>
{buttonEntries.filter(entry => entry.enabled !== false).map(entry => (
<ChatBarButton
key={entry.id}
tooltip={entry.label}
onClick={() => handleButtonClick(entry.message)}
>
<svg xmlns="http://www.w3.org/2000/svg" width="20px" height="20px" viewBox="0 0 24 24">
<g dangerouslySetInnerHTML={{ __html: entry.svg }} />
</svg>
</ChatBarButton>
))}
</>
);
},
async start() {
const storedEntries = await DataStore.get(BUTTON_ENTRIES_KEY) ?? [];
await DataStore.set(BUTTON_ENTRIES_KEY, storedEntries);
buttonEntries = storedEntries;
}
});

View file

@ -1,78 +0,0 @@
.chatButtonsPlus-card {
margin-bottom: 15px;
padding: 15px;
border: 1px solid var(--background-modifier-accent);
border-radius: 8px;
}
.chatButtonsPlus-header {
display: flex;
flex-direction: row;
align-items: center;
margin-bottom: 10px;
}
.chatButtonsPlus-title {
margin: 0;
flex-grow: 1;
}
.chatButtonsPlus-controls {
display: flex;
align-items: center;
gap: 15px;
}
.chatButtonsPlus-toggle div[class*="divider"] {
display: none;
}
.chatButtonsPlus-toggle div[class*="labelRow"] {
display: flex;
gap: 5px;
}
.chatButtonsPlus-toggle {
margin: 0;
}
.chatButtonsPlus-field {
margin-bottom: 10px;
}
.chatButtonsPlus-label {
margin-bottom: 5px;
}
.chatButtonsPlus-description {
font-size: 12px;
color: var(--text-muted);
margin-top: 3px;
}
.chatButtonsPlus-textarea {
width: 100%;
padding: 8px 12px;
border: 2px solid var(--text-muted);
border-radius: 4px;
background-color: var(--background-secondary);
color: var(--header-primary);
font-family: var(--font-primary);
font-size: 14px;
resize: vertical;
min-height: 60px;
box-sizing: border-box;
}
.chatButtonsPlus-textarea:focus {
outline: none;
border-color: var(--brand-experiment);
box-shadow: 0 0 0 1px var(--brand-experiment);
}
.chatButtonsPlus-actions {
display: flex;
gap: 10px;
margin-top: 15px;
}

View file

@ -1,104 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { definePluginSettings } from "@api/Settings";
import { Paragraph } from "@components/Paragraph";
import { Devs, EquicordDevs } from "@utils/constants";
import definePlugin, { OptionType, PluginNative, ReporterTestable } from "@utils/types";
import { Activity } from "@vencord/discord-types";
import { ActivityFlags, ActivityType } from "@vencord/discord-types/enums";
import { ApplicationAssetUtils, FluxDispatcher } from "@webpack/common";
const Native = VencordNative.pluginHelpers.GensokyoRadioRPC as PluginNative<typeof import("./native")>;
const applicationId = "1253772057926303804";
function setActivity(activity: Activity | null) {
FluxDispatcher.dispatch({
type: "LOCAL_ACTIVITY_UPDATE",
activity,
socketId: "GensokyoRadio",
});
}
function getImageAsset(data: string) {
return ApplicationAssetUtils.fetchAssetIds(applicationId, [data]).then(ids => ids[0]);
}
const settings = definePluginSettings({
refreshInterval: {
type: OptionType.SLIDER,
description: "The interval between activity refreshes (seconds)",
markers: [1, 2, 2.5, 3, 5, 10, 15],
default: 15,
restartNeeded: true,
}
});
export default definePlugin({
name: "GensokyoRadioRPC",
description: "Discord rich presence for Gensokyo Radio!",
authors: [Devs.RyanCaoDev, EquicordDevs.Prince527],
reporterTestable: ReporterTestable.None,
settingsAboutComponent() {
return <>
<Paragraph>
Discord rich presence for Gensokyo Radio!
</Paragraph>
</>;
},
settings,
start() {
this.updatePresence();
this.updateInterval = setInterval(() => { this.updatePresence(); }, settings.store.refreshInterval * 1000);
},
stop() {
clearInterval(this.updateInterval);
FluxDispatcher.dispatch({ type: "LOCAL_ACTIVITY_UPDATE", activity: null });
},
updatePresence() {
this.getActivity().then(activity => { setActivity(activity); });
},
async getActivity(): Promise<Activity | null> {
const trackData = await Native.fetchTrackData();
if (!trackData) return null;
return {
application_id: applicationId,
name: "Gensokyo Radio",
details: trackData.title,
state: trackData.artist,
timestamps: {
// start: Date.now() - (trackData.position * 1000),
start: trackData.position * 1000,
// end: Date.now() - (trackData.position * 1000) + (trackData.duration * 1000),
end: trackData.duration * 1000,
},
assets: {
large_image: await getImageAsset(trackData.artwork),
large_text: trackData.album,
small_image: await getImageAsset("logo"),
small_text: "Gensokyo Radio"
},
buttons: undefined,
metadata: { button_urls: undefined },
type: ActivityType.LISTENING,
flags: ActivityFlags.INSTANCE,
};
}
});

View file

@ -1,20 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import type { TrackData } from "./types";
export async function fetchTrackData(): Promise<TrackData | null> {
const song = await (await fetch("https://gensokyoradio.net/api/station/playing/")).json();
return {
title: song.SONGINFO.TITLE,
album: song.SONGINFO.ALBUM,
artist: song.SONGINFO.ARTIST,
position: song.SONGTIMES.SONGSTART,
duration: song.SONGTIMES.SONGEND,
artwork: song.MISC.ALBUMART ? `https://gensokyoradio.net/images/albums/500/${song.MISC.ALBUMART}` : "undefined",
};
}

View file

@ -1,48 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
export interface ActivityAssets {
large_image?: string;
large_text?: string;
small_image?: string;
small_text?: string;
}
export interface Activity {
state: string;
details?: string;
timestamps?: {
start?: number;
end?: number;
};
assets?: ActivityAssets;
buttons?: Array<string>;
name: string;
application_id: string;
metadata?: {
button_urls?: Array<string>;
};
type: number;
flags: number;
}
export interface ActivityAssets {
large_image?: string;
large_text?: string;
small_image?: string;
small_text?: string;
}
export interface TrackData {
title: string;
album: string;
artist: string;
artwork: string;
position: number;
duration: number;
}

View file

@ -1,149 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { DataStore } from "@api/index";
import { Flex } from "@components/Flex";
import { Paragraph } from "@components/Paragraph";
import { Devs, EquicordDevs } from "@utils/constants";
import definePlugin, { PluginNative } from "@utils/types";
import { findComponentByCodeLazy } from "@webpack";
import { Alerts, Button, FluxDispatcher, Toasts, UserProfileStore, UserStore } from "@webpack/common";
const native = VencordNative.pluginHelpers.Identity as PluginNative<typeof import("./native")>;
const CustomizationSection = findComponentByCodeLazy(".customizationSectionBackground");
async function SetNewData() {
const PersonData = JSON.parse(await native.RequestRandomUser());
console.log(PersonData);
const pfpBase64 = JSON.parse(await native.ToBase64ImageUrl({ imgUrl: PersonData.picture.large })).data;
// holy moly
FluxDispatcher.dispatch({ type: "USER_SETTINGS_ACCOUNT_SET_PENDING_AVATAR", avatar: pfpBase64 });
FluxDispatcher.dispatch({ type: "USER_SETTINGS_ACCOUNT_SET_PENDING_GLOBAL_NAME", globalName: `${PersonData.name.first} ${PersonData.name.last}` });
FluxDispatcher.dispatch({ type: "USER_SETTINGS_ACCOUNT_SET_PENDING_PRONOUNS", pronouns: `${PersonData.gender === "male" ? "he/him" : PersonData.gender === "female" ? "she/her" : ""}` });
FluxDispatcher.dispatch({ type: "USER_SETTINGS_ACCOUNT_SET_PENDING_BANNER", banner: null });
FluxDispatcher.dispatch({ type: "USER_SETTINGS_ACCOUNT_SET_PENDING_ACCENT_COLOR", color: null });
FluxDispatcher.dispatch({ type: "USER_SETTINGS_ACCOUNT_SET_PENDING_THEME_COLORS", themeColors: [null, null] });
FluxDispatcher.dispatch({ type: "USER_SETTINGS_ACCOUNT_SET_PENDING_BIO", bio: `Hello! I am ${PersonData.name.first} ${PersonData.name.last}` });
}
async function SaveData() {
const userData = UserProfileStore.getUserProfile(UserStore.getCurrentUser().id)!;
// the getUserProfile function doesn't return all the information we need, so we append the standard user object data to the end
const extraUserObject: any = { extraUserObject: UserStore.getCurrentUser() };
const pfp = JSON.parse(await native.ToBase64ImageUrl({ imgUrl: `https://cdn.discordapp.com/avatars/${userData.userId}/${extraUserObject.extraUserObject.avatar}.webp?size=4096` })).data;
const banner = JSON.parse(await native.ToBase64ImageUrl({ imgUrl: `https://cdn.discordapp.com/banners/${userData.userId}/${userData.banner}.webp?size=4096` })).data;
const fetchedBase64Data =
{
pfpBase64: pfp,
bannerBase64: banner
};
DataStore.set("identity-saved-base", JSON.stringify({ ...userData, ...extraUserObject, ...{ fetchedBase64Data: fetchedBase64Data } }));
}
async function LoadData() {
const userDataMaybeNull = await DataStore.get("identity-saved-base");
if (!userDataMaybeNull) {
Toasts.show({ message: "No saved base! Save one first.", id: Toasts.genId(), type: Toasts.Type.FAILURE });
return;
}
const userData = JSON.parse(userDataMaybeNull);
console.log(userData);
const { pfpBase64, bannerBase64 } = userData.fetchedBase64Data;
FluxDispatcher.dispatch({ type: "USER_SETTINGS_ACCOUNT_SET_PENDING_AVATAR", avatar: pfpBase64 });
FluxDispatcher.dispatch({ type: "USER_SETTINGS_ACCOUNT_SET_PENDING_GLOBAL_NAME", globalName: userData.extraUserObject.globalName });
FluxDispatcher.dispatch({ type: "USER_SETTINGS_ACCOUNT_SET_PENDING_PRONOUNS", pronouns: userData.pronouns });
FluxDispatcher.dispatch({ type: "USER_SETTINGS_ACCOUNT_SET_PENDING_BANNER", banner: bannerBase64 });
FluxDispatcher.dispatch({ type: "USER_SETTINGS_ACCOUNT_SET_PENDING_ACCENT_COLOR", color: userData.accentColor });
FluxDispatcher.dispatch({ type: "USER_SETTINGS_ACCOUNT_SET_PENDING_THEME_COLORS", themeColors: userData.themeColors });
FluxDispatcher.dispatch({ type: "USER_SETTINGS_ACCOUNT_SET_PENDING_BIO", bio: userData.bio });
}
function ResetCard() {
return (
<CustomizationSection
title={"Identity"}
hasBackground={true}
hideDivider={false}
>
<Flex>
<Button
onClick={() => {
Alerts.show({
title: "Hold on!",
body: <div>
<Paragraph>
Saving your base profile will allow you to have a backup of your actual profile
</Paragraph>
<Paragraph>
If you save, it will overwrite your previous data.
</Paragraph>
</div>,
confirmText: "Save Anyway",
cancelText: "Cancel",
onConfirm: SaveData
});
}}
size={Button.Sizes.MEDIUM}
>
Save Base
</Button>
<Button
onClick={() => {
Alerts.show({
title: "Hold on!",
body: <div>
<Paragraph>
Loading your base profile will restore your actual profile settings
</Paragraph>
<Paragraph>
If you load, it will overwrite your current profile configuration.
</Paragraph>
</div>,
confirmText: "Load Anyway",
cancelText: "Cancel",
onConfirm: LoadData
});
}}
size={Button.Sizes.MEDIUM}
>
Load Base
</Button>
<Button
onClick={SetNewData}
size={Button.Sizes.MEDIUM}
>
Randomise
</Button>
</Flex>
</CustomizationSection>
);
}
export default definePlugin({
name: "Identity",
description: "Allows you to edit your profile to a random fake person with the click of a button",
authors: [Devs.Samwich, EquicordDevs.port22exposed],
ResetCard: ResetCard,
patches: [
{
find: "DefaultCustomizationSections",
replacement: {
match: /(?<=#{intl::USER_SETTINGS_AVATAR_DECORATION}\)},"decoration"\),)/,
replace: "$self.ResetCard(),"
}
},
]
});

View file

@ -1,29 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
export async function RequestRandomUser() {
const data = await fetch("https://randomuser.me/api").then(e => e.json());
return JSON.stringify(data.results[0]);
}
export async function ToBase64ImageUrl(_, data) {
const { imgUrl } = data;
try {
const fetchImageUrl = await fetch(imgUrl);
const responseArrBuffer = await fetchImageUrl.arrayBuffer();
const toBase64 =
`data:${fetchImageUrl.headers.get("Content-Type") || "image/png"};base64,${Buffer.from(responseArrBuffer).toString("base64")}`;
return JSON.stringify({ data: toBase64 });
} catch (error) {
console.error("Error converting image to Base64:", error);
return JSON.stringify({ error: "Failed to convert image to Base64" });
}
}

View file

@ -1,115 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { BaseText } from "@components/BaseText";
import { closeModal, ModalCloseButton, ModalContent, ModalHeader, ModalProps, ModalRoot, ModalSize } from "@utils/modal";
import { Button, useEffect, useState } from "@webpack/common";
export function DependencyModal({ props, options: { key, checkytdlp, checkffmpeg } }: {
props: ModalProps;
options: {
key: string;
checkytdlp: () => Promise<boolean>;
checkffmpeg: () => Promise<boolean>;
};
}) {
const checking = <span>Checking...</span>;
const installed = <span style={{ color: "green" }}>Installed!</span>;
const notInstalled = (color: string) => <span style={{ color }}>Not installed.</span>;
const [ytdlpStatus, setYtdlpStatus] = useState(checking);
const [ffmpegStatus, setFfmpegStatus] = useState(checking);
useEffect(() => {
checkytdlp().then(v => v ? setYtdlpStatus(installed) : setYtdlpStatus(notInstalled("red")));
checkffmpeg().then(v => v ? setFfmpegStatus(installed) : setFfmpegStatus(notInstalled("yellow")));
}, []);
return (
<ModalRoot {...props} size={ModalSize.MEDIUM}>
<ModalHeader>
<BaseText size="lg" weight="semibold" style={{ flexGrow: 1 }}>MediaDownloader: Missing dependencies</BaseText>
<ModalCloseButton onClick={() => closeModal(key)} />
</ModalHeader>
<ModalContent>
<div style={{ padding: "16px 0" }}>
<BaseText size="md">
The MediaDownloader plugin requires a working version of yt-dlp to be installed on your system. For extra features such as higher quality videos and gifs, you can also optionally install ffmpeg.
<br /><br />
If you don't know how to install yt-dlp or ffmpeg, check the installation guides below (if you <i>do</i> know how to install it, make sure it's in your PATH).
<br /><br />
<strong>Note:</strong> You may need to completely restart Discord after installing yt-dlp or ffmpeg for the plugin to detect them.
<br /><br />
<ul style={{ listStyleType: "disc", marginLeft: "1rem" }}>
<li>
<a href="https://github.com/yt-dlp/yt-dlp/wiki/Installation#windows" target="_blank" rel="noreferrer">yt-dlp installation guide</a>
</li>
<li>
<a href="https://phoenixnap.com/kb/ffmpeg-windows" target="_blank" rel="noreferrer">Unofficial ffmpeg installation guide</a>
</li>
</ul>
</BaseText>
<div style={{
marginTop: "16px",
display: "grid",
gridTemplateColumns: "repeat(2, 1fr)",
gridTemplateRows: "repeat(2, 1fr)",
columnGap: "16px",
rowGap: "8px"
}}>
<div
style={{
gridArea: "1 / 1 / 2 / 2",
display: "flex",
alignItems: "center",
justifyContent: "center"
}}
>
<BaseText size="md" weight="bold">
yt-dlp status: {ytdlpStatus}
</BaseText>
</div>
<Button
onClick={async () => {
setYtdlpStatus(checking);
setYtdlpStatus(await checkytdlp()
? installed
: notInstalled("red"));
}}
style={{ gridArea: "1 / 2 / 2 / 3" }}
>
Check again
</Button>
<div
style={{
gridArea: "2 / 1 / 3 / 2",
display: "flex",
alignItems: "center",
justifyContent: "center"
}}
>
<BaseText size="md" weight="bold">
ffmpeg status: {ffmpegStatus}
</BaseText>
</div>
<Button
onClick={async () => {
setFfmpegStatus(checking);
setFfmpegStatus(await checkffmpeg()
? installed
: notInstalled("yellow"));
}}
style={{ gridArea: "2 / 2 / 3 / 3" }}
>
Check again
</Button>
</div>
</div>
</ModalContent>
</ModalRoot>
);
}

View file

@ -1,339 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import "./style.css";
import { ApplicationCommandInputType, ApplicationCommandOptionType, findOption, sendBotMessage } from "@api/Commands";
import * as DataStore from "@api/DataStore";
import { definePluginSettings } from "@api/Settings";
import { Divider } from "@components/Divider";
import ErrorBoundary from "@components/ErrorBoundary";
import { Link } from "@components/Link";
import { Paragraph } from "@components/Paragraph";
import { EquicordDevs } from "@utils/constants";
import { Logger } from "@utils/Logger";
import { openModal } from "@utils/modal";
import definePlugin, { OptionType, PluginNative, ReporterTestable } from "@utils/types";
import { Channel } from "@vencord/discord-types";
import { Button, DraftType, FluxDispatcher, UploadHandler, UploadManager, UserStore } from "@webpack/common";
import { DependencyModal } from "./DependencyModal";
type ButtonComponent = {
customId?: string;
disabled?: boolean;
emoji?: {
animated?: boolean | string;
id?: string;
name?: string;
src?: string;
};
id: string;
label?: string;
style: number;
type: number;
url?: string;
};
const Native = VencordNative.pluginHelpers.MediaDownloader as PluginNative<typeof import("./native")>;
const logger = new Logger("MediaDownloader", "#ff0b01");
const maxFileSize = () => {
const premiumType = (UserStore.getCurrentUser().premiumType ?? 0);
if (premiumType === 2) return 500000000; // Nitro 500MB
if (premiumType === 1 || premiumType === 3) return 50000000; // Classic || Basic 50MB
return 25000000; // Base 25MB
};
/** Takes a string and splits it into an array of arguments. */
const argParse = (args: string): string[] => args.match(
/(?:[^\s"']+|"([^"\\]*(?:\\.[^"\\]*)*)"|'([^'\\]*(?:\\.[^'\\]*)*)'|([^\s]+))/g
) ?? [];
function mimetype(extension: "mp4" | "webm" | "gif" | "mp3" | string) {
switch (extension) {
case "mp4":
return "video/mp4";
case "webm":
return "video/webm";
case "gif":
return "image/gif";
case "mp3":
return "audio/mp3";
default:
return "application/octet-stream";
}
}
const CancelButton = [{
components: [{
customId: "media-downloader-stop-download", // ! for some reason customId is always undefined, so I'm just saving the id in the emoji animated field :3
emoji: {
name: "⚪",
animated: "media-downloader-stop-download"
},
label: "Cancel download",
id: "0,0",
style: 4,
type: 2,
}], id: "0", type: 1
}];
async function sendProgress(channelId: string, promise: Promise<{
buffer: Buffer;
title: string;
logs: string;
} | {
error: string;
logs: string;
}>) {
if (!settings.store.showProgress) {
sendBotMessage(channelId, {
components: CancelButton
});
return await promise;
}
const clydeMessage = sendBotMessage(channelId, {
content: "Downloading video...",
components: CancelButton
});
const updateMessage = (stdout: string, append?: string) => {
const text = stdout.toString();
FluxDispatcher.dispatch({
type: "MESSAGE_UPDATE",
message: {
...clydeMessage,
content: `Downloading video...\n\`\`\`\n${text}\n\`\`\`${append || ""}`,
components: append ? [] : clydeMessage.components
}
});
};
// Hacky way to send info from native to renderer for progress updates
const id = setInterval(async () => {
const stdout = await Native.getStdout();
updateMessage(stdout);
}, 500);
const data = await promise;
clearInterval(id);
const stdout = await Native.getStdout();
updateMessage(stdout, "error" in data ? "Error!" : "Done!");
return data;
}
function sendFfmpegWarning(channelId: string) {
sendBotMessage(channelId, {
content: "FFmpeg not detected. You may experience lower download quality and missing features."
});
}
// Mostly taken from viewRaw and betterSessions plugins.
async function openDependencyModal() {
const key = openModal(props => (
<ErrorBoundary>
<DependencyModal props={props} options={{
key,
checkffmpeg: Native.checkffmpeg,
checkytdlp: Native.checkytdlp,
}} />
</ErrorBoundary>
));
}
const settings = definePluginSettings({
supportedWebsites: {
description: "See the link for a list of supported websites.",
type: OptionType.COMPONENT,
default: "none",
component: () => (
<>
<Paragraph>
<Link href="https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md" className="media-downloader-link">
<Button role="link" style={{ width: "100%" }}>
Click to see supported websites.
</Button>
</Link>
</Paragraph>
<Divider />
</>
)
},
showProgress: {
type: OptionType.BOOLEAN,
description: "Send a Clyde message with the download progress.",
default: true,
},
showFfmpegWarning: {
type: OptionType.BOOLEAN,
description: "Show a warning message if ffmpeg is not installed.",
default: true,
},
defaultGifQuality: {
type: OptionType.NUMBER,
description: "The quality level to use if no value is specified when downloading gifs. A number between 1 and 5.",
default: 3,
},
ytdlpArgs: {
type: OptionType.STRING,
description: "Additional arguments to pass to yt-dlp. This may overwrite default plugin arguments such format selection. Note: if modifying the ouptup, ensure the filename starts with `download`.",
placeholder: "--format bestvideo+bestaudio",
},
ffmpegArgs: {
type: OptionType.STRING,
description: "Additional arguments to pass to ffmpeg. This may overwrite default plugin arguments such as auto-scaling. Note: if modifying the output, ensure the filename starts with `remux`.",
placeholder: "-vf scale=1280:720",
}
}, {
defaultGifQuality: {
isValid(value) {
return value >= 1 && value <= 5;
}
}
});
export default definePlugin({
name: "MediaDownloader",
description: "Download and send videos with from YouTube, Twitter, Reddit and more.",
authors: [EquicordDevs.Colorman],
reporterTestable: ReporterTestable.Patches,
settings,
commands: [{
inputType: ApplicationCommandInputType.BUILT_IN,
name: "download",
description: "Download and send videos, audio or gifs.",
options: [
{
name: "url",
description: "The URL of any video supported by yt-dlp.",
required: true,
type: ApplicationCommandOptionType.STRING
},
{
name: "format",
description: "Whether to download a video or audio.",
type: ApplicationCommandOptionType.STRING,
choices: [
{ name: "Video", value: "video", label: "Video" },
{ name: "Audio", value: "audio", label: "Audio" },
{ name: "GIF", value: "gif", label: "GIF" }
],
required: false,
},
{
name: "gif_quality",
type: ApplicationCommandOptionType.INTEGER,
description: "The quality level when using GIF. Try lowering this number if the GIF is too large.",
required: false,
choices: [
{ name: "5", value: "5", label: "5" },
{ name: "4", value: "4", label: "4" },
{ name: "3", value: "3", label: "3" },
{ name: "2", value: "2", label: "2" },
{ name: "1", value: "1", label: "1" }
]
},
{
name: "yt-dlp_args",
description: "Additional arguments to pass to yt-dlp. These will take precedence over arguments set in the settings. This may overwrite default plugin arguments such format selection. Note: if modifying the output, ensure the filename starts with `download`.",
required: false,
type: ApplicationCommandOptionType.STRING
},
{
name: "ffmpeg_args",
description: "Additional arguments to pass to ffmpeg. These will take precedence over arguments set in the settings. This may overwrite default plugin arguments such as auto-scaling. Note: if modifying the output, ensure the filename starts with `remux`.",
required: false,
type: ApplicationCommandOptionType.STRING
}
],
execute: async (args, ctx) => {
if (!await Native.isYtdlpAvailable()) return openDependencyModal();
if (!await Native.isFfmpegAvailable() && settings.store.showFfmpegWarning) sendFfmpegWarning(ctx.channel.id);
const url = findOption<string>(args, "url", "");
const format = findOption<"video" | "audio" | "gif">(args, "format", "video");
const gifQuality = parseInt(findOption<string>(args, "gif_quality", settings.store.defaultGifQuality.toString())) as 1 | 2 | 3 | 4 | 5;
const ytdlpArgs = findOption<string>(args, "yt-dlp_args", "");
const ffmpegArgs = findOption<string>(args, "ffmpeg_args", "");
return await download(ctx.channel, {
url,
format,
gifQuality,
ytdlpArgs,
ffmpegArgs
});
}
}],
patches: [
{
find: "missing validator for this component",
replacement: {
match: /(\i)(\.type\)\{case \i\.\i\.BUTTON):return null;/,
replace: "$1$2:return ($self.handleButtonClick($1),null);"
}
}
],
handleButtonClick: (buttonComponent: ButtonComponent) => {
if (!(buttonComponent.emoji?.animated === "media-downloader-stop-download")) return;
Native.interrupt();
},
start: async () => {
await Native.checkytdlp();
await Native.checkffmpeg();
const videoDir = await DataStore.get<string>("media-downloader-video-dir");
const newVideoDir = await Native.start(videoDir);
await DataStore.set("media-downloader-video-dir", newVideoDir);
},
stop: async () => {
// Clean up the temp files
await Native.stop();
await DataStore.del("media-downloader-video-dir");
}
});
async function download(channel: Channel, {
url, format, ytdlpArgs, ffmpegArgs, gifQuality
}: {
url: string;
format: "video" | "audio" | "gif";
ytdlpArgs: string;
ffmpegArgs: string;
gifQuality: 1 | 2 | 3 | 4 | 5;
}) {
const promise = Native.execute({
url,
format,
gifQuality,
ytdlpArgs: [
...argParse(settings.store.ytdlpArgs || ""),
...argParse(ytdlpArgs)
],
ffmpegArgs: [
...argParse(settings.store.ffmpegArgs || ""),
...argParse(ffmpegArgs)
],
maxFileSize: maxFileSize()
});
const data = await sendProgress(channel.id, promise);
for (const log of data.logs.trim().split("\n")) logger.info(log);
if ("error" in data) {
// Open the modal if the error is due to missing formats (could be fixed by downloading ffmpeg)
if (data.error.includes("--list-formats") && !(await Native.isFfmpegAvailable()))
return sendBotMessage(channel.id, { content: "No good streams found. Consider installing ffmpeg to increase the likelihood of a successful stream." }), openDependencyModal();
return sendBotMessage(channel.id, {
content: `Failed to download video: ${data.error.includes("\n") ? "\n```" + data.error + "\n```" : `\`${data.error}\``}`
});
}
const { buffer, title } = data;
UploadManager.clearAll(channel.id, DraftType.SlashCommand);
const file = new File([buffer], title, { type: mimetype(title.split(".")[1]) });
// See petpet
setTimeout(() => UploadHandler.promptToUpload([file], channel, DraftType.ChannelMessage), 10);
}

View file

@ -1,312 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { ChildProcessWithoutNullStreams, execFileSync, spawn } from "child_process";
import { IpcMainInvokeEvent } from "electron";
import * as fs from "fs";
import os from "os";
import path from "path";
type Format = "video" | "audio" | "gif";
type DownloadOptions = {
url: string;
format?: Format;
gifQuality?: 1 | 2 | 3 | 4 | 5;
ytdlpArgs?: string[];
ffmpegArgs?: string[];
maxFileSize?: number;
};
let workdir: string | null = null;
let stdout_global: string = "";
let logs_global: string = "";
let ytdlpAvailable = false;
let ffmpegAvailable = false;
let ytdlpProcess: ChildProcessWithoutNullStreams | null = null;
let ffmpegProcess: ChildProcessWithoutNullStreams | null = null;
const getdir = () => workdir ?? process.cwd();
const p = (file: string) => path.join(getdir(), file);
const cleanVideoFiles = () => {
if (!workdir) return;
fs.readdirSync(workdir)
.filter(f => f.startsWith("download.") || f.startsWith("remux."))
.forEach(f => fs.unlinkSync(p(f)));
};
const appendOut = (data: string) => ( // Makes carriage return (\r) work
(stdout_global += data), (stdout_global = stdout_global.replace(/^.*\r([^\n])/gm, "$1")));
const log = (...data: string[]) => (console.log(`[Plugin:MediaDownloader] ${data.join(" ")}`), logs_global += `[Plugin:MediaDownloader] ${data.join(" ")}\n`);
const error = (...data: string[]) => console.error(`[Plugin:MediaDownloader] [ERROR] ${data.join(" ")}`);
function ytdlp(args: string[]): Promise<string> {
log(`Executing yt-dlp with args: ["${args.map(a => a.replace('"', '\\"')).join('", "')}"]`);
let errorMsg = "";
return new Promise<string>((resolve, reject) => {
ytdlpProcess = spawn("yt-dlp", args, {
cwd: getdir(),
});
ytdlpProcess.stdout.on("data", data => appendOut(data));
ytdlpProcess.stderr.on("data", data => {
appendOut(data);
error(`yt-dlp encountered an error: ${data}`);
errorMsg += data;
});
ytdlpProcess.on("exit", code => {
ytdlpProcess = null;
code === 0 ? resolve(stdout_global) : reject(new Error(errorMsg || `yt-dlp exited with code ${code}`));
});
});
}
function ffmpeg(args: string[]): Promise<string> {
log(`Executing ffmpeg with args: ["${args.map(a => a.replace('"', '\\"')).join('", "')}"]`);
let errorMsg = "";
return new Promise<string>((resolve, reject) => {
ffmpegProcess = spawn("ffmpeg", args, {
cwd: getdir(),
});
ffmpegProcess.stdout.on("data", data => appendOut(data));
ffmpegProcess.stderr.on("data", data => {
appendOut(data);
error(`ffmpeg encountered an error: ${data}`);
errorMsg += data;
});
ffmpegProcess.on("exit", code => {
ffmpegProcess = null;
code === 0 ? resolve(stdout_global) : reject(new Error(errorMsg || `ffmpeg exited with code ${code}`));
});
});
}
export async function start(_: IpcMainInvokeEvent, _workdir: string | undefined) {
_workdir ||= fs.mkdtempSync(path.join(os.tmpdir(), "vencord_mediaDownloader_"));
if (!fs.existsSync(_workdir)) fs.mkdirSync(_workdir, { recursive: true });
workdir = _workdir;
log("Using workdir: ", workdir);
return workdir;
}
export async function stop(_: IpcMainInvokeEvent) {
if (workdir) {
log("Cleaning up workdir");
fs.rmSync(workdir, { recursive: true });
workdir = null;
}
}
async function metadata(options: DownloadOptions) {
try {
stdout_global = "";
const output = await ytdlp(["-J", options.url, "--no-warnings"]);
const metadata = JSON.parse(output);
if (metadata.is_live) throw new Error("Live streams are not supported.");
stdout_global = "";
return { videoTitle: `${metadata.title || "video"} (${metadata.id})` };
} catch (err) {
throw err;
}
}
function genFormat({ videoTitle }: { videoTitle: string; }, { maxFileSize, format }: DownloadOptions) {
const HAS_LIMIT = !!maxFileSize;
const MAX_VIDEO_SIZE = HAS_LIMIT ? maxFileSize * 0.8 : 0;
const MAX_AUDIO_SIZE = HAS_LIMIT ? maxFileSize * 0.2 : 0;
const audio = {
noFfmpeg: "ba[ext=mp3]{TOT_SIZE}/wa[ext=mp3]{TOT_SIZE}",
ffmpeg: "ba*{TOT_SIZE}/ba{TOT_SIZE}/wa*{TOT_SIZE}/ba*"
};
const video = {
noFfmpeg: "b{TOT_SIZE}{HEIGHT}[ext=webm]/b{TOT_SIZE}{HEIGHT}[ext=mp4]/w{HEIGHT}{TOT_SIZE}",
ffmpeg: "b*{VID_SIZE}{HEIGHT}+ba{AUD_SIZE}/b{TOT_SIZE}{HEIGHT}/b*{HEIGHT}+ba",
};
const gif = {
ffmpeg: "bv{TOT_SIZE}/wv{TOT_SIZE}"
};
let format_group: { noFfmpeg?: string; ffmpeg: string; };
switch (format) {
case "audio":
format_group = audio;
break;
case "gif":
format_group = gif;
break;
case "video":
default:
format_group = video;
break;
}
const format_string = (ffmpegAvailable ? format_group.ffmpeg : format_group.noFfmpeg)
?.replaceAll("{TOT_SIZE}", HAS_LIMIT ? `[filesize<${maxFileSize}]` : "")
.replaceAll("{VID_SIZE}", HAS_LIMIT ? `[filesize<${MAX_VIDEO_SIZE}]` : "")
.replaceAll("{AUD_SIZE}", HAS_LIMIT ? `[filesize<${MAX_AUDIO_SIZE}]` : "")
.replaceAll("{HEIGHT}", "[height<=1080]");
if (!format_string) throw "Gif format is only supported with ffmpeg.";
log("Video formated calculated as ", format_string);
log(`Based on: format=${format}, maxFileSize=${maxFileSize}, ffmpegAvailable=${ffmpegAvailable}`);
return { format: format_string, videoTitle };
}
async function download({ format, videoTitle }: { format: string; videoTitle: string; }, { ytdlpArgs, url, format: usrFormat }: DownloadOptions) {
cleanVideoFiles();
const baseArgs = ["-f", format, "-o", "download.%(ext)s", "--force-overwrites", "-I", "1"];
const remuxArgs = ffmpegAvailable
? usrFormat === "video"
? ["--remux-video", "webm>webm/mp4"]
: usrFormat === "audio"
? ["--extract-audio", "--audio-format", "mp3"]
: []
: [];
const customArgs = ytdlpArgs?.filter(Boolean) || [];
try {
await ytdlp([url, ...baseArgs, ...remuxArgs, ...customArgs]);
} catch (err) {
console.error("Error during yt-dlp execution:", err);
}
const file = fs.readdirSync(getdir()).find(f => f.startsWith("download."));
if (!file) throw "No video file was found!";
return { file, videoTitle };
}
async function remux({ file, videoTitle }: { file: string; videoTitle: string; }, { ffmpegArgs, format, maxFileSize, gifQuality }: DownloadOptions) {
const sourceExtension = file.split(".").pop();
if (!ffmpegAvailable) return log("Skipping remux, ffmpeg is unavailable."), { file, videoTitle, extension: sourceExtension };
// We only really need to remux if
// 1. The file is too big
// 2. The file is in a format not supported by discord
// 3. The user provided custom ffmpeg arguments
// 4. The target format is gif
const acceptableFormats = ["mp3", "mp4", "webm"];
const fileSize = fs.statSync(p(file)).size;
const customArgs = ffmpegArgs?.filter(Boolean) || [];
const isFormatAcceptable = acceptableFormats.includes(sourceExtension ?? "");
const isFileSizeAcceptable = (!maxFileSize || fileSize <= maxFileSize);
const hasCustomArgs = customArgs.length > 0;
const isGif = format === "gif";
if (isFormatAcceptable && isFileSizeAcceptable && !hasCustomArgs && !isGif)
return log("Skipping remux, file type and size are good, and no ffmpeg arguments were specified."), { file, videoTitle, extension: sourceExtension };
const duration = parseFloat(execFileSync("ffprobe", ["-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", p(file)]).toString());
if (isNaN(duration)) throw "Failed to get video duration.";
// ffmpeg tends to go above the target size, so I'm setting it to 7/8
const targetBits = maxFileSize ? (maxFileSize * 7) / duration : 9999999;
const kilobits = ~~(targetBits / 1024);
let baseArgs: string[];
let ext: string;
switch (format) {
case "audio":
baseArgs = ["-i", p(file), "-b:a", `${kilobits}k`, "-maxrate", `${kilobits}k`, "-bufsize", "1M", "-y"];
ext = "mp3";
break;
case "video":
default:
// Dynamically resize based on target bitrate
const height = kilobits <= 100 ? 480 : kilobits <= 500 ? 720 : 1080;
baseArgs = ["-i", p(file), "-b:v", `${~~(kilobits * 0.8)}k`, "-b:a", `${~~(kilobits * 0.2)}k`, "-maxrate", `${kilobits}k`, "-bufsize", "1M", "-y", "-filter:v", `scale=-1:${height}`];
ext = "mp4";
break;
case "gif":
let fps: number, width: number, colors: number, bayer_scale: number;
// WARNING: these parameters have been arbitrarily chosen, optimization is welcome!
switch (gifQuality) {
case 1:
fps = 5, width = 360, colors = 24, bayer_scale = 5;
break;
case 2:
fps = 10, width = 420, colors = 32, bayer_scale = 5;
break;
default:
case 3:
fps = 15, width = 480, colors = 64, bayer_scale = 4;
break;
case 4:
fps = 20, width = 540, colors = 64, bayer_scale = 3;
break;
case 5:
fps = 30, width = 720, colors = 128, bayer_scale = 1;
break;
}
baseArgs = ["-i", p(file), "-vf", `fps=${fps},scale=w=${width}:h=-1:flags=lanczos,mpdecimate,split[s0][s1];[s0]palettegen=max_colors=${colors}[p];[s1][p]paletteuse=dither=bayer:bayer_scale=${bayer_scale}`, "-loop", "0", "-bufsize", "1M", "-y"];
ext = "gif";
break;
}
await ffmpeg([...baseArgs, ...customArgs, `remux.${ext}`]);
return { file: `remux.${ext}`, videoTitle, extension: ext };
}
function upload({ file, videoTitle, extension }: { file: string; videoTitle: string; extension: string | undefined; }) {
if (!extension) throw "Invalid extension.";
const buffer = fs.readFileSync(p(file));
return { buffer, title: `${videoTitle}.${extension}` };
}
export async function execute(
_: IpcMainInvokeEvent,
opt: DownloadOptions
): Promise<{
buffer: Buffer;
title: string;
logs: string;
} | {
error: string;
logs: string;
}> {
logs_global = "";
try {
const videoMetadata = await metadata(opt);
const videoFormat = genFormat(videoMetadata, opt);
const videoDownload = await download(videoFormat, opt);
const videoRemux = await remux(videoDownload, opt);
const videoUpload = upload(videoRemux);
return { logs: logs_global, ...videoUpload };
} catch (e: any) {
return { error: e.toString(), logs: logs_global };
}
}
export function checkffmpeg(_?: IpcMainInvokeEvent) {
try {
execFileSync("ffmpeg", ["-version"]);
execFileSync("ffprobe", ["-version"]);
ffmpegAvailable = true;
return true;
} catch (e) {
ffmpegAvailable = false;
return false;
}
}
export async function checkytdlp(_?: IpcMainInvokeEvent) {
try {
execFileSync("yt-dlp", ["--version"]);
ytdlpAvailable = true;
return true;
} catch (e) {
ytdlpAvailable = false;
return false;
}
}
export async function interrupt(_: IpcMainInvokeEvent) {
log("Interrupting...");
ytdlpProcess?.kill();
ffmpegProcess?.kill();
cleanVideoFiles();
}
export const getStdout = () => stdout_global;
export const isYtdlpAvailable = () => ytdlpAvailable;
export const isFfmpegAvailable = () => ffmpegAvailable;

View file

@ -1,9 +0,0 @@
/* Only way to make on:hover styles */
.media-downloader-link {
text-decoration: none !important;
}
.media-downloader-link:hover {
text-decoration: none !important;
}

View file

@ -1,127 +0,0 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { findGroupChildrenByChildId, NavContextMenuPatchCallback } from "@api/ContextMenu";
import { addMessagePopoverButton, removeMessagePopoverButton } from "@api/MessagePopover";
import { classNameFactory } from "@api/Styles";
import { EquicordDevs } from "@utils/constants";
import { sendMessage } from "@utils/discord";
import { classes } from "@utils/misc";
import definePlugin from "@utils/types";
import { Message } from "@vencord/discord-types";
import { ChannelStore, Menu } from "@webpack/common";
function RepeatMessageIcon({ className }: { className?: string; }) {
return (
<svg
viewBox="0 -960 960 960"
height={24}
width={24}
className={classes(classNameFactory("vc-repeat-")("icon"), className)}
>
<path fill="currentColor" d="m274-200 34 34q12 12 11.5 28T308-110q-12 12-28.5 12.5T251-109L148-212q-6-6-8.5-13t-2.5-15q0-8 2.5-15t8.5-13l103-103q12-12 28.5-11.5T308-370q11 12 11.5 28T308-314l-34 34h406v-120q0-17 11.5-28.5T720-440q17 0 28.5 11.5T760-400v120q0 33-23.5 56.5T680-200H274Zm412-480H280v120q0 17-11.5 28.5T240-520q-17 0-28.5-11.5T200-560v-120q0-33 23.5-56.5T280-760h406l-34-34q-12-12-11.5-28t11.5-28q12-12 28.5-12.5T709-851l103 103q6 6 8.5 13t2.5 15q0 8-2.5 15t-8.5 13L709-589q-12 12-28.5 11.5T652-590q-11-12-11.5-28t11.5-28l34-34Z" />
</svg>
);
}
let shift = false;
function repeatMessage(channelId: string, id: string, content: string, stickers: any[]) {
sendMessage(channelId, {
content
}, true, {
allowedMentions: {
parse: [],
replied_user: false
},
messageReference: shift ? {
channel_id: channelId,
message_id: id
} : undefined,
stickerIds: stickers.map(s => s.id)
});
}
const messageCtxPatch: NavContextMenuPatchCallback = (children, { message }: { message: Message; }) => {
if (!message.content && message.stickerItems.length === 0) return;
const group = findGroupChildrenByChildId("copy-text", children);
if (!group) return;
group.splice(group.findIndex(c => c?.props?.id === "reply") + 1, 0, (
<Menu.MenuItem
id="vc-repeat"
label="Repeat"
icon={RepeatMessageIcon}
action={async () => repeatMessage(message.channel_id, message.id, message.content, message.stickerItems)}
/>
));
};
function setTitle(title: string) {
const contextMenuOption = document.querySelector("#message-vc-repeat .label__563c3");
if (contextMenuOption) contextMenuOption.innerHTML = title;
}
const keyupListener = (event: KeyboardEvent) => {
if (event.key === "Shift") {
shift = false;
setTitle("Repeat");
}
};
const keydownListener = (event: KeyboardEvent) => {
if (event.key === "Shift") {
shift = true;
setTitle("Repeat and Reply");
}
};
export default definePlugin({
name: "RepeatMessage",
description: "Allows you to repeat messages quickly. If you hold shift while clicking the Repeat option, it will reply to the message.",
authors: [EquicordDevs.Tolgchu],
contextMenus: {
"message": messageCtxPatch
},
start() {
addMessagePopoverButton("vc-repeat", message => {
if (!message.content && message.stickerItems.length === 0) return null;
return {
label: "Repeat (Click) / Repeat and Reply (Shift + Click)",
icon: RepeatMessageIcon,
message,
channel: ChannelStore.getChannel(message.channel_id),
onClick: async () => repeatMessage(message.channel_id, message.id, message.content, message.stickerItems)
};
});
document.addEventListener("keyup", keyupListener);
document.addEventListener("keydown", keydownListener);
},
stop() {
removeMessagePopoverButton("vc-repeat");
document.removeEventListener("keyup", keyupListener);
document.removeEventListener("keydown", keydownListener);
},
});

View file

@ -1,40 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { addMessagePreSendListener, MessageSendListener, removeMessagePreSendListener } from "@api/MessageEvents";
import { definePluginSettings, Settings } from "@api/Settings";
import { EquicordDevs } from "@utils/constants";
import definePlugin, { OptionType } from "@utils/types";
import { MessageStore } from "@webpack/common";
import { transferMessage } from "./native";
const presendObject: MessageSendListener = async (channelId, msg, extra) => {
const messageRef = extra.replyOptions.messageReference;
const repliedMessage = ((messageRef?.message_id && messageRef.channel_id) && MessageStore.getMessage(messageRef?.channel_id, messageRef?.message_id)) || undefined;
msg.content = await transferMessage(msg, Settings.plugins.Shakespearean.model, repliedMessage);
};
export default definePlugin({
name: "Shakespearean",
description: "Makes every message you send shakespearean",
authors: [EquicordDevs.vmohammad],
dependencies: ["MessageEventsAPI"],
settings: definePluginSettings(
{
model: {
type: OptionType.STRING,
description: "Which model to use for this... thing",
default: "llama3"
}
}),
start() {
addMessagePreSendListener(presendObject);
},
stop() {
removeMessagePreSendListener(presendObject);
}
});

View file

@ -1,76 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { MessageObject } from "@api/MessageEvents";
import { Message } from "@vencord/discord-types";
export async function transferMessage(message: MessageObject, model: string, repliedMessage?: Message) {
// you can use this on anything really
// NOTE: you need to have ollama running with cors set to *, to do this set OLLAMA_ORIGINS to *, in your envioument variables.
const req = await fetch("http://127.0.0.1:11434/api/chat", {
method: "POST",
body: JSON.stringify({
model: model,
messages: [
{
"role": "system",
"content": `Transform any user message into a Shakespearean style, except do not change any URLs or text matching the regex <(?:@[!&]?|#)\\d+>. Only repeat the user's message in a Shakespearean voice without acknowledging or responding to anything else. for context this is your message object *START* ${JSON.stringify(message)} *END* and this is the replied message object (if avalaible): *START* ${JSON.stringify(repliedMessage)} *END*, remember to use context in the response`
},
{
"role": "user",
"content": "hello"
},
{
"role": "assistant",
"content": "Hark! A gentle greeting to thee, 'tis 'hello' I say."
},
{
"role": "user",
"content": "How are you?"
},
{
"role": "assistant",
"content": "How art thou faring, noble soul?"
},
{
"role": "user",
"content": "What is your name?"
},
{
"role": "assistant",
"content": "What doth they call thee, fair entity?"
},
{
"role": "user",
"content": "Goodbye"
},
{
"role": "assistant",
"content": "Fare thee well, until we meet again."
},
{
"role": "user",
"content": "Check this link: https://example.com"
},
{
"role": "assistant",
"content": "Prithee, observe this link: https://example.com"
},
{
content: message.content,
role: "user"
}
],
stream: false
}),
headers: {
"Content-Type": "application/json"
}
});
const res = await req.json();
const msg = res.message.content;
return msg;
}

View file

@ -1,93 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import "./style.css";
import { Devs } from "@utils/constants";
import { classes } from "@utils/misc";
import definePlugin from "@utils/types";
import { React, Tooltip, useMemo } from "@webpack/common";
import { useKatex } from "./katexLoader";
export default definePlugin({
name: "TeX",
description: "Typesets math in messages, written as `$x$` or `$$x$$`.",
authors: [Devs.Kyuuhachi],
patches: [
{
find: "inlineCode:{react",
replacement: {
match: /inlineCode:\{react:\((\i,\i,\i)\)=>/,
replace: "$&$self.render($1)??"
},
},
],
render({ content }) {
const displayMatch = /^\$\$(.*)\$\$$/.exec(content);
const inlineMatch = /^\$(.*)\$$/.exec(content);
if (displayMatch)
return <LazyLatex displayMode formula={displayMatch[1]} delim="$$" />;
if (inlineMatch)
return <LazyLatex formula={inlineMatch[1]} delim="$" />;
}
});
function LazyLatex(props) {
const { formula, delim } = props;
const katex = useKatex();
return katex
? <Latex {...props} katex={katex} />
: <LatexPlaceholder className="tex-loading" delim={delim}>{formula}</LatexPlaceholder>;
}
function Latex({ katex, formula, displayMode, delim }) {
const result = useMemo(() => {
try {
const html = katex.renderToString(formula, { displayMode });
return { html };
} catch (error) {
return { error };
}
}, [formula, displayMode]);
return result.html
? <span className="tex" dangerouslySetInnerHTML={{ __html: result.html }} />
: <LatexError formula={formula} delim={delim} error={result.error} />;
}
function LatexError({ formula, delim, error }) {
const { rawMessage, position, length } = error;
const pre = formula.slice(0, position);
const mid = formula.slice(position, position + length);
const suf = formula.slice(position + length);
return (
<Tooltip text={rawMessage}>
{({ onMouseLeave, onMouseEnter }) => (
<LatexPlaceholder
onMouseLeave={onMouseLeave}
onMouseEnter={onMouseEnter}
delim={delim}
className="tex-error"
>
{pre}<strong>{mid}</strong>{suf}
</LatexPlaceholder>
)}
</Tooltip>
);
}
function LatexPlaceholder({ className, delim, children, ...props }) {
return (
<code className={classes(className, "tex-placeholder inline")} {...props}>
{delim}
{children}
{delim}
</code>
);
}

View file

@ -1,29 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { makeLazy } from "@utils/lazy";
import { useEffect, useState } from "@webpack/common";
const SCRIPT_URL = "https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.mjs";
const STYLE_URL = "https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css";
let theKatex: undefined | any = undefined;
export const loadKatex = makeLazy(async () => {
const style = document.createElement("link");
style.setAttribute("rel", "stylesheet");
style.setAttribute("href", STYLE_URL);
document.head.appendChild(style);
return theKatex = (await import(SCRIPT_URL)).default;
});
export function useKatex() {
const [katex, setKatex] = useState(theKatex);
useEffect(() => {
if (katex === undefined)
loadKatex().then(setKatex);
});
return katex;
}

View file

@ -1,8 +0,0 @@
.katex-display {
margin: 0;
display: inline-block;
}
.tex-error {
color: var(--status-danger);
}

View file

@ -1,377 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { CodeBlock } from "@components/CodeBlock";
import { openImageModal } from "@utils/discord";
import { copyWithToast } from "@utils/misc";
import { ModalCloseButton, ModalContent, ModalHeader, ModalRoot, ModalSize, openModal } from "@utils/modal";
import { React, useEffect, useMemo } from "@webpack/common";
// SVG Icons
const CloseIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width={20} height={20} fill="currentColor">
<path d="M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z" />
</svg>
);
const CopyIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width={20} height={20} fill="currentColor">
<path d="M19,21H8V7H19M19,5H8A2,2 0 0,0 6,7V21A2,2 0 0,0 8,23H19A2,2 0 0,0 21,21V7A2,2 0 0,0 19,5M16,1H4A2,2 0 0,0 2,3V17H4V3H16V1Z" />
</svg>
);
const DownloadIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width={20} height={20} fill="currentColor">
<path d="M5,20H19V18H5M19,9H15V3H9V9H5L12,16L19,9Z" />
</svg>
);
// Map file extensions to language identifiers for syntax highlighting
function getLanguageFromExtension(filename: string): string {
const ext = filename.split(".").pop()?.toLowerCase() ?? "";
const langMap: Record<string, string> = {
js: "javascript",
jsx: "javascript",
ts: "typescript",
tsx: "typescript",
py: "python",
rb: "ruby",
java: "java",
c: "c",
cpp: "cpp",
cs: "csharp",
go: "go",
rs: "rust",
php: "php",
html: "html",
css: "css",
scss: "scss",
sass: "sass",
less: "less",
json: "json",
xml: "xml",
yaml: "yaml",
yml: "yaml",
md: "markdown",
sql: "sql",
sh: "bash",
bash: "bash",
ps1: "powershell",
r: "r",
kt: "kotlin",
swift: "swift",
lua: "lua",
diff: "diff",
patch: "diff",
};
return langMap[ext] || "";
}
type TextFileModalProps = {
name: string;
blob: Blob;
buffer: ArrayBuffer;
transitionState: number;
onClose: () => void;
};
interface CodeBlockWithDownloadProps {
lang: string;
content: string;
onDownload: () => void;
}
function CodeBlockWithDownload({ lang, content, onDownload }: CodeBlockWithDownloadProps) {
const containerRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (!containerRef.current) return;
// Wait for ShikiCodeBlocks to render
const checkAndInject = () => {
const codeContainer = containerRef.current?.querySelector(".vc-shiki-root") as HTMLElement;
if (!codeContainer) return;
// Check if download button already exists
if (codeContainer.querySelector(".zp-download-btn")) return;
// Ensure the code container has position relative
if (!codeContainer.style.position || codeContainer.style.position === "static") {
codeContainer.style.position = "relative";
}
// Move copy button to outer container so it's always visible
const copyBtns = codeContainer.querySelector(".vc-shiki-btns") as HTMLElement;
if (copyBtns && containerRef.current && !containerRef.current.querySelector(".vc-shiki-btns-moved")) {
copyBtns.classList.add("vc-shiki-btns-moved");
Object.assign(copyBtns.style, {
position: "absolute",
bottom: "12px",
right: "12px",
zIndex: "10",
});
// Move the actual element (preserves event listeners)
containerRef.current.appendChild(copyBtns);
}
// Create download button
const downloadBtn = document.createElement("button");
downloadBtn.className = "zp-download-btn";
downloadBtn.title = "Download";
downloadBtn.onclick = onDownload;
downloadBtn.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M5,20H19V18H5M19,9H15V3H9V9H5L12,16L19,9Z" />
</svg>
`;
// Style as floating button at top-right
Object.assign(downloadBtn.style, {
position: "absolute",
top: "12px",
right: "12px",
width: "24px",
height: "24px",
padding: "4px",
background: "var(--background-floating)",
border: "1px solid var(--background-modifier-accent)",
borderRadius: "4px",
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "var(--interactive-normal)",
zIndex: "10",
opacity: "0",
transition: "opacity 0.15s ease",
});
downloadBtn.onmouseenter = () => {
downloadBtn.style.opacity = "1";
downloadBtn.style.color = "var(--interactive-hover)";
};
downloadBtn.onmouseleave = () => {
downloadBtn.style.color = "var(--interactive-normal)";
};
// Insert into the code container (inside ShikiCodeBlocks)
codeContainer.appendChild(downloadBtn);
};
// Check immediately and after a short delay
checkAndInject();
const timer = setTimeout(checkAndInject, 100);
return () => clearTimeout(timer);
}, [onDownload]);
return (
<div ref={containerRef} className="zp-code-modal" style={{
background: "var(--background-secondary)",
borderRadius: "8px",
border: "2px solid var(--background-modifier-accent)",
overflow: "hidden",
maxHeight: "50vh",
width: "100%",
position: "relative",
}}
onMouseEnter={() => {
const downloadBtn = containerRef.current?.querySelector(".zp-download-btn") as HTMLElement;
const copyBtns = containerRef.current?.querySelector(".vc-shiki-btns-moved") as HTMLElement;
if (downloadBtn) downloadBtn.style.opacity = "1";
if (copyBtns) copyBtns.style.opacity = "1";
}}
onMouseLeave={() => {
const downloadBtn = containerRef.current?.querySelector(".zp-download-btn") as HTMLElement;
const copyBtns = containerRef.current?.querySelector(".vc-shiki-btns-moved") as HTMLElement;
if (downloadBtn) downloadBtn.style.opacity = "0";
if (copyBtns) copyBtns.style.opacity = "0";
}}>
<div style={{ overflow: "auto", maxHeight: "50vh" }}>
<CodeBlock lang={lang} content={content} />
</div>
</div>
);
}
function TextFileModal({ name, blob, buffer, transitionState, onClose }: TextFileModalProps) {
const url = URL.createObjectURL(blob);
useEffect(() => () => URL.revokeObjectURL(url), [url]);
const text = useMemo(() => {
try { return new TextDecoder().decode(new Uint8Array(buffer)); } catch { return null; }
}, [buffer]);
const language = useMemo(() => getLanguageFromExtension(name), [name]);
const handleCopy = () => {
if (text) {
copyWithToast(text, "Text copied to clipboard!");
}
};
const handleDownload = () => {
const a = document.createElement("a");
a.href = url;
a.download = name;
document.body.appendChild(a);
a.click();
a.remove();
};
return (
<ModalRoot transitionState={transitionState} size={ModalSize.LARGE}>
<ModalHeader separator={false}>
<div style={{
fontSize: "20px",
fontWeight: 600,
color: "var(--header-primary)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
flex: 1
}}>{name}</div>
<ModalCloseButton onClick={onClose} />
</ModalHeader>
<ModalContent style={{ padding: "16px", display: "flex", flexDirection: "column" }}>
{text ? (
language ? (
<CodeBlockWithDownload
lang={language}
content={text}
onDownload={handleDownload}
/>
) : (
<div style={{
background: "var(--background-secondary)",
borderRadius: "8px",
border: "2px solid var(--background-modifier-accent)",
padding: "16px",
maxHeight: "50vh",
overflow: "auto",
overflowX: "auto",
width: "100%",
position: "relative",
}}
onMouseEnter={e => {
const btns = e.currentTarget.querySelector(".zp-hover-buttons") as HTMLElement;
if (btns) btns.style.opacity = "1";
}}
onMouseLeave={e => {
const btns = e.currentTarget.querySelector(".zp-hover-buttons") as HTMLElement;
if (btns) btns.style.opacity = "0";
}}>
<div className="zp-hover-buttons" style={{
position: "absolute",
top: "8px",
right: "8px",
display: "flex",
gap: "4px",
zIndex: 10,
opacity: 0,
transition: "opacity 0.15s ease",
pointerEvents: "all",
}}>
<button
onClick={handleCopy}
style={{
background: "var(--background-floating)",
border: "1px solid var(--background-modifier-accent)",
borderRadius: "4px",
padding: "4px 6px",
cursor: "pointer",
display: "flex",
alignItems: "center",
color: "var(--interactive-normal)",
fontSize: "12px",
gap: "4px",
}}
title="Copy"
onMouseEnter={e => e.currentTarget.style.color = "var(--interactive-hover)"}
onMouseLeave={e => e.currentTarget.style.color = "var(--interactive-normal)"}
>
<CopyIcon />
</button>
<button
onClick={handleDownload}
style={{
background: "var(--background-floating)",
border: "1px solid var(--background-modifier-accent)",
borderRadius: "4px",
padding: "4px 6px",
cursor: "pointer",
display: "flex",
alignItems: "center",
color: "var(--interactive-normal)",
fontSize: "12px",
gap: "4px",
}}
title="Download"
onMouseEnter={e => e.currentTarget.style.color = "var(--interactive-hover)"}
onMouseLeave={e => e.currentTarget.style.color = "var(--interactive-normal)"}
>
<DownloadIcon />
</button>
</div>
<pre style={{
whiteSpace: "pre",
wordBreak: "normal",
margin: 0,
fontFamily: "monospace",
fontSize: "14px",
lineHeight: "1.5",
color: "var(--header-primary)",
}}>{text}</pre>
</div>
)
) : (
<div style={{
background: "var(--background-secondary)",
borderRadius: "8px",
border: "2px solid var(--background-modifier-accent)",
color: "var(--text-muted)",
textAlign: "center",
padding: "20px",
width: "100%",
}}>
Cannot preview this binary file
</div>
)}
</ModalContent>
</ModalRoot>
);
}
export default function openFilePreview(name: string, blob: Blob, buffer: ArrayBuffer) {
const ext = name.split(".").pop()?.toLowerCase() ?? "";
const images = ["png", "jpg", "jpeg", "gif", "webp", "avif"];
if (images.includes(ext)) {
// Convert blob to data URL (won't expire like blob URLs)
const reader = new FileReader();
reader.onload = () => {
const dataUrl = reader.result as string;
openImageModal({
url: dataUrl,
original: dataUrl,
width: 1920,
height: 1080,
});
};
reader.readAsDataURL(blob);
} else {
// open text file modal
openModal(props => (
<TextFileModal
name={name}
blob={blob}
buffer={buffer}
transitionState={props.transitionState}
onClose={props.onClose}
/>
));
}
}

View file

@ -1,128 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { React, useEffect, useState } from "@webpack/common";
import openFilePreview from "./FilePreview";
import { unzipBlob, ZipEntry } from "./unzip";
const ArrowExpandDown = () => <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M22,4V2H2V4H11V18.17L5.5,12.67L4.08,14.08L12,22L19.92,14.08L18.5,12.67L13,18.17V4H22Z" /></svg>;
const ArrowExpandUp = () => <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M2,20V22H22V20H13V5.83L18.5,11.33L19.92,9.92L12,2L4.08,9.92L5.5,11.33L11,5.83V20H2Z" /></svg>;
const FolderArrowLeft = () => <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M20,18H4V8H20M20,6H12L10,4H4A2,2 0 0,0 2,6V18A2,2 0 0,0 4,20H20A2,2 0 0,0 22,18V8A2,2 0 0,0 20,6M14,10V12H10V15L6,11.5L10,8V10H14Z" /></svg>;
function formatSize(n: number) {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(2)} KB`;
if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(2)} MB`;
return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`;
}
export default function ZipPreview({ blob, name, expanded: expandedProp, onExpandedChange }: { blob: Blob; name?: string; expanded?: boolean; onExpandedChange?: (v: boolean) => void; }) {
const [internalExpanded, setInternalExpanded] = useState(true);
const expanded = typeof expandedProp === "boolean" ? expandedProp : internalExpanded;
const setExpanded = (next: boolean) => {
if (onExpandedChange) onExpandedChange(next);
if (typeof expandedProp !== "boolean") setInternalExpanded(next);
};
const [tree, setTree] = useState<any | null>(null);
const [zipName, setZipName] = useState<string | null>(name ?? null);
const [zipSize, setZipSize] = useState<number | null>(null);
useEffect(() => {
(async () => {
setZipSize((blob as any).size ?? null);
if (!zipName) {
try {
// try to hint name from blob if available (some native helpers attach name)
const anyb = blob as any;
if (anyb?.name) setZipName(anyb.name);
} catch { }
}
const { entries, readEntry } = await unzipBlob(blob);
const root = { folders: {}, files: {}, path: "/", parent: null } as any;
for (const e of entries) {
if (e.isDirectory) continue;
const parts = e.name.split("/").filter(Boolean);
let cur = root;
for (let i = 0; i < parts.length - 1; i++) {
const p = parts[i];
cur.folders[p] = cur.folders[p] || { folders: {}, files: {}, path: cur.path + p + "/", parent: cur };
cur = cur.folders[p];
}
cur.files[parts[parts.length - 1]] = { entry: e, read: () => readEntry(e) };
}
setTree(root);
})();
}, [blob]);
async function openFile(name: string, file: { entry: ZipEntry; read: () => Promise<ArrayBuffer>; }) {
const ab = await file.read();
// Get proper MIME type based on file extension
const ext = name.split(".").pop()?.toLowerCase() ?? "";
const mimeTypes: Record<string, string> = {
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
webp: "image/webp",
avif: "image/avif",
svg: "image/svg+xml",
bmp: "image/bmp",
ico: "image/x-icon",
};
const mimeType = mimeTypes[ext] || "application/octet-stream";
const b = new Blob([ab], { type: mimeType });
openFilePreview(name, b, ab);
}
return (
<div>
<div className="zp-card">
<div className="zp-header">
<div className="zp-file-icon">
<svg width={36} height={36} viewBox="0 0 24 24" fill="none">
<path d="M6 2h7l3 3v13a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2z" fill="rgba(0,150,200,0.08)" />
<path d="M9 8h6v2H9zM9 11h6v2H9z" fill="var(--text-link)" />
</svg>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="zp-filename" title={zipName || "archive.zip"}>{zipName || "archive.zip"}</div>
<div className="zp-zipmeta">{zipSize ? formatSize(zipSize) : ""}</div>
</div>
</div>
<div className={"zp-zip-preview" + (expanded ? " expanded" : "")}>
{tree ? (
<>
<div className="zp-path">
<div className="zp-folder-return" onClick={() => { if (tree.parent) setTree(tree.parent); }}>
<FolderArrowLeft />
</div>
<div className="zp-path-text">{tree.path}</div>
</div>
{Object.keys(tree.folders).map(name => (
<div key={name} className="zp-entry-row" onClick={() => setTree(tree.folders[name])}>
<span className="zp-entry-name">{name}/</span>
</div>
))}
{Object.entries(tree.files).map(([name, file]: any) => (
<div key={name} className="zp-entry-row" onClick={() => openFile(name, file)}>
<span className="zp-entry-name">{name}</span>
<span className="zp-entry-size">({formatSize((file as any).entry.size)})</span>
</div>
))}
</>
) : "Loading..."}
</div>
<div className="zp-dropdown-expander" onClick={() => setExpanded(!expanded)}>
{expanded ? <ArrowExpandUp /> : <ArrowExpandDown />}
</div>
</div>
</div>
);
}

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M22,4V2H2V4H11V18.17L5.5,12.67L4.08,14.08L12,22L19.92,14.08L18.5,12.67L13,18.17V4H22Z" /></svg>

Before

Width:  |  Height:  |  Size: 164 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M2,20V22H22V20H13V5.83L18.5,11.33L19.92,9.92L12,2L4.08,9.92L5.5,11.33L11,5.83V20H2Z" /></svg>

Before

Width:  |  Height:  |  Size: 162 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z" /></svg>

Before

Width:  |  Height:  |  Size: 188 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19,21H8V7H19M19,5H8A2,2 0 0,0 6,7V21A2,2 0 0,0 8,23H19A2,2 0 0,0 21,21V7A2,2 0 0,0 19,5M16,1H4A2,2 0 0,0 2,3V17H4V3H16V1Z" /></svg>

Before

Width:  |  Height:  |  Size: 201 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M5,20H19V18H5M19,9H15V3H9V9H5L12,16L19,9Z" /></svg>

Before

Width:  |  Height:  |  Size: 120 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M22 8V13.81C21.39 13.46 20.72 13.22 20 13.09V8H4V18H13.09C13.04 18.33 13 18.66 13 19C13 19.34 13.04 19.67 13.09 20H4C2.9 20 2 19.11 2 18V6C2 4.89 2.89 4 4 4H10L12 6H20C21.1 6 22 6.89 22 8M18 16L15 19L18 22V20H22V18H18V16Z" /></svg>

Before

Width:  |  Height:  |  Size: 300 B

View file

@ -1,193 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import "./styles.css";
import { EquicordDevs } from "@utils/constants";
import { openModal } from "@utils/modal";
import definePlugin from "@utils/types";
import { Menu, React, showToast, useEffect, useState } from "@webpack/common";
import ZipPreview from "./ZipPreview";
async function fetchBlobWithDebug(url: string) {
try {
const res = await fetch("https://corsproxy.io/?url=" + encodeURIComponent(url));
if (!res.ok) {
console.error("ZipPreview: fetch failed", url, res.status, res.statusText);
return null;
}
const blob = await res.blob();
return blob;
} catch (err) {
console.error("ZipPreview: fetch error for", url, err);
return null;
}
}
async function tryNativeDownloadAttachment(attachment: any) {
try {
const helpers = (globalThis as any).VencordNative?.pluginHelpers?.MessageLoggerEnhanced;
if (!helpers) return null;
const filename = attachment?.filename || attachment?.title || attachment?.name || "";
const extMatch = filename?.includes(".") ? `.${filename.split(".").pop()}` : "";
const logged = {
id: attachment?.id,
url: attachment?.url || attachment?.proxy_url || attachment?.proxyUrl,
oldUrl: attachment?.oldUrl || attachment?.url || attachment?.proxy_url || attachment?.proxyUrl,
fileExtension: attachment?.fileExtension || extMatch,
filename: filename,
proxy_url: attachment?.proxy_url,
content_type: attachment?.content_type
};
if (!logged.id || !logged.url) return null;
const res = await helpers.downloadAttachment(logged);
if (!res || res.error || !res.path) return null;
// now retrieve raw bytes from native cache
const bytes = await helpers.getImageNative(logged.id);
if (!bytes) return null;
// bytes may be Buffer-like or Uint8Array
const arr = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
return new Blob([arr.buffer]);
} catch (err) {
console.error("ZipPreview: native download failed", err);
return null;
}
}
function MessageContextMenu(children: Array<any>, props: any) {
try {
const { mediaItem, message } = props ?? {};
if (!mediaItem || !message) return;
const attachment = (message.attachments || []).find((a: any) =>
a?.proxy_url === mediaItem.proxyUrl || a?.url === mediaItem.url || a?.proxy_url === mediaItem.url || a?.url === mediaItem.proxyUrl
);
const filename = attachment?.filename || attachment?.title || mediaItem?.filename || mediaItem?.name || "";
const contentType = (attachment?.content_type || mediaItem?.contentType || "").toLowerCase();
const looksLikeZip = contentType.includes("zip") || filename.toLowerCase().endsWith(".zip") || (mediaItem?.url || "").toLowerCase().endsWith(".zip");
if (!looksLikeZip) return;
children.push(
<Menu.MenuItem
id="zippreview-open"
label="Preview zip"
action={async () => {
try {
const url = attachment?.proxy_url || attachment?.url || mediaItem?.proxyUrl || mediaItem?.url;
if (!url) return;
// try native download first to avoid CORS issues
let blob = await tryNativeDownloadAttachment(attachment || mediaItem);
if (!blob) blob = await fetchBlobWithDebug(url);
if (!blob || blob.size === 0) {
console.error("ZipPreview: fetched empty blob for", url);
showToast("Failed to fetch attachment for preview (empty response). Try Download.");
return;
}
openModal((props: any) => <ZipPreview blob={blob} name={filename} /> as any);
} catch (err) {
console.error("ZipPreview: failed to open from context menu", err);
}
}}
/>
);
} catch (err) {
// ignore
}
}
// Store for expanded state and loaded blobs per attachment
const expandedState = new Map<string, boolean>();
const blobCache = new Map<string, Blob>();
// Component to render inside each zip attachment
function ZipAttachmentPreview({ attachment }: { attachment: any; }) {
const key = attachment?.id || attachment?.url || attachment?.proxy_url;
const ext = attachment.fileName.match(/\.tar\.\w+$|(\.\w+)$/)?.[0] ?? "";
if (ext !== ".zip") return;
const [blob, setBlob] = useState<Blob | null>(() => blobCache.get(key) || null);
const [error, setError] = useState<string | null>(null);
const [expanded, setExpanded] = useState<boolean>(() => expandedState.get(key) ?? false);
useEffect(() => {
// If already cached, skip fetch
if (blobCache.has(key)) return;
let mounted = true;
(async () => {
try {
const url = attachment.proxy_url || attachment.url;
if (!url) {
if (mounted) setError("No URL for attachment");
return;
}
let b = await tryNativeDownloadAttachment(attachment);
if (!b) b = await fetchBlobWithDebug(url);
if (!b || b.size === 0) {
if (mounted) setError("Failed to fetch archive");
return;
}
if (mounted) {
setBlob(b);
blobCache.set(key, b);
}
} catch {
if (mounted) setError("Failed to fetch archive");
}
})();
return () => { mounted = false; };
}, [key]);
if (error) return <div className="zp-error">{error}</div>;
if (!blob) return <div className="zp-loading">Loading preview…</div>;
return (
<div className="zp-attachment-integrated">
<ZipPreview
blob={blob}
name={attachment.filename || attachment.name || "archive.zip"}
expanded={expanded}
onExpandedChange={v => {
setExpanded(v);
expandedState.set(key, v);
}}
/>
</div>
);
}
export default definePlugin({
name: "ZipPreview",
description: "Preview and navigate inside zip files without extracting.",
authors: [EquicordDevs.justjxke, EquicordDevs.SteelTech],
patches: [
{
find: "#{intl::ATTACHMENT_PROCESSING}",
replacement: {
match: /null!=\i&&\i\(\)(?<=renderAdjacentContent.*?\}=(\i);.*?)/,
replace: "$&,$self.ZipAttachmentPreview({ attachment: $1 })"
}
}
],
contextMenus: {
"message": MessageContextMenu
},
ZipAttachmentPreview,
});

View file

@ -1,347 +0,0 @@
.zp-file-wrapper {
display: flex;
flex-direction: column;
width: 100%;
padding: 0 !important;
overflow: hidden;
}
.zp-wrap {
display: flex;
flex-direction: column;
width: 100%;
border-radius: inherit;
overflow: hidden;
background: inherit;
}
.zp-content {
padding: 16px;
padding-bottom: 10px;
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.zp-dropdown-expander {
width: 100%;
display: flex;
height: 16px;
align-items: flex-end;
justify-content: center;
border-top: 2px solid var(--border-subtle);
cursor: pointer;
}
.zp-dropdown-expander svg {
width: 16px;
height: 16px;
fill: var(--text-link);
margin-top: 0;
}
.zp-card {
width: 100%;
max-width: 560px;
}
.zp-attachment-integrated .zp-card {
width: 100%;
border: none;
border-radius: 0;
background: transparent;
max-width: none;
}
.zp-header {
display: flex;
gap: 12px;
align-items: center;
padding: 12px 12px 8px;
border-bottom: 1px solid var(--background-modifier-border);
}
.zp-file-icon {
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
}
.zp-filename {
font-weight: 600;
font-size: 14px;
color: var(--text-normal);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.zp-zipmeta {
font-size: 12px;
color: var(--text-muted);
margin-top: 2px;
}
.zp-zip-preview {
max-height: 0;
overflow: hidden;
transition: max-height 0.2s ease, padding 0.2s ease;
color: var(--text-normal);
padding: 0 16px;
display: flex;
flex-direction: column;
gap: 4px;
}
.zp-zip-preview.expanded {
max-height: 380px;
overflow-y: auto;
padding: 8px 16px 12px;
}
.zp-path {
color: var(--text-muted);
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 2px;
line-height: 1.4;
}
.zp-path-text {
color: var(--text-muted);
}
.zp-entry-row {
display: flex;
align-items: baseline;
gap: 6px;
cursor: pointer;
padding: 1px 0;
}
.zp-entry-name {
color: var(--text-link);
text-decoration: none;
}
.zp-entry-row:hover .zp-entry-name {
text-decoration: underline;
}
.zp-entry-size {
color: var(--text-muted);
font-size: 12px;
}
.zp-folder-return {
cursor: pointer;
}
.zp-folder-return svg {
fill: var(--text-muted);
width: 20px;
height: 20px;
}
.zp-preview {
color: var(--text-normal);
max-width: 80vw;
min-width: 30vw;
max-height: 80vh;
min-height: 20vh;
overflow: hidden;
display: flex;
flex-direction: column;
user-select: text;
border-radius: var(--radius-md);
}
.zp-preview-header {
height: 46px;
background: var(--background-accent-tertiary);
flex-shrink: 0;
display: flex;
align-items: center;
font-size: 18px;
padding: 0 12px;
font-weight: 700;
border-bottom: 1px solid rgba(0 0 0 / 8%);
}
.zp-preview-content-wrap {
margin: 12px;
padding: 12px;
border: 1px solid rgba(0 0 0 / 8%);
border-radius: 8px;
min-height: 0;
overflow: hidden;
display: flex;
position: relative;
align-self: stretch;
flex-grow: 1;
}
.zp-preview-copy {
position: absolute;
top: 8px;
right: 8px;
width: 20px;
height: 20px;
cursor: pointer;
}
.zp-preview-content {
min-height: 0;
overflow: auto;
width: 100%;
padding-top: 3px;
padding-bottom: 3px;
}
.zp-preview img,
.zp-preview video {
width: 100%;
height: auto;
}
.zp-preview-footer {
height: 52px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: flex-end;
padding: 8px 12px;
}
.zp-attachment-tab {
padding: 6px 10px;
background: var(--background-secondary);
border-radius: 6px;
cursor: pointer;
font-size: 13px;
color: var(--text-link);
}
.zp-attachment-integrated {
max-width: 520px;
overflow: hidden;
}
.zp-attachment-integrated .zp-header {
display: none;
}
.zp-attachment-integrated .zp-zip-preview {
background: transparent;
}
.zp-injected-preview .zp-dropdown-expander {
width: 100%;
margin: 0;
}
.zp-attachment-integrated .zp-dropdown-expander {
background: transparent;
border-radius: 0 0 8px 8px;
transition: background 0.2s ease;
}
.zp-attachment-integrated .zp-dropdown-expander:hover {
background: var(--background-modifier-hover);
}
.zp-error,
.zp-loading {
padding: 8px 12px;
text-align: center;
color: var(--text-muted);
font-size: 12px;
}
.zp-injected-preview .zp-error,
.zp-injected-preview .zp-loading {
background: transparent;
border-top: 1px solid var(--background-modifier-accent);
margin-top: 8px;
width: 100%;
display: block;
}
.zp-inline-container {
margin-top: 8px;
}
.zp-inline-preview {
margin-top: 8px;
}
[class^="file__"][data-zp-processed="true"] {
flex-wrap: wrap !important;
}
.zp-injected-preview {
width: 100%;
display: flex;
flex-direction: column;
}
.zp-injected-preview .zp-attachment-integrated {
max-width: none;
border-top: none;
padding-top: 0;
margin-top: 0;
}
.zp-injected-preview .zp-card {
background: transparent;
}
.zp-injected-preview .zp-header {
display: none;
}
/* Force code block container to full width and add balanced padding */
.zp-code-modal .vc-shiki-container {
width: 100% !important;
min-width: 100% !important;
max-width: 100% !important;
flex: 1 1 100% !important;
}
.zp-code-modal .vc-shiki-root {
width: 100% !important;
min-width: 100% !important;
max-width: 100% !important;
position: relative !important;
}
.zp-code-modal .vc-shiki-code {
width: 100% !important;
min-width: 100% !important;
max-width: 100% !important;
padding-right: 48px !important;
box-sizing: border-box !important;
}
.zp-code-modal .vc-shiki-table {
width: 100% !important;
min-width: 100% !important;
max-width: 100% !important;
}
.zp-code-modal .vc-shiki-root .vc-shiki-table-cell:last-child {
padding-right: 48px !important;
}
/* Reposition copy button to be accessible without scrolling */
.zp-code-modal .vc-shiki-btns {
position: absolute !important;
bottom: 8px !important;
right: 8px !important;
z-index: 100 !important;
}

View file

@ -1,130 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
export type ZipEntry = {
name: string;
isDirectory: boolean;
compressedSize: number;
size: number;
offset: number;
compression: number; // 0=stored, 8=deflate
};
function readLE(buf: Uint8Array, off: number, len: number) {
let v = 0;
for (let i = 0; i < len; i++) v |= buf[off + i] << (8 * i);
return v >>> 0;
}
export async function unzipBlob(blob: Blob): Promise<{ entries: ZipEntry[]; readEntry: (e: ZipEntry) => Promise<ArrayBuffer>; }> {
const buf = new Uint8Array(await blob.arrayBuffer());
// find end of central directory (EOCD)
const EOCD_SIG = 0x06054b50;
let eocdOff = -1;
for (let i = buf.length - 22; i >= 0; i--) {
if (readLE(buf, i, 4) === EOCD_SIG) { eocdOff = i; break; }
}
const entries: ZipEntry[] = [];
if (eocdOff !== -1) {
// central directory available: read it
const cdSize = readLE(buf, eocdOff + 12, 4);
const cdOff = readLE(buf, eocdOff + 16, 4);
let ptr = cdOff;
const CDFH_SIG = 0x02014b50;
while (ptr < cdOff + cdSize) {
if (readLE(buf, ptr, 4) !== CDFH_SIG) break;
const nameLen = readLE(buf, ptr + 28, 2);
const extraLen = readLE(buf, ptr + 30, 2);
const commentLen = readLE(buf, ptr + 32, 2);
const compression = readLE(buf, ptr + 10, 2);
const compressedSize = readLE(buf, ptr + 20, 4);
const size = readLE(buf, ptr + 24, 4);
const localHeaderOffset = readLE(buf, ptr + 42, 4);
const nameBytes = buf.slice(ptr + 46, ptr + 46 + nameLen);
const name = new TextDecoder().decode(nameBytes);
entries.push({ name, isDirectory: name.endsWith("/"), compressedSize, size, offset: localHeaderOffset, compression });
ptr += 46 + nameLen + extraLen + commentLen;
}
} else {
// fallback: try to parse sequential local file headers (useful if EOCD/central dir missing or truncated)
const LFH_SIG = 0x04034b50;
let ptr = 0;
while (ptr + 30 < buf.length) {
if (readLE(buf, ptr, 4) !== LFH_SIG) {
// try to find the next LFH
ptr++;
continue;
}
const compression = readLE(buf, ptr + 8, 2);
const compressedSize = readLE(buf, ptr + 18, 4);
const size = readLE(buf, ptr + 22, 4);
const nameLen = readLE(buf, ptr + 26, 2);
const extraLen = readLE(buf, ptr + 28, 2);
const nameBytes = buf.slice(ptr + 30, ptr + 30 + nameLen);
const name = new TextDecoder().decode(nameBytes);
const dataStart = ptr + 30 + nameLen + extraLen;
entries.push({ name, isDirectory: name.endsWith("/"), compressedSize, size, offset: ptr, compression });
// advance past data
ptr = dataStart + compressedSize;
}
if (entries.length === 0) {
// helpful debug info for common failure modes
try {
const head = Array.from(buf.slice(0, 16)).map(b => b.toString(16).padStart(2, "0")).join(" ");
console.error("ZipPreview: EOCD not found and no local file headers parsed. Buffer length:", buf.length, "head:", head);
} catch (err) { console.error("ZipPreview: debug failed", err); }
throw new Error("Not a zip or unsupported");
}
}
async function readEntry(e: ZipEntry) {
// read local file header to find data offset
const LFH_SIG = 0x04034b50;
const off = e.offset;
if (readLE(buf, off, 4) !== LFH_SIG) throw new Error("Invalid local header at offset " + off + ". Buffer length: " + buf.length);
const nameLen = readLE(buf, off + 26, 2);
const extraLen = readLE(buf, off + 28, 2);
const dataStart = off + 30 + nameLen + extraLen;
const slice = buf.slice(dataStart, dataStart + e.compressedSize);
if (e.compression === 0) {
return slice.buffer.slice(slice.byteOffset, slice.byteOffset + slice.byteLength);
}
// deflate: use built-in CompressionStream if available
// deflate: prefer DecompressionStream for inflating raw deflate data
if (typeof (DecompressionStream) !== "undefined") {
const ds = new DecompressionStream("deflate-raw");
const writer = ds.writable.getWriter();
await writer.write(slice);
await writer.close();
const reader = ds.readable.getReader();
const chunks: Uint8Array[] = [];
while (true) {
const r = await reader.read();
if (r.done) break;
chunks.push(new Uint8Array(r.value));
}
const total = chunks.reduce((s, c) => s + c.length, 0);
const out = new Uint8Array(total);
let pos = 0;
for (const c of chunks) { out.set(c, pos); pos += c.length; }
return out.buffer;
}
// If DecompressionStream isn't available, try the older API name (some runtimes exposed 'CompressionStream' only for compress/decompress)
if (typeof (CompressionStream) !== "undefined" && typeof (globalThis as any).DecompressionStream === "undefined") {
// Some environments only provide CompressionStream; try using a hack: create a Response around a blob with 'deflate-raw' content, then use 'arrayBuffer'—but this is unreliable.
throw new Error("Deflate-compressed entries require DecompressionStream support in this environment");
}
throw new Error("Deflate-compressed entries require DecompressionStream support in this environment");
}
return { entries, readEntry };
}

View file

@ -19,7 +19,7 @@
import { NavContextMenuPatchCallback } from "@api/ContextMenu";
import { definePluginSettings } from "@api/Settings";
import { debounce } from "@shared/debounce";
import { Devs, EquicordDevs } from "@utils/constants";
import { Devs } from "@utils/constants";
import { Logger } from "@utils/Logger";
import definePlugin, { makeRange, OptionType } from "@utils/types";
import { createRoot, Menu } from "@webpack/common";
@ -30,19 +30,6 @@ import { Magnifier, MagnifierProps } from "./components/Magnifier";
import { ELEMENT_ID } from "./constants";
import managedStyle from "./styles.css?managed";
interface ImageMetadata {
filename: string;
dimensions: string;
size?: string;
fetching?: boolean;
}
const imageMetadataCache = new Map<string, ImageMetadata>();
let lastClickTime = 0;
const DOUBLE_CLICK_THRESHOLD = 300;
export const settings = definePluginSettings({
saveZoomValues: {
type: OptionType.BOOLEAN,
@ -90,12 +77,6 @@ export const settings = definePluginSettings({
default: 0.5,
stickToMarkers: false,
},
showMetadata: {
type: OptionType.BOOLEAN,
description: "Show image metadata when double clicking on selected image",
default: true,
}
});
@ -105,7 +86,7 @@ const imageContextMenuPatch: NavContextMenuPatchCallback = (children, props) =>
// emojis in user statuses
if (props.target?.classList?.contains("emoji")) return;
const { square, nearestNeighbour, showMetadata } = settings.use(["square", "nearestNeighbour", "showMetadata"]);
const { square, nearestNeighbour } = settings.use(["square", "nearestNeighbour"]);
children.push(
<Menu.MenuGroup id="image-zoom">
@ -168,138 +149,14 @@ const imageContextMenuPatch: NavContextMenuPatchCallback = (children, props) =>
/>
)}
/>
<Menu.MenuSeparator />
<Menu.MenuCheckboxItem
id="vc-show-metadata"
label="Show Image Metadata"
checked={showMetadata}
action={() => {
settings.store.showMetadata = !showMetadata;
}}
/>
<Menu.MenuItem
id="vc-view-metadata"
label="View Metadata"
action={() => {
const target = props.target as HTMLImageElement;
if (target && target.src) {
toggleMetadata(target);
}
}}
/>
</Menu.MenuGroup>
);
};
function toggleMetadata(imgElement: HTMLImageElement) {
if (!imgElement || !imgElement.src) return;
const parent = imgElement.parentElement;
if (!parent) return;
const metadataContainer = parent.querySelector(".vc-image-metadata");
if (metadataContainer) {
metadataContainer.remove();
return;
}
createMetadataDisplay(imgElement);
}
function createMetadataDisplay(imgElement: HTMLImageElement) {
if (!imgElement || !imgElement.src) return;
const { src } = imgElement;
const parent = imgElement.parentElement;
if (!parent) return;
const wrapper = document.createElement("div");
wrapper.className = "vc-image-wrapper";
parent.insertBefore(wrapper, imgElement);
wrapper.appendChild(imgElement);
let metadata = imageMetadataCache.get(src);
if (!metadata) {
metadata = {
filename: getFilenameFromURL(src),
dimensions: `${imgElement.naturalWidth || imgElement.width} × ${imgElement.naturalHeight || imgElement.height} px`,
fetching: true
};
imageMetadataCache.set(src, metadata);
fetchFileSize(src).then(size => {
if (size !== undefined) {
const cachedMetadata = imageMetadataCache.get(src);
if (cachedMetadata) {
cachedMetadata.size = formatFileSize(size);
cachedMetadata.fetching = false;
imageMetadataCache.set(src, cachedMetadata);
const container = parent.querySelector(".vc-image-metadata");
if (container) {
const sizeElement = container.querySelector(".vc-metadata-row:last-child span:last-child");
if (sizeElement) {
sizeElement.textContent = formatFileSize(size);
}
}
}
}
});
}
const container = document.createElement("div");
container.className = "vc-image-metadata";
container.innerHTML = `
<div class="vc-metadata-row">
<span class="vc-metadata-label">Filename:</span>
<span>${metadata.filename}</span>
</div>
<div class="vc-metadata-row">
<span class="vc-metadata-label">Dimensions:</span>
<span>${metadata.dimensions}</span>
</div>
<div class="vc-metadata-row">
<span class="vc-metadata-label">Size:</span>
<span>${metadata.size || "Loading..."}</span>
</div>
`;
wrapper.appendChild(container);
return container;
}
function getFilenameFromURL(url: string): string {
try {
const cleanUrl = url.split("?")[0];
const parts = cleanUrl.split("/");
return decodeURIComponent(parts[parts.length - 1]);
} catch {
return "Unknown";
}
}
async function fetchFileSize(url: string): Promise<number | undefined> {
try {
const response = await fetch(url, { method: "HEAD" });
return parseInt(response.headers.get("content-length") || "0");
} catch {
return undefined;
}
}
function formatFileSize(bytes: number): string {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ["Bytes", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
}
export default definePlugin({
name: "ImageZoom",
description: "Lets you zoom in to images and gifs as well as displays image metadata. Use scroll wheel to zoom in and shift + scroll wheel to increase lens radius.",
authors: [Devs.Aria, EquicordDevs.Campfire],
description: "Lets you zoom in to images and gifs. Use scroll wheel to zoom in and shift + scroll wheel to increase lens radius / size",
authors: [Devs.Aria],
tags: ["ImageUtilities"],
managedStyle,
@ -318,6 +175,7 @@ export default definePlugin({
}
]
},
// Make media viewer options not hide when zoomed in with the default Discord feature
{
find: '="FOCUS_SENSITIVE",',
replacement: {
@ -325,6 +183,7 @@ export default definePlugin({
replace: "false"
}
},
{
find: ".handleImageLoad)",
replacement: [
@ -332,14 +191,17 @@ export default definePlugin({
match: /placeholderVersion:\i,(?=.{0,50}children:)/,
replace: "...$self.makeProps(this),$&"
},
{
match: /componentDidMount\(\){/,
replace: "$&$self.renderMagnifier(this);",
},
{
match: /componentWillUnmount\(\){/,
replace: "$&$self.unMountMagnifier();"
},
{
match: /componentDidUpdate\(\i\){/,
replace: "$&$self.updateMagnifier(this);"
@ -353,44 +215,30 @@ export default definePlugin({
"image-context": imageContextMenuPatch
},
// to stop from rendering twice /shrug
currentMagnifierElement: null as React.FunctionComponentElement<MagnifierProps & JSX.IntrinsicAttributes> | null,
element: null as HTMLDivElement | null,
Magnifier,
root: null as Root | null,
makeProps(instance) {
return {
onMouseOver: () => this.onMouseOver(instance),
onMouseOut: () => this.onMouseOut(instance),
onMouseDown: (e: React.MouseEvent) => this.onMouseDown(e, instance),
onMouseUp: () => this.onMouseUp(instance),
onClick: (e: React.MouseEvent) => this.handleImageClick(e, instance),
id: instance.props.id,
};
},
handleImageClick(e: React.MouseEvent | MouseEvent, instance: any) {
if (!settings.store.showMetadata) return;
const target = e.target as HTMLImageElement;
if (target && target.tagName === "IMG" && target.src) {
const currentTime = new Date().getTime();
if (currentTime - lastClickTime < DOUBLE_CLICK_THRESHOLD) {
toggleMetadata(target);
}
lastClickTime = currentTime;
}
},
renderMagnifier(instance) {
try {
if (instance.props.id === ELEMENT_ID) {
if (!this.root) {
if (!this.currentMagnifierElement) {
this.currentMagnifierElement = <Magnifier size={settings.store.size} zoom={settings.store.zoom} instance={instance} />;
this.root = createRoot(this.element!);
this.root.render(this.currentMagnifierElement);
}
this.currentMagnifierElement = <Magnifier size={settings.store.size} zoom={settings.store.zoom} instance={instance} />;
this.root.render(this.currentMagnifierElement);
}
} catch (error) {
new Logger("ImageZoom").error("Failed to render magnifier:", error);
@ -398,6 +246,7 @@ export default definePlugin({
},
updateMagnifier(instance) {
this.unMountMagnifier();
this.renderMagnifier(instance);
},
@ -425,41 +274,11 @@ export default definePlugin({
this.element = document.createElement("div");
this.element.classList.add("MagnifierContainer");
document.body.appendChild(this.element);
const style = document.createElement("style");
style.id = "image-metadata-styles";
style.textContent = `
.vc-image-metadata {
padding: 8px;
margin: 6px 0;
background-color: var(--background-secondary);
border-radius: 4px;
font-size: 14px;
color: var(--text-default);
display: flex;
flex-direction: column;
gap: 4px;
}
.vc-metadata-row {
display: flex;
justify-content: space-between;
}
.vc-metadata-label {
font-weight: 600;
margin-right: 8px;
}
`;
document.head.appendChild(style);
},
stop() {
// so componenetWillUnMount gets called if Magnifier component is still alive
this.root && this.root.unmount();
this.element?.remove();
document.getElementById("image-metadata-styles")?.remove();
document.querySelectorAll(".vc-image-metadata").forEach(el => el.remove());
}
});