Merge branch 'dev' into bun
This commit is contained in:
commit
d0212642d2
64 changed files with 1001 additions and 2237 deletions
3
.github/workflows/build.yml
vendored
3
.github/workflows/build.yml
vendored
|
|
@ -41,7 +41,8 @@ jobs:
|
|||
cd dist
|
||||
mkdir release
|
||||
cp browser/browser.* release
|
||||
cp Vencord.user.{js,js.LEGAL.txt} release
|
||||
cp Equicord.user.{js,js.LEGAL.txt} release
|
||||
|
||||
# copy the plugin data jsons, the extension zips and the desktop/vesktop asars
|
||||
cp *.{json,zip,asar} release
|
||||
# legacy un-asared files
|
||||
|
|
|
|||
3
.github/workflows/nixosBuild.yml
vendored
3
.github/workflows/nixosBuild.yml
vendored
|
|
@ -39,7 +39,8 @@ jobs:
|
|||
cd dist
|
||||
mkdir release
|
||||
cp browser/browser.* release
|
||||
cp Vencord.user.{js,js.LEGAL.txt} release
|
||||
cp Equicord.user.{js,js.LEGAL.txt} release
|
||||
|
||||
# copy the plugin data jsons, the extension zips and the desktop/vesktop asars
|
||||
cp *.{json,zip,asar} release
|
||||
# legacy un-asared files
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ if (typeof browser === "undefined") {
|
|||
const style = document.createElement("link");
|
||||
style.type = "text/css";
|
||||
style.rel = "stylesheet";
|
||||
style.href = browser.runtime.getURL("dist/Vencord.css");
|
||||
style.href = browser.runtime.getURL("dist/Equicord.css");
|
||||
|
||||
document.addEventListener(
|
||||
"DOMContentLoaded",
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
"minimum_chrome_version": "111",
|
||||
"name": "Equicord Web",
|
||||
"description": "The other cutest Discord mod now in your browser",
|
||||
"author": "Thororen",
|
||||
"author": "Equicord",
|
||||
"homepage_url": "https://github.com/Equicord/Equicord",
|
||||
"icons": {
|
||||
"128": "icon.png"
|
||||
|
|
@ -33,7 +33,7 @@
|
|||
"*://*.discord.com/*"
|
||||
],
|
||||
"js": [
|
||||
"dist/Vencord.js"
|
||||
"dist/Equicord.js"
|
||||
],
|
||||
"all_frames": true,
|
||||
"world": "MAIN"
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
"minimum_chrome_version": "91",
|
||||
"name": "Equicord Web",
|
||||
"description": "The other cutest Discord mod now in your browser",
|
||||
"author": "Thororen",
|
||||
"author": "Equicord",
|
||||
"homepage_url": "https://github.com/Equicord/Equicord",
|
||||
"icons": {
|
||||
"128": "icon.png"
|
||||
|
|
@ -32,7 +32,7 @@
|
|||
"*://*.discord.com/*"
|
||||
],
|
||||
"js": [
|
||||
"dist/Vencord.js"
|
||||
"dist/Equicord.js"
|
||||
],
|
||||
"all_frames": true,
|
||||
"world": "MAIN"
|
||||
|
|
@ -44,13 +44,13 @@
|
|||
]
|
||||
},
|
||||
"web_accessible_resources": [
|
||||
"dist/Vencord.js",
|
||||
"dist/Vencord.css"
|
||||
"dist/Equicord.js",
|
||||
"dist/Equicord.css"
|
||||
],
|
||||
"browser_specific_settings": {
|
||||
"gecko": {
|
||||
"id": "vencord-firefox@vendicated.dev",
|
||||
"id": "equicord-firefox@equicord.org",
|
||||
"strict_min_version": "128.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
2
packages/discord-types/src/common/Guild.d.ts
vendored
2
packages/discord-types/src/common/Guild.d.ts
vendored
|
|
@ -47,7 +47,7 @@ export class Guild extends DiscordRecord {
|
|||
};
|
||||
premiumProgressBarEnabled: boolean;
|
||||
premiumSubscriberCount: number;
|
||||
premiumTier: number;
|
||||
premiumTier: 0 | 1 | 2 | 3;
|
||||
profile: {
|
||||
badge: string | undefined;
|
||||
tag: string | undefined;
|
||||
|
|
|
|||
|
|
@ -1,10 +1,37 @@
|
|||
import { FluxStore } from "..";
|
||||
|
||||
export class MediaEngineStore extends FluxStore {
|
||||
isSelfMute(): boolean;
|
||||
isSelfDeaf(): boolean;
|
||||
isAdvancedVoiceActivitySupported(): boolean;
|
||||
isAecDumpSupported(): boolean;
|
||||
isAnyLocalVideoAutoDisabled(): boolean;
|
||||
isAutomaticGainControlSupported(): boolean;
|
||||
isDeaf(): boolean;
|
||||
isEnableHardwareMuteNotice(): boolean;
|
||||
isEnabled(): boolean;
|
||||
isExperimentalEncodersSupported(): boolean;
|
||||
isHardwareMute(): boolean;
|
||||
isInputProfileCustom(): boolean;
|
||||
isInteractionRequired(): boolean;
|
||||
isLocalMute(): boolean;
|
||||
isLocalMute(userId): boolean;
|
||||
isLocalVideoAutoDisabled(userId): boolean;
|
||||
isLocalVideoDisabled(userId): boolean;
|
||||
isLocalVideoDisabled(): boolean;
|
||||
isMediaFilterSettingLoading(): boolean;
|
||||
isMute(): boolean;
|
||||
isNativeAudioPermissionReady(): boolean;
|
||||
isNoiseCancellationError(): boolean;
|
||||
isNoiseCancellationSupported(): boolean;
|
||||
isNoiseSuppressionSupported(): boolean;
|
||||
isScreenSharing(): boolean;
|
||||
isSelfDeaf(): boolean;
|
||||
isSelfMute(): boolean;
|
||||
isSelfMutedTemporarily(): boolean;
|
||||
isSimulcastSupported(): boolean;
|
||||
isSoundSharing(): boolean;
|
||||
isSupported(): boolean;
|
||||
isVideoAvailable(): boolean;
|
||||
isVideoEnabled(): boolean;
|
||||
|
||||
setAv1Enabled(AV1: boolean): void;
|
||||
setH265Enabled(H265: boolean): void;
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ const buildConfigs = [
|
|||
IS_USERSCRIPT: "true",
|
||||
window: "unsafeWindow",
|
||||
},
|
||||
outfile: "dist/Vencord.user.js",
|
||||
outfile: "dist/Equicord.user.js",
|
||||
banner: {
|
||||
js: readFileSync("browser/userscript.meta.js", "utf-8").replace("%version%", `${VERSION}.${new Date().getTime()}`)
|
||||
},
|
||||
|
|
@ -155,8 +155,8 @@ async function loadDir(dir, basePath = "") {
|
|||
*/
|
||||
async function buildExtension(target, files) {
|
||||
const entries = {
|
||||
"dist/Vencord.js": await readFile("dist/browser/extension.js"),
|
||||
"dist/Vencord.css": await readFile("dist/browser/extension.css"),
|
||||
"dist/Equicord.js": await readFile("dist/browser/extension.js"),
|
||||
"dist/Equicord.css": await readFile("dist/browser/extension.css"),
|
||||
...await loadDir("dist/browser/vendor/monaco", "dist/browser/"),
|
||||
...Object.fromEntries(await Promise.all(files.map(async f => {
|
||||
let content = await readFile(join("browser", f));
|
||||
|
|
@ -184,7 +184,7 @@ async function buildExtension(target, files) {
|
|||
console.info("Unpacked Extension written to dist/browser/" + target);
|
||||
}
|
||||
|
||||
const appendCssRuntime = readFile("dist/Vencord.user.css", "utf-8").then(content => {
|
||||
const appendCssRuntime = readFile("dist/Equicord.user.css", "utf-8").then(content => {
|
||||
const cssRuntime = `
|
||||
;document.addEventListener("DOMContentLoaded", () => document.body.insertAdjacentElement("afterend",
|
||||
Object.assign(document.createElement("style"), {
|
||||
|
|
@ -194,7 +194,7 @@ const appendCssRuntime = readFile("dist/Vencord.user.css", "utf-8").then(content
|
|||
), { once: true });
|
||||
`;
|
||||
|
||||
return appendFile("dist/Vencord.user.js", cssRuntime);
|
||||
return appendFile("dist/Equicord.user.js", cssRuntime);
|
||||
});
|
||||
|
||||
if (!process.argv.includes("--skip-extension")) {
|
||||
|
|
|
|||
|
|
@ -13,13 +13,15 @@ import { DeleteIcon } from "@components/Icons";
|
|||
import { Link } from "@components/Link";
|
||||
import { Paragraph } from "@components/Paragraph";
|
||||
import { SettingsTab, wrapTab } from "@components/settings/tabs/BaseTab";
|
||||
import { gitHashShort } from "@shared/vencordUserAgent";
|
||||
import { Margins } from "@utils/margins";
|
||||
import { useAwaiter } from "@utils/react";
|
||||
import { getRepo, shortGitHash, UpdateLogger } from "@utils/updater";
|
||||
import { getRepo, UpdateLogger } from "@utils/updater";
|
||||
import { Alerts, Button, Card, React, Toasts } from "@webpack/common";
|
||||
|
||||
import gitHash from "~git-hash";
|
||||
|
||||
import { HashLink } from "../updater/Components";
|
||||
import {
|
||||
ChangelogEntry,
|
||||
ChangelogHistory,
|
||||
|
|
@ -40,22 +42,6 @@ import {
|
|||
} from "./changelogManager";
|
||||
import { NewPluginsCompact, NewPluginsSection } from "./NewPluginsSection";
|
||||
|
||||
function HashLink({
|
||||
repo,
|
||||
hash,
|
||||
disabled = false,
|
||||
}: {
|
||||
repo: string;
|
||||
hash: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Link href={`${repo}/commit/${hash}`} disabled={disabled}>
|
||||
{hash}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function ChangelogCard({
|
||||
entry,
|
||||
repo,
|
||||
|
|
@ -78,7 +64,7 @@ function ChangelogCard({
|
|||
<code className="vc-changelog-entry-hash">
|
||||
<HashLink
|
||||
repo={repo}
|
||||
hash={entry.hash.slice(0, 7)}
|
||||
hash={entry.hash}
|
||||
disabled={repoPending}
|
||||
/>
|
||||
</code>
|
||||
|
|
@ -131,7 +117,7 @@ function UpdateLogCard({
|
|||
<Button
|
||||
size={Button.Sizes.NONE}
|
||||
color={Button.Colors.TRANSPARENT}
|
||||
look={Button.Looks.LINK}
|
||||
look={Button.Looks.FILLED}
|
||||
className="vc-changelog-delete-button"
|
||||
style={{
|
||||
padding: "4px",
|
||||
|
|
@ -509,7 +495,7 @@ function ChangelogContent() {
|
|||
)}{" "}
|
||||
(Current:{" "}
|
||||
<span className="vc-changelog-current-hash">
|
||||
{shortGitHash()}
|
||||
{gitHashShort}
|
||||
</span>
|
||||
)
|
||||
</Paragraph>
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@
|
|||
line-height: 16px;
|
||||
color: var(--white-500);
|
||||
text-align: center;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.vc-plugins-dep-name {
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ export interface CommonProps {
|
|||
export function HashLink({ repo, hash, disabled = false }: { repo: string, hash: string, disabled?: boolean; }) {
|
||||
return (
|
||||
<Link href={`${repo}/commit/${hash}`} disabled={disabled}>
|
||||
{hash}
|
||||
{hash.slice(0, 7)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,11 +30,13 @@ import { classes } from "@utils/misc";
|
|||
import { ModalCloseButton, ModalContent, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal";
|
||||
import { relaunch } from "@utils/native";
|
||||
import { useAwaiter } from "@utils/react";
|
||||
import { changes, checkForUpdates, getRepo, isNewer, shortGitHash, update, updateError, UpdateLogger } from "@utils/updater";
|
||||
import { Alerts, Button, Card, Parser, React, Toasts } from "@webpack/common";
|
||||
import { changes, checkForUpdates, getRepo, isNewer, update, updateError, UpdateLogger } from "@utils/updater";
|
||||
import { Alerts, Button, Parser, React, Toasts } from "@webpack/common";
|
||||
|
||||
import gitHash from "~git-hash";
|
||||
|
||||
import { Changes, HashLink } from "./Components";
|
||||
|
||||
function withDispatcher(dispatcher: React.Dispatch<React.SetStateAction<boolean>>, action: () => any) {
|
||||
return async () => {
|
||||
dispatcher(true);
|
||||
|
|
@ -80,31 +82,6 @@ interface CommonProps {
|
|||
repoPending: boolean;
|
||||
}
|
||||
|
||||
function HashLink({ repo, hash, disabled = false, longHash = hash }: { repo: string, hash: string, disabled?: boolean; longHash?: string; }) {
|
||||
return <Link href={`${repo}/commit/${longHash}`} disabled={disabled}>
|
||||
{hash}
|
||||
</Link>;
|
||||
}
|
||||
|
||||
function Changes({ updates, repo, repoPending }: CommonProps & { updates: typeof changes; }) {
|
||||
return (
|
||||
<Card style={{ padding: "0 0.5em" }}>
|
||||
{updates.map(({ hash, author, message }) => (
|
||||
<div key={hash} style={{
|
||||
marginTop: "0.5em",
|
||||
marginBottom: "0.5em"
|
||||
}}>
|
||||
<code><HashLink {...{ repo, hash }} disabled={repoPending} /></code>
|
||||
<span style={{
|
||||
marginLeft: "0.5em",
|
||||
color: "var(--text-default)"
|
||||
}}>{message} - {author}</span>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Updatable(props: CommonProps) {
|
||||
const [updates, setUpdates] = React.useState(changes);
|
||||
const [isChecking, setIsChecking] = React.useState(false);
|
||||
|
|
@ -235,7 +212,7 @@ function Updater() {
|
|||
</Link>
|
||||
)
|
||||
}
|
||||
{" "}(<HashLink hash={shortGitHash()} repo={repo} disabled={repoPending} longHash={gitHash} />)
|
||||
{" "}(<HashLink hash={gitHash} repo={repo} disabled={repoPending} />)
|
||||
</Paragraph>
|
||||
|
||||
<Divider className={Margins.top8 + " " + Margins.bottom8} />
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ export default definePlugin({
|
|||
find: 'tutorialId:"direct-messages"',
|
||||
replacement: [
|
||||
{
|
||||
match: /NAVIGATION_LINK\}\}\}.{0,15}\)(?=,\i&&\()/,
|
||||
match: /NAVIGATION_LINK\}\}\}\)(?=,\i(&&\(|\]))/,
|
||||
replace: "$&&&undefined",
|
||||
},
|
||||
{
|
||||
|
|
|
|||
230
src/equicordplugins/arRPCBun.web/index.tsx
Normal file
230
src/equicordplugins/arRPCBun.web/index.tsx
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2025 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { popNotice, showNotice } from "@api/Notices";
|
||||
import { definePluginSettings } from "@api/Settings";
|
||||
import { EquicordDevs } from "@utils/constants";
|
||||
import { Logger } from "@utils/Logger";
|
||||
import { isAnyPluginDev, isEquicordGuild } from "@utils/misc";
|
||||
import definePlugin, { OptionType, ReporterTestable } from "@utils/types";
|
||||
import { findByCodeLazy } from "@webpack";
|
||||
import { ApplicationAssetUtils, FluxDispatcher, Toasts, UserStore } from "@webpack/common";
|
||||
|
||||
const fetchApplicationsRPC = findByCodeLazy('"Invalid Origin"', ".application");
|
||||
const logger = new Logger("arRPCBun");
|
||||
|
||||
async function lookupAsset(applicationId: string, key: string): Promise<string> {
|
||||
return (await ApplicationAssetUtils.fetchAssetIds(applicationId, [key]))[0];
|
||||
}
|
||||
|
||||
const apps: any = {};
|
||||
async function lookupApp(applicationId: string): Promise<string> {
|
||||
const socket: any = {};
|
||||
await fetchApplicationsRPC(socket, applicationId);
|
||||
return socket.application;
|
||||
}
|
||||
|
||||
let ws: WebSocket;
|
||||
|
||||
export const settings = definePluginSettings({
|
||||
oldVerNotice: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "old version notice for why arrpc bun doesnt work",
|
||||
default: false,
|
||||
hidden: true
|
||||
},
|
||||
oneTimeNotice: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "One time notice for showing arrpc disabled",
|
||||
default: false,
|
||||
hidden: true
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
export default definePlugin({
|
||||
name: "arRPCBun",
|
||||
description: "arRPCBun integration",
|
||||
authors: [EquicordDevs.creations],
|
||||
reporterTestable: ReporterTestable.None,
|
||||
enabledByDefault: IS_EQUIBOP,
|
||||
settings,
|
||||
|
||||
commands: [
|
||||
{
|
||||
name: "arrpc-debug",
|
||||
description: "Show arRPCBun debug information",
|
||||
predicate: ctx => {
|
||||
const result = isAnyPluginDev(UserStore.getCurrentUser()?.id) || isEquicordGuild(ctx?.guild?.id, true);
|
||||
return result;
|
||||
},
|
||||
execute: () => {
|
||||
const arrpcStatus = IS_EQUIBOP ? VesktopNative.arrpc?.getStatus?.() : null;
|
||||
|
||||
let content = "";
|
||||
|
||||
if (IS_EQUIBOP) {
|
||||
const version = VesktopNative.app.getVersion();
|
||||
const gitHash = VesktopNative.app.getGitHash?.();
|
||||
const shortHash = gitHash?.slice(0, 7);
|
||||
|
||||
content += `Equibop: v${version}`;
|
||||
if (shortHash) {
|
||||
content += ` • [${shortHash}](<https://github.com/Equicord/Equibop/commit/${gitHash}>)`;
|
||||
}
|
||||
content += "\n";
|
||||
}
|
||||
|
||||
if (arrpcStatus) {
|
||||
content += `Running: ${arrpcStatus.running ? "Yes" : "No"}\n`;
|
||||
content += `Enabled: ${arrpcStatus.enabled ? "Yes" : "No"}\n`;
|
||||
|
||||
if (arrpcStatus.running) {
|
||||
content += `Port: ${arrpcStatus.port}\n`;
|
||||
content += `PID: ${arrpcStatus.pid}\n`;
|
||||
|
||||
if (arrpcStatus.uptime) {
|
||||
const seconds = Math.floor(arrpcStatus.uptime / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
|
||||
if (hours > 0) {
|
||||
content += `Uptime: ${hours}h ${minutes % 60}m ${seconds % 60}s\n`;
|
||||
} else if (minutes > 0) {
|
||||
content += `Uptime: ${minutes}m ${seconds % 60}s\n`;
|
||||
} else {
|
||||
content += `Uptime: ${seconds}s\n`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const info = [
|
||||
arrpcStatus.restartCount > 0 && ["Restarts", arrpcStatus.restartCount],
|
||||
arrpcStatus.bunPath && ["Bun", arrpcStatus.bunPath],
|
||||
arrpcStatus.warnings?.length && ["Warnings", arrpcStatus.warnings.join(", ")],
|
||||
arrpcStatus.lastError && ["Last Error", arrpcStatus.lastError],
|
||||
arrpcStatus.lastExitCode && arrpcStatus.lastExitCode !== 0 && ["Exit Code", arrpcStatus.lastExitCode]
|
||||
].filter(Boolean);
|
||||
|
||||
content += info.map(([type, value]) => `${type}: ${value}`).join("\n");
|
||||
} else {
|
||||
if (ws) {
|
||||
content += ws.readyState === WebSocket.OPEN
|
||||
? "WebSocket: Connected to external arRPCBun server\n"
|
||||
: `WebSocket: ${["Connecting", "Open", "Closing", "Closed"][ws.readyState]}\n`;
|
||||
} else {
|
||||
content += "WebSocket: Not connected\n";
|
||||
}
|
||||
}
|
||||
|
||||
return { content };
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
async handleEvent(e: MessageEvent<any>) {
|
||||
const data = JSON.parse(e.data);
|
||||
|
||||
const { activity } = data;
|
||||
const assets = activity?.assets;
|
||||
|
||||
if (assets?.large_image) assets.large_image = await lookupAsset(activity.application_id, assets.large_image);
|
||||
if (assets?.small_image) assets.small_image = await lookupAsset(activity.application_id, assets.small_image);
|
||||
|
||||
if (activity) {
|
||||
const appId = activity.application_id;
|
||||
apps[appId] ||= await lookupApp(appId);
|
||||
|
||||
const app = apps[appId];
|
||||
activity.name ||= app.name;
|
||||
}
|
||||
|
||||
FluxDispatcher.dispatch({ type: "LOCAL_ACTIVITY_UPDATE", ...data });
|
||||
},
|
||||
|
||||
async start() {
|
||||
// only works on 3.0.8+
|
||||
if (IS_EQUIBOP) {
|
||||
const version = VesktopNative.app.getVersion();
|
||||
const [major, minor, patch] = version.split(".").map(Number);
|
||||
|
||||
if (major < 3 || (major === 3 && minor === 0 && patch < 8) && !settings.store.oldVerNotice) {
|
||||
logger.error(`Equibop ${version} is too old. Requires 3.0.8+ for arRPCBun fix.`);
|
||||
showNotice(`arRPCBun requires Equibop 3.0.8+. You have ${version}. Update Equibop to use this plugin.`, "OK", () => {
|
||||
settings.store.oldVerNotice = true;
|
||||
popNotice();
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// disable WebRichPresence to avoid conflicts
|
||||
const webRPC = Vencord.Plugins.plugins.WebRichPresence;
|
||||
if (webRPC && Vencord.Plugins.isPluginEnabled("WebRichPresence")) {
|
||||
logger.info("Disabling WebRichPresence to avoid conflicts");
|
||||
Vencord.Plugins.stopPlugin(webRPC);
|
||||
}
|
||||
|
||||
// get arRPC status from Equibop if available, otherwise use defaults
|
||||
const arrpcStatus = IS_EQUIBOP ? VesktopNative.arrpc?.getStatus?.() : null;
|
||||
|
||||
// if on Equibop and arRPC is disabled AND not running, warn user
|
||||
if (IS_EQUIBOP && !arrpcStatus?.enabled && !arrpcStatus?.running && !settings.store.oneTimeNotice) {
|
||||
logger.warn("Equibop's built-in arRPC is disabled and not running");
|
||||
showNotice("arRPC is not running. Enable it in Equibop settings, or run your own arRPCBun server.", "OK", () => {
|
||||
settings.store.oneTimeNotice = true;
|
||||
popNotice();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const host = arrpcStatus?.host || "127.0.0.1";
|
||||
const port = arrpcStatus?.port || 1337;
|
||||
|
||||
const wsUrl = `ws://${host}:${port}`;
|
||||
logger.info(`Connecting to arRPCBun at ${wsUrl}${arrpcStatus?.host ? "" : " (using defaults)"}`);
|
||||
|
||||
if (ws) ws.close();
|
||||
ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.onmessage = this.handleEvent;
|
||||
|
||||
ws.onerror = error => {
|
||||
logger.error("WebSocket error:", error);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
logger.info("WebSocket closed");
|
||||
};
|
||||
|
||||
const connectionSuccessful = await new Promise(res => setTimeout(() => res(ws.readyState === WebSocket.OPEN), 5000));
|
||||
if (!connectionSuccessful) {
|
||||
logger.error("Failed to connect to arRPCBun");
|
||||
showNotice("Failed to connect to arRPCBun, is it running?", "Retry", () => {
|
||||
popNotice();
|
||||
this.start();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("Successfully connected to arRPCBun");
|
||||
Toasts.show({
|
||||
message: "Connected to arRPCBun",
|
||||
type: Toasts.Type.SUCCESS,
|
||||
id: Toasts.genId(),
|
||||
options: {
|
||||
duration: 1000,
|
||||
position: Toasts.Position.BOTTOM
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
stop() {
|
||||
FluxDispatcher.dispatch({ type: "LOCAL_ACTIVITY_UPDATE", activity: null });
|
||||
ws?.close();
|
||||
logger.info("Stopped arRPCBun connection");
|
||||
}
|
||||
});
|
||||
|
|
@ -75,7 +75,7 @@ async function readDirectoryEntry(entry: FileSystemDirectoryEntry): Promise<Reco
|
|||
|
||||
const readBatch = async (): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
reader.readEntries(async (entries) => {
|
||||
reader.readEntries(async entries => {
|
||||
if (entries.length === 0) {
|
||||
resolve();
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ function ReasonsComponent() {
|
|||
reasons.splice(i, 1);
|
||||
settings.store.reasons = reasons;
|
||||
}}
|
||||
look={Button.Looks.LINK}
|
||||
look={Button.Looks.FILLED}
|
||||
size={Button.Sizes.MIN}
|
||||
>
|
||||
<DeleteIcon />
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ export function EditModal({ modalProps, modalKey, bookmark, onSave }: {
|
|||
>Save</Button>
|
||||
<Button
|
||||
color={Button.Colors.TRANSPARENT}
|
||||
look={Button.Looks.LINK}
|
||||
look={Button.Looks.FILLED}
|
||||
onClick={() => closeModal(modalKey)}
|
||||
>Cancel</Button>
|
||||
</ModalFooter>
|
||||
|
|
@ -128,7 +128,7 @@ function AddToFolderModal({ modalProps, modalKey, bookmarks, onSave }: {
|
|||
>Save</Button>
|
||||
<Button
|
||||
color={Button.Colors.TRANSPARENT}
|
||||
look={Button.Looks.LINK}
|
||||
look={Button.Looks.FILLED}
|
||||
onClick={() => closeModal(modalKey)}
|
||||
>Cancel</Button>
|
||||
</ModalFooter>
|
||||
|
|
@ -156,7 +156,7 @@ function DeleteFolderConfirmationModal({ modalProps, modalKey, onConfirm }) {
|
|||
</Button>
|
||||
<Button
|
||||
color={Button.Colors.TRANSPARENT}
|
||||
look={Button.Looks.LINK}
|
||||
look={Button.Looks.FILLED}
|
||||
onClick={() => closeModal(modalKey)}
|
||||
>
|
||||
Cancel
|
||||
|
|
|
|||
|
|
@ -192,7 +192,7 @@ function ButtonEntries() {
|
|||
/>
|
||||
<Button
|
||||
onClick={() => removeButtonEntry(entry.id, update)}
|
||||
look={Button.Looks.LINK}
|
||||
look={Button.Looks.FILLED}
|
||||
color={Button.Colors.RED}
|
||||
size={Button.Sizes.SMALL}
|
||||
>
|
||||
|
|
@ -245,7 +245,7 @@ function ButtonEntries() {
|
|||
<Button onClick={() => addButtonEntry(update)}>Add Button</Button>
|
||||
<Button
|
||||
onClick={() => resetAllButtons(update)}
|
||||
look={Button.Looks.LINK}
|
||||
look={Button.Looks.FILLED}
|
||||
color={Button.Colors.RED}
|
||||
>
|
||||
Reset All
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@ function FlaggedInput({ index, forceUpdate }) {
|
|||
|
||||
<Button
|
||||
onClick={removeSelf}
|
||||
look={Button.Looks.LINK}
|
||||
look={Button.Looks.FILLED}
|
||||
size={Button.Sizes.SMALL}
|
||||
style={{
|
||||
padding: 0,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { shortGitHash } from "@utils/updater";
|
||||
import { gitHashShort } from "@shared/vencordUserAgent";
|
||||
import { React } from "@webpack/common";
|
||||
import SettingsPlugin from "plugins/_core/settings";
|
||||
import { JSX } from "react";
|
||||
|
|
@ -26,7 +26,7 @@ export function makeDevBanner(state?: string): string | JSX.Element {
|
|||
.replace(/{buildHash}/g, VERSION_HASH.slice(0, 9))
|
||||
.replace(/{equicordName}/g, "Equicord")
|
||||
.replace(/{equicordVersion}/g, VERSION)
|
||||
.replace(/{equicordHash}/g, shortGitHash())
|
||||
.replace(/{equicordHash}/g, gitHashShort)
|
||||
.replace(/{equicordPlatform}/g, getVersionInfo(false))
|
||||
.replace(/{electronName}/g, "Electron")
|
||||
.replace(/{electronVersion}/g, electronVersion)
|
||||
|
|
|
|||
|
|
@ -1,104 +0,0 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2025 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import "./style.css";
|
||||
|
||||
import { EquicordDevs } from "@utils/constants";
|
||||
import definePlugin from "@utils/types";
|
||||
|
||||
import { settings } from "./settings";
|
||||
import { GDMContextMenu, GuildContextMenu, handleExpandedModalDownloadButtonClicked, handleHoverDownloadButtonClicked, MessageContextMenu, UserContextMenu, VoiceMessageDownloadButton } from "./utils/handlers";
|
||||
|
||||
export default definePlugin({
|
||||
name: "Downloadify",
|
||||
description: "Download various assets directly in Discord without having to open a browser or dig through HTML.",
|
||||
authors: [EquicordDevs.Etorix],
|
||||
settings,
|
||||
|
||||
VoiceMessageDownloadButton,
|
||||
handleHoverDownloadButtonClicked,
|
||||
handleExpandedModalDownloadButtonClicked,
|
||||
|
||||
contextMenus: {
|
||||
"message": MessageContextMenu,
|
||||
"guild-context": GuildContextMenu,
|
||||
"user-context": UserContextMenu,
|
||||
"gdm-context": GDMContextMenu
|
||||
},
|
||||
|
||||
patches: [
|
||||
{
|
||||
// Pass the guild ID to the profile modal context. Used by the next patch.
|
||||
find: "clanTagChiplet}),",
|
||||
replacement: {
|
||||
match: /("right",)(avatarUrl:null)/,
|
||||
replace: "$1guildId:arguments[0]?.channel?.guild_id,$2"
|
||||
}
|
||||
},
|
||||
{
|
||||
// Pass the guild ID to the profile modal renderer. Allows guild specific profiles
|
||||
// to load on the initial click instead of having to expand the profile first.
|
||||
// Needed specifically for member banners.
|
||||
find: '["children","userId","user"]',
|
||||
replacement: {
|
||||
match: /(\),{)(user:\i,currentUser:\i,children)/,
|
||||
replace: "$1guildId:arguments[0].guildId,$2"
|
||||
}
|
||||
},
|
||||
{
|
||||
// Adds a download button to voice messages before the volume slider.
|
||||
find: "volumeSlider,muted",
|
||||
replacement: {
|
||||
match: /(}\),)(\(0,\i.\i\)\(\i.\i,{className:\i.volumeButton)/,
|
||||
replace: "$1$self.VoiceMessageDownloadButton(arguments[0]),$2"
|
||||
}
|
||||
},
|
||||
{
|
||||
// Hides the inline download button on text files in
|
||||
// favor of the hover download button enabled below.
|
||||
find: "formattedSize),children",
|
||||
replacement: {
|
||||
match: /(\(0,\i.\i\)\(\i.\i,{text:"".concat\(\i.intl.string)/,
|
||||
replace: "false&&$1"
|
||||
}
|
||||
},
|
||||
{
|
||||
// Passes on the file information to be used by the hover download buttons.
|
||||
find: "downloadUrl,showDownload",
|
||||
replacement: {
|
||||
match: /(showImageAppPicker:\i&&\i)/,
|
||||
replace: "item:arguments[0].item,$1"
|
||||
}
|
||||
},
|
||||
{
|
||||
// Forces the hover download button to always be visible on supported media.
|
||||
// Also overwrites the onClick function to use the custom download handling.
|
||||
find: "downloadHoverButtonIcon,focusProps:{",
|
||||
replacement: {
|
||||
match: /((\i)=>{)(.{0,60}?)showDownload:(\i),(.{0,1250}?)href:\i,/,
|
||||
replace: "$1const downloadifyHoverItem=$2;$3downloadifyShowDownload:$4=!0,$5onClick:()=>{$self.handleHoverDownloadButtonClicked(downloadifyHoverItem);},"
|
||||
},
|
||||
},
|
||||
{
|
||||
// Overwrites the default download button behavior for expanded image & video modals.
|
||||
// This patch is lazy loaded. You must open an image or video modal for it to resolve.
|
||||
find: "SAVE_MEDIA_PRESSED),",
|
||||
group: true,
|
||||
replacement: [
|
||||
{
|
||||
// Make use of the download function.
|
||||
match: /(let{item:\i}=(\i).{0,450}?)(\i\(!0\);try{.{0,400}?finally{\i\(!1\)})/,
|
||||
replace: "$1await $self.handleExpandedModalDownloadButtonClicked($2,async()=>{$3});",
|
||||
},
|
||||
{
|
||||
// Prevent videos from opening in browser.
|
||||
match: /(let{item:\i}=\i.{0,350}?)(SAVE_MEDIA_PRESSED\)).{0,100}?type\){/,
|
||||
replace: "$1$2,true){"
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
});
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2025 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { dialog } from "electron";
|
||||
import fs from "fs";
|
||||
import { Readable } from "stream";
|
||||
import { finished } from "stream/promises";
|
||||
|
||||
/**
|
||||
* Check if a file exists at a given path.
|
||||
*/
|
||||
export async function fileExists(_: Electron.IpcMainInvokeEvent, filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.promises.access(filePath, fs.constants.F_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the user to select a directory to save files.
|
||||
*/
|
||||
export async function getDirectory(_: Electron.IpcMainInvokeEvent): Promise<string | null> {
|
||||
const result = await dialog.showOpenDialog({
|
||||
properties: ["openDirectory"]
|
||||
});
|
||||
|
||||
return result.filePaths[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the user to select a file name and location to save a file.
|
||||
*/
|
||||
export async function getFilePath(_: Electron.IpcMainInvokeEvent, fileName: string, fileTypes: string[] | null): Promise<string | null> {
|
||||
const filters = fileTypes?.map(ext => ({
|
||||
name: ext.toUpperCase(),
|
||||
extensions: [ext]
|
||||
})) ?? [];
|
||||
|
||||
if (!fileTypes?.length) {
|
||||
filters.push({ name: "All Files", extensions: ["*"] });
|
||||
}
|
||||
|
||||
const result = await dialog.showSaveDialog({
|
||||
title: "Save File",
|
||||
defaultPath: fileName,
|
||||
buttonLabel: "Save",
|
||||
properties: ["createDirectory"],
|
||||
filters
|
||||
});
|
||||
|
||||
return result.filePath ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the content type of a file at a URL.
|
||||
*
|
||||
* @returns The content type of the file if it exists, undefined if no content type is available, or null if the file doesn't exist.
|
||||
*/
|
||||
export async function queryURL(_: Electron.IpcMainInvokeEvent, url: string): Promise<string | null | undefined> {
|
||||
const response = await fetch(url, { method: "HEAD" });
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return response.headers.get("content-type") ?? undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a file from a URL to a local file path.
|
||||
*/
|
||||
export async function downloadURL(_: Electron.IpcMainInvokeEvent, url: string, filePath: string): Promise<boolean> {
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const readableStream = Readable.fromWeb(response.body as any);
|
||||
const fileWriteStream = fs.createWriteStream(filePath);
|
||||
readableStream.pipe(fileWriteStream);
|
||||
await finished(fileWriteStream);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
|
@ -1,111 +0,0 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2025 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import "./style.css";
|
||||
|
||||
import { definePluginSettings } from "@api/Settings";
|
||||
import ErrorBoundary from "@components/ErrorBoundary";
|
||||
import { Heading } from "@components/Heading";
|
||||
import { Paragraph } from "@components/Paragraph";
|
||||
import { OptionType } from "@utils/types";
|
||||
import { Button, showToast, Toasts, useState } from "@webpack/common";
|
||||
import { JSX } from "react";
|
||||
|
||||
import { d, DownloadifyLogger, DownloadifyNative } from "./utils/definitions";
|
||||
import { getFormattedNow } from "./utils/misc";
|
||||
|
||||
function DefaultDirectorySetting(): JSX.Element {
|
||||
const { defaultDirectory } = settings.use(["defaultDirectory"]);
|
||||
const [isDialogueOpen, setDialogueOpen] = useState(false);
|
||||
|
||||
const handlePickDirectory = async () => {
|
||||
try {
|
||||
setDialogueOpen(true);
|
||||
const directory = await DownloadifyNative.getDirectory();
|
||||
directory && (settings.store.defaultDirectory = directory);
|
||||
} catch (error) {
|
||||
DownloadifyLogger.error(`[${getFormattedNow()}] [FAILED TO SET DOWNLOAD DIRECTORY]`, error);
|
||||
showToast("Failed to set download directory.", Toasts.Type.FAILURE, { duration: 3000 });
|
||||
} finally {
|
||||
setDialogueOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearDirectory = () => {
|
||||
settings.store.defaultDirectory = "";
|
||||
};
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<section>
|
||||
<Heading className={d("form-title")}>
|
||||
Default Directory
|
||||
</Heading>
|
||||
<Paragraph className={d("form-description")}>
|
||||
Default download location. If set, the file will always be downloaded in its original format even if alternatives are available. Leave empty to pick a folder and file type each time.
|
||||
</Paragraph>
|
||||
<div className={d("directory-container")}>
|
||||
<Paragraph className={d("directory-display")}>
|
||||
{defaultDirectory || "No Directory Set"}
|
||||
</Paragraph>
|
||||
<div className={d("directory-buttons")}>
|
||||
<Button
|
||||
disabled={isDialogueOpen}
|
||||
className={d("directory-button", "browse-button", { "disabled": isDialogueOpen })}
|
||||
onClick={handlePickDirectory}
|
||||
color={Button.Colors.CUSTOM}
|
||||
>
|
||||
{isDialogueOpen ? "Browsing..." : "Browse"}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!defaultDirectory || isDialogueOpen}
|
||||
className={d("directory-button", "clear-button", { "disabled": !defaultDirectory || isDialogueOpen })}
|
||||
onClick={handleClearDirectory}
|
||||
color={Button.Colors.CUSTOM}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
export const settings = definePluginSettings({
|
||||
displayStatus: {
|
||||
description: "Display a status notification when downloads start, finish, or error.",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: true,
|
||||
},
|
||||
statusDuration: {
|
||||
type: OptionType.SLIDER,
|
||||
description: "The number of seconds to display status notifications.",
|
||||
markers: [1, 3, 5],
|
||||
default: 2.5,
|
||||
stickToMarkers: false,
|
||||
},
|
||||
voiceMessages: {
|
||||
description: "Add a download button to voice messages.",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: true,
|
||||
},
|
||||
allowUnicode: {
|
||||
description: "Allow non-ASCII characters in file names.",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: true,
|
||||
},
|
||||
overwriteFiles: {
|
||||
description: "If a default directory is set and a download file name matches an existing file, overwrite the file. If disabled, a number will be appended to the file name.",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: false,
|
||||
},
|
||||
defaultDirectory: {
|
||||
component: DefaultDirectorySetting,
|
||||
type: OptionType.COMPONENT,
|
||||
default: "",
|
||||
},
|
||||
});
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
.downloadify-voice-message-container {
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
position: relative;
|
||||
margin: 0 -2px;
|
||||
}
|
||||
|
||||
.downloadify-voice-message-button {
|
||||
cursor: pointer;
|
||||
line-height: 0;
|
||||
border: none;
|
||||
width: auto;
|
||||
background: transparent;
|
||||
color: var(--interactive-normal);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
align-items: center;
|
||||
border-radius: 8px;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
font-size: 14px;
|
||||
font-weight: var(--font-weight-medium);
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
transition-duration: .2s;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.downloadify-voice-message-button:hover {
|
||||
color: var(--interactive-hover);
|
||||
}
|
||||
|
||||
.downloadify-directory-container {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.downloadify-directory-buttons {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.downloadify-directory-button {
|
||||
border-radius: 4px;
|
||||
min-height: 34px;
|
||||
color: white;
|
||||
height: 34px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.downloadify-directory-button:not(.downloadify-disabled):hover {
|
||||
filter: brightness(0.75);
|
||||
transition: filter 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
.downloadify-clear-button {
|
||||
background-color: rgb(180 71 78);
|
||||
}
|
||||
|
||||
.downloadify-browse-button {
|
||||
background-color: rgb(87 100 240);
|
||||
}
|
||||
|
||||
.downloadify-directory-display {
|
||||
background-color: var(--background-base-lower);
|
||||
overflow-wrap: break-word;
|
||||
font-family: monospace;
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
border-radius: 4px;
|
||||
user-select: text;
|
||||
font-size: 14px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
|
@ -1,371 +0,0 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2025 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { classNameFactory } from "@api/Styles";
|
||||
import { Logger } from "@utils/Logger";
|
||||
import { PluginNative } from "@utils/types";
|
||||
import { Channel, Guild, GuildMember, Message, User } from "@vencord/discord-types";
|
||||
import { findByPropsLazy } from "@webpack";
|
||||
import { JSX } from "react";
|
||||
|
||||
export const d = classNameFactory("downloadify-");
|
||||
export const CDN_BASE = "https://cdn.discordapp.com";
|
||||
export const MEDIA_PROXY_BASE = "https://media.discordapp.net";
|
||||
export const PRIMARY_DOMAIN_BASE = "https://discord.com";
|
||||
export const IMAGE_EXT_1_DOMAIN_BASE = "https://images-ext-1.discordapp.net";
|
||||
export const IMAGE_EXT_2_DOMAIN_BASE = "https://images-ext-2.discordapp.net";
|
||||
export const TENOR_BASE = "https://c.tenor.com";
|
||||
export const TENOR_GIF_ID = "Ad";
|
||||
export const TENOR_MP4_ID = "Po";
|
||||
export const ASSET_TYPE_EXTRACTOR = new RegExp(/^https:\/\/(?:cdn\.discordapp\.com|media.discordapp.net)\/([a-z-]+(?:\/[a-z-]+){0,2})(?:(?:\/\d+\/[a-z-]+\/\d+\/([a-z-]+))|(?:\/([a-z-]+(?:\/[a-z-_]+)?)))?/i);
|
||||
export const DownloadifyLogger = new Logger("Downloadify");
|
||||
export const DownloadifyNative = VencordNative.pluginHelpers.Downloadify as PluginNative<typeof import("../native")>;
|
||||
export const defaultAssets = findByPropsLazy("DEFAULT_GROUP_DM_AVATARS") as DefaultAssets;
|
||||
|
||||
interface DefaultAssets {
|
||||
BOT_AVATARS: Record<string, string>;
|
||||
DEFAULT_AVATARS: string[];
|
||||
DEFAULT_CHANNEL_ICON: string;
|
||||
DEFAULT_GROUP_DM_AVATARS: string[];
|
||||
DEFAULT_PROVISIONAL_AVATARS: string[];
|
||||
}
|
||||
|
||||
export interface AssetInfo {
|
||||
source: AssetSource;
|
||||
static: string[];
|
||||
animated: string[];
|
||||
type?: string;
|
||||
forceAnimated?: boolean;
|
||||
}
|
||||
|
||||
export enum AssetSource {
|
||||
CDN = "cdn",
|
||||
EXTERNAL_IMAGE_PROXY = "external_image_proxy",
|
||||
ATTACHMENT_MEDIA_PROXY = "attachment_media_proxy",
|
||||
ASSET_MEDIA_PROXY = "asset_media_proxy",
|
||||
PRIMARY_DOMAIN = "primary_domain",
|
||||
EXTERNAL = "external",
|
||||
TENOR = "tenor"
|
||||
}
|
||||
|
||||
export const unknownExternal = {
|
||||
source: AssetSource.EXTERNAL,
|
||||
static: [],
|
||||
animated: []
|
||||
};
|
||||
|
||||
export const unknownPrimaryDomain = {
|
||||
source: AssetSource.PRIMARY_DOMAIN,
|
||||
static: [],
|
||||
animated: []
|
||||
};
|
||||
|
||||
export const unknownCDN = {
|
||||
source: AssetSource.CDN,
|
||||
static: [],
|
||||
animated: []
|
||||
};
|
||||
|
||||
export const unknownAssetMediaProxy = {
|
||||
source: AssetSource.ASSET_MEDIA_PROXY,
|
||||
static: [],
|
||||
animated: []
|
||||
};
|
||||
|
||||
export const unknownAttachmentMediaProxy = {
|
||||
source: AssetSource.ATTACHMENT_MEDIA_PROXY,
|
||||
static: [],
|
||||
animated: []
|
||||
};
|
||||
|
||||
export const unknownExternalImageProxy = {
|
||||
source: AssetSource.EXTERNAL_IMAGE_PROXY,
|
||||
static: [],
|
||||
animated: []
|
||||
};
|
||||
|
||||
export const defaultResourceAvailability = {
|
||||
source: AssetSource.PRIMARY_DOMAIN,
|
||||
static: ["png"],
|
||||
animated: []
|
||||
};
|
||||
|
||||
export const APNGAvailability = {
|
||||
source: AssetSource.ASSET_MEDIA_PROXY,
|
||||
static: ["png", "webp", "jpg"],
|
||||
animated: ["apng", "png", "webp", "jpg"]
|
||||
};
|
||||
|
||||
export const hashableAvailability = {
|
||||
source: AssetSource.ASSET_MEDIA_PROXY,
|
||||
static: ["png", "webp", "jpg"],
|
||||
animated: ["gif", "awebp", "png", "webp", "jpg"]
|
||||
};
|
||||
|
||||
// Defines known asset type availability. Unknown types which may be
|
||||
// encountered are AssetSource.EXTERNAL which are present in some embeds,
|
||||
// as well as OTHER attachment types.
|
||||
export const assetAvailability = {
|
||||
"user-avatar": hashableAvailability,
|
||||
"user-banner": hashableAvailability,
|
||||
"avatar-decoration": APNGAvailability,
|
||||
"clan-badge": {
|
||||
source: AssetSource.ASSET_MEDIA_PROXY,
|
||||
static: ["png"],
|
||||
animated: []
|
||||
},
|
||||
"nameplate": {
|
||||
source: AssetSource.CDN,
|
||||
static: ["png"],
|
||||
animated: ["webm", "apng", "png"],
|
||||
type: "nameplate"
|
||||
},
|
||||
"default-user-avatar": defaultResourceAvailability,
|
||||
"default-group-icon": defaultResourceAvailability,
|
||||
"role-icon-custom": {
|
||||
source: AssetSource.ASSET_MEDIA_PROXY,
|
||||
static: ["png", "webp", "jpg"],
|
||||
animated: [],
|
||||
},
|
||||
"role-icon-unicode-emoji": {
|
||||
source: AssetSource.PRIMARY_DOMAIN,
|
||||
static: ["svg"],
|
||||
animated: [],
|
||||
},
|
||||
"guild-icon": hashableAvailability,
|
||||
"guild-banner": hashableAvailability,
|
||||
"guild-invite-splash": hashableAvailability,
|
||||
"guild-discovery-splash": hashableAvailability,
|
||||
"custom-emoji": hashableAvailability,
|
||||
"unicode-emoji": {
|
||||
source: AssetSource.PRIMARY_DOMAIN,
|
||||
static: ["svg"],
|
||||
animated: []
|
||||
},
|
||||
"sticker": {
|
||||
"APNG": APNGAvailability,
|
||||
"PNG": {
|
||||
source: AssetSource.ASSET_MEDIA_PROXY,
|
||||
static: ["png", "webp", "jpg"],
|
||||
animated: []
|
||||
},
|
||||
"GIF": {
|
||||
source: AssetSource.ASSET_MEDIA_PROXY,
|
||||
static: [],
|
||||
animated: ["gif", "awebp", "png", "webp", "jpg"],
|
||||
forceAnimated: true
|
||||
},
|
||||
"LOTTIE": {
|
||||
source: AssetSource.CDN,
|
||||
static: ["json"],
|
||||
animated: ["json"]
|
||||
},
|
||||
},
|
||||
"tenor": {
|
||||
source: AssetSource.TENOR,
|
||||
static: [],
|
||||
animated: ["gif", "mp4"],
|
||||
forceAnimated: true
|
||||
},
|
||||
"external": {
|
||||
"image/png": {
|
||||
source: AssetSource.EXTERNAL_IMAGE_PROXY,
|
||||
static: ["png", "webp", "jpg"],
|
||||
animated: []
|
||||
},
|
||||
"image/webp": {
|
||||
source: AssetSource.EXTERNAL_IMAGE_PROXY,
|
||||
static: ["png", "webp", "jpg"],
|
||||
animated: []
|
||||
},
|
||||
"image/jpeg": {
|
||||
source: AssetSource.EXTERNAL_IMAGE_PROXY,
|
||||
static: ["png", "webp", "jpg"],
|
||||
animated: []
|
||||
},
|
||||
"video/mp4": {
|
||||
source: AssetSource.EXTERNAL,
|
||||
static: [],
|
||||
animated: ["mp4"],
|
||||
forceAnimated: true
|
||||
},
|
||||
},
|
||||
"attachment": {
|
||||
// Image attachments over a certain size and/or resolution are treated as
|
||||
// non-image files and are therefore not available in alternative formats.
|
||||
// This is designated by an attachment type of OTHER instead of IMAGE.
|
||||
"image/png": {
|
||||
source: AssetSource.ATTACHMENT_MEDIA_PROXY,
|
||||
static: ["png", "webp", "jpg"],
|
||||
// APNG attachments are technically supported, but appear to get
|
||||
// broken by Discord squashing them into static PNGs. APNG is kept
|
||||
// here in case it is fixed some day.
|
||||
animated: ["apng", "png", "webp", "jpg"]
|
||||
},
|
||||
"image/webp": {
|
||||
source: AssetSource.ATTACHMENT_MEDIA_PROXY,
|
||||
static: ["webp", "png", "jpg"],
|
||||
animated: ["awebp", "webp", "png", "jpg"]
|
||||
},
|
||||
"image/avif": {
|
||||
source: AssetSource.ATTACHMENT_MEDIA_PROXY,
|
||||
static: ["avif", "png", "webp"],
|
||||
animated: ["avif", "awebp", "png", "webp"]
|
||||
},
|
||||
"image/jpeg": {
|
||||
source: AssetSource.ATTACHMENT_MEDIA_PROXY,
|
||||
static: ["jpg", "png", "webp"],
|
||||
animated: []
|
||||
},
|
||||
"image/gif": {
|
||||
source: AssetSource.ATTACHMENT_MEDIA_PROXY,
|
||||
static: [],
|
||||
animated: ["gif", "awebp", "png", "webp", "jpg"],
|
||||
forceAnimated: true
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
export function DownloadIcon({ width, height }: { width: number; height: number; }): JSX.Element {
|
||||
return (
|
||||
<svg
|
||||
className={d("-download-icon")}
|
||||
role="img"
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M12 2a1 1 0 0 1 1 1v10.59l3.3-3.3a1 1 0 1 1 1.4 1.42l-5 5a1 1 0 0 1-1.4 0l-5-5a1 1 0 1 1 1.4-1.42l3.3 3.3V3a1 1 0 0 1 1-1ZM3 20a1 1 0 1 0 0 2h18a1 1 0 1 0 0-2H3Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ParsedFile {
|
||||
path: string;
|
||||
baseName: string;
|
||||
extension: string | null;
|
||||
twitterExtra: string | null;
|
||||
}
|
||||
|
||||
export interface ParsedURL {
|
||||
url: string;
|
||||
host: string;
|
||||
path: string;
|
||||
baseName: string;
|
||||
extension: string | null;
|
||||
params: { expiry: Record<string, string>, other: Record<string, string>; };
|
||||
twitterExtra: string | null;
|
||||
}
|
||||
|
||||
export interface InsertGroup {
|
||||
id: { group?: string, child?: string; };
|
||||
type: "WITH_GROUP" | "BEFORE_GROUP" | "AFTER_GROUP";
|
||||
position?: "START" | "END";
|
||||
}
|
||||
|
||||
export interface VoiceMessageDownloadButtonProps {
|
||||
message: Message;
|
||||
item: {
|
||||
type: "AUDIO";
|
||||
originalItem: {
|
||||
filename: string;
|
||||
proxy_url: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface HoverDownloadProps {
|
||||
item: {
|
||||
type: "AUDIO" | "IMAGE" | "VIDEO" | "OTHER";
|
||||
downloadUrl: string;
|
||||
contentType: string;
|
||||
srcIsAnimated: boolean;
|
||||
originalItem: {
|
||||
title?: string;
|
||||
filename: string;
|
||||
proxy_url: string;
|
||||
url: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface ExpandedModalDownloadProps {
|
||||
item: {
|
||||
type: "AUDIO" | "IMAGE" | "VIDEO" | "OTHER";
|
||||
url: string;
|
||||
original: string;
|
||||
proxyUrl?: string;
|
||||
contentType?: string;
|
||||
srcIsAnimated: boolean;
|
||||
sourceMetadata?: {
|
||||
identifier: {
|
||||
type: "attachment" | "embed";
|
||||
title?: string;
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface MessageContextMenuProps {
|
||||
message: Message;
|
||||
channel: Channel;
|
||||
itemSrc: string;
|
||||
itemSafeSrc?: string;
|
||||
favoriteableType: "sticker" | "emoji" | null;
|
||||
favoriteableName: string | null;
|
||||
favoriteableId: string | null;
|
||||
contextMenuAPIArguments: any[];
|
||||
mediaItem?: {
|
||||
contentType: string;
|
||||
proxyUrl: string;
|
||||
url: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface GDMContextMenuProps {
|
||||
channel: Channel & {
|
||||
icon: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface GuildContextMenuProps {
|
||||
guild: Guild;
|
||||
}
|
||||
|
||||
export interface UserContextMenuProps {
|
||||
user: User;
|
||||
guildId?: string;
|
||||
}
|
||||
|
||||
export interface CustomizedUser extends User {
|
||||
avatarDecorationData: null | {
|
||||
asset: string;
|
||||
};
|
||||
collectibles: null | Record<"nameplate", {
|
||||
asset: string;
|
||||
label: string;
|
||||
palette: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface CustomizedMember extends Omit<GuildMember, "avatarDecoration"> {
|
||||
avatarDecoration: null | {
|
||||
asset: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const AttachmentFlags = {
|
||||
IS_CLIP: 1 << 0,
|
||||
IS_THUMBNAIL: 1 << 1,
|
||||
IS_REMIX: 1 << 2,
|
||||
IS_SPOILER: 1 << 3,
|
||||
CONTAINS_EXPLICIT_MEDIA: 1 << 4,
|
||||
IS_ANIMATED: 1 << 5,
|
||||
CONTAINS_GORE_CONTENT: 1 << 6
|
||||
};
|
||||
|
|
@ -1,999 +0,0 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2025 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { ImageIcon } from "@components/Icons";
|
||||
import { StickerFormatType } from "@vencord/discord-types/enums";
|
||||
import { GuildMemberStore, GuildRoleStore, GuildStore, Menu, showToast, Toasts, UserProfileStore, UserStore } from "@webpack/common";
|
||||
import { JSX } from "react";
|
||||
|
||||
import { settings } from "../settings";
|
||||
import { ASSET_TYPE_EXTRACTOR, assetAvailability, AssetInfo, AssetSource, AttachmentFlags, CDN_BASE, CustomizedMember, CustomizedUser, d, defaultAssets, DownloadIcon, DownloadifyLogger, DownloadifyNative, ExpandedModalDownloadProps, GDMContextMenuProps, GuildContextMenuProps, HoverDownloadProps, IMAGE_EXT_1_DOMAIN_BASE, IMAGE_EXT_2_DOMAIN_BASE, MEDIA_PROXY_BASE, MessageContextMenuProps, ParsedURL, PRIMARY_DOMAIN_BASE, TENOR_BASE, TENOR_GIF_ID, TENOR_MP4_ID, unknownAttachmentMediaProxy, unknownCDN, unknownExternal, unknownExternalImageProxy, unknownPrimaryDomain, UserContextMenuProps, VoiceMessageDownloadButtonProps } from "./definitions";
|
||||
import { getFormattedNow, joinOrCreateContextMenuGroup, parseFile, parseURL, sanitizeFilename } from "./misc";
|
||||
|
||||
export function MessageContextMenu(children: Array<any>, props: MessageContextMenuProps): void {
|
||||
if (!children?.length || !props?.message?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { allowUnicode } = settings.store;
|
||||
let { favoriteableId, favoriteableName, favoriteableType } = props;
|
||||
const { itemSrc, itemSafeSrc, message, channel, mediaItem, contextMenuAPIArguments } = props;
|
||||
const target = contextMenuAPIArguments?.[0]?.target;
|
||||
let targetURL = itemSafeSrc ?? itemSrc;
|
||||
|
||||
const emojiTarget = target.dataset?.type === "emoji"
|
||||
? target
|
||||
: target.children?.[1]?.firstChild?.dataset?.type === "emoji"
|
||||
? target.children[1].firstChild
|
||||
: target.parentElement?.children?.[1]?.firstChild?.dataset?.type === "emoji"
|
||||
? target.parentElement.children[1].firstChild
|
||||
: null;
|
||||
|
||||
if (!!emojiTarget?.dataset?.id) {
|
||||
favoriteableType = "emoji";
|
||||
favoriteableId = emojiTarget.dataset.id;
|
||||
targetURL = emojiTarget.src;
|
||||
} else if (!!emojiTarget?.dataset?.name) {
|
||||
favoriteableType = "emoji";
|
||||
favoriteableName = emojiTarget.alt ?? emojiTarget.dataset.name;
|
||||
favoriteableId = null;
|
||||
targetURL = emojiTarget.src;
|
||||
}
|
||||
|
||||
const roleIconTarget = target.classList && (Array.from(target.classList) as string[]).some(cls => cls.includes("roleIcon"))
|
||||
? target
|
||||
: target.firstChild?.classList && (Array.from(target.firstChild.classList) as string[]).some(cls => cls.includes("roleIcon"))
|
||||
? target.firstChild
|
||||
: null;
|
||||
|
||||
const clanBadgeTarget = target.classList && (Array.from(target.classList) as string[]).some(cls => cls.includes("badge"))
|
||||
? target
|
||||
: target.parentElement?.firstChild?.classList && (Array.from(target.parentElement.firstChild.classList) as string[]).some(cls => cls.includes("badge"))
|
||||
? target.parentElement.firstChild
|
||||
: target.firstChild?.firstChild?.classList && (Array.from(target.firstChild.firstChild.classList) as string[]).some(cls => cls.includes("badge"))
|
||||
? target.firstChild.firstChild
|
||||
: null;
|
||||
|
||||
if (target.tagName === "VIDEO" && !!target.src) {
|
||||
targetURL = contextMenuAPIArguments[0].target.src;
|
||||
} else if (target.parentElement?.parentElement?.firstChild?.tagName === "VIDEO" && !!target.parentElement?.parentElement?.firstChild?.src) {
|
||||
targetURL = contextMenuAPIArguments[0].target.parentElement.parentElement.firstChild.src;
|
||||
} else if (target.parentElement?.parentElement?.parentElement?.firstChild?.tagName === "VIDEO" && !!target.parentElement?.parentElement?.parentElement?.firstChild?.src) {
|
||||
targetURL = contextMenuAPIArguments[0].target.parentElement.parentElement.parentElement.firstChild.src;
|
||||
} else if (target.parentElement?.parentElement?.parentElement?.parentElement?.parentElement?.firstChild?.tagName === "VIDEO" && !!target.parentElement?.parentElement?.parentElement?.parentElement?.parentElement?.firstChild?.src) {
|
||||
targetURL = contextMenuAPIArguments[0].target.parentElement.parentElement.parentElement.parentElement.parentElement.firstChild.src;
|
||||
}
|
||||
|
||||
const isRoleIcon = !!roleIconTarget;
|
||||
const isCustomEmoji = favoriteableType === "emoji" && !!favoriteableId;
|
||||
const isUnicodeEmoji = favoriteableType === "emoji" && !favoriteableId;
|
||||
const isSticker = favoriteableType === "sticker";
|
||||
const isAttachment = !!mediaItem;
|
||||
const emojiRegex = favoriteableId ? new RegExp(`<(a)?:(\\w+):(${favoriteableId})>`) : null;
|
||||
let emojiName = isUnicodeEmoji ? (favoriteableName?.replaceAll(":", "") || null) : (favoriteableName ?? (emojiRegex?.exec(message.content || "")?.[2] || null));
|
||||
let emojiAnimated = isCustomEmoji ? emojiRegex?.exec(message.content || "")?.[1] === "a" : false;
|
||||
const emojiURL = (isCustomEmoji || isUnicodeEmoji) ? parseURL(targetURL.replace(CDN_BASE, MEDIA_PROXY_BASE)) : null;
|
||||
const sticker = isSticker ? message.stickerItems?.find(sticker => sticker.id === favoriteableId) : null;
|
||||
const downloadifyItems: any[] = [];
|
||||
|
||||
function findNestedComponent(components: any[], checkFor: string): any {
|
||||
for (const innerComponent of components) {
|
||||
if (innerComponent.emoji?.id === checkFor || innerComponent.emoji?.name === checkFor) {
|
||||
return innerComponent;
|
||||
}
|
||||
|
||||
if (innerComponent.components?.length) {
|
||||
const found = findNestedComponent(innerComponent.components, checkFor);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isRoleIcon) {
|
||||
const parsedURL = parseURL(roleIconTarget.src.replace(CDN_BASE, MEDIA_PROXY_BASE));
|
||||
const isUnicodeRoleIcon = itemSrc.startsWith(PRIMARY_DOMAIN_BASE);
|
||||
const isCustomRoleIcon = !isUnicodeRoleIcon;
|
||||
const roleID = GuildMemberStore.getMember(channel.guild_id, message.author.id)?.iconRoleId;
|
||||
const role = roleID ? GuildRoleStore.getRole(channel.guild_id, roleID) : null;
|
||||
const sanitizedRoleName = role ? sanitizeFilename(role.name, allowUnicode) : null;
|
||||
|
||||
downloadifyItems.push(
|
||||
<Menu.MenuItem
|
||||
id="downloadify-role-icon"
|
||||
label={`Download ${role?.name ?? "Role"} Icon`}
|
||||
submenuItemLabel={` ${role?.name ?? "Role"} Icon`}
|
||||
icon={() => ImageIcon({ width: 20, height: 20 })}
|
||||
action={async () => {
|
||||
await handleDownload(
|
||||
parsedURL,
|
||||
sanitizedRoleName ? `${sanitizedRoleName}-icon` : null,
|
||||
isCustomRoleIcon ? assetAvailability["role-icon-custom"] : assetAvailability["role-icon-unicode-emoji"],
|
||||
false
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else if (clanBadgeTarget && !!(message.author as any).primaryGuild) {
|
||||
const { primaryGuild } = (message.author as any);
|
||||
const sanitizedGuildTag = sanitizeFilename(primaryGuild.tag, allowUnicode);
|
||||
|
||||
downloadifyItems.push(
|
||||
<Menu.MenuItem
|
||||
id="downloadify-clan-badge"
|
||||
label={`Download ${primaryGuild.tag} Clan Badge`}
|
||||
submenuItemLabel={`${primaryGuild.tag} Clan Badge`}
|
||||
icon={() => ImageIcon({ width: 20, height: 20 })}
|
||||
action={async () => {
|
||||
await handleDownload(
|
||||
parseURL(clanBadgeTarget.src.replace(CDN_BASE, MEDIA_PROXY_BASE)),
|
||||
sanitizedGuildTag ? `${sanitizedGuildTag}-clan-badge` : null,
|
||||
assetAvailability["clan-badge"],
|
||||
false
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else if (isCustomEmoji) {
|
||||
const reaction = message.reactions?.find(reaction => reaction.emoji.id === favoriteableId)?.emoji;
|
||||
const component = message.components?.length ? findNestedComponent(message.components, favoriteableId as string) : null;
|
||||
emojiAnimated = component?.emoji?.animated || reaction?.animated || emojiAnimated;
|
||||
emojiName = component?.emoji?.name || reaction?.name || emojiName;
|
||||
|
||||
downloadifyItems.push(
|
||||
<Menu.MenuItem
|
||||
id="downloadify-custom-emoji"
|
||||
label={`Download ${emojiName || "Custom Emoji"}`}
|
||||
submenuItemLabel={`${emojiName || "Custom Emoji"}`}
|
||||
icon={() => ImageIcon({ width: 20, height: 20 })}
|
||||
action={async () => {
|
||||
await handleDownload(
|
||||
emojiURL as ParsedURL,
|
||||
emojiName,
|
||||
assetAvailability["custom-emoji"],
|
||||
emojiAnimated
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else if (isUnicodeEmoji) {
|
||||
const reaction = message.reactions?.find(reaction => reaction.emoji.name === favoriteableName)?.emoji;
|
||||
const component = message.components?.length ? findNestedComponent(message.components, favoriteableName as string) : null;
|
||||
emojiName = component?.emoji?.name || reaction?.name || emojiName;
|
||||
emojiAnimated = false;
|
||||
|
||||
downloadifyItems.push(
|
||||
<Menu.MenuItem
|
||||
id="downloadify-unicode-emoji"
|
||||
label={`Download ${emojiName || "Unicode Emoji"}`}
|
||||
submenuItemLabel={`${emojiName || "Unicode Emoji"}`}
|
||||
icon={() => ImageIcon({ width: 20, height: 20 })}
|
||||
action={async () => {
|
||||
await handleDownload(
|
||||
emojiURL as ParsedURL,
|
||||
emojiName,
|
||||
assetAvailability["unicode-emoji"],
|
||||
emojiAnimated
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else if (isSticker) {
|
||||
let stickerURL;
|
||||
let stickerSource;
|
||||
let stickerAnimated;
|
||||
|
||||
if (sticker?.format_type === StickerFormatType.PNG) {
|
||||
stickerURL = parseURL(`${MEDIA_PROXY_BASE}/stickers/${sticker.id}.png`);
|
||||
stickerSource = assetAvailability.sticker.PNG;
|
||||
stickerAnimated = false;
|
||||
} else if (sticker?.format_type === StickerFormatType.GIF) {
|
||||
stickerURL = parseURL(`${MEDIA_PROXY_BASE}/stickers/${sticker.id}.gif`);
|
||||
stickerSource = assetAvailability.sticker.GIF;
|
||||
stickerAnimated = true;
|
||||
} else if (sticker?.format_type === StickerFormatType.APNG) {
|
||||
stickerURL = parseURL(`${MEDIA_PROXY_BASE}/stickers/${sticker.id}.png`);
|
||||
stickerSource = assetAvailability.sticker.APNG;
|
||||
stickerAnimated = true;
|
||||
} else if (sticker?.format_type === StickerFormatType.LOTTIE) {
|
||||
stickerURL = parseURL(`${CDN_BASE}/stickers/${sticker.id}.json`);
|
||||
stickerSource = assetAvailability.sticker.LOTTIE;
|
||||
stickerAnimated = true;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
downloadifyItems.push(
|
||||
<Menu.MenuItem
|
||||
id="downloadify-sticker"
|
||||
label={`Download ${sticker.name || "Sticker"}`}
|
||||
submenuItemLabel={`${sticker.name || "Sticker"}`}
|
||||
icon={() => ImageIcon({ width: 20, height: 20 })}
|
||||
action={async () => {
|
||||
await handleDownload(
|
||||
stickerURL,
|
||||
sticker.name,
|
||||
stickerSource,
|
||||
stickerAnimated
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else if (isAttachment) {
|
||||
const assetInfo = assetAvailability.attachment[mediaItem.contentType] ?? unknownCDN;
|
||||
const attachment = message.attachments?.find(attachment => attachment.proxy_url === mediaItem.proxyUrl || attachment.url === mediaItem.url);
|
||||
|
||||
if (!attachment) {
|
||||
return;
|
||||
}
|
||||
|
||||
downloadifyItems.push(
|
||||
<Menu.MenuItem
|
||||
id="downloadify-attachment"
|
||||
label="Download Media"
|
||||
submenuItemLabel="Media"
|
||||
icon={() => ImageIcon({ width: 20, height: 20 })}
|
||||
action={async () => {
|
||||
await handleDownload(
|
||||
parseURL(assetInfo.source === AssetSource.CDN ? mediaItem.url : mediaItem.proxyUrl),
|
||||
(attachment as any)?.title || null,
|
||||
assetInfo,
|
||||
!!((attachment as any).flags & AttachmentFlags.IS_ANIMATED)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else if (targetURL) {
|
||||
let isTenor = false;
|
||||
|
||||
const embedImage = message.embeds?.find(embed => {
|
||||
const images = (embed as any).images ? (embed as any).images : embed.image ? [embed.image] : [];
|
||||
return images.some(image => targetURL.startsWith(image.url) || targetURL.startsWith(image.proxyURL));
|
||||
});
|
||||
|
||||
const embedVideo = embedImage ? undefined : message.embeds?.find(embed => {
|
||||
isTenor = embed.provider?.name === "Tenor";
|
||||
const videos = (embed as any).videos ? (embed as any).videos : embed.video ? [embed.video] : [];
|
||||
return videos.some(video => targetURL.startsWith(video.url) || targetURL.startsWith(video.proxyURL));
|
||||
});
|
||||
|
||||
const embedThumbnail = message.embeds?.find(embed => {
|
||||
return (embed.thumbnail?.url && targetURL.startsWith(embed.thumbnail.url)) || (embed.thumbnail?.proxyURL && targetURL.startsWith(embed.thumbnail.proxyURL));
|
||||
});
|
||||
|
||||
const embedAuthor = message.embeds?.find(embed => {
|
||||
return (embed.author?.iconURL && targetURL.startsWith(embed.author.iconURL)) || (embed.author?.iconProxyURL && targetURL.startsWith(embed.author.iconProxyURL));
|
||||
});
|
||||
|
||||
const embedFooter = (message.embeds?.find(embed => {
|
||||
return ((embed as any).footer?.iconURL && targetURL.startsWith((embed as any).footer.iconURL)) || ((embed as any).footer?.iconProxyURL && targetURL.startsWith((embed as any).footer.iconProxyURL));
|
||||
}) as any);
|
||||
|
||||
const targetEmbed = (embedImage || embedVideo || embedThumbnail || embedAuthor || embedFooter);
|
||||
const targetEmbedItem = (embedImage?.image || embedVideo?.video || embedThumbnail?.thumbnail || embedAuthor?.author || embedFooter?.footer);
|
||||
const labelEmbedMedia = embedFooter?.footer
|
||||
? "Footer Icon"
|
||||
: embedAuthor?.author
|
||||
? "Author Icon"
|
||||
: embedThumbnail?.thumbnail
|
||||
? "Thumbnail Image"
|
||||
: targetEmbed?.type === "rich" || !!targetEmbed?.provider
|
||||
? "Embed Media"
|
||||
: "Media";
|
||||
|
||||
downloadifyItems.push(
|
||||
<Menu.MenuItem
|
||||
id="downloadify-attachment"
|
||||
label={isTenor ? "Download Tenor GIF" : `Download ${labelEmbedMedia}`}
|
||||
submenuItemLabel={isTenor ? "Tenor GIF" : labelEmbedMedia}
|
||||
icon={() => ImageIcon({ width: 20, height: 20 })}
|
||||
action={async () => {
|
||||
let srcIsAnimated = !!(targetEmbedItem as any)?.srcIsAnimated;
|
||||
let aliasBasename: string | null = null;
|
||||
let assetInfo: any;
|
||||
|
||||
const contentType = ((targetEmbedItem as any)?.contentType || await DownloadifyNative.queryURL(itemSrc));
|
||||
const isMediaProxy = targetURL?.startsWith(MEDIA_PROXY_BASE);
|
||||
const isPrimaryDomain = targetURL?.startsWith(PRIMARY_DOMAIN_BASE);
|
||||
const isCDN = targetURL?.startsWith(CDN_BASE);
|
||||
const isImageExt1 = targetURL?.startsWith(IMAGE_EXT_1_DOMAIN_BASE);
|
||||
const isImageExt2 = targetURL?.startsWith(IMAGE_EXT_2_DOMAIN_BASE);
|
||||
const isImageExt = isImageExt1 || isImageExt2;
|
||||
|
||||
if (isTenor && embedVideo) {
|
||||
targetURL = embedVideo.url ?? targetURL;
|
||||
}
|
||||
|
||||
const parsedURL = parseURL(targetURL);
|
||||
|
||||
if (isTenor) {
|
||||
assetInfo = assetAvailability.tenor;
|
||||
srcIsAnimated = true;
|
||||
} else if (isImageExt) {
|
||||
assetInfo = assetAvailability.attachment[contentType]
|
||||
?? assetAvailability.external[contentType]
|
||||
?? unknownExternalImageProxy;
|
||||
} else if (isMediaProxy || isCDN) {
|
||||
const guestimate = guesstimateAsset(parsedURL, contentType);
|
||||
assetInfo = guestimate.assetInfo ?? (isCDN ? unknownCDN : (isImageExt ? unknownExternalImageProxy : unknownExternal));
|
||||
aliasBasename = guestimate.aliasBasename ?? aliasBasename;
|
||||
srcIsAnimated = guestimate.srcIsAnimated ?? srcIsAnimated;
|
||||
} else {
|
||||
assetInfo = isPrimaryDomain ? unknownPrimaryDomain : unknownExternal;
|
||||
}
|
||||
|
||||
await handleDownload(
|
||||
parsedURL,
|
||||
aliasBasename,
|
||||
assetInfo,
|
||||
srcIsAnimated
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!downloadifyItems.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
joinOrCreateContextMenuGroup(
|
||||
children,
|
||||
downloadifyItems,
|
||||
"message-content-group",
|
||||
"downloadify-submenu",
|
||||
"Download",
|
||||
[{
|
||||
id: { child: "devmode-copy-id" },
|
||||
type: "WITH_GROUP",
|
||||
position: "START"
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
export function GDMContextMenu(children: Array<any>, props: GDMContextMenuProps): void {
|
||||
if (!children?.length || !props?.channel?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { channel } = props;
|
||||
const channelIconHash = channel.icon || null;
|
||||
const channelIconURL = channelIconHash ? parseURL(`${MEDIA_PROXY_BASE}/channel-icons/${channel.id}/${channelIconHash}.png`) : null;
|
||||
const channelTimestamp = Number(BigInt(channel.id) >> 22n);
|
||||
const defaultChannelIconURL = channelTimestamp ? parseURL(`${PRIMARY_DOMAIN_BASE}${defaultAssets.DEFAULT_GROUP_DM_AVATARS[channelTimestamp % 8]}`) : null;
|
||||
|
||||
const downloadifyItems = [
|
||||
channelIconURL ? (
|
||||
<Menu.MenuItem
|
||||
id="downloadify-gdm-channel-icon"
|
||||
label="Download Group Icon"
|
||||
submenuItemLabel="Group Icon"
|
||||
icon={() => ImageIcon({ width: 20, height: 20 })}
|
||||
action={async () => {
|
||||
await handleDownload(
|
||||
channelIconURL,
|
||||
channel.name ? `${channel.name}-group-icon` : null,
|
||||
assetAvailability["guild-icon"],
|
||||
false
|
||||
);
|
||||
}}
|
||||
/>
|
||||
) : null,
|
||||
defaultChannelIconURL ? (
|
||||
<Menu.MenuItem
|
||||
id="downloadify-default-gdm-channel-icon"
|
||||
label="Download Default Icon"
|
||||
submenuItemLabel="Default Icon"
|
||||
icon={() => ImageIcon({ width: 20, height: 20 })}
|
||||
action={async () => {
|
||||
await handleDownload(
|
||||
defaultChannelIconURL,
|
||||
channel.name ? `${channel.name}-default-group-icon` : null,
|
||||
assetAvailability["default-group-icon"],
|
||||
false
|
||||
);
|
||||
}}
|
||||
/>
|
||||
) : null
|
||||
].filter(Boolean);
|
||||
|
||||
joinOrCreateContextMenuGroup(
|
||||
children,
|
||||
downloadifyItems,
|
||||
"channel-content-group",
|
||||
"downloadify-submenu",
|
||||
"Download",
|
||||
[{
|
||||
id: { child: "devmode-copy-id" },
|
||||
type: "WITH_GROUP",
|
||||
position: "START"
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
export function GuildContextMenu(children: Array<any>, props: GuildContextMenuProps): void {
|
||||
if (!children?.length || !props?.guild?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { guild } = props;
|
||||
const guildIconHash = guild.icon || null;
|
||||
const guildBannerHash = guild.banner || null;
|
||||
const guildInviteHash = guild.splash !== guildBannerHash ? guild.splash || null : null;
|
||||
const guildDiscoveryHash = guild.discoverySplash !== guildInviteHash && guild.discoverySplash !== guildBannerHash ? guild.discoverySplash || null : null;
|
||||
const guildIconHashAnimated = guildIconHash?.startsWith("a_");
|
||||
const guildBannerHashAnimated = guildBannerHash?.startsWith("a_");
|
||||
const guildIconURL = guildIconHash ? parseURL(`${MEDIA_PROXY_BASE}/icons/${guild.id}/${guildIconHash}.${guildIconHashAnimated ? "gif" : "png"}`) : null;
|
||||
const guildBannerURL = guildBannerHash ? parseURL(`${MEDIA_PROXY_BASE}/banners/${guild.id}/${guildBannerHash}.${guildBannerHashAnimated ? "gif" : "png"}`) : null;
|
||||
const guildInviteURL = guildInviteHash ? parseURL(`${MEDIA_PROXY_BASE}/splashes/${guild.id}/${guildInviteHash}.png`) : null;
|
||||
const guildDiscoveryURL = guildDiscoveryHash ? parseURL(`${MEDIA_PROXY_BASE}/discovery-splashes/${guild.id}/${guildDiscoveryHash}.png`) : null;
|
||||
|
||||
if (!guildIconURL && !guildBannerURL && !guildInviteURL && !guildDiscoveryURL) {
|
||||
return;
|
||||
}
|
||||
|
||||
const downloadifyItems = [guildIconURL ? (
|
||||
<Menu.MenuItem
|
||||
id="downloadify-guild-icon"
|
||||
label="Download Icon"
|
||||
submenuItemLabel="Icon"
|
||||
icon={() => ImageIcon({ width: 20, height: 20 })}
|
||||
action={async () => await handleDownload(
|
||||
guildIconURL,
|
||||
guild.name ? `${guild.name}-icon` : null,
|
||||
assetAvailability["guild-icon"],
|
||||
!!guildIconHash?.startsWith("a_")
|
||||
)}
|
||||
/>
|
||||
) : null,
|
||||
guildBannerURL ? (
|
||||
<Menu.MenuItem
|
||||
id="downloadify-guild-banner"
|
||||
label="Download Banner"
|
||||
submenuItemLabel="Banner"
|
||||
icon={() => ImageIcon({ width: 20, height: 20 })}
|
||||
action={async () => await handleDownload(
|
||||
guildBannerURL,
|
||||
guild.name ? `${guild.name}-banner` : null,
|
||||
assetAvailability["guild-banner"],
|
||||
!!guildBannerHash?.startsWith("a_")
|
||||
)}
|
||||
/>
|
||||
) : null,
|
||||
guildInviteURL ? (
|
||||
<Menu.MenuItem
|
||||
id="downloadify-guild-invite-splash"
|
||||
label="Download Invite Splash"
|
||||
submenuItemLabel="Invite Splash"
|
||||
icon={() => ImageIcon({ width: 20, height: 20 })}
|
||||
action={async () => await handleDownload(
|
||||
guildInviteURL,
|
||||
guild.name ? `${guild.name}-invite-splash` : null,
|
||||
assetAvailability["guild-invite-splash"],
|
||||
!!guildInviteHash?.startsWith("a_")
|
||||
)}
|
||||
/>
|
||||
) : null,
|
||||
guildDiscoveryURL ? (
|
||||
<Menu.MenuItem
|
||||
id="downloadify-guild-discovery-splash"
|
||||
label="Download Discovery Splash"
|
||||
submenuItemLabel="Discovery Splash"
|
||||
icon={() => ImageIcon({ width: 20, height: 20 })}
|
||||
action={async () => await handleDownload(
|
||||
guildDiscoveryURL,
|
||||
guild.name ? `${guild.name}-discovery-splash` : null,
|
||||
assetAvailability["guild-discovery-splash"],
|
||||
!!guildDiscoveryHash?.startsWith("a_")
|
||||
)}
|
||||
/>
|
||||
) : null].filter(Boolean);
|
||||
|
||||
joinOrCreateContextMenuGroup(
|
||||
children,
|
||||
downloadifyItems,
|
||||
"guild-content-group",
|
||||
"downloadify-submenu",
|
||||
"Download",
|
||||
[{
|
||||
id: { child: "devmode-copy-id" },
|
||||
type: "WITH_GROUP",
|
||||
position: "START"
|
||||
},
|
||||
{
|
||||
id: { child: "privacy" },
|
||||
type: "WITH_GROUP",
|
||||
position: "END"
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
export function UserContextMenu(children: Array<any>, props: UserContextMenuProps): void {
|
||||
if (!children?.length || !props.user?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const user = UserStore.getUser(props.user.id) as CustomizedUser;
|
||||
const member = (props.guildId ? GuildMemberStore.getMember(props.guildId, props.user.id) : null) as CustomizedMember | null;
|
||||
const guild = props.guildId ? GuildStore.getGuild(props.guildId) : null;
|
||||
const userAvatarHash = user.avatar || null;
|
||||
const memberAvatarHash = member?.avatar || null;
|
||||
const userBannerHash = UserProfileStore.getUserProfile(user.id)?.banner || null;
|
||||
const memberBannerHash = UserProfileStore.getGuildMemberProfile(user.id, props.guildId)?.banner || null;
|
||||
const userAvatarDecorationHash = user.avatarDecorationData?.asset || null;
|
||||
const memberAvatarDecorationHash = member?.avatarDecoration?.asset || null;
|
||||
const userNameplatePath = user.collectibles?.nameplate?.asset || null;
|
||||
const userAvatarURL = userAvatarHash ? parseURL(`${MEDIA_PROXY_BASE}/avatars/${user.id}/${userAvatarHash}.png`) : null;
|
||||
const memberAvatarURL = memberAvatarHash ? parseURL(`${MEDIA_PROXY_BASE}/guilds/${props.guildId}/users/${user.id}/avatars/${memberAvatarHash}.png`) : null;
|
||||
const userBannerURL = userBannerHash ? parseURL(`${MEDIA_PROXY_BASE}/banners/${user.id}/${userBannerHash}.png`) : null;
|
||||
const memberBannerURL = memberBannerHash ? parseURL(`${MEDIA_PROXY_BASE}/guilds/${props.guildId}/users/${user.id}/banners/${memberBannerHash}.png`) : null;
|
||||
const userAvatarDecorationURL = userAvatarDecorationHash ? parseURL(`${MEDIA_PROXY_BASE}/avatar-decoration-presets/${userAvatarDecorationHash}.png`) : null;
|
||||
const memberAvatarDecorationURL = memberAvatarDecorationHash ? parseURL(`${MEDIA_PROXY_BASE}/avatar-decoration-presets/${memberAvatarDecorationHash}.png`) : null;
|
||||
const userNameplateURL = userNameplatePath ? parseURL(`${CDN_BASE}/assets/collectibles/${userNameplatePath}static.png`) : null;
|
||||
const defaultUserAvatarURL = parseURL(`${PRIMARY_DOMAIN_BASE}${defaultAssets.DEFAULT_AVATARS[user.discriminator === "0" ? (Math.floor(Number(BigInt(user.id) >> 22n)) % 6) : (Number(user.discriminator) % 5)]}`);
|
||||
|
||||
const downloadifyItems = [userAvatarURL ? (
|
||||
<Menu.MenuItem
|
||||
id="downloadify-user-avatar"
|
||||
label="Download User Avatar"
|
||||
submenuItemLabel="User Avatar"
|
||||
icon={() => ImageIcon({ width: 20, height: 20 })}
|
||||
action={async () => await handleDownload(
|
||||
userAvatarURL,
|
||||
`${user.username}-avatar`,
|
||||
assetAvailability["user-avatar"],
|
||||
!!userAvatarHash?.startsWith("a_")
|
||||
)}
|
||||
/>
|
||||
) : null,
|
||||
userBannerURL ? (
|
||||
<Menu.MenuItem
|
||||
id="downloadify-user-banner"
|
||||
label="Download User Banner"
|
||||
submenuItemLabel="User Banner"
|
||||
icon={() => ImageIcon({ width: 20, height: 20 })}
|
||||
action={async () => await handleDownload(
|
||||
userBannerURL,
|
||||
`${user.username}-banner`,
|
||||
assetAvailability["user-banner"],
|
||||
!!userBannerHash?.startsWith("a_")
|
||||
)}
|
||||
/>
|
||||
) : null,
|
||||
userAvatarDecorationURL ? (
|
||||
<Menu.MenuItem
|
||||
id="downloadify-user-avatar-decoration"
|
||||
label="Download User Avatar Decoration"
|
||||
submenuItemLabel="User Avatar Decoration"
|
||||
icon={() => ImageIcon({ width: 20, height: 20 })}
|
||||
action={async () => await handleDownload(
|
||||
userAvatarDecorationURL,
|
||||
`${user.username}-avatar-decoration`,
|
||||
assetAvailability["avatar-decoration"],
|
||||
true
|
||||
)}
|
||||
/>
|
||||
) : null,
|
||||
memberAvatarURL ? (
|
||||
<Menu.MenuItem
|
||||
id="downloadify-member-avatar"
|
||||
label="Download Member Avatar"
|
||||
submenuItemLabel="Member Avatar"
|
||||
icon={() => ImageIcon({ width: 20, height: 20 })}
|
||||
action={async () => await handleDownload(
|
||||
memberAvatarURL,
|
||||
guild ? `${guild.name}-${user.username}-avatar` : `${user.username}-avatar`,
|
||||
assetAvailability["user-avatar"],
|
||||
!!memberAvatarHash?.startsWith("a_")
|
||||
)}
|
||||
/>
|
||||
) : null,
|
||||
memberBannerURL ? (
|
||||
<Menu.MenuItem
|
||||
id="downloadify-member-banner"
|
||||
label="Download Member Banner"
|
||||
submenuItemLabel="Member Banner"
|
||||
icon={() => ImageIcon({ width: 20, height: 20 })}
|
||||
action={async () => await handleDownload(
|
||||
memberBannerURL,
|
||||
guild ? `${guild.name}-${user.username}-banner` : `${user.username}-banner`,
|
||||
assetAvailability["user-banner"],
|
||||
!!memberBannerHash?.startsWith("a_")
|
||||
)}
|
||||
/>
|
||||
) : null,
|
||||
memberAvatarDecorationURL ? (
|
||||
<Menu.MenuItem
|
||||
id="downloadify-member-avatar-decoration"
|
||||
label="Download Member Avatar Decoration"
|
||||
submenuItemLabel="Member Avatar Decoration"
|
||||
icon={() => ImageIcon({ width: 20, height: 20 })}
|
||||
action={async () => await handleDownload(
|
||||
memberAvatarDecorationURL,
|
||||
guild ? `${guild.name}-${user.username}-avatar-decoration` : `${user.username}-avatar-decoration`,
|
||||
assetAvailability["avatar-decoration"],
|
||||
true
|
||||
)}
|
||||
/>
|
||||
) : null,
|
||||
defaultUserAvatarURL ? (
|
||||
<Menu.MenuItem
|
||||
id="downloadify-default-user-avatar"
|
||||
label="Download Default Avatar"
|
||||
submenuItemLabel="Default Avatar"
|
||||
icon={() => ImageIcon({ width: 20, height: 20 })}
|
||||
action={async () => await handleDownload(
|
||||
defaultUserAvatarURL,
|
||||
`${user.username}-default-avatar`,
|
||||
assetAvailability["default-user-avatar"],
|
||||
false
|
||||
)}
|
||||
/>
|
||||
) : null,
|
||||
userNameplateURL ? (
|
||||
<Menu.MenuItem
|
||||
id="downloadify-user-nameplate"
|
||||
label="Download Nameplate"
|
||||
submenuItemLabel="Nameplate"
|
||||
icon={() => ImageIcon({ width: 20, height: 20 })}
|
||||
action={async () => await handleDownload(
|
||||
userNameplateURL,
|
||||
`${user.username}-nameplate`,
|
||||
assetAvailability.nameplate,
|
||||
true
|
||||
)}
|
||||
/>
|
||||
) : null].filter(Boolean);
|
||||
|
||||
joinOrCreateContextMenuGroup(
|
||||
children,
|
||||
downloadifyItems,
|
||||
"user-content-group",
|
||||
"downloadify-submenu",
|
||||
"Download",
|
||||
[{
|
||||
id: { child: "devmode-copy-id" },
|
||||
type: "WITH_GROUP",
|
||||
position: "START"
|
||||
},
|
||||
{
|
||||
id: { child: "user-profile" },
|
||||
type: "WITH_GROUP",
|
||||
position: "END"
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
export async function handleHoverDownloadButtonClicked(props: HoverDownloadProps): Promise<void> {
|
||||
let { contentType } = props.item;
|
||||
const { type, downloadUrl, srcIsAnimated, originalItem } = props.item;
|
||||
const { displayStatus, statusDuration } = settings.store;
|
||||
|
||||
if (!originalItem) {
|
||||
// Fallback to default behavior.
|
||||
DownloadifyLogger.info(`[${getFormattedNow()}] [UNRECOGNIZED HOVER DOWNLOAD ITEM]`, props.item);
|
||||
displayStatus && showToast("Unknown Media Item. Falling Back to Default Behavior", Toasts.Type.MESSAGE, { duration: statusDuration * 1250 });
|
||||
window.open(downloadUrl, "_blank");
|
||||
} else {
|
||||
contentType ??= (await DownloadifyNative.queryURL(downloadUrl) || "");
|
||||
const assetInfo = (type === "IMAGE" ? (assetAvailability.attachment[contentType]) : null) ?? unknownCDN;
|
||||
|
||||
await handleDownload(
|
||||
parseURL(assetInfo.source === AssetSource.CDN ? downloadUrl : originalItem.proxy_url),
|
||||
originalItem.title || null,
|
||||
assetInfo,
|
||||
!!srcIsAnimated
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleExpandedModalDownloadButtonClicked(props: ExpandedModalDownloadProps, fallback: () => Promise<void>): Promise<void> {
|
||||
let { contentType, srcIsAnimated } = props.item;
|
||||
const { url, original, proxyUrl, sourceMetadata } = props.item;
|
||||
const { displayStatus, statusDuration } = settings.store;
|
||||
|
||||
if (!url && !original) {
|
||||
// Fallback to default behavior.
|
||||
DownloadifyLogger.info(`[${getFormattedNow()}] [UNRECOGNIZED EXPANDED MODAL DOWNLOAD ITEM]`, props.item);
|
||||
displayStatus && showToast("Unknown Media Item. Falling Back to Default Behavior", Toasts.Type.MESSAGE, { duration: statusDuration * 1250 });
|
||||
await fallback();
|
||||
} else {
|
||||
contentType ??= (await DownloadifyNative.queryURL(url) || "");
|
||||
let aliasBasename = sourceMetadata?.identifier.title ?? null;
|
||||
const isAttachment = sourceMetadata?.identifier.type === "attachment";
|
||||
const urlIsMediaProxy = url?.startsWith(MEDIA_PROXY_BASE);
|
||||
const originalIsMediaProxy = original?.startsWith(MEDIA_PROXY_BASE);
|
||||
const isMediaProxy = urlIsMediaProxy || originalIsMediaProxy;
|
||||
const isPrimaryDomain = original?.startsWith(PRIMARY_DOMAIN_BASE);
|
||||
const isCDN = original?.startsWith(CDN_BASE);
|
||||
const isImageExt1 = url?.startsWith(IMAGE_EXT_1_DOMAIN_BASE);
|
||||
const isImageExt2 = url?.startsWith(IMAGE_EXT_2_DOMAIN_BASE);
|
||||
const isImageExt = isImageExt1 || isImageExt2;
|
||||
let parsedURL: ParsedURL;
|
||||
let assetInfo: any;
|
||||
|
||||
if (isAttachment) {
|
||||
parsedURL = parseURL(proxyUrl || url);
|
||||
assetInfo = assetAvailability.attachment[contentType] ?? unknownAttachmentMediaProxy;
|
||||
} else if (isImageExt) {
|
||||
parsedURL = parseURL(url);
|
||||
assetInfo = assetAvailability.attachment[contentType] ?? assetAvailability.external[contentType] ?? unknownExternalImageProxy;
|
||||
} else if (isMediaProxy || isCDN) {
|
||||
parsedURL = parseURL(urlIsMediaProxy ? url : original);
|
||||
const guestimate = guesstimateAsset(parsedURL, contentType);
|
||||
assetInfo = guestimate.assetInfo ?? (isCDN ? unknownCDN : unknownExternal);
|
||||
aliasBasename = guestimate.aliasBasename ?? aliasBasename;
|
||||
srcIsAnimated = guestimate.srcIsAnimated ?? srcIsAnimated;
|
||||
} else {
|
||||
parsedURL = parseURL(original ?? url);
|
||||
assetInfo = isPrimaryDomain ? unknownPrimaryDomain : unknownExternal;
|
||||
}
|
||||
|
||||
await handleDownload(
|
||||
parsedURL,
|
||||
aliasBasename,
|
||||
assetInfo,
|
||||
!!srcIsAnimated,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function guesstimateAsset(parsedURL: ParsedURL, contentType: string): {
|
||||
assetInfo?: AssetInfo;
|
||||
aliasBasename?: string;
|
||||
srcIsAnimated?: boolean;
|
||||
} {
|
||||
// Check if the URL is a known asset type. The only defined member
|
||||
// of assetAvailability that we can't completely check for here are stickers
|
||||
// due to their format information not being present in the URL. We can make
|
||||
// accurate guesses based on certain extensions, but not for every format.
|
||||
let assetInfo: AssetInfo | undefined;
|
||||
let aliasBasename: string | undefined;
|
||||
let srcIsAnimated: boolean | undefined;
|
||||
const detected = parsedURL.url.match(ASSET_TYPE_EXTRACTOR);
|
||||
const detectedPrimary = detected?.[1] ?? "";
|
||||
const detectedSecondary = detected?.[2] ?? "";
|
||||
const detectedTertiary = detected?.[3] ?? "";
|
||||
|
||||
if (detectedPrimary === "attachments") {
|
||||
assetInfo = assetAvailability.attachment[contentType];
|
||||
} else if (detectedPrimary === "emojis") {
|
||||
assetInfo = assetAvailability["custom-emoji"];
|
||||
} else if (detectedPrimary === "clan-badges") {
|
||||
assetInfo = assetAvailability["clan-badge"];
|
||||
} else if (detectedPrimary === "role-icons") {
|
||||
assetInfo = assetAvailability["role-icon-custom"];
|
||||
} else if (detectedPrimary === "discovery-splashes") {
|
||||
assetInfo = assetAvailability["guild-discovery-splash"];
|
||||
} else if (detectedPrimary === "splashes") {
|
||||
assetInfo = assetAvailability["guild-invite-splash"];
|
||||
} else if (detectedPrimary === "banners") {
|
||||
assetInfo = assetAvailability["guild-banner"];
|
||||
} else if (detectedPrimary === "icons") {
|
||||
assetInfo = assetAvailability["guild-icon"];
|
||||
} else if (detectedPrimary === "avatar-decoration-presets") {
|
||||
assetInfo = assetAvailability["avatar-decoration"];
|
||||
srcIsAnimated = true; // Avatar decorations always have animated variants.
|
||||
} else if (detectedPrimary === "avatars" || (detectedPrimary === "guilds" && detectedSecondary === "avatars")) {
|
||||
assetInfo = assetAvailability["user-avatar"];
|
||||
} else if (detectedPrimary === "banners" || (detectedPrimary === "guilds" && detectedSecondary === "banners")) {
|
||||
assetInfo = assetAvailability["user-banner"];
|
||||
} else if (detectedPrimary === "assets/collectibles/nameplates") {
|
||||
assetInfo = assetAvailability.nameplate;
|
||||
aliasBasename = detectedTertiary ?? "nameplate";
|
||||
srcIsAnimated = true; // Nameplates always have animated variants.
|
||||
} else if (detectedPrimary === "stickers") {
|
||||
if (parsedURL.extension === "gif") {
|
||||
assetInfo = assetAvailability.sticker.GIF;
|
||||
} else if (parsedURL.extension === "webp") {
|
||||
if (srcIsAnimated) {
|
||||
assetInfo = assetAvailability.sticker.GIF;
|
||||
} else {
|
||||
// There is no way to know if a static WEBP sticker
|
||||
// link is sourced from an APNG, PNG, or GIF sticker.
|
||||
}
|
||||
} else if (parsedURL.extension === "png") {
|
||||
if (srcIsAnimated) {
|
||||
assetInfo = assetAvailability.sticker.PNG;
|
||||
} else {
|
||||
// There is no way to know if a static PNG sticker
|
||||
// link is sourced from an APNG, PNG, or GIF sticker.
|
||||
}
|
||||
} else if (parsedURL.extension === "jpg") {
|
||||
// There is no way to know if a JPG sticker link
|
||||
// is sourced from an APNG, PNG, or GIF sticker.
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
assetInfo,
|
||||
aliasBasename,
|
||||
srcIsAnimated
|
||||
};
|
||||
}
|
||||
|
||||
export function VoiceMessageDownloadButton(props: VoiceMessageDownloadButtonProps): JSX.Element {
|
||||
async function voiceMessageDownloadClicked(event: React.MouseEvent<HTMLButtonElement>) {
|
||||
event.preventDefault();
|
||||
const { item, message } = props;
|
||||
const { proxy_url } = item.originalItem;
|
||||
const baseName = `voice-message-${message.id}`;
|
||||
const parsedURL = parseURL(proxy_url);
|
||||
|
||||
await handleDownload(
|
||||
parsedURL,
|
||||
baseName,
|
||||
{ source: AssetSource.ATTACHMENT_MEDIA_PROXY, static: [], animated: [] },
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
const { voiceMessages } = settings.use(["voiceMessages"]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{voiceMessages && <div className={d("voice-message-container")}>
|
||||
<button
|
||||
onClick={voiceMessageDownloadClicked}
|
||||
className={d("voice-message-button")}
|
||||
aria-label="Download Voice Message"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
{DownloadIcon({ width: 20, height: 20 })}
|
||||
</button>
|
||||
</div>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Common download handler for all other handlers.
|
||||
*/
|
||||
async function handleDownload(
|
||||
url: ParsedURL,
|
||||
alias: string | null,
|
||||
asset: AssetInfo,
|
||||
animated: boolean,
|
||||
) {
|
||||
const extensions = animated || !!asset.forceAnimated ? asset.animated : asset.static;
|
||||
extensions.length < 1 && url.extension && extensions.push(url.extension);
|
||||
const { defaultDirectory, overwriteFiles, displayStatus, allowUnicode, statusDuration } = settings.store;
|
||||
const baseName = alias
|
||||
? sanitizeFilename(alias, allowUnicode, "discord-download")
|
||||
: url.baseName
|
||||
? sanitizeFilename(url.baseName, allowUnicode, "discord-download")
|
||||
: "discord-download";
|
||||
|
||||
let chosenExtension = extensions[0] ?? "";
|
||||
let resolvedExtension = (chosenExtension).replace("apng", "png").replace("awebp", "webp");
|
||||
let resolvedPath: string | null = null;
|
||||
let resolvedURL = url.url;
|
||||
|
||||
if (defaultDirectory) {
|
||||
const resolvedDirectory = defaultDirectory.trim().replace(/^["']|["']$/g, "").replace(/[/\\]+$/, "").replace(/\\/g, "/");
|
||||
resolvedPath = `${resolvedDirectory}/${baseName}${resolvedExtension ? `.${resolvedExtension}` : ""}`;
|
||||
displayStatus && showToast("Download Started", Toasts.Type.MESSAGE, { duration: statusDuration * 1000 });
|
||||
|
||||
if (!overwriteFiles) {
|
||||
for (let num = 1; await DownloadifyNative.fileExists(resolvedPath); num++) {
|
||||
resolvedPath = `${resolvedDirectory}/${baseName}-${num}${resolvedExtension ? `.${resolvedExtension}` : ""}`;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
resolvedPath = await DownloadifyNative.getFilePath(baseName, extensions);
|
||||
|
||||
if (!resolvedPath) {
|
||||
DownloadifyLogger.info(`[${getFormattedNow()}] [SAVE DIALOGUE CLOSED / DOWNLOAD CANCELED]`);
|
||||
displayStatus && showToast("Download Canceled", Toasts.Type.MESSAGE, { duration: statusDuration * 1000 });
|
||||
return;
|
||||
}
|
||||
|
||||
const chosenFile = parseFile(resolvedPath);
|
||||
chosenExtension = chosenFile.extension || "";
|
||||
resolvedExtension = (chosenExtension || "").replace("apng", "png").replace("awebp", "webp");
|
||||
|
||||
if (url.extension) {
|
||||
const extReplacer = new RegExp(`\\.${chosenExtension}(?!.*\\.${chosenExtension})`, "i");
|
||||
resolvedPath = resolvedPath.replace(extReplacer, `.${resolvedExtension}`);
|
||||
}
|
||||
} catch (error) {
|
||||
DownloadifyLogger.error(`[${getFormattedNow()}] [ERROR OPENING SAVE DIALOGUE]:`, error);
|
||||
displayStatus && showToast("Error Opening Save Dialogue", Toasts.Type.FAILURE, { duration: statusDuration * 1000 });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
let success = false;
|
||||
|
||||
if ([AssetSource.ASSET_MEDIA_PROXY, AssetSource.ATTACHMENT_MEDIA_PROXY, AssetSource.EXTERNAL_IMAGE_PROXY].includes(asset.source)) {
|
||||
let baseURL;
|
||||
|
||||
if (asset.source === AssetSource.ASSET_MEDIA_PROXY) {
|
||||
baseURL = url.host + url.path + url.baseName + (resolvedExtension ? `.${resolvedExtension}` : "");
|
||||
} else {
|
||||
// Attachments on the media proxy use the `format` query parameter (seen below) to retrieve alternate
|
||||
// formats instead of changing the file extension directly like assets on the media proxy do.
|
||||
baseURL = url.host + url.path + url.baseName +
|
||||
(url.extension ? `.${url.extension}` : "") +
|
||||
(url.twitterExtra ? encodeURIComponent(url.twitterExtra) : "");
|
||||
}
|
||||
|
||||
const authParams = url.params.expiry;
|
||||
const newURL = new URL(baseURL);
|
||||
|
||||
Object.entries(authParams).forEach(([key, value]) => {
|
||||
newURL.searchParams.append(key, value);
|
||||
});
|
||||
|
||||
if ([AssetSource.ATTACHMENT_MEDIA_PROXY, AssetSource.EXTERNAL_IMAGE_PROXY].includes(asset.source)) {
|
||||
if (url.extension !== resolvedExtension) {
|
||||
newURL.searchParams.append("format", resolvedExtension.replace("jpg", "jpeg"));
|
||||
}
|
||||
} else {
|
||||
// Attachments on the media proxy do not support
|
||||
// resizing but assets on the media proxy do.
|
||||
newURL.searchParams.append("size", "4096");
|
||||
}
|
||||
|
||||
if (chosenExtension === "awebp" && animated) {
|
||||
newURL.searchParams.append("animated", "true");
|
||||
} else if (chosenExtension === "png" && animated) {
|
||||
newURL.searchParams.append("passthrough", "false");
|
||||
}
|
||||
|
||||
if (resolvedExtension === "webp") {
|
||||
newURL.searchParams.append("lossless", "true");
|
||||
}
|
||||
|
||||
resolvedURL = newURL.href;
|
||||
} else if (asset.source === AssetSource.CDN) {
|
||||
if (asset.type === "nameplate") {
|
||||
resolvedURL = url.host + url.path + (
|
||||
chosenExtension === "apng"
|
||||
? "img.png"
|
||||
: chosenExtension === "webm"
|
||||
? "asset.webm"
|
||||
: "static.png"
|
||||
);
|
||||
} else {
|
||||
// Currently only the LOTTIE sticker.
|
||||
resolvedURL = url.url;
|
||||
}
|
||||
} else if (asset.source === AssetSource.TENOR) {
|
||||
const tenorID = url.path.replaceAll("/", "").slice(0, -2);
|
||||
|
||||
if (chosenExtension === "gif") {
|
||||
resolvedURL = `${TENOR_BASE}/${tenorID}${TENOR_GIF_ID}/tenor.gif`;
|
||||
} else if (chosenExtension === "mp4") {
|
||||
resolvedURL = `${TENOR_BASE}/${tenorID}${TENOR_MP4_ID}/tenor.mp4`;
|
||||
} else {
|
||||
DownloadifyLogger.warn(`[${getFormattedNow()}] [UNSUPPORTED TENOR EXTENSION]\n${chosenExtension}\n${resolvedPath}\n${resolvedURL}`);
|
||||
displayStatus && showToast("Unsupported Tenor Extension", Toasts.Type.FAILURE, { duration: statusDuration * 1000 });
|
||||
return;
|
||||
}
|
||||
} else if ([AssetSource.EXTERNAL, AssetSource.PRIMARY_DOMAIN].includes(asset.source)) {
|
||||
resolvedURL = url.url;
|
||||
}
|
||||
|
||||
DownloadifyLogger.info(`[${getFormattedNow()}] [STARTING DOWNLOAD]\n${resolvedPath}\n${resolvedURL}`);
|
||||
success = await DownloadifyNative.downloadURL(resolvedURL, resolvedPath);
|
||||
|
||||
if (success) {
|
||||
DownloadifyLogger.info(`[${getFormattedNow()}] [DOWNLOAD SUCCESS]\n${resolvedPath}\n${resolvedURL}`);
|
||||
displayStatus && showToast("File Downloaded", Toasts.Type.SUCCESS, { duration: statusDuration * 1000 });
|
||||
} else {
|
||||
DownloadifyLogger.error(`[${getFormattedNow()}] [DOWNLOAD FAILED]\n${resolvedPath}\n${resolvedURL}`);
|
||||
displayStatus && showToast("File Download Failed", Toasts.Type.FAILURE, { duration: statusDuration * 1000 });
|
||||
}
|
||||
} catch (error) {
|
||||
DownloadifyLogger.error(`[${getFormattedNow()}] [DOWNLOAD ERRORED]\n${resolvedPath}\n${resolvedURL}`, error);
|
||||
displayStatus && showToast("Error Downloading File", Toasts.Type.FAILURE, { duration: statusDuration * 1000 });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,253 +0,0 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2025 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { findGroupChildrenByChildId } from "@api/ContextMenu";
|
||||
import { Menu } from "@webpack/common";
|
||||
|
||||
import { InsertGroup, ParsedFile, ParsedURL } from "./definitions";
|
||||
|
||||
/**
|
||||
* Retrieve information about a file path by parsing its parts.
|
||||
*/
|
||||
export function parseFile(filePath: string): ParsedFile {
|
||||
const parts = filePath.split(/[\\/]/);
|
||||
let fileNameWithExtra = parts.pop() as string;
|
||||
|
||||
while (fileNameWithExtra.includes("%25")) {
|
||||
fileNameWithExtra = decodeURIComponent(fileNameWithExtra);
|
||||
}
|
||||
|
||||
fileNameWithExtra = decodeURIComponent(fileNameWithExtra);
|
||||
|
||||
const posColon = fileNameWithExtra.indexOf(":");
|
||||
const fileNameWithoutExtra = posColon === -1 ? fileNameWithExtra : fileNameWithExtra.slice(0, posColon);
|
||||
const twitterExtra = posColon === -1 ? null : fileNameWithExtra.slice(posColon);
|
||||
|
||||
const path = parts.join("/") + "/";
|
||||
const posDot = fileNameWithoutExtra.lastIndexOf(".");
|
||||
|
||||
if (fileNameWithoutExtra === "" || posDot < 1) {
|
||||
return {
|
||||
path,
|
||||
baseName: fileNameWithoutExtra,
|
||||
extension: null,
|
||||
twitterExtra: null
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
path,
|
||||
baseName: fileNameWithoutExtra.slice(0, posDot),
|
||||
extension: fileNameWithoutExtra.slice(posDot + 1).toLowerCase(),
|
||||
twitterExtra: twitterExtra ?? null
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve information about a URL by parsing its parts.
|
||||
*/
|
||||
export function parseURL(url: string): ParsedURL {
|
||||
const parsed = new URL(url);
|
||||
const { path, baseName, extension, twitterExtra } = parseFile(parsed.pathname);
|
||||
const params = Array.from(parsed.searchParams.entries()).reduce(
|
||||
(acc, [key, value]) => {
|
||||
if (["ex", "is", "hm"].includes(key)) {
|
||||
acc.expiry[key] = value;
|
||||
} else {
|
||||
acc.other[key] = value;
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, { expiry: {}, other: {} } as { expiry: Record<string, string>, other: Record<string, string>; }
|
||||
);
|
||||
|
||||
return {
|
||||
url: url,
|
||||
host: parsed.origin.toLowerCase(),
|
||||
path,
|
||||
baseName,
|
||||
extension,
|
||||
params,
|
||||
twitterExtra
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a file name by replacing invalid characters with underscores.
|
||||
*/
|
||||
export function sanitizeFilename(filename: string, allowUnicode: boolean, fallback?: string): string {
|
||||
let sanitized = filename.replace(/[<>:"/\\|?*\x00-\x1F]/g, "_") // windows-reserved
|
||||
.replace(/^\./, "_") // dot files
|
||||
.replace(/(\.\.)+/g, "_") // relative paths
|
||||
.replace(/[ \t\n\r]+/g, "_"); // spaces
|
||||
|
||||
if (!allowUnicode) {
|
||||
sanitized = sanitized.replace(/[^\x00-\x7F]/g, "_"); // non-ASCII characters
|
||||
} else {
|
||||
sanitized = sanitized.normalize("NFD"); // normalize unicode
|
||||
}
|
||||
|
||||
sanitized = sanitized.replace(/^_+|_+$/g, ""); // leading and trailing underscores
|
||||
return sanitized || fallback || "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current date and time formatted as a string.
|
||||
*/
|
||||
export function getFormattedNow(): string {
|
||||
return new Date().toLocaleString(undefined, {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: true
|
||||
}).replace(",", "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Join or create a group in the guild context menu.
|
||||
*/
|
||||
export function joinOrCreateContextMenuGroup(
|
||||
children: Array<any>,
|
||||
items: Array<any>,
|
||||
groupId: string,
|
||||
submenuId?: string,
|
||||
submenuLabel?: string,
|
||||
insertOrder?: InsertGroup[]
|
||||
) {
|
||||
function joinOrCreateSubmenu() {
|
||||
const existingItemShouldJoinSubmenu = existingGroup.props.children.find(child => child?.props?.submenuId === submenuId);
|
||||
let existingSubmenu = existingGroup.props.children.find(child => child?.props?.id === submenuId);
|
||||
let numitems = items.length;
|
||||
|
||||
if (existingItemShouldJoinSubmenu) {
|
||||
existingSubmenu = null;
|
||||
items.unshift(existingItemShouldJoinSubmenu);
|
||||
numitems++;
|
||||
const indexOfExistingItemShouldJoinSubmenu = existingGroup.props.children.findIndex(child => child === existingItemShouldJoinSubmenu);
|
||||
existingGroup.props.children.splice(indexOfExistingItemShouldJoinSubmenu, 1);
|
||||
}
|
||||
|
||||
if (existingSubmenu) {
|
||||
if (!Array.isArray(existingSubmenu.props.children)) {
|
||||
existingSubmenu.props.children = [existingSubmenu.props.children];
|
||||
}
|
||||
|
||||
items.forEach(member => member.props.label = member.props.submenuItemLabel ?? member.props.label);
|
||||
existingSubmenu.props.children.push(...items);
|
||||
} else if (numitems === 1) {
|
||||
if (!chosenInsertOption?.position || chosenInsertOption.position === "END") {
|
||||
existingGroup.props.children.push(items[0]);
|
||||
} else {
|
||||
existingGroup.props.children.unshift(items[0]);
|
||||
}
|
||||
} else {
|
||||
existingSubmenu = (
|
||||
<Menu.MenuItem
|
||||
id={submenuId as string}
|
||||
label={submenuLabel}
|
||||
>
|
||||
{[...items]}
|
||||
</Menu.MenuItem>
|
||||
);
|
||||
|
||||
items.forEach(member => member.props.label = member.props.submenuItemLabel ?? member.props.label);
|
||||
|
||||
if (!chosenInsertOption?.position || chosenInsertOption.position === "END") {
|
||||
existingGroup.props.children.push(existingSubmenu);
|
||||
} else {
|
||||
existingGroup.props.children.unshift(existingSubmenu);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items.forEach(member => {
|
||||
member.props.groupId = groupId;
|
||||
member.props.submenuId = submenuId;
|
||||
member.props.submenuLabel = submenuLabel;
|
||||
});
|
||||
|
||||
const insertWithOptions = insertOrder?.filter(item => item.type === "WITH_GROUP");
|
||||
let existingGroup = children.find(child => child?.props?.id === groupId);
|
||||
let chosenInsertOption;
|
||||
|
||||
if (!existingGroup && insertWithOptions) {
|
||||
let targetItem;
|
||||
|
||||
for (const item of insertWithOptions) {
|
||||
if (item.id.group) {
|
||||
targetItem = children.find(child => child?.props?.id === item.id.group);
|
||||
}
|
||||
|
||||
if (!targetItem && item.id.child) {
|
||||
targetItem = findGroupChildrenByChildId(item.id.child, children, true);
|
||||
}
|
||||
|
||||
if (targetItem) {
|
||||
existingGroup = children.find(child => child?.props?.children === targetItem);
|
||||
chosenInsertOption = item;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (existingGroup) {
|
||||
if (!Array.isArray(existingGroup.props.children)) {
|
||||
existingGroup.props.children = [existingGroup.props.children];
|
||||
}
|
||||
|
||||
if (!submenuId) {
|
||||
if (!chosenInsertOption?.position || chosenInsertOption.position === "END") {
|
||||
existingGroup.props.children.push(...items);
|
||||
} else {
|
||||
existingGroup.props.children.unshift(...items);
|
||||
}
|
||||
|
||||
return;
|
||||
} else {
|
||||
joinOrCreateSubmenu();
|
||||
}
|
||||
} else {
|
||||
if (!submenuId) {
|
||||
existingGroup = (
|
||||
<Menu.MenuGroup id={groupId}>
|
||||
{[...items]}
|
||||
</Menu.MenuGroup>
|
||||
);
|
||||
} else {
|
||||
existingGroup = (<Menu.MenuGroup id={groupId}>{[]}</Menu.MenuGroup>);
|
||||
joinOrCreateSubmenu();
|
||||
}
|
||||
|
||||
const insertAfterBeforeOptions = insertOrder?.filter(item => item.type !== "WITH_GROUP") || [];
|
||||
let targetIndex = children.length;
|
||||
let targetItem;
|
||||
|
||||
for (const item of insertAfterBeforeOptions) {
|
||||
if (item.id.group) {
|
||||
targetItem = children.find(child => child?.props?.id === item.id.group);
|
||||
}
|
||||
|
||||
if (!targetItem && item.id.child) {
|
||||
targetItem = findGroupChildrenByChildId(item.id.child, children, true);
|
||||
}
|
||||
|
||||
if (targetItem) {
|
||||
targetIndex = children.findIndex(child => child?.props?.children === targetItem);
|
||||
|
||||
if (item.type === "AFTER_GROUP") {
|
||||
targetIndex++;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
children.splice(targetIndex, 0, existingGroup);
|
||||
}
|
||||
}
|
||||
|
|
@ -100,3 +100,31 @@ export async function toggleEnabled(name: string) {
|
|||
settings.enabled = !wasEnabled;
|
||||
return await beforeReturn(settings, wasEnabled);
|
||||
}
|
||||
|
||||
export function getWindowsName(release: string) {
|
||||
const build = parseInt(release.split(".")[2]);
|
||||
if (build >= 22000) return "Windows 11";
|
||||
if (build >= 10240) return "Windows 10";
|
||||
if (build >= 9200) return "Windows 8.1";
|
||||
if (build >= 7600) return "Windows 7";
|
||||
return `Windows (${release})`;
|
||||
}
|
||||
|
||||
export function getMacOSName(release: string) {
|
||||
const major = parseInt(release.split(".")[0]);
|
||||
if (major === 24) return "MacOS 15 (Sequoia)";
|
||||
if (major === 23) return "MacOS 14 (Sonoma)";
|
||||
if (major === 22) return "MacOS 13 (Ventura)";
|
||||
if (major === 21) return "MacOS 12 (Monterey)";
|
||||
if (major === 20) return "MacOS 11 (Big Sur)";
|
||||
if (major === 19) return "MacOS 10.15 (Catalina)";
|
||||
return `MacOS (${release})`;
|
||||
}
|
||||
|
||||
export function platformName() {
|
||||
if (typeof DiscordNative === "undefined") return navigator.platform;
|
||||
if (DiscordNative.process.platform === "win32") return `${getWindowsName(DiscordNative.os.release)}`;
|
||||
if (DiscordNative.process.platform === "darwin") return `${getMacOSName(DiscordNative.os.release)} (${DiscordNative.process.arch === "arm64" ? "Apple Silicon" : "Intel Silicon"})`;
|
||||
if (DiscordNative.process.platform === "linux") return `Linux (${DiscordNative.os.release})`;
|
||||
return DiscordNative.process.platform;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { definePluginSettings } from "@api/Settings";
|
|||
import { Devs } from "@utils/constants";
|
||||
import { sendMessage } from "@utils/discord";
|
||||
import definePlugin, { OptionType } from "@utils/types";
|
||||
import { Message } from "@vencord/discord-types";
|
||||
import { Channel, Message } from "@vencord/discord-types";
|
||||
|
||||
// Taken From Signature :)
|
||||
const settings = definePluginSettings({
|
||||
|
|
@ -43,11 +43,21 @@ export default definePlugin({
|
|||
}
|
||||
},
|
||||
],
|
||||
sendForward(channels: any, message: Message) {
|
||||
for (const c of channels) {
|
||||
sendMessage(c.id, {
|
||||
content: `${message.content}\n${settings.store.forwardPreface} Forwarded from <#${message.channel_id}>`
|
||||
});
|
||||
}
|
||||
|
||||
sendForward(channels: Channel[], message: Message) {
|
||||
const chunkSize = 5;
|
||||
channels.forEach(c => {
|
||||
if (message.attachments.length) {
|
||||
for (let i = 0; i < message.attachments.length; i += chunkSize) {
|
||||
const group = message.attachments.slice(i, i + chunkSize);
|
||||
const text = `${message.content}\nAttachments:\n${group.map(a => a.url).join("\n")}\n${settings.store.forwardPreface} Forwarded from <#${message.channel_id}>`;
|
||||
sendMessage(c.id, { content: text });
|
||||
}
|
||||
} else {
|
||||
sendMessage(c.id, {
|
||||
content: `${message.content}\n${settings.store.forwardPreface} Forwarded from <#${message.channel_id}>`
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ export default function FriendCodesPanel() {
|
|||
<Button
|
||||
style={{ marginLeft: "8px" }}
|
||||
color={Button.Colors.RED}
|
||||
look={Button.Looks.LINK}
|
||||
look={Button.Looks.FILLED}
|
||||
disabled={!invites.length}
|
||||
onClick={() => revokeFriendInvites().then(setInvites([]))}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@
|
|||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
export function sproutIcon(props) {
|
||||
export function sproutIcon() {
|
||||
return (
|
||||
<svg width={props.height} height={props.height} viewBox="0 0 303 303" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg width={"22px"} height={"22px"} viewBox="0 0 303 303" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="shape-gradient-Circle" gradientTransform="rotate(90)">
|
||||
<stop offset="0%" stopColor="#5865F1" />
|
||||
|
|
@ -52,9 +52,9 @@ export function sproutIcon(props) {
|
|||
);
|
||||
}
|
||||
|
||||
export function bloomingIcon(props) {
|
||||
export function bloomingIcon() {
|
||||
return (
|
||||
<svg width={props.height} height={props.height} viewBox="0 0 303 303" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg width={"22px"} height={"22px"} viewBox="0 0 303 303" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient gradientTransform="rotate(90)">
|
||||
<stop offset="0%" stopColor="#5865F1" />
|
||||
|
|
@ -99,9 +99,9 @@ export function bloomingIcon(props) {
|
|||
);
|
||||
}
|
||||
|
||||
export function burningIcon(props) {
|
||||
export function burningIcon() {
|
||||
return (
|
||||
<svg width={props.width} height={props.height} viewBox="0 0 303 303" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg width={"22px"} height={"22px"} viewBox="0 0 303 303" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient gradientTransform="rotate(90)">
|
||||
<stop offset="0%" stopColor="#5865F1" />
|
||||
|
|
@ -146,9 +146,9 @@ export function burningIcon(props) {
|
|||
);
|
||||
}
|
||||
|
||||
export function fighterIcon(props) {
|
||||
export function fighterIcon() {
|
||||
return (
|
||||
<svg width={props.width} height={props.height} viewBox="0 0 303 303" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg width={"22px"} height={"22px"} viewBox="0 0 303 303" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="shape-gradient-Circle" gradientTransform="rotate(90)">
|
||||
<stop offset="0%" stopColor="#5865F1" />
|
||||
|
|
@ -192,9 +192,9 @@ export function fighterIcon(props) {
|
|||
);
|
||||
}
|
||||
|
||||
export function starIcon(props) {
|
||||
export function starIcon() {
|
||||
return (
|
||||
<svg width={props.width} height={props.height} viewBox="0 0 303 303" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg width={"22px"} height={"22px"} viewBox="0 0 303 303" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="shape-gradient-Circle" gradientTransform="rotate(90)">
|
||||
<stop offset="0%" stopColor="#5865F1" />
|
||||
|
|
@ -239,9 +239,9 @@ export function starIcon(props) {
|
|||
);
|
||||
}
|
||||
|
||||
export function royalIcon(props) {
|
||||
export function royalIcon() {
|
||||
return (
|
||||
<svg width={props.width} height={props.height} viewBox="0 0 303 303" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg width={"22px"} height={"22px"} viewBox="0 0 303 303" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="shape-gradient-Circle" gradientTransform="rotate(90)">
|
||||
<stop offset="0%" stopColor="#5865F1" />
|
||||
|
|
@ -286,9 +286,9 @@ export function royalIcon(props) {
|
|||
);
|
||||
}
|
||||
|
||||
export function bestiesIcon(props) {
|
||||
export function bestiesIcon() {
|
||||
return (
|
||||
<svg width={props.width} height={props.height} viewBox="0 0 303 303" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg width={"22px"} height={"22px"} viewBox="0 0 303 303" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="shape-gradient-Circle" gradientTransform="rotate(90)">
|
||||
<stop offset="0%" stopColor="#5865F1" />
|
||||
|
|
|
|||
|
|
@ -111,12 +111,12 @@ function openRankModal(rank: rankInfo) {
|
|||
));
|
||||
}
|
||||
|
||||
function getBadgeComponent(rank,) {
|
||||
function getBadgeComponent(rank) {
|
||||
// there may be a better button component to do this with
|
||||
return (
|
||||
<div style={{ transform: "scale(0.80)" }}>
|
||||
<Button onClick={() => openRankModal(rank)} width={"21.69px"} height={"21.69px"} size={Button.Sizes.NONE} look={Button.Looks.LINK}>
|
||||
<rank.assetSVG height={"21.69px"} />
|
||||
<div style={{ transform: "scale(0.80)", marginTop: "3px" }}>
|
||||
<Button onClick={() => openRankModal(rank)} width={"22px"} height={"22px"} size={Button.Sizes.NONE} look={Button.Looks.LINK}>
|
||||
<rank.assetSVG />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ export function ReposModal({ repos, username, rootProps }: ReposModalProps) {
|
|||
</Button>
|
||||
<Button
|
||||
color={Button.Colors.TRANSPARENT}
|
||||
look={Button.Looks.LINK}
|
||||
look={Button.Looks.FILLED}
|
||||
onClick={rootProps.onClose}
|
||||
>
|
||||
Close
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ function HiddenServersButton() {
|
|||
{actuallyHidden > 0 ? (
|
||||
<Button
|
||||
className={cl("button")}
|
||||
look={Button.Looks.LINK}
|
||||
look={Button.Looks.FILLED}
|
||||
size={Button.Sizes.MIN}
|
||||
onClick={() => openHiddenServersModal()}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -147,7 +147,7 @@ export function NoteBookTabs({ tabs, selectedTabId, onSelectTab }: { tabs: strin
|
|||
{...props}
|
||||
className={"vc-notebook-overflow-chevron"}
|
||||
size={Button.Sizes.SMALL}
|
||||
look={Button.Looks.LINK}
|
||||
look={Button.Looks.FILLED}
|
||||
onClick={() => setShow(v => !v)}
|
||||
>
|
||||
<SvgOverFlowIcon className={classes(overflowIcon)} width={16} height={16} />
|
||||
|
|
|
|||
|
|
@ -130,7 +130,6 @@ export default definePlugin({
|
|||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
onClick={() => {
|
||||
ignoredChannelIds.add(channel.id);
|
||||
FluxDispatcher.dispatch({
|
||||
type: "CALL_UPDATE",
|
||||
channelId: channel.id,
|
||||
|
|
|
|||
|
|
@ -4,45 +4,60 @@
|
|||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { getUserSettingLazy } from "@api/UserSettings";
|
||||
import { HeadingSecondary } from "@components/Heading";
|
||||
import { Paragraph } from "@components/Paragraph";
|
||||
import { Devs } from "@utils/constants";
|
||||
import { Devs, EquicordDevs } from "@utils/constants";
|
||||
import definePlugin from "@utils/types";
|
||||
import { VoiceState } from "@vencord/discord-types";
|
||||
import { findByCodeLazy } from "@webpack";
|
||||
import { ChannelStore, PermissionsBits, PermissionStore, SelectedChannelStore, UserStore } from "@webpack/common";
|
||||
import { findByCodeLazy, findStoreLazy } from "@webpack";
|
||||
import { ChannelStore, MediaEngineStore, PermissionsBits, PermissionStore, SelectedChannelStore, UserStore, VoiceActions } from "@webpack/common";
|
||||
|
||||
import { getCurrentMedia, settings } from "./utils";
|
||||
|
||||
const startStream = findByCodeLazy('type:"STREAM_START"');
|
||||
|
||||
let hasStreamed;
|
||||
const startStream = findByCodeLazy('type:"STREAM_START"');
|
||||
const StreamPreviewSettings = getUserSettingLazy("voiceAndVideo", "disableStreamPreviews")!;
|
||||
const ApplicationStreamingSettingsStore = findStoreLazy("ApplicationStreamingSettingsStore");
|
||||
|
||||
async function autoStartStream() {
|
||||
const selected = SelectedChannelStore.getVoiceChannelId();
|
||||
if (!selected) return;
|
||||
const channel = ChannelStore.getChannel(selected);
|
||||
|
||||
if (channel.type === 13 || !PermissionStore.can(PermissionsBits.STREAM, channel)) return;
|
||||
const channel = ChannelStore.getChannel(selected);
|
||||
const isGuildChannel = !channel.isDM() && !channel.isGroupDM();
|
||||
|
||||
if (channel.type === 13 || isGuildChannel && !PermissionStore.can(PermissionsBits.STREAM, channel)) return;
|
||||
|
||||
if (settings.store.autoDeafen && !MediaEngineStore.isSelfDeaf()) {
|
||||
VoiceActions.toggleSelfDeaf();
|
||||
} else if (settings.store.autoMute && !MediaEngineStore.isSelfMute()) {
|
||||
VoiceActions.toggleSelfMute();
|
||||
}
|
||||
|
||||
const streamMedia = await getCurrentMedia();
|
||||
const preview = StreamPreviewSettings.getSetting();
|
||||
const { soundshareEnabled } = ApplicationStreamingSettingsStore.getState();
|
||||
let sourceId = streamMedia.id;
|
||||
if (streamMedia.type === "video_device") sourceId = `camera:${streamMedia.id}`;
|
||||
|
||||
startStream(channel.guild_id, selected, {
|
||||
startStream(channel.guild_id ?? null, selected, {
|
||||
"pid": null,
|
||||
"sourceId": streamMedia.id,
|
||||
"sourceId": sourceId,
|
||||
"sourceName": streamMedia.name,
|
||||
"audioSourceId": null,
|
||||
"sound": true,
|
||||
"previewDisabled": false
|
||||
"audioSourceId": streamMedia.name,
|
||||
"sound": soundshareEnabled,
|
||||
"previewDisabled": preview
|
||||
});
|
||||
}
|
||||
|
||||
export default definePlugin({
|
||||
name: "InstantScreenshare",
|
||||
description: "Instantly screenshare when joining a voice channel",
|
||||
authors: [Devs.HAHALOSAH, Devs.thororen],
|
||||
description: "Instantly screenshare when joining a voice channel with support for desktop sources, windows, and video input devices (cameras, capture cards)",
|
||||
authors: [Devs.HAHALOSAH, Devs.thororen, EquicordDevs.mart],
|
||||
getCurrentMedia,
|
||||
settings,
|
||||
|
||||
settingsAboutComponent: () => (
|
||||
<>
|
||||
<HeadingSecondary>For Linux</HeadingSecondary>
|
||||
|
|
@ -51,8 +66,19 @@ export default definePlugin({
|
|||
<br />
|
||||
For X11 it may or may not work :shrug:
|
||||
</Paragraph>
|
||||
<br />
|
||||
<HeadingSecondary>Video Devices</HeadingSecondary>
|
||||
<Paragraph>
|
||||
Supports cameras and capture cards (like Elgato HD60X) when enabled in settings
|
||||
</Paragraph>
|
||||
<br />
|
||||
<HeadingSecondary>Regarding Sound & Preview Settings</HeadingSecondary>
|
||||
<Paragraph>
|
||||
We use the settings set and used by discord to decide if stream preview and sound should be enabled or not
|
||||
</Paragraph>
|
||||
</>
|
||||
),
|
||||
|
||||
flux: {
|
||||
async VOICE_STATE_UPDATES({ voiceStates }: { voiceStates: VoiceState[]; }) {
|
||||
const myId = UserStore.getCurrentUser().id;
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { Heading } from "@components/Heading";
|
|||
import { Paragraph } from "@components/Paragraph";
|
||||
import { Logger } from "@utils/Logger";
|
||||
import { OptionType } from "@utils/types";
|
||||
import { findByCodeLazy } from "@webpack";
|
||||
import { findByCodeLazy, findByPropsLazy } from "@webpack";
|
||||
import { MediaEngineStore, SearchableSelect, useEffect, useState } from "@webpack/common";
|
||||
|
||||
interface PickerProps {
|
||||
|
|
@ -18,26 +18,53 @@ interface PickerProps {
|
|||
}
|
||||
|
||||
const getDesktopSources = findByCodeLazy("desktop sources");
|
||||
const configModule = findByPropsLazy("getOutputVolume");
|
||||
const log = new Logger("InstantScreenShare");
|
||||
|
||||
export const settings = definePluginSettings({
|
||||
streamMedia: {
|
||||
type: OptionType.COMPONENT,
|
||||
component: SettingSection,
|
||||
},
|
||||
includeVideoDevices: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Include video input devices (cameras, capture cards) in the source list",
|
||||
default: false,
|
||||
},
|
||||
autoMute: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Automatically mute your microphone when joining a voice channel",
|
||||
default: false,
|
||||
},
|
||||
autoDeafen: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Automatically deafen when joining a voice channel (also mutes you)",
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
export async function getCurrentMedia() {
|
||||
const media = MediaEngineStore.getMediaEngine();
|
||||
const sources = [
|
||||
...(await getDesktopSources(media, ["screen"], null) ?? []),
|
||||
...(await getDesktopSources(media, ["window", "application"], null) ?? [])
|
||||
];
|
||||
const streamMedia = sources.find(screen => screen.id === settings.store.streamMedia);
|
||||
console.log(sources);
|
||||
const sources = await getDesktopSources(media, ["screen", "window"], null) ?? [];
|
||||
|
||||
if (settings.store.includeVideoDevices) {
|
||||
try {
|
||||
const videoDevices = Object.values(configModule.getVideoDevices() || {});
|
||||
const videoSources = videoDevices.map((device: any) => ({
|
||||
id: device.id,
|
||||
name: device.name,
|
||||
type: "video_device"
|
||||
}));
|
||||
sources.push(...videoSources);
|
||||
} catch (e) {
|
||||
new log.warn("Failed to get video devices:", e);
|
||||
}
|
||||
}
|
||||
|
||||
const streamMedia = sources.find(screen => screen.id === settings.store.streamMedia);
|
||||
if (streamMedia) return streamMedia;
|
||||
|
||||
new Logger("InstantScreenShare").error(`Stream Media "${settings.store.streamMedia}" not found. Resetting to default.`);
|
||||
log.error(`Stream Media "${settings.store.streamMedia}" not found. Resetting to default.`);
|
||||
|
||||
settings.store.streamMedia = sources[0];
|
||||
return sources[0];
|
||||
|
|
@ -63,7 +90,7 @@ function StreamSimplePicker({ streamMediaSelection, streamMedia }: PickerProps)
|
|||
}
|
||||
|
||||
function ScreenSetting() {
|
||||
const { streamMedia } = settings.use(["streamMedia"]);
|
||||
const { streamMedia, includeVideoDevices } = settings.use(["streamMedia", "includeVideoDevices"]);
|
||||
const media = MediaEngineStore.getMediaEngine();
|
||||
const [streamMediaSelection, setStreamMediaSelection] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
|
@ -72,10 +99,21 @@ function ScreenSetting() {
|
|||
let active = true;
|
||||
async function fetchMedia() {
|
||||
setLoading(true);
|
||||
const sources = [
|
||||
...(await getDesktopSources(media, ["screen"], null) ?? []),
|
||||
...(await getDesktopSources(media, ["window", "application"], null) ?? [])
|
||||
];
|
||||
const sources = await getDesktopSources(media, ["screen", "window"], null) ?? [];
|
||||
|
||||
if (includeVideoDevices) {
|
||||
try {
|
||||
const videoDevices = Object.values(configModule.getVideoDevices() || {});
|
||||
const videoSources = videoDevices.map((device: any) => ({
|
||||
id: device.id,
|
||||
name: device.name,
|
||||
type: "video_device"
|
||||
}));
|
||||
sources.push(...videoSources);
|
||||
} catch (e) {
|
||||
log.warn("Failed to get video devices:", e);
|
||||
}
|
||||
}
|
||||
|
||||
if (active) {
|
||||
setStreamMediaSelection(sources);
|
||||
|
|
@ -84,7 +122,7 @@ function ScreenSetting() {
|
|||
}
|
||||
fetchMedia();
|
||||
return () => { active = false; };
|
||||
}, []);
|
||||
}, [includeVideoDevices]);
|
||||
|
||||
if (loading) return <Paragraph>Loading media sources...</Paragraph>;
|
||||
if (!streamMediaSelection.length) return <Paragraph>No Media found.</Paragraph>;
|
||||
|
|
|
|||
|
|
@ -6,9 +6,10 @@
|
|||
|
||||
import { showNotification } from "@api/Notifications";
|
||||
import { Settings } from "@api/Settings";
|
||||
import { gitHashShort } from "@shared/vencordUserAgent";
|
||||
import { copyToClipboard } from "@utils/clipboard";
|
||||
import { relaunch, showItemInFolder } from "@utils/native";
|
||||
import { checkForUpdates, getRepo, shortGitHash } from "@utils/updater";
|
||||
import { checkForUpdates, getRepo } from "@utils/updater";
|
||||
import { GuildStore, NavigationRouter, SettingsRouter, Toasts } from "@webpack/common";
|
||||
|
||||
import gitRemote from "~git-remote";
|
||||
|
|
@ -115,7 +116,7 @@ export const actions: ButtonAction[] = [
|
|||
|
||||
{
|
||||
id: "copyGitInfo", label: "Copy Git Info", callback: async () => {
|
||||
copyToClipboard(`gitHash: ${shortGitHash()}\ngitRemote: ${gitRemote}`);
|
||||
copyToClipboard(`gitHash: ${gitHashShort}\ngitRemote: ${gitRemote}`);
|
||||
|
||||
Toasts.show({
|
||||
message: "Copied git info to clipboard!",
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@ function Collapsible({ title, children }) {
|
|||
<div>
|
||||
<Button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
look={Button.Looks.LINK}
|
||||
look={Button.Looks.FILLED}
|
||||
size={Button.Sizes.SMALL}
|
||||
className={cl("collapsible")}>
|
||||
<div style={{ display: "flex", alignItems: "center" }}>
|
||||
|
|
@ -145,7 +145,7 @@ function ListedIds({ listIds, setListIds }) {
|
|||
setListIds(values);
|
||||
update();
|
||||
}}
|
||||
look={Button.Looks.LINK}
|
||||
look={Button.Looks.FILLED}
|
||||
size={Button.Sizes.SMALL}
|
||||
className={cl("delete")}>
|
||||
<DeleteIcon />
|
||||
|
|
@ -221,7 +221,7 @@ function KeywordEntries() {
|
|||
</div>
|
||||
<Button
|
||||
onClick={() => removeKeywordEntry(i, update)}
|
||||
look={Button.Looks.LINK}
|
||||
look={Button.Looks.FILLED}
|
||||
size={Button.Sizes.SMALL}
|
||||
className={cl("delete")}>
|
||||
<DeleteIcon />
|
||||
|
|
|
|||
|
|
@ -214,7 +214,7 @@ function VerifyModal({
|
|||
) : (
|
||||
<Button
|
||||
color={Button.Colors.LINK}
|
||||
look={Button.Looks.LINK}
|
||||
look={Button.Looks.FILLED}
|
||||
onClick={props.onClose}
|
||||
>
|
||||
{state === VerifyState.NotFound
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import { closeAllModals, ModalContent, ModalFooter, ModalHeader, ModalProps, Mod
|
|||
import { LazyComponent } from "@utils/react";
|
||||
import { User } from "@vencord/discord-types";
|
||||
import { find, findByCode, findByCodeLazy } from "@webpack";
|
||||
import { Alerts, Button, ChannelStore, ContextMenuApi, FluxDispatcher, Menu, NavigationRouter, React, TabBar, TextInput, Tooltip, useMemo, useRef, useState } from "@webpack/common";
|
||||
import { Alerts, Button, ChannelStore, ContextMenuApi, FluxDispatcher, GuildStore, Menu, NavigationRouter, React, TabBar, TextInput, Tooltip, useMemo, useRef, useState } from "@webpack/common";
|
||||
|
||||
import { clearMessagesIDB, DBMessageRecord, deleteMessageIDB, deleteMessagesBulkIDB } from "../db";
|
||||
import { settings } from "../index";
|
||||
|
|
@ -159,7 +159,7 @@ export function LogsModal({ modalProps, initalQuery }: Props) {
|
|||
</Button>
|
||||
<Button
|
||||
style={{ marginRight: "16px" }}
|
||||
color={Button.Colors.LINK}
|
||||
color={Button.Colors.BRAND}
|
||||
disabled={messages?.length === 0}
|
||||
onClick={() => Alerts.show({
|
||||
title: "Clear Logs",
|
||||
|
|
@ -176,7 +176,7 @@ export function LogsModal({ modalProps, initalQuery }: Props) {
|
|||
Clear Visible Logs
|
||||
</Button>
|
||||
<Button
|
||||
look={Button.Looks.LINK}
|
||||
style={{ marginRight: "16px" }}
|
||||
color={Button.Colors.PRIMARY}
|
||||
onClick={() => {
|
||||
setSortNewest(e => {
|
||||
|
|
@ -308,6 +308,9 @@ function LMessage({ log, isGroupStart, reset, }: LMessageProps) {
|
|||
|
||||
if (!message) return null;
|
||||
|
||||
const channel = ChannelStore.getChannel(message?.channel_id);
|
||||
const guild = GuildStore.getGuild(channel?.guild_id);
|
||||
|
||||
return (
|
||||
<div
|
||||
onContextMenu={e => {
|
||||
|
|
@ -416,8 +419,16 @@ function LMessage({ log, isGroupStart, reset, }: LMessageProps) {
|
|||
isAutomodBlockedMessage={false}
|
||||
/>
|
||||
}
|
||||
|
||||
/>
|
||||
{settings.store.ShowWhereMessageIsFrom && channel?.isDM() && message?.author && (
|
||||
<span className={`${cl("from")} ${message.deleted ? cl("from-deleted") : cl("from-edited")}`}>From {message.author.username}'s DMs</span>
|
||||
)}
|
||||
{settings.store.ShowWhereMessageIsFrom && channel?.isGroupDM() && channel?.name && (
|
||||
<span className={`${cl("from")} ${message.deleted ? cl("from-deleted") : cl("from-edited")}`}>From {channel.name} Group DM</span>
|
||||
)}
|
||||
{settings.store.ShowWhereMessageIsFrom && !channel?.isDM() && !channel?.isGroupDM() && channel?.name && guild?.name && (
|
||||
<span className={`${cl("from")} ${message.deleted ? cl("from-deleted") : cl("from-edited")}`}>From {channel.name} in {guild.name}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -111,6 +111,12 @@ export const settings = definePluginSettings({
|
|||
restartNeeded: true,
|
||||
},
|
||||
|
||||
ShowWhereMessageIsFrom: {
|
||||
default: false,
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Show message channel/author name and server name",
|
||||
},
|
||||
|
||||
messagesToDisplayAtOnceInLogs: {
|
||||
default: 100,
|
||||
type: OptionType.NUMBER,
|
||||
|
|
|
|||
|
|
@ -58,6 +58,19 @@
|
|||
justify-content: space-around;
|
||||
}
|
||||
|
||||
.msg-logger-modal-from {
|
||||
font-size: 14px;
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.msg-logger-modal-from-deleted {
|
||||
color: var(--status-danger);
|
||||
}
|
||||
|
||||
.msg-logger-modal-from-edited {
|
||||
color: var(--white-600);
|
||||
}
|
||||
|
||||
.vc-log-toolbox-btn svg {
|
||||
color: var(--interactive-normal);
|
||||
}
|
||||
|
|
@ -82,4 +95,4 @@
|
|||
.folder-upload-button {
|
||||
margin: 6px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@
|
|||
|
||||
import "./styles.css";
|
||||
|
||||
import { showNotification } from "@api/Notifications";
|
||||
import { addServerListElement, removeServerListElement, ServerListRenderPosition } from "@api/ServerList";
|
||||
import { ErrorBoundary, openPluginModal } from "@components/index";
|
||||
import { EquicordDevs } from "@utils/constants";
|
||||
|
|
@ -558,6 +559,15 @@ async function startVideoProgressTracking(quest: Quest, questDuration: number):
|
|||
|
||||
if (success) {
|
||||
QuestifyLogger.info(`[${getFormattedNow()}] Quest ${questName} completed.`);
|
||||
|
||||
if (settings.store.notifyOnQuestComplete) {
|
||||
showNotification({
|
||||
title: "Quest Completed!",
|
||||
body: `The ${questName} Quest has completed.`,
|
||||
dismissOnClick: true,
|
||||
onClick: () => NavigationRouter.transitionTo(`${questPath}#${quest.id}`)
|
||||
});
|
||||
}
|
||||
} else {
|
||||
QuestifyLogger.error(`[${getFormattedNow()}] Failed to complete Quest ${questName}.`);
|
||||
}
|
||||
|
|
@ -642,6 +652,15 @@ async function startPlayGameProgressTracking(quest: Quest, questDuration: number
|
|||
|
||||
if (success) {
|
||||
QuestifyLogger.info(`[${getFormattedNow()}] Quest ${questName} completed.`);
|
||||
|
||||
if (settings.store.notifyOnQuestComplete) {
|
||||
showNotification({
|
||||
title: "Quest Completed!",
|
||||
body: `The ${questName} Quest has completed.`,
|
||||
dismissOnClick: true,
|
||||
onClick: () => NavigationRouter.transitionTo(`${questPath}#${quest.id}`),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
QuestifyLogger.error(`[${getFormattedNow()}] Failed to complete Quest ${questName}.`);
|
||||
}
|
||||
|
|
@ -655,6 +674,15 @@ async function startPlayGameProgressTracking(quest: Quest, questDuration: number
|
|||
|
||||
if (success) {
|
||||
QuestifyLogger.info(`[${getFormattedNow()}] Quest ${questName} completed.`);
|
||||
|
||||
if (settings.store.notifyOnQuestComplete) {
|
||||
showNotification({
|
||||
title: "Quest Completed!",
|
||||
body: `The ${questName} Quest has completed.`,
|
||||
dismissOnClick: true,
|
||||
onClick: () => NavigationRouter.transitionTo(`${questPath}#${quest.id}`),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
QuestifyLogger.error(`[${getFormattedNow()}] Failed to complete Quest ${questName}.`);
|
||||
}
|
||||
|
|
@ -1115,14 +1143,6 @@ export default definePlugin({
|
|||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
// Prevent scrolling to a sponsored Quest.
|
||||
find: "Id(\"quest-tile-\".concat",
|
||||
replacement: {
|
||||
match: /(?=document.getElementById)/,
|
||||
replace: "null&&"
|
||||
}
|
||||
},
|
||||
{
|
||||
// Whether preloading assets is enabled or not, the placeholders loading
|
||||
// before the assets causes a lot of element shifting, whereas if
|
||||
|
|
|
|||
|
|
@ -674,7 +674,8 @@ function DisableQuestsSetting(): JSX.Element {
|
|||
disableFriendsListActiveNowPromotion,
|
||||
makeMobileQuestsDesktopCompatible,
|
||||
completeVideoQuestsInBackground,
|
||||
completeGameQuestsInBackground
|
||||
completeGameQuestsInBackground,
|
||||
notifyOnQuestComplete
|
||||
} = settings.use([
|
||||
"disableQuestsEverything",
|
||||
"disableQuestsDiscoveryTab",
|
||||
|
|
@ -685,7 +686,8 @@ function DisableQuestsSetting(): JSX.Element {
|
|||
"disableFriendsListActiveNowPromotion",
|
||||
"makeMobileQuestsDesktopCompatible",
|
||||
"completeVideoQuestsInBackground",
|
||||
"completeGameQuestsInBackground"
|
||||
"completeGameQuestsInBackground",
|
||||
"notifyOnQuestComplete"
|
||||
]);
|
||||
|
||||
const options: DynamicDropdownSettingOption[] = [
|
||||
|
|
@ -696,9 +698,10 @@ function DisableQuestsSetting(): JSX.Element {
|
|||
{ label: "Disable Popup Above User Panel", value: "popup", selected: disableQuestsPopupAboveAccountPanel, type: "disable" },
|
||||
{ label: "Disable Gift Inventory Relocation Notice", value: "inventory", selected: disableQuestsGiftInventoryRelocationNotice, type: "disable" },
|
||||
{ label: "Disable Friends List Active Now Promotion", value: "friends-list", selected: disableFriendsListActiveNowPromotion, type: "disable" },
|
||||
{ label: "Make Mobile Quests Desktop Compatible", value: "mobile-desktop-compatible", selected: makeMobileQuestsDesktopCompatible, type: "modification" },
|
||||
{ label: "Complete Game Quests in Background", value: "game-quests-background", selected: completeGameQuestsInBackground, type: "modification" },
|
||||
{ label: "Complete Video Quests in Background", value: "video-quests-background", selected: completeVideoQuestsInBackground, type: "modification" },
|
||||
{ label: "Make Mobile Quests Desktop Compatible", value: "mobile-desktop-compatible", selected: makeMobileQuestsDesktopCompatible, type: "modification" }
|
||||
{ label: "Notify on Auto-Complete", value: "notify-on-complete", selected: notifyOnQuestComplete, type: "modification" },
|
||||
];
|
||||
|
||||
const disableOptions = options.filter(option => option.type === "disable");
|
||||
|
|
@ -725,9 +728,10 @@ function DisableQuestsSetting(): JSX.Element {
|
|||
settings.store.disableQuestsBadgeOnUserProfiles = enabledValues.includes("badge");
|
||||
settings.store.disableQuestsGiftInventoryRelocationNotice = enabledValues.includes("inventory");
|
||||
settings.store.disableFriendsListActiveNowPromotion = enabledValues.includes("friends-list");
|
||||
settings.store.makeMobileQuestsDesktopCompatible = enabledValues.includes("mobile-desktop-compatible");
|
||||
settings.store.completeGameQuestsInBackground = enabledValues.includes("game-quests-background");
|
||||
settings.store.completeVideoQuestsInBackground = enabledValues.includes("video-quests-background");
|
||||
settings.store.makeMobileQuestsDesktopCompatible = enabledValues.includes("mobile-desktop-compatible");
|
||||
settings.store.notifyOnQuestComplete = enabledValues.includes("notify-on-complete");
|
||||
|
||||
redoAutoFetch ? checkAutoFetchInterval(settings.store.fetchingQuestsInterval) : null;
|
||||
setCurrentValue(enabled);
|
||||
|
|
@ -1658,6 +1662,12 @@ export const settings = definePluginSettings({
|
|||
}
|
||||
},
|
||||
},
|
||||
notifyOnQuestComplete: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Show a notification when a Quest is auto-completed.",
|
||||
default: true,
|
||||
hidden: true
|
||||
},
|
||||
questButton: {
|
||||
type: OptionType.COMPONENT,
|
||||
component: QuestButtonSettings,
|
||||
|
|
|
|||
97
src/equicordplugins/songLink.desktop/Providers.ts
Normal file
97
src/equicordplugins/songLink.desktop/Providers.ts
Normal file
File diff suppressed because one or more lines are too long
62
src/equicordplugins/songLink.desktop/Settings.tsx
Normal file
62
src/equicordplugins/songLink.desktop/Settings.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2025 nin0
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { FormSwitch } from "@components/FormSwitch";
|
||||
import { HeadingTertiary } from "@components/Heading";
|
||||
|
||||
import { settings } from ".";
|
||||
import Providers from "./Providers";
|
||||
|
||||
export function Settings() {
|
||||
const blazinglyFastSettings = settings.use(["servicesSettings"]);
|
||||
|
||||
return <div style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 16,
|
||||
}}>
|
||||
{
|
||||
Object.entries(Providers).map(provider => <div key={provider[0]} style={{
|
||||
borderBottom: "1px solid var(--border-subtle)",
|
||||
paddingBottom: 8
|
||||
}}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<img
|
||||
src={provider[1].logo}
|
||||
alt={`${provider[1].name} logo`}
|
||||
style={{ width: 16, height: 16, objectFit: "contain", display: "block" }}
|
||||
/>
|
||||
<div style={{ display: "flex", alignItems: "center" }}>
|
||||
<HeadingTertiary>{provider[1].name}</HeadingTertiary>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 4
|
||||
}}>
|
||||
<FormSwitch
|
||||
title="Show if available"
|
||||
hideBorder={true}
|
||||
key={`${provider[0]}-visible`}
|
||||
value={blazinglyFastSettings.servicesSettings[provider[0]].enabled}
|
||||
onChange={v => settings.store.servicesSettings[provider[0]].enabled = v}
|
||||
/>
|
||||
{
|
||||
/* @ts-ignore */
|
||||
provider[1].native && <FormSwitch
|
||||
title="Open in desktop app"
|
||||
hideBorder={true}
|
||||
key={`${provider[0]}-native`}
|
||||
value={blazinglyFastSettings.servicesSettings[provider[0]].openInNative}
|
||||
onChange={v => settings.store.servicesSettings[provider[0]].openInNative = v}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</div>)
|
||||
}
|
||||
</div>;
|
||||
}
|
||||
61
src/equicordplugins/songLink.desktop/SongLinker.tsx
Normal file
61
src/equicordplugins/songLink.desktop/SongLinker.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2025 nin0
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { BaseText } from "@components/BaseText";
|
||||
import { findComponentByCodeLazy } from "@webpack";
|
||||
import { Button, Card, useEffect, useState } from "@webpack/common";
|
||||
|
||||
import pl, { Native, settings, SongLinkResult } from ".";
|
||||
import Providers from "./Providers";
|
||||
|
||||
const HeadphonesIcon = findComponentByCodeLazy("4.1-2.13h1.86A9");
|
||||
|
||||
export default function SongLinker({ url }: { url: string; }) {
|
||||
const [songData, setSongData] = useState<SongLinkResult>();
|
||||
|
||||
useEffect(() => {
|
||||
async function doStuff() {
|
||||
if (pl.cache[url]) return void setSongData(pl.cache[url]);
|
||||
|
||||
const sd = await Native.getTrackData(url);
|
||||
setSongData(sd);
|
||||
pl.addToCache(url, sd);
|
||||
}
|
||||
doStuff();
|
||||
});
|
||||
|
||||
return <BaseText>
|
||||
{
|
||||
songData ? <Card style={{
|
||||
padding: "10px 15px"
|
||||
}}>
|
||||
<div>
|
||||
<BaseText style={{ display: "flex", alignItems: "center", gap: "6px", fontWeight: 600, fontSize: "1.05rem" }}>
|
||||
<HeadphonesIcon /> {songData.info?.title} - {songData.info?.artist}
|
||||
</BaseText>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: "10px", marginTop: "10px" }}>
|
||||
{
|
||||
Object.keys(songData.links).map(service => settings.store.servicesSettings[service].enabled && <Button key={`${service}-${url}`} style={{
|
||||
width: "20px !important"
|
||||
// @ts-ignore
|
||||
}} variant="secondary" onClick={() => {
|
||||
// FIXME: fix type error
|
||||
// @ts-expect-error ???
|
||||
VencordNative.native.openExternal(settings.store.servicesSettings[service].openInNative && Providers[service].native ? songData.links[service].nativeUri : songData.links[service].url);
|
||||
}}>
|
||||
<img
|
||||
src={Providers[service].logo}
|
||||
alt={`${Providers[service]} logo`}
|
||||
style={{ width: 16, height: 16, objectFit: "contain", display: "block" }}
|
||||
/>
|
||||
</Button>)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</Card> : <BaseText>Loading song link...</BaseText>
|
||||
}
|
||||
</BaseText >;
|
||||
}
|
||||
87
src/equicordplugins/songLink.desktop/index.tsx
Normal file
87
src/equicordplugins/songLink.desktop/index.tsx
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2025 nin0
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { definePluginSettings } from "@api/Settings";
|
||||
import { Devs } from "@utils/constants";
|
||||
import definePlugin, { OptionType, PluginNative } from "@utils/types";
|
||||
|
||||
import Providers from "./Providers";
|
||||
import { Settings } from "./Settings";
|
||||
import SongLinker from "./SongLinker";
|
||||
|
||||
export const settings = definePluginSettings({
|
||||
servicesSettings: {
|
||||
type: OptionType.CUSTOM,
|
||||
description: "settings for services",
|
||||
default: Object.fromEntries(Object.entries(Providers).map(([name, data]) => [name, {
|
||||
enabled: true,
|
||||
// @ts-ignore
|
||||
openInNative: data.native || false
|
||||
}]))
|
||||
},
|
||||
userCountry: {
|
||||
type: OptionType.STRING,
|
||||
description: "Country used for lookup (Two letter country code)",
|
||||
default: "US"
|
||||
},
|
||||
servicesComponent: {
|
||||
type: OptionType.COMPONENT,
|
||||
component: () => <Settings />
|
||||
}
|
||||
});
|
||||
|
||||
export type SongLinkResult = {
|
||||
info?: {
|
||||
title: string;
|
||||
artist: string;
|
||||
};
|
||||
links: {
|
||||
[platform: string]: {
|
||||
url: string;
|
||||
nativeUri?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export const Native = VencordNative.pluginHelpers.SongLink as PluginNative<typeof import("./native")>;
|
||||
|
||||
export default definePlugin({
|
||||
name: "SongLink",
|
||||
description: "Adds streaming service buttons below song links",
|
||||
authors: [Devs.nin0dev],
|
||||
settings,
|
||||
Providers,
|
||||
cache: ({} as Record<string, SongLinkResult>),
|
||||
addToCache(link, data: SongLinkResult) {
|
||||
this.cache[link] = data;
|
||||
},
|
||||
renderMessageAccessory(props: Record<string, any>) {
|
||||
const { content }: {
|
||||
content: string;
|
||||
} = props.message;
|
||||
if (!content) return;
|
||||
|
||||
const regexes = [
|
||||
/https:\/\/(?:open|play)\.spotify\.com\/track\/[a-zA-Z0-9]+/, // spotify
|
||||
/https:\/\/(music|itunes)\.apple\.com\/[a-z]{2}\/album\/\S+/, // apple music/itunes
|
||||
/https:\/\/music\.youtube\.com\/watch\?v=[0-9A-Za-z_-]+/, // yt music
|
||||
/https:\/\/tidal\.com\/track\/[0-9]+\/u/ // tidal
|
||||
];
|
||||
const musicLinks = content.match(new RegExp(regexes.map(r => r.source).join("|"), "g"));
|
||||
if (!musicLinks?.length) return;
|
||||
|
||||
return <div style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "10px",
|
||||
marginTop: "7px"
|
||||
}}>
|
||||
{
|
||||
musicLinks.map(item => <SongLinker key={item} url={item} />)
|
||||
}
|
||||
</div>;
|
||||
}
|
||||
});
|
||||
40
src/equicordplugins/songLink.desktop/native.ts
Normal file
40
src/equicordplugins/songLink.desktop/native.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2025 nin0
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { RendererSettings } from "@main/settings";
|
||||
|
||||
type SongLinkResult = {
|
||||
info?: {
|
||||
title: string;
|
||||
artist: string;
|
||||
};
|
||||
links: {
|
||||
[platform: string]: {
|
||||
url: string;
|
||||
nativeUri?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export async function getTrackData(_, trackURL: string): Promise<SongLinkResult> {
|
||||
const url = new URL("https://api.song.link/v1-alpha.1/links");
|
||||
url.searchParams.set("url", trackURL);
|
||||
url.searchParams.set("userCountry", RendererSettings.store.plugins?.SongLink.userCountry || "US");
|
||||
const raw = await fetch(url.toString()).then(u => u.json());
|
||||
const [, entry]: any = Object.entries(raw.entitiesByUniqueId)
|
||||
.find(([key]) => !key.includes("YOUTUBE")) || [];
|
||||
const possibleTrackInfo = entry
|
||||
? { title: (entry.title as string), artist: (entry.artistName as string) }
|
||||
: null;
|
||||
return {
|
||||
// @ts-ignore
|
||||
info: possibleTrackInfo,
|
||||
links: Object.fromEntries(Object.entries<any>(raw.linksByPlatform).map(([name, data]) => [name, {
|
||||
url: data.url,
|
||||
nativeUri: data.nativeAppUriDesktop
|
||||
}]))
|
||||
};
|
||||
}
|
||||
|
|
@ -42,7 +42,7 @@ export function IconWithTooltip({ text, icon, onClick }) {
|
|||
aria-haspopup="dialog"
|
||||
aria-label={text}
|
||||
size=""
|
||||
look={Button.Looks.LINK}
|
||||
look={Button.Looks.FILLED}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
className={ButtonWrapperClasses.button}
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ export const LikesComponent = ({ themeId, likedThemes: initialLikedThemes }: { t
|
|||
<Button onClick={() => handleLikeClick(themeId)}
|
||||
size={Button.Sizes.MEDIUM}
|
||||
color={Button.Colors.PRIMARY}
|
||||
look={Button.Looks.LINK}
|
||||
look={Button.Looks.FILLED}
|
||||
disabled={themeId === "preview"}
|
||||
style={{ marginLeft: "8px" }}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -141,7 +141,7 @@ export const ThemeInfoModal: React.FC<ThemeInfoModalProps> = ({ author, theme, .
|
|||
<ModalFooter>
|
||||
<Button
|
||||
color={Button.Colors.RED}
|
||||
look={Button.Looks.LINK}
|
||||
look={Button.Looks.FILLED}
|
||||
onClick={() => modalProps.onClose()}
|
||||
>
|
||||
Close
|
||||
|
|
@ -186,14 +186,14 @@ export const ThemeInfoModal: React.FC<ThemeInfoModalProps> = ({ author, theme, .
|
|||
<ModalFooter>
|
||||
<Button
|
||||
color={Button.Colors.RED}
|
||||
look={Button.Looks.LINK}
|
||||
look={Button.Looks.FILLED}
|
||||
onClick={() => props.onClose()}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
color={Button.Colors.GREEN}
|
||||
look={Button.Looks.LINK}
|
||||
look={Button.Looks.FILLED}
|
||||
className={classes("vce-button", Margins.right8)}
|
||||
disabled={!theme.content || theme.id === "preview"}
|
||||
onClick={async () => {
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ function VoiceUserButton({ user, tooltip, icon, onClick }: { user: User; tooltip
|
|||
<Button
|
||||
size={Button.Sizes.MIN}
|
||||
color={Button.Colors.TRANSPARENT}
|
||||
look={Button.Looks.LINK}
|
||||
look={Button.Looks.FILLED}
|
||||
disabled={disabled}
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
|
|
|
|||
|
|
@ -15,10 +15,10 @@ import {
|
|||
VencordTab,
|
||||
} from "@components/settings";
|
||||
import ThemesTab from "@components/ThemeSettings/ThemesTab";
|
||||
import { gitHashShort } from "@shared/vencordUserAgent";
|
||||
import { Devs } from "@utils/constants";
|
||||
import { getIntlMessage } from "@utils/discord";
|
||||
import definePlugin, { OptionType } from "@utils/types";
|
||||
import { shortGitHash } from "@utils/updater";
|
||||
import { React } from "@webpack/common";
|
||||
|
||||
type SectionType = "HEADER" | "DIVIDER" | "CUSTOM";
|
||||
|
|
@ -285,7 +285,7 @@ export default definePlugin({
|
|||
getInfoRows() {
|
||||
const { electronVersion, chromiumVersion, getVersionInfo } = this;
|
||||
|
||||
const rows = [`Equicord ${shortGitHash()}${getVersionInfo()}`];
|
||||
const rows = [`Equicord ${gitHashShort}${getVersionInfo()}`];
|
||||
|
||||
if (electronVersion) rows.push(`Electron ${electronVersion}`);
|
||||
if (chromiumVersion) rows.push(`Chromium ${chromiumVersion}`);
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ import { Flex } from "@components/Flex";
|
|||
import { Link } from "@components/Link";
|
||||
import { Paragraph } from "@components/Paragraph";
|
||||
import { openUpdaterModal } from "@components/settings/tabs/updater";
|
||||
import { platformName } from "@equicordplugins/equicordHelper/utils";
|
||||
import { gitHashShort } from "@shared/vencordUserAgent";
|
||||
import { CONTRIB_ROLE_ID, Devs, DONOR_ROLE_ID, EQUIBOP_CONTRIB_ROLE_ID, EQUICORD_TEAM, GUILD_ID, SUPPORT_CHANNEL_ID, SUPPORT_CHANNEL_IDS, VC_CONTRIB_ROLE_ID, VC_DONOR_ROLE_ID, VC_GUILD_ID, VC_REGULAR_ROLE_ID, VC_SUPPORT_CHANNEL_IDS, VENCORD_CONTRIB_ROLE_ID } from "@utils/constants";
|
||||
import { sendMessage } from "@utils/discord";
|
||||
import { Logger } from "@utils/Logger";
|
||||
|
|
@ -34,7 +36,7 @@ import { relaunch } from "@utils/native";
|
|||
import { onlyOnce } from "@utils/onlyOnce";
|
||||
import { makeCodeblock } from "@utils/text";
|
||||
import definePlugin from "@utils/types";
|
||||
import { checkForUpdates, isOutdated, shortGitHash, update } from "@utils/updater";
|
||||
import { checkForUpdates, isOutdated, update } from "@utils/updater";
|
||||
import { Alerts, Button, Card, ChannelStore, GuildMemberStore, Parser, PermissionsBits, PermissionStore, RelationshipStore, SelectedChannelStore, showToast, Toasts, UserStore } from "@webpack/common";
|
||||
import { JSX } from "react";
|
||||
|
||||
|
|
@ -71,41 +73,20 @@ async function forceUpdate() {
|
|||
return outdated;
|
||||
}
|
||||
|
||||
function getWindowsName(release: string) {
|
||||
const build = parseInt(release.split(".")[2]);
|
||||
if (build >= 22000) return "Windows 11";
|
||||
if (build >= 10240) return "Windows 10";
|
||||
if (build >= 9200) return "Windows 8.1";
|
||||
if (build >= 7600) return "Windows 7";
|
||||
return `Windows (${release})`;
|
||||
}
|
||||
|
||||
function getMacOSName(release: string) {
|
||||
const major = parseInt(release.split(".")[0]);
|
||||
if (major === 24) return "MacOS 15 (Sequoia)";
|
||||
if (major === 23) return "MacOS 14 (Sonoma)";
|
||||
if (major === 22) return "MacOS 13 (Ventura)";
|
||||
if (major === 21) return "MacOS 12 (Monterey)";
|
||||
if (major === 20) return "MacOS 11 (Big Sur)";
|
||||
if (major === 19) return "MacOS 10.15 (Catalina)";
|
||||
return `MacOS (${release})`;
|
||||
}
|
||||
|
||||
function platformName() {
|
||||
if (typeof DiscordNative === "undefined") return navigator.platform;
|
||||
if (DiscordNative.process.platform === "win32") return `${getWindowsName(DiscordNative.os.release)}`;
|
||||
if (DiscordNative.process.platform === "darwin") return `${getMacOSName(DiscordNative.os.release)} (${DiscordNative.process.arch === "arm64" ? "Apple Silicon" : "Intel Silicon"})`;
|
||||
if (DiscordNative.process.platform === "linux") return `Linux (${DiscordNative.os.release})`;
|
||||
return DiscordNative.process.platform;
|
||||
}
|
||||
|
||||
async function generateDebugInfoMessage() {
|
||||
const { RELEASE_CHANNEL } = window.GLOBAL_ENV;
|
||||
|
||||
const client = (() => {
|
||||
if (IS_DISCORD_DESKTOP) return `Discord Desktop v${DiscordNative.app.getVersion()}`;
|
||||
if (IS_VESKTOP) return `Vesktop v${VesktopNative.app.getVersion()}`;
|
||||
if (IS_EQUIBOP) return `Equibop v${VesktopNative.app.getVersion()}`;
|
||||
if (IS_EQUIBOP) {
|
||||
const equibopGitHash = tryOrElse(() => VesktopNative.app.getGitHash?.(), null);
|
||||
if (equibopGitHash) {
|
||||
const shortHash = equibopGitHash.slice(0, 7);
|
||||
return `Equibop v${VesktopNative.app.getVersion()} • [${shortHash}](<https://github.com/Equicord/Equibop/commit/${equibopGitHash}>)`;
|
||||
}
|
||||
return `Equibop v${VesktopNative.app.getVersion()}`;
|
||||
}
|
||||
if ("legcord" in window) return `LegCord v${window.legcord.version}`;
|
||||
|
||||
// @ts-expect-error
|
||||
|
|
@ -115,8 +96,8 @@ async function generateDebugInfoMessage() {
|
|||
|
||||
const info = {
|
||||
Equicord:
|
||||
`v${VERSION} • [${shortGitHash()}](<https://github.com/Equicord/Equicord/commit/${gitHash}>)` +
|
||||
`${SettingsPlugin.getVersionInfo()} - ${Intl.DateTimeFormat("en-GB", { dateStyle: "medium" }).format(BUILD_TIMESTAMP)}`,
|
||||
`v${VERSION} • [${gitHashShort}](<https://github.com/Equicord/Equicord/commit/${gitHash}>)` +
|
||||
`${IS_EQUIBOP ? "" : SettingsPlugin.getVersionInfo()} - ${Intl.DateTimeFormat("en-US", { dateStyle: "medium" }).format(BUILD_TIMESTAMP)}`,
|
||||
Client: `${RELEASE_CHANNEL} ~ ${client}`,
|
||||
Platform: platformName()
|
||||
};
|
||||
|
|
@ -138,6 +119,7 @@ async function generateDebugInfoMessage() {
|
|||
"Activity Sharing Disabled": tryOrElse(() => !ShowCurrentGame.getSetting(), false),
|
||||
"Link Embeds Disabled": tryOrElse(() => !ShowEmbeds.getSetting(), false),
|
||||
"Equicord DevBuild": !IS_STANDALONE,
|
||||
"Equibop DevBuild": IS_EQUIBOP && tryOrElse(() => VesktopNative.app.isDevBuild?.(), false),
|
||||
"Has UserPlugins": Object.values(PluginMeta).some(m => m.userPlugin),
|
||||
">2 Weeks Outdated": BUILD_TIMESTAMP < Date.now() - 12096e5,
|
||||
[`Potentially Problematic Plugins: ${potentiallyProblematicPlugins.join(", ")}`]: potentiallyProblematicPlugins.length
|
||||
|
|
@ -360,7 +342,7 @@ export default definePlugin({
|
|||
);
|
||||
}
|
||||
|
||||
if (isSupportChannel(props.channel.id) && PermissionStore.can(PermissionsBits.SEND_MESSAGES, props.channel)) {
|
||||
if (isSupportChannel(props.channel.id) && PermissionStore.can(PermissionsBits.SEND_MESSAGES, props.channel) && equicordSupport) {
|
||||
if (props.message.content.includes("/equicord-debug") || props.message.content.includes("/equicord-plugins")) {
|
||||
buttons.push(
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -39,22 +39,15 @@ async function lookupApp(applicationId: string): Promise<string> {
|
|||
return socket.application;
|
||||
}
|
||||
|
||||
let hideSetting = false;
|
||||
let ws: WebSocket;
|
||||
|
||||
if (IS_VESKTOP || IS_EQUIBOP || "legcord" in window) {
|
||||
hideSetting = true;
|
||||
} else if ("goofcord" in window) {
|
||||
hideSetting = false;
|
||||
}
|
||||
|
||||
migratePluginSettings("WebRichPresence", "WebRichPresence (arRPC)");
|
||||
export default definePlugin({
|
||||
name: "WebRichPresence",
|
||||
description: "Client plugin for arRPC to enable RPC on Discord Web (experimental)",
|
||||
authors: [Devs.Ducko],
|
||||
reporterTestable: ReporterTestable.None,
|
||||
hidden: hideSetting,
|
||||
hidden: !IS_EQUIBOP && !IS_VESKTOP && !("legcord" in window),
|
||||
|
||||
settingsAboutComponent: () => (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import { Margins } from "@utils/margins";
|
|||
import { ModalContent, ModalHeader, ModalRoot, openModalLazy } from "@utils/modal";
|
||||
import definePlugin from "@utils/types";
|
||||
import { Guild, GuildSticker } from "@vencord/discord-types";
|
||||
import { StickerFormatType } from "@vencord/discord-types/enums";
|
||||
import { findByCodeLazy } from "@webpack";
|
||||
import { Constants, EmojiStore, FluxDispatcher, GuildStore, IconUtils, Menu, PermissionsBits, PermissionStore, React, RestAPI, StickersStore, Toasts, Tooltip, UserStore } from "@webpack/common";
|
||||
import { Promisable } from "type-fest";
|
||||
|
|
@ -49,13 +50,32 @@ interface Emoji {
|
|||
|
||||
type Data = Emoji | Sticker;
|
||||
|
||||
const StickerExt = [, "png", "png", "json", "gif"] as const;
|
||||
const StickerExtMap = {
|
||||
[StickerFormatType.PNG]: "png",
|
||||
[StickerFormatType.APNG]: "png",
|
||||
[StickerFormatType.LOTTIE]: "json",
|
||||
[StickerFormatType.GIF]: "gif"
|
||||
} as const;
|
||||
|
||||
const PremiumTierStickerLimitMap = {
|
||||
0: 5,
|
||||
1: 15,
|
||||
2: 30,
|
||||
3: 60
|
||||
} as const;
|
||||
|
||||
function getGuildMaxStickerSlots(guild: Guild) {
|
||||
if (guild.features.has("MORE_STICKERS") && guild.premiumTier === 3)
|
||||
return 120;
|
||||
|
||||
return PremiumTierStickerLimitMap[guild.premiumTier] ?? PremiumTierStickerLimitMap[0];
|
||||
}
|
||||
|
||||
function getUrl(data: Data) {
|
||||
if (data.t === "Emoji")
|
||||
return `${location.protocol}//${window.GLOBAL_ENV.CDN_HOST}/emojis/${data.id}.${data.isAnimated ? "gif" : "png"}?size=4096&lossless=true`;
|
||||
|
||||
return `${window.GLOBAL_ENV.MEDIA_PROXY_ENDPOINT}/stickers/${data.id}.${StickerExt[data.format_type]}?size=4096&lossless=true`;
|
||||
return `${window.GLOBAL_ENV.MEDIA_PROXY_ENDPOINT}/stickers/${data.id}.${StickerExtMap[data.format_type]}?size=4096&lossless=true`;
|
||||
}
|
||||
|
||||
async function fetchSticker(id: string) {
|
||||
|
|
@ -120,17 +140,25 @@ function getGuildCandidates(data: Data) {
|
|||
(PermissionStore.getGuildPermissions({ id: g.id }) & PermissionsBits.CREATE_GUILD_EXPRESSIONS) === PermissionsBits.CREATE_GUILD_EXPRESSIONS;
|
||||
if (!canCreate) return false;
|
||||
|
||||
if (data.t === "Sticker") return true;
|
||||
if (data.t === "Sticker") {
|
||||
const stickerSlots = getGuildMaxStickerSlots(g);
|
||||
const stickers = StickersStore.getStickersByGuildId(g.id);
|
||||
|
||||
return !stickers || stickers.length < stickerSlots;
|
||||
}
|
||||
|
||||
const { isAnimated } = data as Emoji;
|
||||
|
||||
const emojiSlots = getGuildMaxEmojiSlots(g);
|
||||
const { emojis } = EmojiStore.getGuilds()[g.id];
|
||||
const emojis = EmojiStore.getGuildEmoji(g.id);
|
||||
|
||||
let count = 0;
|
||||
for (const emoji of emojis)
|
||||
if (emoji.animated === isAnimated && !emoji.managed)
|
||||
for (const emoji of emojis) {
|
||||
if (emoji.animated === isAnimated && !emoji.managed) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
return count < emojiSlots;
|
||||
}).sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,11 @@ const DefaultEngines = {
|
|||
Wikipedia: "https://wikipedia.org/w/index.php?search=",
|
||||
} as const;
|
||||
|
||||
const enum ReplacementEngineValue {
|
||||
OFF = "off",
|
||||
CUSTOM = "custom",
|
||||
}
|
||||
|
||||
const settings = definePluginSettings({
|
||||
customEngineName: {
|
||||
description: "Name of the custom search engine",
|
||||
|
|
@ -32,6 +37,15 @@ const settings = definePluginSettings({
|
|||
description: "The URL of your Engine",
|
||||
type: OptionType.STRING,
|
||||
placeholder: "https://google.com/search?q="
|
||||
},
|
||||
replacementEngine: {
|
||||
description: "Replace with a specific search engine instead of adding a menu",
|
||||
type: OptionType.SELECT,
|
||||
options: [
|
||||
{ label: "Off", value: ReplacementEngineValue.OFF, default: true },
|
||||
{ label: "Custom Engine", value: ReplacementEngineValue.CUSTOM },
|
||||
...Object.keys(DefaultEngines).map(engine => ({ label: engine, value: engine }))
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -40,13 +54,31 @@ function search(src: string, engine: string) {
|
|||
}
|
||||
|
||||
function makeSearchItem(src: string) {
|
||||
let Engines = {};
|
||||
const { customEngineName, customEngineURL, replacementEngine } = settings.store;
|
||||
|
||||
if (settings.store.customEngineName && settings.store.customEngineURL) {
|
||||
Engines[settings.store.customEngineName] = settings.store.customEngineURL;
|
||||
const hasCustomEngine = Boolean(customEngineName && customEngineURL);
|
||||
const hasValidReplacementEngine = replacementEngine !== ReplacementEngineValue.OFF && !(replacementEngine === ReplacementEngineValue.CUSTOM && !hasCustomEngine);
|
||||
|
||||
const Engines = { ...DefaultEngines };
|
||||
|
||||
if (hasCustomEngine) {
|
||||
Engines[customEngineName!] = customEngineURL;
|
||||
}
|
||||
|
||||
Engines = { ...Engines, ...DefaultEngines };
|
||||
if (hasValidReplacementEngine) {
|
||||
const name = replacementEngine === ReplacementEngineValue.CUSTOM && hasCustomEngine
|
||||
? customEngineName
|
||||
: replacementEngine;
|
||||
|
||||
return (
|
||||
<Menu.MenuItem
|
||||
label={`Search with ${name}`}
|
||||
key="search-custom-engine"
|
||||
id="vc-search-custom-engine"
|
||||
action={() => search(src, Engines[name!])}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Menu.MenuItem
|
||||
|
|
@ -96,7 +128,7 @@ const messageContextMenuPatch: NavContextMenuPatchCallback = (children, _props)
|
|||
migratePluginSettings("ReplaceGoogleSearch", "Search");
|
||||
export default definePlugin({
|
||||
name: "ReplaceGoogleSearch",
|
||||
description: "Replaces the Google search with different Engines",
|
||||
description: "Replaces the Google search with different Engine(s)",
|
||||
authors: [Devs.Moxxie, Devs.Ethan],
|
||||
|
||||
settings,
|
||||
|
|
|
|||
|
|
@ -9,5 +9,6 @@ import gitRemote from "~git-remote";
|
|||
|
||||
export { gitHash, gitRemote };
|
||||
|
||||
export const gitHashShort = gitHash.slice(0, 7);
|
||||
export const VENCORD_USER_AGENT = `Equicord/${gitHash}${gitRemote ? ` (https://github.com/${gitRemote})` : ""}`;
|
||||
export const VENCORD_USER_AGENT_HASHLESS = `Equicord${gitRemote ? ` (https://github.com/${gitRemote})` : ""}`;
|
||||
|
|
|
|||
|
|
@ -1174,6 +1174,10 @@ export const EquicordDevs = Object.freeze({
|
|||
name: "ZcraftElite",
|
||||
id: 926788037785047050n
|
||||
},
|
||||
mart: {
|
||||
name: "mja00",
|
||||
id: 108698087769260032n
|
||||
},
|
||||
} satisfies Record<string, Dev>);
|
||||
|
||||
// iife so #__PURE__ works correctly
|
||||
|
|
|
|||
|
|
@ -119,18 +119,24 @@ export function tryOrElse<T>(func: () => T, fallback: T): T {
|
|||
}
|
||||
}
|
||||
|
||||
export function isEquicordGuild(id: string, isGuildId: boolean = false): boolean {
|
||||
export function isEquicordGuild(id: string | null | undefined, isGuildId: boolean = false): boolean {
|
||||
if (!id) return false;
|
||||
|
||||
if (isGuildId) return id === GUILD_ID;
|
||||
|
||||
const channel = ChannelStore.getChannel(id);
|
||||
return channel.guild_id === GUILD_ID;
|
||||
}
|
||||
|
||||
export function isSupportChannel(channelId: string): boolean {
|
||||
export function isSupportChannel(channelId: string | null | undefined): boolean {
|
||||
if (!channelId) return false;
|
||||
|
||||
return channelId === SUPPORT_CHANNEL_ID;
|
||||
}
|
||||
|
||||
export function isEquicordSupport(userId: string): boolean {
|
||||
export function isEquicordSupport(userId: string | null | undefined): boolean {
|
||||
if (!userId) return false;
|
||||
|
||||
const member = GuildMemberStore.getMember(GUILD_ID, userId);
|
||||
return member?.roles?.includes(EQUICORD_HELPERS) || false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,11 +28,6 @@ export let isNewer = false;
|
|||
export let updateError: any;
|
||||
export let changes: Record<"hash" | "author" | "message", string>[];
|
||||
|
||||
|
||||
export function shortGitHash(length = 7) {
|
||||
return gitHash.slice(0, length);
|
||||
}
|
||||
|
||||
async function Unwrap<T>(p: Promise<IpcRes<T>>) {
|
||||
const res = await p;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue