Security: Drop A Lot Of Plugins
Thank you V
This commit is contained in:
parent
81a4ea94c9
commit
01c464e0c4
29 changed files with 15 additions and 3251 deletions
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
|
@ -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",
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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(),"
|
||||
}
|
||||
},
|
||||
]
|
||||
});
|
||||
|
|
@ -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" });
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
},
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
.katex-display {
|
||||
margin: 0;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.tex-error {
|
||||
color: var(--status-danger);
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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,
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue