Merge branch 'dev'

This commit is contained in:
thororen1234 2025-08-22 14:37:04 -04:00
commit 8f19b84966
No known key found for this signature in database
162 changed files with 3167 additions and 166 deletions

View file

@ -69,13 +69,6 @@ jobs:
run: |
gh release upload latest --clobber dist/release/*
- name: Create Tag
uses: butlerlogic/action-autotag@1.1.2
env:
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
with:
tag_prefix: "v"
- name: Upload Plugin JSONs to Equibored repo
run: |
git config --global user.name "GitHub-Actions"

View file

@ -1,44 +1,75 @@
name: Release Browser Extension
on:
push:
tags:
- v*
push:
branches:
- main
jobs:
Publish:
runs-on: ubuntu-latest
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: check that tag matches package.json version
run: |
pkg_version="v$(jq -r .version < package.json)"
if [[ "${{ github.ref_name }}" != "$pkg_version" ]]; then
echo "Tag ${{ github.ref_name }} does not match package.json version $pkg_version" >&2
exit 1
fi
- name: Get package.json version
id: pkg
run: echo "version=v$(jq -r .version < package.json)" >> $GITHUB_OUTPUT
- uses: pnpm/action-setup@v3
- name: Get latest Git tag
id: tag
run: |
latest_tag=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
echo "latest=$latest_tag" >> $GITHUB_OUTPUT
- name: Use Node.js 20
uses: actions/setup-node@v4
with:
node-version: 20
cache: "pnpm"
- name: Check if package.json version is newer
id: check
run: |
pkg="${{ steps.pkg.outputs.version }}"
latest="${{ steps.tag.outputs.latest }}"
- name: Install dependencies
run: pnpm install --frozen-lockfile
echo "Latest tag: $latest"
echo "Package version: $pkg"
- name: Build web
run: pnpm buildWebStandalone
if [ "$pkg" != "$latest" ] && [ "$(sort -V <<<"$latest"$'\n'"$pkg" | tail -n1)" = "$pkg" ]; then
echo "release=true" >> $GITHUB_OUTPUT
else
echo "release=false" >> $GITHUB_OUTPUT
- name: Publish extension
run: |
cd dist/browser/chromium-unpacked
pnpx chrome-webstore-upload-cli@2.1.0 upload --auto-publish
env:
EXTENSION_ID: ${{ secrets.CHROME_EXTENSION_ID }}
CLIENT_ID: ${{ secrets.CHROME_CLIENT_ID }}
CLIENT_SECRET: ${{ secrets.CHROME_CLIENT_SECRET }}
REFRESH_TOKEN: ${{ secrets.CHROME_REFRESH_TOKEN }}
- name: Stop if not newer
if: steps.check.outputs.release == 'false'
run: |
echo "No new version to release (package.json ${{ steps.pkg.outputs.version }} <= latest tag ${{ steps.tag.outputs.latest }})"
exit 0
- name: Create Tag
uses: butlerlogic/action-autotag@1.1.2
env:
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
with:
tag_prefix: "v"
- uses: pnpm/action-setup@v3
- name: Use Node.js 20
uses: actions/setup-node@v4
with:
node-version: 20
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build web
run: pnpm buildWebStandalone
- name: Publish extension
run: |
cd dist/browser/chromium-unpacked
pnpx chrome-webstore-upload-cli@2.1.0 upload --auto-publish
env:
EXTENSION_ID: ${{ secrets.CHROME_EXTENSION_ID }}
CLIENT_ID: ${{ secrets.CHROME_CLIENT_ID }}
CLIENT_SECRET: ${{ secrets.CHROME_CLIENT_SECRET }}
REFRESH_TOKEN: ${{ secrets.CHROME_REFRESH_TOKEN }}

View file

@ -0,0 +1,12 @@
# AllCallTimers
Adds a timer to each user in a guild voice call.
Original author: [AllCallTimersDiscordPlugin](https://github.com/Max-Herbold/AllCallTimersDiscordPlugin)
<img width="323rem" alt="image" src="https://cdn.nest.rip/uploads/43f2c839-59ee-4d49-9e28-6eff0edca79f.png">
<img width="300rem" alt="image" src="https://cdn.nest.rip/uploads/3598369c-302a-464b-b8dd-e2314e2d4fb5.png">
<br/>
<img height="346rem" alt="image" src="https://cdn.nest.rip/uploads/a080902e-4acb-4782-869a-8fc66a631ba0.png">
<img width="424rem" alt="image" src="https://cdn.nest.rip/uploads/43bec8a6-ee35-433c-8dfa-0e4c8ac48661.png">
<br/>

View file

@ -119,7 +119,7 @@ export default definePlugin({
predicate: () => settings.store.showWithoutHover,
replacement: [
{
match: /function\(\)\{.+:""(?=.*?userId:(\i))/,
match: /function \i\(\)\{.+:""(?=.*?userId:(\i))/,
replace: "$&,$self.renderTimer($1.id),"
}
]

View file

@ -0,0 +1,3 @@
# AltKrispSwitch
Swaps between None and Krisp instead of Krisp and Standard

View file

@ -0,0 +1,3 @@
# AlwaysExpandProfiles
Always expands a user or bots profile skipping the popout

View file

@ -17,18 +17,12 @@ export default definePlugin({
authors: [EquicordDevs.thororen],
patches: [
{
find: '"UserProfilePopout"})',
find: '"view-profile"',
replacement: {
match: /(?<=user:(\i).*?"PRESS_VIEW_PROFILE".{0,150})return/,
replace: "return $self.openUserProfile($1);"
}
},
{
find: '"BotUserProfilePopout"',
replacement: {
match: /(?<=user:(\i).*?"PRESS_VIEW_PROFILE".{0,100})return/,
replace: "return $self.openUserProfile($1);"
}
match: /(user:(\i).*?"PRESS_VIEW_PROFILE".{0,200})return/,
replace: "$1return $self.openUserProfile($2);"
},
all: true
},
],
openUserProfile(user: User) {

View file

@ -0,0 +1,3 @@
# AmITyping
Simply shows if you are typing. This is almost exactly what it will look like for other users, so it may show after you have finished typing.

View file

@ -0,0 +1,9 @@
# Anammox
Removes Nitro-exclusive stuff from various places:
- The shops above private chats
- The gift button in the chat box
- The "billing" section in settings
While this can be done with css, a plugin is able to do it much more thorougly — for example, the shops are still accessible via keyboard navigation.

View file

@ -0,0 +1,10 @@
# AtSomeone
Mention someone randomly
Due to some limitations, it won't show on autocomplete in dms/groups but it still works.
Do `@role*` to mention a random person with that role.
![image](https://cdn.nest.rip/uploads/20743dd7-0484-4f86-a7ed-0d56229ce778.png)
![image](https://cdn.nest.rip/uploads/a96afd16-8dc9-429a-8d63-f915fc28bae0.png)

View file

@ -0,0 +1,3 @@
# AutoJump
Jumps to the first message in new messages for a channel

View file

@ -0,0 +1,5 @@
# BannersEverywhere
Displays nitro and USRBG banners as a background in the member list
![](https://cdn.nest.rip/uploads/450f3161-ad26-4965-8e69-33104a2e7cae.png)

View file

@ -0,0 +1,7 @@
# BetterActivities
Shows activity icons in the member list and allows showing all activities
![Shows activity icons next to the status line in the member list](https://cdn.nest.rip/uploads/2ebf9c64-fb58-4e6c-a916-630db88be8ca.png)
![Shows all activities in the profile via a carousel](https://cdn.nest.rip/uploads/1f33b612-3b36-4aba-961d-0e94a7b31757.png)
![Optionally show all activities as list](https://cdn.nest.rip/uploads/acd042b7-2a57-4875-b7d4-9c558bfb9a0c.png)

View file

@ -0,0 +1,3 @@
# BetterAudioPlayer
Adds visualizers to audio attachments

View file

@ -0,0 +1,7 @@
# BetterBanReasons
* Create custom reasons to use in the Discord ban modal.
* Show a text input by default instead of the options
<img width="593" alt="Settings screenshot" src="https://cdn.nest.rip/uploads/68a4c4ee-7406-4df0-9afd-f239f9344a10.png" />
<img width="421" alt="Ban modal Screenshot" src="https://cdn.nest.rip/uploads/cea1aee3-c2ee-4ac7-86d5-dbbb2b774e80.png" />

View file

@ -0,0 +1,3 @@
# BetterBlockedUsers
Adds searching and opening the profile of a blocked user

View file

@ -31,7 +31,7 @@ export default definePlugin({
replace: "$self.renderUser($1,$2)",
},
{
match: /(?<=\}=(\i).{0,10}(\i).useState\(.{0,1}\);)/,
match: /(?<=\}=(\i).{0,10}(\i).useState\(\i\).{0,25}\};)/,
replace: "let [searchResults,setSearchResults]=$2.useState([]);$self.setUpdateFunc($1,setSearchResults);"
},
{

View file

@ -0,0 +1,3 @@
# BetterCommands
Adds/Fixes various things about commands

View file

@ -0,0 +1,11 @@
# BetterInvites
![Inviter preview](https://cdn.nest.rip/uploads/0438e1e9-405f-479b-a6ec-ed36224c5b28.png)
![Receiver preview](https://cdn.nest.rip/uploads/0c454567-31ea-48da-9684-adb619bc2098.png)
# features
- be able to see the inviter and inviter profile
- preview discoverable servers by clicking the server name
- be able to see the invite relative expiration date

View file

@ -0,0 +1,3 @@
# BetterPlusReacts
<https://cdn.nest.rip/uploads/1e1c130c-6b10-4582-982d-2cff379924dc.mp4>

View file

@ -4,7 +4,6 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*/
// https://github.com/Masterjoona/vc-betterplusreacts/blob/main/index.ts
import { Devs } from "@utils/constants";
import definePlugin from "@utils/types";
import { MessageStore } from "@webpack/common";

View file

@ -0,0 +1,5 @@
# BlockKrisp
Prevent Krisp from loading
![Screenshot](https://cdn.nest.rip/uploads/8e08689e-992f-4c48-9cdf-9166304182a5.png)

View file

@ -0,0 +1,3 @@
# BypassPinPrompt
Bypasses the prompt for pinning messages

View file

@ -0,0 +1,3 @@
# BypassStatus
Bypass Status allows you to set specific guilds/channels to still receive notifications and calls from when in a set status.

View file

@ -0,0 +1,3 @@
# ChannelBadges
Badges for channels based on their type such as text, voice, forum, etc.

View file

@ -0,0 +1,7 @@
# ChannelTabs
Group your commonly visited channels in tabs, like a browser
![screenshot of the plugin showing how the tabs look, as well as notification dots, typing indicators and a compact tab](https://cdn.nest.rip/uploads/d4435b4b-dbe4-4378-b984-923fba99b79f.png)
![bookmark bar with folders and various channel types](https://cdn.nest.rip/uploads/b2a1b5be-463a-4961-a654-3fb1c8803164.png)
![used together with a custom theme, tabs and bookmarks being dragged around](https://cdn.nest.rip/uploads/09d5af8b-5049-45d1-806c-1a5f702a4647.gif)

View file

@ -0,0 +1,3 @@
# CharacterCounter
Character counter for chat input

View file

@ -0,0 +1,3 @@
# CleanChannelName
Cleans emojis and other things out of channel names

View file

@ -0,0 +1,3 @@
# ClientSideBlock
Blocks a user client side so they don't know they're blocked

View file

@ -36,14 +36,6 @@ const settings = definePluginSettings(
restartNeeded: true,
default: true
},
/*
hideNewUsers: {
type: OptionType.BOOLEAN,
description: "Should content from users with the \"I'm new here, say hi!\" badge be blocked\"",
restartNeeded: true,
default: true
},
*/
blockedReplyDisplay: {
type: OptionType.SELECT,
description: "What should display instead of the message when someone replies to someone you have hidden",

View file

@ -0,0 +1,3 @@
# ClipsEnhancements
Add more Clip FPS and duration options, plus RPC tagging!

View file

@ -0,0 +1,3 @@
# CommandPalette
Navigating with a keyboard

View file

@ -0,0 +1,49 @@
# Command Palette Actions Guide
Welcome to the Command Palette Actions Guide! This guide serves to inform you on how to implement your own actions to the CommandPalette plugin. To do so you have two options: hardcoding or utilizing the `registerAction` function. This guide will focus on using the function as its best practice and is what should be used in most situations.
> [!IMPORTANT]
> While both methods hardcoding and using `registerAction` offer similar implementations, it's recommended to refrain from Hardcoding unless you solely plan on using this locally.
### Implementing Multiple Choice Modals
```ts
registerAction({
id: 'multipleChoiceCommand',
label: 'Multiple Choice',
callback: async () => {
// Open a modal with multiple choices
const choice = await openMultipleChoice([
{ id: 'test1', label: 'Test 1' },
{ id: 'test2', label: 'Test 2' },
]);
// Log the selected choice with its label and ID
console.log(`Selected ${choice.label} with the ID ${choice.id}`);
},
});
```
- **ID**: A unique identifier for the command/action, ensuring uniqueness across specific options in that modal instance.
- **Label**: The text displayed in the command palette for user recognition.
- **Callback (optional)**: The function executed when the command is triggered.
Inside the callback, the `openMultipleChoice` function opens a modal with a list of choices. Users can select an option, and upon choosing, a `ButtonAction` type is returned. The user's choice is then logged for reference.
### Implementing String Input Modals
```ts
registerAction({
id: 'stringInputCommand',
label: 'String Input',
callback: async () => {
// Open a modal with a text input
const text = await openSimpleTextInput();
// Log the inputted text to console
console.log(`They typed: ${text}`);
},
});
```
When the `stringInputCommand` is triggered, a modal with a simple text input field appears. Users can input text, and the entered string is returned. In this case, we log their input to console.

View file

@ -0,0 +1,3 @@
# ContentWarning
Specify certain words to blur

View file

@ -133,7 +133,7 @@ const settings = definePluginSettings({
export default definePlugin({
name: "ContentWarning",
authors: [EquicordDevs.camila314],
description: "Allows you to specify certain trigger words that will be blurred by default. Clicking on the blurred content will reveal it.",
description: "Allows you to specify certain trigger words that will be blurred by default. Hovering on the blurred content will reveal it.",
settings,
patches: [
{

View file

@ -0,0 +1,3 @@
# CopyProfileColors
Allows copying profile colors

View file

@ -0,0 +1,3 @@
# CopyStatusUrls
Allows copying status urls

View file

@ -0,0 +1,3 @@
# CopyUserMention
Allows copying a users mention

View file

@ -0,0 +1,11 @@
# CustomFolderIcons
Allows you to set custom images/svgs as folder icons
Available as "Change Icon" in the folder context menu
![the context menu with the "Change Icon" button](https://cdn.nest.rip/uploads/17256db9-8b5c-40a2-acdc-b37449eb9c0f.png)
![an image of some example custom folder icons](https://cdn.nest.rip/uploads/109163ab-384d-4638-a035-f8743248147f.png)
![the modal used to customize the folder icons](https://cdn.nest.rip/uploads/594d0792-0961-4766-bca8-74ae56e914fd.png)

View file

@ -0,0 +1,3 @@
# CustomSounds
Allows customizing discords sounds

View file

@ -0,0 +1,3 @@
# CustomTimestamps
Allows custom timestamp formatting

View file

@ -0,0 +1,3 @@
# CustomUserColors
Like IrcColors except allows setting custom user colors

View file

@ -0,0 +1,3 @@
# CuteAnimeBoys
Adds the cuteanimeboys command

View file

@ -0,0 +1,3 @@
# CuteNekos
Adds the nekos command

View file

@ -0,0 +1,3 @@
# CutePats
Adds the pat command

View file

@ -0,0 +1,3 @@
# Demonstration
Helps for taking theme screenshots

View file

@ -0,0 +1,3 @@
# DisableCameras
Disables cameras by default when in a voice call

View file

@ -0,0 +1,3 @@
# DiscordDevBanner
Enables developer banner and adds custom options to display in it

View file

@ -0,0 +1,3 @@
# DontFilterMe
Warns you if automod might flag your message

View file

@ -0,0 +1,13 @@
# Downloadify
Download various assets directly in Discord without having to open a browser or dig through HTML.
## Features
1. Download non-image files such as ZIP files directly inside of Discord.
- Allow or disallow unicode characters in file names.
- Various image assets are available in multiple formats as Discord makes converted copies.
- APNG and AWEBP are presented with the `.apng` and `.awebp` extensions during download in order to differentiate them from static PNG and WEBP variants, but upon download their extensions are converted back to `.png` and `.webp` respectively.
2. Show a notification when downloads start and finish with a customizable duration.
3. Add a download button to voice messages.
4. Set a default directory or pick each time.
- Pick whether to overwrite existing files or not.

View file

@ -0,0 +1,115 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import "./style.css";
import { EquicordDevs } from "@utils/constants";
import definePlugin from "@utils/types";
import { settings } from "./settings";
import { GDMContextMenu, GuildContextMenu, handleExpandedModalDownloadButtonClicked, handleHoverDownloadButtonClicked, MessageContextMenu, UserContextMenu, VoiceMessageDownloadButton } from "./utils/handlers";
export default definePlugin({
name: "Downloadify",
description: "Download various assets directly in Discord without having to open a browser or dig through HTML.",
authors: [EquicordDevs.Etorix],
hidden: IS_WEB,
settings,
VoiceMessageDownloadButton,
handleHoverDownloadButtonClicked,
handleExpandedModalDownloadButtonClicked,
contextMenus: {
"message": MessageContextMenu,
"guild-context": GuildContextMenu,
"user-context": UserContextMenu,
"gdm-context": GDMContextMenu
},
patches: [
{
// Pass the guild ID to the profile modal context. Used by the next patch.
find: "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}?)(await \i.\i.saveImage\(\i,\i.contentType,\i.\i\)),/,
replace: "$1await $self.handleExpandedModalDownloadButtonClicked($2,async()=>{$3});",
},
{
// Disable default success toast.
match: /(\(0,.{0,85}?ToastType.SUCCESS\)\))/,
replace: "",
},
{
// Disable default failure toast.
match: /(\(0,.{0,85}?ToastType.FAILURE\)\))/,
replace: "",
},
{
// Prevent videos from opening in browser.
match: /(let{item:\i}=\i.{0,350}?)(SAVE_MEDIA_PRESSED\)).{0,100}?(\i\(!0\);)/,
replace: "$1$2,true){$3"
}
],
}
]
});

View file

@ -0,0 +1,94 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { dialog } from "electron";
import fs from "fs";
import { Readable } from "stream";
import { finished } from "stream/promises";
/**
* Check if a file exists at a given path.
*/
export async function fileExists(_: Electron.IpcMainInvokeEvent, filePath: string): Promise<boolean> {
try {
await fs.promises.access(filePath, fs.constants.F_OK);
return true;
} catch {
return false;
}
}
/**
* Ask the user to select a directory to save files.
*/
export async function getDirectory(_: Electron.IpcMainInvokeEvent): Promise<string | null> {
const result = await dialog.showOpenDialog({
properties: ["openDirectory"]
});
return result.filePaths[0] ?? null;
}
/**
* Ask the user to select a file name and location to save a file.
*/
export async function getFilePath(_: Electron.IpcMainInvokeEvent, fileName: string, fileTypes: string[] | null): Promise<string | null> {
const filters = fileTypes?.map(ext => ({
name: ext.toUpperCase(),
extensions: [ext]
})) ?? [];
if (!fileTypes?.length) {
filters.push({ name: "All Files", extensions: ["*"] });
}
const result = await dialog.showSaveDialog({
title: "Save File",
defaultPath: fileName,
buttonLabel: "Save",
properties: ["createDirectory"],
filters
});
return result.filePath ?? null;
}
/**
* Get the content type of a file at a URL.
*
* @returns The content type of the file if it exists, undefined if no content type is available, or null if the file doesn't exist.
*/
export async function queryURL(_: Electron.IpcMainInvokeEvent, url: string): Promise<string | null | undefined> {
const response = await fetch(url, { method: "HEAD" });
if (!response.ok) {
return null;
}
return response.headers.get("content-type") ?? undefined;
}
/**
* Download a file from a URL to a local file path.
*/
export async function downloadURL(_: Electron.IpcMainInvokeEvent, url: string, filePath: string): Promise<boolean> {
const response = await fetch(url);
if (!response.ok) {
return false;
}
if (!response.body) {
return false;
}
const readableStream = Readable.fromWeb(response.body as any);
const fileWriteStream = fs.createWriteStream(filePath);
readableStream.pipe(fileWriteStream);
await finished(fileWriteStream);
return true;
}

View file

@ -0,0 +1,109 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import "./style.css";
import { definePluginSettings } from "@api/Settings";
import ErrorBoundary from "@components/ErrorBoundary";
import { OptionType } from "@utils/types";
import { Button, Forms, showToast, Toasts, useState } from "@webpack/common";
import { JSX } from "react";
import { d, DownloadifyLogger, DownloadifyNative } from "./utils/definitions";
import { getFormattedNow } from "./utils/misc";
function DefaultDirectorySetting(): JSX.Element {
const { defaultDirectory } = settings.use(["defaultDirectory"]);
const [isDialogueOpen, setDialogueOpen] = useState(false);
const handlePickDirectory = async () => {
try {
setDialogueOpen(true);
const directory = await DownloadifyNative.getDirectory();
directory && (settings.store.defaultDirectory = directory);
} catch (error) {
DownloadifyLogger.error(`[${getFormattedNow()}] [FAILED TO SET DOWNLOAD DIRECTORY]`, error);
showToast("Failed to set download directory.", Toasts.Type.FAILURE, { duration: 3000 });
} finally {
setDialogueOpen(false);
}
};
const handleClearDirectory = () => {
settings.store.defaultDirectory = "";
};
return (
<ErrorBoundary>
<Forms.FormSection>
<Forms.FormTitle className={d("form-title")}>
Default Directory
</Forms.FormTitle>
<Forms.FormText className={d("form-description")}>
Default download location. If set, the file will always be downloaded in its original format even if alternatives are available. Leave empty to pick a folder and file type each time.
</Forms.FormText>
<div className={d("directory-container")}>
<Forms.FormText className={d("directory-display")}>
{defaultDirectory || "No Directory Set"}
</Forms.FormText>
<div className={d("directory-buttons")}>
<Button
disabled={isDialogueOpen}
className={d("directory-button", "browse-button", { "disabled": isDialogueOpen })}
onClick={handlePickDirectory}
color={Button.Colors.CUSTOM}
>
{isDialogueOpen ? "Browsing..." : "Browse"}
</Button>
<Button
disabled={!defaultDirectory || isDialogueOpen}
className={d("directory-button", "clear-button", { "disabled": !defaultDirectory || isDialogueOpen })}
onClick={handleClearDirectory}
color={Button.Colors.CUSTOM}
>
Clear
</Button>
</div>
</div>
</Forms.FormSection>
</ErrorBoundary>
);
}
export const settings = definePluginSettings({
displayStatus: {
description: "Display a status notification when downloads start, finish, or error.",
type: OptionType.BOOLEAN,
default: true,
},
statusDuration: {
type: OptionType.SLIDER,
description: "The number of seconds to display status notifications.",
markers: [1, 3, 5],
default: 2.5,
stickToMarkers: false,
},
voiceMessages: {
description: "Add a download button to voice messages.",
type: OptionType.BOOLEAN,
default: true,
},
allowUnicode: {
description: "Allow non-ASCII characters in file names.",
type: OptionType.BOOLEAN,
default: true,
},
overwriteFiles: {
description: "If a default directory is set and a download file name matches an existing file, overwrite the file. If disabled, a number will be appended to the file name.",
type: OptionType.BOOLEAN,
default: false,
},
defaultDirectory: {
component: DefaultDirectorySetting,
type: OptionType.COMPONENT,
default: "",
},
});

View file

@ -0,0 +1,77 @@
.downloadify-voice-message-container {
flex-shrink: 0;
align-items: center;
display: flex;
flex-direction: column;
justify-content: flex-end;
position: relative;
margin: 0 -2px;
}
.downloadify-voice-message-button {
cursor: pointer;
line-height: 0;
border: none;
width: auto;
background: transparent;
color: var(--interactive-normal);
margin: 0;
padding: 0;
align-items: center;
border-radius: 8px;
box-sizing: border-box;
display: flex;
font-size: 14px;
font-weight: var(--font-weight-medium);
justify-content: center;
position: relative;
transition-duration: .2s;
user-select: none;
}
.downloadify-voice-message-button:hover {
color: var(--interactive-hover);
}
.downloadify-directory-container {
margin-top: 10px;
}
.downloadify-directory-buttons {
margin-top: 10px;
display: flex;
gap: 10px;
}
.downloadify-directory-button {
border-radius: 4px;
min-height: 34px;
color: white;
height: 34px;
flex: 1;
}
.downloadify-directory-button:not(.downloadify-disabled):hover {
filter: brightness(0.75);
transition: filter 0.15s ease-in-out;
}
.downloadify-clear-button {
background-color: rgb(180 71 78);
}
.downloadify-browse-button {
background-color: rgb(87 100 240);
}
.downloadify-directory-display {
background-color: var(--background-base-lower);
overflow-wrap: break-word;
font-family: monospace;
word-wrap: break-word;
white-space: pre-wrap;
border-radius: 4px;
user-select: text;
font-size: 14px;
padding: 8px;
}

View file

@ -0,0 +1,371 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { classNameFactory } from "@api/Styles";
import { Logger } from "@utils/Logger";
import { PluginNative } from "@utils/types";
import { Channel, Guild, GuildMember, Message, User } from "@vencord/discord-types";
import { findByPropsLazy } from "@webpack";
import { JSX } from "react";
export const d = classNameFactory("downloadify-");
export const CDN_BASE = "https://cdn.discordapp.com";
export const MEDIA_PROXY_BASE = "https://media.discordapp.net";
export const PRIMARY_DOMAIN_BASE = "https://discord.com";
export const IMAGE_EXT_1_DOMAIN_BASE = "https://images-ext-1.discordapp.net";
export const IMAGE_EXT_2_DOMAIN_BASE = "https://images-ext-2.discordapp.net";
export const TENOR_BASE = "https://c.tenor.com";
export const TENOR_GIF_ID = "Ad";
export const TENOR_MP4_ID = "Po";
export const ASSET_TYPE_EXTRACTOR = new RegExp(/^https:\/\/(?:cdn\.discordapp\.com|media.discordapp.net)\/([a-z-]+(?:\/[a-z-]+){0,2})(?:(?:\/\d+\/[a-z-]+\/\d+\/([a-z-]+))|(?:\/([a-z-]+(?:\/[a-z-_]+)?)))?/i);
export const DownloadifyLogger = new Logger("Downloadify");
export const DownloadifyNative = VencordNative.pluginHelpers.Downloadify as PluginNative<typeof import("../native")>;
export const defaultAssets = findByPropsLazy("DEFAULT_GROUP_DM_AVATARS") as DefaultAssets;
interface DefaultAssets {
BOT_AVATARS: Record<string, string>;
DEFAULT_AVATARS: string[];
DEFAULT_CHANNEL_ICON: string;
DEFAULT_GROUP_DM_AVATARS: string[];
DEFAULT_PROVISIONAL_AVATARS: string[];
}
export interface AssetInfo {
source: AssetSource;
static: string[];
animated: string[];
type?: string;
forceAnimated?: boolean;
}
export enum AssetSource {
CDN = "cdn",
EXTERNAL_IMAGE_PROXY = "external_image_proxy",
ATTACHMENT_MEDIA_PROXY = "attachment_media_proxy",
ASSET_MEDIA_PROXY = "asset_media_proxy",
PRIMARY_DOMAIN = "primary_domain",
EXTERNAL = "external",
TENOR = "tenor"
}
export const unknownExternal = {
source: AssetSource.EXTERNAL,
static: [],
animated: []
};
export const unknownPrimaryDomain = {
source: AssetSource.PRIMARY_DOMAIN,
static: [],
animated: []
};
export const unknownCDN = {
source: AssetSource.CDN,
static: [],
animated: []
};
export const unknownAssetMediaProxy = {
source: AssetSource.ASSET_MEDIA_PROXY,
static: [],
animated: []
};
export const unknownAttachmentMediaProxy = {
source: AssetSource.ATTACHMENT_MEDIA_PROXY,
static: [],
animated: []
};
export const unknownExternalImageProxy = {
source: AssetSource.EXTERNAL_IMAGE_PROXY,
static: [],
animated: []
};
export const defaultResourceAvailability = {
source: AssetSource.PRIMARY_DOMAIN,
static: ["png"],
animated: []
};
export const APNGAvailability = {
source: AssetSource.ASSET_MEDIA_PROXY,
static: ["png", "webp", "jpg"],
animated: ["apng", "png", "webp", "jpg"]
};
export const hashableAvailability = {
source: AssetSource.ASSET_MEDIA_PROXY,
static: ["png", "webp", "jpg"],
animated: ["gif", "awebp", "png", "webp", "jpg"]
};
// Defines known asset type availability. Unknown types which may be
// encountered are AssetSource.EXTERNAL which are present in some embeds,
// as well as OTHER attachment types.
export const assetAvailability = {
"user-avatar": hashableAvailability,
"user-banner": hashableAvailability,
"avatar-decoration": APNGAvailability,
"clan-badge": {
source: AssetSource.ASSET_MEDIA_PROXY,
static: ["png"],
animated: []
},
"nameplate": {
source: AssetSource.CDN,
static: ["png"],
animated: ["webm", "apng", "png"],
type: "nameplate"
},
"default-user-avatar": defaultResourceAvailability,
"default-group-icon": defaultResourceAvailability,
"role-icon-custom": {
source: AssetSource.ASSET_MEDIA_PROXY,
static: ["png", "webp", "jpg"],
animated: [],
},
"role-icon-unicode-emoji": {
source: AssetSource.PRIMARY_DOMAIN,
static: ["svg"],
animated: [],
},
"guild-icon": hashableAvailability,
"guild-banner": hashableAvailability,
"guild-invite-splash": hashableAvailability,
"guild-discovery-splash": hashableAvailability,
"custom-emoji": hashableAvailability,
"unicode-emoji": {
source: AssetSource.PRIMARY_DOMAIN,
static: ["svg"],
animated: []
},
"sticker": {
"APNG": APNGAvailability,
"PNG": {
source: AssetSource.ASSET_MEDIA_PROXY,
static: ["png", "webp", "jpg"],
animated: []
},
"GIF": {
source: AssetSource.ASSET_MEDIA_PROXY,
static: [],
animated: ["gif", "awebp", "png", "webp", "jpg"],
forceAnimated: true
},
"LOTTIE": {
source: AssetSource.CDN,
static: ["json"],
animated: ["json"]
},
},
"tenor": {
source: AssetSource.TENOR,
static: [],
animated: ["gif", "mp4"],
forceAnimated: true
},
"external": {
"image/png": {
source: AssetSource.EXTERNAL_IMAGE_PROXY,
static: ["png", "webp", "jpg"],
animated: []
},
"image/webp": {
source: AssetSource.EXTERNAL_IMAGE_PROXY,
static: ["png", "webp", "jpg"],
animated: []
},
"image/jpeg": {
source: AssetSource.EXTERNAL_IMAGE_PROXY,
static: ["png", "webp", "jpg"],
animated: []
},
"video/mp4": {
source: AssetSource.EXTERNAL,
static: [],
animated: ["mp4"],
forceAnimated: true
},
},
"attachment": {
// Image attachments over a certain size and/or resolution are treated as
// non-image files and are therefore not available in alternative formats.
// This is designated by an attachment type of OTHER instead of IMAGE.
"image/png": {
source: AssetSource.ATTACHMENT_MEDIA_PROXY,
static: ["png", "webp", "jpg"],
// APNG attachments are technically supported, but appear to get
// broken by Discord squashing them into static PNGs. APNG is kept
// here in case it is fixed some day.
animated: ["apng", "png", "webp", "jpg"]
},
"image/webp": {
source: AssetSource.ATTACHMENT_MEDIA_PROXY,
static: ["webp", "png", "jpg"],
animated: ["awebp", "webp", "png", "jpg"]
},
"image/avif": {
source: AssetSource.ATTACHMENT_MEDIA_PROXY,
static: ["avif", "png", "webp"],
animated: ["avif", "awebp", "png", "webp"]
},
"image/jpeg": {
source: AssetSource.ATTACHMENT_MEDIA_PROXY,
static: ["jpg", "png", "webp"],
animated: []
},
"image/gif": {
source: AssetSource.ATTACHMENT_MEDIA_PROXY,
static: [],
animated: ["gif", "awebp", "png", "webp", "jpg"],
forceAnimated: true
},
}
};
export function DownloadIcon({ width, height }: { width: number; height: number; }): JSX.Element {
return (
<svg
className={d("-download-icon")}
role="img"
width={width}
height={height}
viewBox="0 0 24 24"
fill="currentColor"
>
<path
d="M12 2a1 1 0 0 1 1 1v10.59l3.3-3.3a1 1 0 1 1 1.4 1.42l-5 5a1 1 0 0 1-1.4 0l-5-5a1 1 0 1 1 1.4-1.42l3.3 3.3V3a1 1 0 0 1 1-1ZM3 20a1 1 0 1 0 0 2h18a1 1 0 1 0 0-2H3Z"
/>
</svg>
);
}
export interface ParsedFile {
path: string;
baseName: string;
extension: string | null;
twitterExtra: string | null;
}
export interface ParsedURL {
url: string;
host: string;
path: string;
baseName: string;
extension: string | null;
params: { expiry: Record<string, string>, other: Record<string, string>; };
twitterExtra: string | null;
}
export interface InsertGroup {
id: { group?: string, child?: string; };
type: "WITH_GROUP" | "BEFORE_GROUP" | "AFTER_GROUP";
position?: "START" | "END";
}
export interface VoiceMessageDownloadButtonProps {
message: Message;
item: {
type: "AUDIO";
originalItem: {
filename: string;
proxy_url: string;
};
};
}
export interface HoverDownloadProps {
item: {
type: "AUDIO" | "IMAGE" | "VIDEO" | "OTHER";
downloadUrl: string;
contentType: string;
srcIsAnimated: boolean;
originalItem: {
title?: string;
filename: string;
proxy_url: string;
url: string;
};
};
}
export interface ExpandedModalDownloadProps {
item: {
type: "AUDIO" | "IMAGE" | "VIDEO" | "OTHER";
url: string;
original: string;
proxyUrl?: string;
contentType?: string;
srcIsAnimated: boolean;
sourceMetadata?: {
identifier: {
type: "attachment" | "embed";
title?: string;
},
};
};
}
export interface MessageContextMenuProps {
message: Message;
channel: Channel;
itemSrc: string;
itemSafeSrc?: string;
favoriteableType: "sticker" | "emoji" | null;
favoriteableName: string | null;
favoriteableId: string | null;
contextMenuAPIArguments: any[];
mediaItem?: {
contentType: string;
proxyUrl: string;
url: string;
};
}
export interface GDMContextMenuProps {
channel: Channel & {
icon: string | null;
};
}
export interface GuildContextMenuProps {
guild: Guild;
}
export interface UserContextMenuProps {
user: User;
guildId?: string;
}
export interface CustomizedUser extends User {
avatarDecorationData: null | {
asset: string;
};
collectibles: null | Record<"nameplate", {
asset: string;
label: string;
palette: string;
}>;
}
export interface CustomizedMember extends Omit<GuildMember, "avatarDecoration"> {
avatarDecoration: null | {
asset: string;
};
}
export const AttachmentFlags = {
IS_CLIP: 1 << 0,
IS_THUMBNAIL: 1 << 1,
IS_REMIX: 1 << 2,
IS_SPOILER: 1 << 3,
CONTAINS_EXPLICIT_MEDIA: 1 << 4,
IS_ANIMATED: 1 << 5,
CONTAINS_GORE_CONTENT: 1 << 6
};

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,253 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { findGroupChildrenByChildId } from "@api/ContextMenu";
import { Menu } from "@webpack/common";
import { InsertGroup, ParsedFile, ParsedURL } from "./definitions";
/**
* Retrieve information about a file path by parsing its parts.
*/
export function parseFile(filePath: string): ParsedFile {
const parts = filePath.split(/[\\/]/);
let fileNameWithExtra = parts.pop() as string;
while (fileNameWithExtra.includes("%25")) {
fileNameWithExtra = decodeURIComponent(fileNameWithExtra);
}
fileNameWithExtra = decodeURIComponent(fileNameWithExtra);
const posColon = fileNameWithExtra.indexOf(":");
const fileNameWithoutExtra = posColon === -1 ? fileNameWithExtra : fileNameWithExtra.slice(0, posColon);
const twitterExtra = posColon === -1 ? null : fileNameWithExtra.slice(posColon);
const path = parts.join("/") + "/";
const posDot = fileNameWithoutExtra.lastIndexOf(".");
if (fileNameWithoutExtra === "" || posDot < 1) {
return {
path,
baseName: fileNameWithoutExtra,
extension: null,
twitterExtra: null
};
}
return {
path,
baseName: fileNameWithoutExtra.slice(0, posDot),
extension: fileNameWithoutExtra.slice(posDot + 1).toLowerCase(),
twitterExtra: twitterExtra ?? null
};
}
/**
* Retrieve information about a URL by parsing its parts.
*/
export function parseURL(url: string): ParsedURL {
const parsed = new URL(url);
const { path, baseName, extension, twitterExtra } = parseFile(parsed.pathname);
const params = Array.from(parsed.searchParams.entries()).reduce(
(acc, [key, value]) => {
if (["ex", "is", "hm"].includes(key)) {
acc.expiry[key] = value;
} else {
acc.other[key] = value;
}
return acc;
}, { expiry: {}, other: {} } as { expiry: Record<string, string>, other: Record<string, string>; }
);
return {
url: url,
host: parsed.origin.toLowerCase(),
path,
baseName,
extension,
params,
twitterExtra
};
}
/**
* Sanitize a file name by replacing invalid characters with underscores.
*/
export function sanitizeFilename(filename: string, allowUnicode: boolean, fallback?: string): string {
let sanitized = filename.replace(/[<>:"/\\|?*\x00-\x1F]/g, "_") // windows-reserved
.replace(/^\./, "_") // dot files
.replace(/(\.\.)+/g, "_") // relative paths
.replace(/[ \t\n\r]+/g, "_"); // spaces
if (!allowUnicode) {
sanitized = sanitized.replace(/[^\x00-\x7F]/g, "_"); // non-ASCII characters
} else {
sanitized = sanitized.normalize("NFD"); // normalize unicode
}
sanitized = sanitized.replace(/^_+|_+$/g, ""); // leading and trailing underscores
return sanitized || fallback || "";
}
/**
* Get the current date and time formatted as a string.
*/
export function getFormattedNow(): string {
return new Date().toLocaleString(undefined, {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: true
}).replace(",", "");
}
/**
* Join or create a group in the guild context menu.
*/
export function joinOrCreateContextMenuGroup(
children: Array<any>,
items: Array<any>,
groupId: string,
submenuId?: string,
submenuLabel?: string,
insertOrder?: InsertGroup[]
) {
function joinOrCreateSubmenu() {
const existingItemShouldJoinSubmenu = existingGroup.props.children.find(child => child?.props?.submenuId === submenuId);
let existingSubmenu = existingGroup.props.children.find(child => child?.props?.id === submenuId);
let numitems = items.length;
if (existingItemShouldJoinSubmenu) {
existingSubmenu = null;
items.unshift(existingItemShouldJoinSubmenu);
numitems++;
const indexOfExistingItemShouldJoinSubmenu = existingGroup.props.children.findIndex(child => child === existingItemShouldJoinSubmenu);
existingGroup.props.children.splice(indexOfExistingItemShouldJoinSubmenu, 1);
}
if (existingSubmenu) {
if (!Array.isArray(existingSubmenu.props.children)) {
existingSubmenu.props.children = [existingSubmenu.props.children];
}
items.forEach(member => member.props.label = member.props.submenuItemLabel ?? member.props.label);
existingSubmenu.props.children.push(...items);
} else if (numitems === 1) {
if (!chosenInsertOption?.position || chosenInsertOption.position === "END") {
existingGroup.props.children.push(items[0]);
} else {
existingGroup.props.children.unshift(items[0]);
}
} else {
existingSubmenu = (
<Menu.MenuItem
id={submenuId as string}
label={submenuLabel}
>
{[...items]}
</Menu.MenuItem>
);
items.forEach(member => member.props.label = member.props.submenuItemLabel ?? member.props.label);
if (!chosenInsertOption?.position || chosenInsertOption.position === "END") {
existingGroup.props.children.push(existingSubmenu);
} else {
existingGroup.props.children.unshift(existingSubmenu);
}
}
}
items.forEach(member => {
member.props.groupId = groupId;
member.props.submenuId = submenuId;
member.props.submenuLabel = submenuLabel;
});
const insertWithOptions = insertOrder?.filter(item => item.type === "WITH_GROUP");
let existingGroup = children.find(child => child?.props?.id === groupId);
let chosenInsertOption;
if (!existingGroup && insertWithOptions) {
let targetItem;
for (const item of insertWithOptions) {
if (item.id.group) {
targetItem = children.find(child => child?.props?.id === item.id.group);
}
if (!targetItem && item.id.child) {
targetItem = findGroupChildrenByChildId(item.id.child, children, true);
}
if (targetItem) {
existingGroup = children.find(child => child?.props?.children === targetItem);
chosenInsertOption = item;
break;
}
}
}
if (existingGroup) {
if (!Array.isArray(existingGroup.props.children)) {
existingGroup.props.children = [existingGroup.props.children];
}
if (!submenuId) {
if (!chosenInsertOption?.position || chosenInsertOption.position === "END") {
existingGroup.props.children.push(...items);
} else {
existingGroup.props.children.unshift(...items);
}
return;
} else {
joinOrCreateSubmenu();
}
} else {
if (!submenuId) {
existingGroup = (
<Menu.MenuGroup id={groupId}>
{[...items]}
</Menu.MenuGroup>
);
} else {
existingGroup = (<Menu.MenuGroup id={groupId}>{[]}</Menu.MenuGroup>);
joinOrCreateSubmenu();
}
const insertAfterBeforeOptions = insertOrder?.filter(item => item.type !== "WITH_GROUP") || [];
let targetIndex = children.length;
let targetItem;
for (const item of insertAfterBeforeOptions) {
if (item.id.group) {
targetItem = children.find(child => child?.props?.id === item.id.group);
}
if (!targetItem && item.id.child) {
targetItem = findGroupChildrenByChildId(item.id.child, children, true);
}
if (targetItem) {
targetIndex = children.findIndex(child => child?.props?.children === targetItem);
if (item.type === "AFTER_GROUP") {
targetIndex++;
}
break;
}
}
children.splice(targetIndex, 0, existingGroup);
}
}

View file

@ -0,0 +1,3 @@
# EquicordHelper
Fixes various issues for crashing and adds other items that might help with support

View file

@ -0,0 +1,3 @@
# EquicordToolbox
Adds a toolbox for various control options

View file

@ -165,7 +165,7 @@ export default definePlugin({
{
find: ".controlButtonWrapper,",
replacement: {
match: /(?<=toolbar:function\(.{0,100}\()\i.Fragment,/,
match: /(?<=function (\i).{0,100}\()\i.Fragment,(?=.+?toolbar:\1\(\))/,
replace: "$self.ToolboxFragmentWrapper,"
}
}

View file

@ -0,0 +1,3 @@
# Equissant
Plays crossant every 10 clicks

View file

@ -0,0 +1,3 @@
# ExportContacts
Exports your friends list onto your clipboard

View file

@ -0,0 +1,3 @@
# ExportMessages
Allows exporting a message to a file

View file

@ -0,0 +1,3 @@
# FakeProfileThemesAndEffects
Expanded FakeProfileThemes with effects

View file

@ -0,0 +1,3 @@
# FastDeleteChannels
Adds a trash can icon when pressing specific keybinds to delete a channel/channels instantly

View file

@ -0,0 +1,5 @@
# FindReply
Lets you jump to the replies to a message. Makes it easier to follow past conversations/Q&A's.
![](https://cdn.nest.rip/uploads/1a49e773-acae-49aa-9444-f340b828cbba.gif)

View file

@ -0,0 +1,3 @@
# FixFileExtensions
Fixes file extensions by renaming them to work on discord

View file

@ -0,0 +1,3 @@
# FollowVoiceUser
Allows following a friend through voice chats

View file

@ -0,0 +1,3 @@
# FontLoader
Allows loading and using different fonts from Google

View file

@ -0,0 +1,3 @@
# ForwardAnywhere
Allows forwarding a message anywhere

View file

@ -0,0 +1,3 @@
# Freaky
Changes your text to look like this 𝓔𝓺𝓾𝓲𝓬𝓸𝓻𝓭

View file

@ -0,0 +1,3 @@
# FrequentQuickSwitcher
Changes quick switcher to be your most frequent channels

View file

@ -0,0 +1,7 @@
# FriendCloud
Visualize the users you interact with most in a cloud!
Simply run the `/friendcloud` command to generate a cloud of your friends based on your interactions with them.
Use the `count` option to set the number of users to display.

View file

@ -0,0 +1,3 @@
# FriendCodes
Easily generate friend codes

View file

@ -0,0 +1,3 @@
# FriendTags
Allows filtering friends in quick switcher

View file

@ -0,0 +1,3 @@
# FriendshipRanks
Adds badges to show how long you've been friends with a user

View file

@ -0,0 +1,3 @@
# FullVCPFP
Makes profile pictures show fully

View file

@ -0,0 +1,5 @@
# IgnoreTerms
Ignore Discord's new terms of service
![Screenshot](https://cdn.nest.rip/uploads/aa0b1baf-6256-4c31-9bee-73cdaa24aed5.png)

View file

@ -0,0 +1,3 @@
# MessageLinkTooltip
MessageLinkEmbed but with space constrants

View file

@ -36,8 +36,8 @@ export default definePlugin({
{
find: "renderLurkerModeUpsellPopout,position:",
replacement: {
match: /200:300/g,
replace: "0:0",
match: /(\i)=(200|300)/g,
replace: "$1=0",
},
}
]

View file

@ -0,0 +1,3 @@
# NotificationTitle
Improved Desktop Notifications

View file

@ -262,14 +262,14 @@ export default definePlugin({
settings,
patches: [
{
find: "BkZhUF)}",
find: "BkZhUF),",
replacement: {
match: /(?<=questId:(\i\.id).*?\.PRIMARY,)disabled:!0/,
match: /(?<=questId:(\i\.id).*?"secondary",)disabled:!0/,
replace: "onClick: () => $self.mobileQuestPatch($1)"
},
},
{
find: "AppTitleBar",
find: '"BACK_FORWARD_NAVIGATION"',
replacement: {
match: /(?<=trailing:.{0,70}\(\i\.Fragment,{children:\[)/,
replace: "$self.renderQuestButton(),"

View file

@ -0,0 +1,68 @@
# Questify
Enhance your Quest experience with a suite of features, or disable them entirely if they're not your thing.
![An example of the Restyle and Reorder features.](https://github.com/user-attachments/assets/452ba894-d0e0-46d5-894a-af901c42d8e5)
## Features
1. Quest Features
- Modify specific Quest features.
- Allows for disabling the following Quest related features:
- Everything — All of the below is disabled.
- Discovery Tab — Quests tab becomes hidden.
- Fetching Quests — Will prevent fetching Quests resulting in a blank Quests page.
- Badge on User Profiles — Hides the profile badge which doubles as a shortcut to the Quests page.
- Popup Above User Panel — Hides the new Quest promotion that appears above your profile panel.
- Gift Inventory Relocation Notice — Hides the text in the gift inventory which doubles as a shortcut to the Quests page.
- Friends List Active Now Promotion — Hides the promotion that displays when your friends play a game which has an active Quest.
- Allows for modifying the following Quest related features:
- Complete Video Quests in Background — Click the watch button on video Quests and wait for the video duration to pass. The first click will start the Quest without opening the video modal. Subsequent clicks will show the modal as normal.
- Complete Game Quests in Background — Click the accept button on game Quests and wait for the play duration to pass.
- Make Mobile Quests Desktop Compatible — Make mobile-only video Quests completable on Desktop.
2. Quests Button
- Show a Quest button in the server list with an optional indicator for unclaimed Quests.
- Button Visibility
- Always — Always display the button.
- Unclaimed — Only display the button when you have unclaimed Quests.
- Never — Never display the button.
- Unclaimed Indicator
- Pill — Shows an "unread" pill when there are unclaimed Quests.
- Badge — Shows the number of unclaimed Quests as a "ping" badge.
- Both — Both of the above.
- None — Show no indication of unclaimed Quests.
- Badge Color
- Use the color picker to pick a custom badge color or use the default Discord ping badge color by clicking `Disable`.
- Left / Middle / Right Click Actions
- Open Quests — Navigate to the Quests tab in Discovery.
- Plugin Settings — Open the plugin settings.
- Context Menu — Show the options `Mark All Ignored`, `Reset Ignored List`, and `Fetch Quests`.
- Nothing — Do nothing.
3. Fetching Quests
- Configure how often to fetch Quests from Discord and set up alerts for new Quests.
- By default, Discord only fetches Quests on load and when visiting the Quests page. This means that without a fetch interval defined below, this plugin will become unaware of new Quests added throughout the day.
- This relies on the Quest Button being enabled and set to either `Unclaimed`, or set to `Always` with unclaimed `Pill`, `Badge`, or `Both` indicators enabled. Otherwise, there is no reason to periodically fetch Quests.
- Also, if `Fetching Quests` is blocked in the `Quest Features` setting, this will not work.
- Fetch Interval
- Any time between 30 minutes and 12 hours. Defaults to every 45 minutes.
- Alert Sound
- Pick a default Discord sound to play when new Quests are fetched, or provide a custom audio URL. The audio must be hosted on a domain whitelisted by the Vencord CSP, such as `catbox`.
- If the provided URL is not on a supported domain, you will receive an error. If it *is* on a supported domain, but the audio file is invalid, it will silently fail. To ensure your audio file plays fine, click the audio preview button in the plugin settings.
4. Restyle Quests
- Highlight Quests with optional theme colors for visibility.
- Unclaimed / Claimed / Ignored / Expired
- Use the color picker to pick a custom Quest tile color or use the default Discord coloring by clicking `Disable`.
- Gradient Style
- Intense Restyle Gradient — A more intense gradient than default in order to hide hard edges under the gradient.
- Default Restyle Gradient — The default gradient intensity with the restyle color.
- Subtle Black Gradient — A much less intense black gradient for contrast with the Quest assets.
- No Gradient — Remove the gradient altogether. May cause the Quest assets to be difficult to see.
- Asset Preload
- Load All Quest Assets On Page Load — As soon as the Quest page is opened, load all Quest assets on the page.
- Load Quest Assets During Page Scroll — Keep the default behavior of loading Quest assets as they come into view.
5. Reorder Quests
- Sort Quests by their status.
- Comma-separated list must contain all of: `UNCLAIMED, CLAIMED, IGNORED, EXPIRED`.
- Subsort status groups by Quest added date, expiring date, or claimed date.
- Ignored Quest Profile
- Share ignored quests between accounts on a single client or keep them separate.
- Ignore Quests by opening their context menu and selecting `Mark as Ignored`.

View file

@ -430,7 +430,7 @@ function preprocessQuests(quests: Quest[]): Quest[] {
const unclaimedSortFunction = createSortFunction(unclaimedSubsort || "Recent DESC");
// Divide unclaimed quests by completion status before applying subsort.
// Divide unclaimed Quests by completion status before applying subsort.
questGroups.unclaimed.sort((a: Quest, b: Quest) => {
const aCompleted = !!a.userStatus?.completedAt;
const bCompleted = !!b.userStatus?.completedAt;
@ -688,12 +688,12 @@ function processQuestForAutoComplete(quest: Quest): boolean {
if (quest.userStatus?.completedAt || existingInterval) {
return true;
} else if (!playType && !watchType) {
QuestifyLogger.warn(`[${getFormattedNow()}] Could not recognize the quest type for ${questName}.`);
QuestifyLogger.warn(`[${getFormattedNow()}] Could not recognize the Quest type for ${questName}.`);
return true;
} else if ((watchType && !completeVideoQuestsInBackground) || (playType && (!completeGameQuestsInBackground || !IS_DISCORD_DESKTOP))) {
return true;
} else if (!questDuration) {
QuestifyLogger.warn(`[${getFormattedNow()}] Could not find duration for quest ${questName}.`);
QuestifyLogger.warn(`[${getFormattedNow()}] Could not find duration for Quest ${questName}.`);
return true;
} else if (watchType) {
startVideoProgressTracking(quest, questDuration);
@ -747,7 +747,7 @@ function getQuestAcceptedButtonText(quest: Quest): string | null {
return null;
}
function getActiveQuestClosestToCompletion(): Quest | null {
function getQuestPanelOverride(): Quest | null {
let closestQuest: Quest | null = null;
let closestTimeRemaining = Infinity;
@ -766,6 +766,23 @@ function getActiveQuestClosestToCompletion(): Quest | null {
}
});
if (!closestQuest) {
const completedQuests = Array.from(QuestsStore.quests.values() as Quest[]).filter(q => q.userStatus?.completedAt).sort((a, b) => {
const aTime = new Date(a.userStatus?.completedAt as string);
const bTime = new Date(b.userStatus?.completedAt as string);
return bTime.getTime() - aTime.getTime();
});
completedQuests.forEach(quest => {
const completedQuest = quest.userStatus?.completedAt;
const questStatus = getQuestStatus(quest);
if (completedQuest && questStatus === QuestStatus.Unclaimed) {
closestQuest = quest;
}
});
}
return closestQuest;
}
@ -789,7 +806,7 @@ export default definePlugin({
shouldHideGiftInventoryRelocationNotice,
shouldHideFriendsListActiveNowPromotion,
shouldDisableQuestAcceptedButton,
getActiveQuestClosestToCompletion,
getQuestPanelOverride,
getQuestAcceptedButtonText,
processQuestForAutoComplete,
activeQuestIntervals,
@ -858,7 +875,7 @@ export default definePlugin({
},
{
// Hides the new Quest popup above the account panel.
// Allows in-progress quests to still show.
// Allows in-progress Quests to still show.
find: "QUESTS_BAR,questId",
replacement: {
match: /return null==(\i)\?null:\(/,
@ -871,7 +888,7 @@ export default definePlugin({
find: "questDeliveryOverride)?",
replacement: {
match: /(\i=)(\i.\i.questDeliveryOverride)/,
replace: "$1$self.getActiveQuestClosestToCompletion()??$2"
replace: "$1$self.getQuestPanelOverride()??$2"
}
},
{
@ -957,7 +974,7 @@ export default definePlugin({
group: true,
replacement: [
{
// Restyles quest tiles with colors.
// Restyles Quest tiles with colors.
match: /className:(\i\(\)\(\i.container,\i\)),/,
replace: "className:$self.getQuestTileClasses($1,arguments[0].quest),style:$self.getQuestTileStyle(arguments[0].quest),"
},
@ -974,15 +991,7 @@ export default definePlugin({
]
},
{
// Prevents default pinning of specific Quests to the top of the list.
find: "questTile,{[",
replacement: {
match: /\i.unshift\(\i\):(\i.push\(\i\))/,
replace: "$1:$1"
}
},
{
// Sorts the "All Quests" tab quest tiles.
// Sorts the "All Quests" tab Quest tiles.
// Also sets mobile-only Quests as desktop compatible if the setting is enabled.
find: ".ALL);return(",
replacement: {
@ -991,7 +1000,7 @@ export default definePlugin({
}
},
{
// Sorts the "Claimed Quests" tab quest tiles.
// Sorts the "Claimed Quests" tab Quest tiles.
find: ".ALL)}):(",
replacement: {
match: /(claimedQuests:(\i).{0,50}?;)/,
@ -1025,7 +1034,7 @@ export default definePlugin({
},
{
// Open Video Modal
match: /(\i\?)(\(0,\i.openVideoQuestModal\)\({quest:(\i))/,
match: /(\i\?)?(\(0,\i.openVideoQuestModal\)\({quest:(\i))/,
replace: "$1questifyContinue&&$2"
},
{
@ -1054,7 +1063,7 @@ export default definePlugin({
{
// The Quest Accepted button is disabled by default. If the user reloads the client, they need a way
// to resume the automatic completion, so patch in optionally enabling it if the feature is enabled.
match: /(START_QUEST_CTA.{0,400}?)(!0)/,
match: /(START_QUEST_CTA.{0,400}?disabled:)(!0)/,
replace: "$1$self.shouldDisableQuestAcceptedButton(arguments[0].quest)??$2"
},
{

View file

@ -50,10 +50,10 @@ export function fetchAndAlertQuests(source: string, logger: Logger): void {
const shouldAlert = settings.store.fetchingQuestsAlert;
if (shouldAlert) {
logger.info(`[${getFormattedNow()}] New quests detected. Playing alert sound.`);
logger.info(`[${getFormattedNow()}] New Quests detected. Playing alert sound.`);
AudioPlayer(shouldAlert, 1).play();
} else {
logger.info(`[${getFormattedNow()}] New quests detected.`);
logger.info(`[${getFormattedNow()}] New Quests detected.`);
}
}
}
@ -75,13 +75,13 @@ export function startAutoFetchingQuests(seconds?: number): void {
}
const interval = seconds ? seconds * 1000 : settings.store.fetchingQuestsInterval * 1000;
QuestifyLogger.info(`[${getFormattedNow()}] Starting AutoFetch of quests every ${(interval / 60000).toFixed(2)} minutes.`);
QuestifyLogger.info(`[${getFormattedNow()}] Starting AutoFetch of Quests every ${(interval / 60000).toFixed(2)} minutes.`);
autoFetchInterval = setInterval(() => { fetchAndAlertQuests("Questify-AutoFetch", QuestifyLogger); }, interval);
}
export function stopAutoFetchingQuests(): void {
if (autoFetchInterval) {
QuestifyLogger.info(`[${getFormattedNow()}] Stopping AutoFetch of quests.`);
QuestifyLogger.info(`[${getFormattedNow()}] Stopping AutoFetch of Quests.`);
clearInterval(autoFetchInterval);
autoFetchInterval = null;
}
@ -712,8 +712,7 @@ function RestyleQuestsSetting() {
restyleQuestsIgnored,
restyleQuestsExpired,
restyleQuestsGradient,
restyleQuestsPreload,
ignoredQuestProfile
restyleQuestsPreload
} = settings.use([
"restyleQuestsUnclaimed",
"restyleQuestsClaimed",
@ -721,7 +720,6 @@ function RestyleQuestsSetting() {
"restyleQuestsExpired",
"restyleQuestsGradient",
"restyleQuestsPreload",
"ignoredQuestProfile"
]);
const [unclaimedColor, setUnclaimedColor] = useState<number | null>(restyleQuestsUnclaimed);
@ -871,26 +869,6 @@ function RestyleQuestsSetting() {
/>
</div>
</div>
<div className={q("main-inline-group")}>
<div className={q("inline-group-item")}>
<Forms.FormTitle className={q("form-subtitle")}>
Ignored Quest Profile
</Forms.FormTitle>
<Select
options={[
{ label: "Shared: All accounts on this client share ignores.", value: "shared" },
{ label: "Private: All accounts on this client have separate ignores.", value: "private" }
]}
className={q("select")}
popoutPosition="bottom"
serialize={String}
isSelected={(value: string) => value === ignoredQuestProfile}
select={(value: string) => {
settings.store.ignoredQuestProfile = value;
}}
/>
</div>
</div>
<div className={q("color-picker-container")}>
{colorPickers.map(({ label, idx, defaultValue, value }) => (
<div
@ -942,12 +920,14 @@ function ReorderQuestsSetting(): JSX.Element {
unclaimedSubsort,
claimedSubsort,
ignoredSubsort,
expiredSubsort
expiredSubsort,
ignoredQuestProfile
} = settings.use([
"unclaimedSubsort",
"claimedSubsort",
"ignoredSubsort",
"expiredSubsort"
"expiredSubsort",
"ignoredQuestProfile"
]);
const getSubsortOptions = (source: string): SelectOption[] => {
@ -1083,6 +1063,26 @@ function ReorderQuestsSetting(): JSX.Element {
/>
</div>
</div>
<div className={q("main-inline-group")}>
<div className={q("inline-group-item")}>
<Forms.FormTitle className={q("form-subtitle")}>
Ignored Quest Profile
</Forms.FormTitle>
<Select
options={[
{ label: "Shared: All accounts on this client share ignores.", value: "shared" },
{ label: "Private: All accounts on this client have separate ignores.", value: "private" }
]}
className={q("select")}
popoutPosition="bottom"
serialize={String}
isSelected={(value: string) => value === ignoredQuestProfile}
select={(value: string) => {
settings.store.ignoredQuestProfile = value;
}}
/>
</div>
</div>
</Forms.FormSection>
</div>
</ErrorBoundary>
@ -1314,7 +1314,7 @@ function FetchingQuestsSetting(): JSX.Element {
<Forms.FormText className={q("form-description")}>
Configure how often to fetch Quests from Discord and set up alerts for new Quests.
<br /><br />
By default, Discord only fetches quests on load and when visiting the Quests page.
By default, Discord only fetches Quests on load and when visiting the Quests page.
This means that without a fetch interval defined below, this plugin will become unaware
of new Quests added throughout the day.
<br /><br />
@ -1609,25 +1609,25 @@ export const settings = definePluginSettings({
},
unclaimedSubsort: {
type: OptionType.STRING,
description: "Subsort method for unclaimed quests.",
description: "Subsort method for unclaimed Quests.",
default: "Expiring ASC", // "Recent ASC", "Recent DESC", "Expiring ASC", "Expiring DESC"
hidden: true
},
claimedSubsort: {
type: OptionType.STRING,
description: "Subsort method for claimed quests.",
description: "Subsort method for claimed Quests.",
default: "Claimed DESC", // "Recent ASC", "Recent DESC", "Claimed ASC", "Claimed DESC"
hidden: true
},
ignoredSubsort: {
type: OptionType.STRING,
description: "Subsort method for ignored quests.",
description: "Subsort method for ignored Quests.",
default: "Recent DESC", // "Recent ASC", "Recent DESC", "Expiring ASC", "Expiring DESC"
hidden: true
},
expiredSubsort: {
type: OptionType.STRING,
description: "Subsort method for expired quests.",
description: "Subsort method for expired Quests.",
default: "Expiring DESC", // "Recent ASC", "Recent DESC", "Expiring ASC", "Expiring DESC"
hidden: true
},
@ -1639,7 +1639,7 @@ export const settings = definePluginSettings({
},
onQuestsPage: {
type: OptionType.BOOLEAN,
description: "Whether the user is currently on the quests page.",
description: "Whether the user is currently on the Quests page.",
default: false,
hidden: true
},

View file

@ -18,7 +18,7 @@ export const QuestifyLogger = new Logger("Questify");
export const PlayAudio = findByCodeLazy("Unable to find sound for pack name:");
// Takes an audio name or URL, volume, and a callback function. Plays when play() is called.
const AudioPlayerConstructor = findByCodeLazy("sound has no duration");
export function AudioPlayer(name: string, volume: number = 1, callback?: () => void): any { return new AudioPlayerConstructor(name, null, volume, "default", callback || (() => { })); }
export function AudioPlayer(name: string, volume: number = 1, callback?: () => void): any { return new AudioPlayerConstructor(name, null, volume, "default", callback ?? (() => { })); }
export const QuestsStore = findStoreLazy("QuestsStore");
export const questPath = "/discovery/quests";
export const leftClick = 0;
@ -267,7 +267,7 @@ export async function reportPlayGameQuestProgress(quest: Quest, terminal: boolea
const progress = progressPlayType?.value || 1;
if (!questPlayType) {
logger?.warn(`[${getFormattedNow()}] Could not recognize the quest type for Quest ${questName}.`);
logger?.warn(`[${getFormattedNow()}] Could not recognize the Quest type for Quest ${questName}.`);
return { progress: null };
}

View file

@ -43,7 +43,7 @@ export default definePlugin({
authors: [EquicordDevs.Jaxx],
patches: [
{
find: '"SearchQueryStore";',
find: '"SearchQueryStore");',
replacement: {
match: /\i\.searchResultsQuery=(\i)/,
replace: "$&,$self.main($1)"

View file

@ -0,0 +1,5 @@
# SidebarChat
View another channel, dm or group dm as a resizable sidebar.
![vencord and aliucord support channels both open](https://cdn.nest.rip/uploads/bc92a8bd-fc25-4775-9744-360de3f1b872.png)

View file

@ -0,0 +1,5 @@
# StatusWhilePlaying
This plugin automatically updates your online status (online, idle, dnd) when launching games.
It will change your status back to the prior status when the game is closed.

View file

@ -0,0 +1,8 @@
# TeX
Renders TeX formulas.
`` `$\sin x$` `` renders as $\sin x$,
`` `$$x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}$$` `` renders as $\displaystyle x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}$.
The latter is intentionally set to an inline-block instead of a block, to save space.
This plugin requires external libraries, which will not work in the userscript version of Vencord without additional workarounds.

View file

@ -0,0 +1,3 @@
# TimelessClips
Custom clip length

View file

@ -0,0 +1,3 @@
# Title
In the browser version, the window/tab title is always prefixed with "Discord -". This plugin lets you change or remove this prefix.

View file

@ -0,0 +1,7 @@
# UnreadCountBadge
Show a badge in the channel list for unread messages.
you have to load the channel or get new messages in that channel for it show idk why maybe sometime ill figure it out
![image](https://why-are-you.gay/HpPVuwSV)

View file

@ -0,0 +1,3 @@
# ViewRawVariant
Improved ViewRaw with more features and places

View file

@ -0,0 +1,5 @@
# VoiceChatUtilities
Allows you to perform multiple actions on an entire channel (move, mute, disconnect, etc.)
![Screenshot](https://cdn.nest.rip/uploads/9d6d2b72-0346-4951-90f3-6cab963728eb.png)

View file

@ -0,0 +1,7 @@
# WallpaperFree
Recreation of the old DM wallpaper experiment; Set a background image for any channel, user or server.
<https://cdn.nest.rip/uploads/2e8a2928-8b10-458f-8036-362fab36633d.mp4>
*ignore the Discord wallpaper picker it is removed*

View file

@ -0,0 +1,5 @@
# WebpackTarball
Adds an option to the Vencord Toolbox that lets you grab Discord's webpack as a tarball, which is very convenient for inspecting the code.
The reason it's a tarball instead of for example a zip is just simplicity — the tar file writer is only like 60ish lines.

View file

@ -33,8 +33,8 @@ export default definePlugin({
replace: "if(Vencord.Api.Notices.currentNotice)return false;$&"
},
{
match: /(?<=,NOTICE_DISMISS:function\(\i\){)return null!=(\i)/,
replace: (m, notice) => `if(${notice}?.id=="EquicordNotice")return(${notice}=null,Vencord.Api.Notices.nextNotice(),true);${m}`
match: /(?<=function (\i)\(\i\){)return null!=(\i)(?=.+?NOTICE_DISMISS:\1)/,
replace: (m, _, notice) => `if(${notice}?.id=="VencordNotice")return(${notice}=null,Vencord.Api.Notices.nextNotice(),true);${m}`
}
]
}

Some files were not shown because too many files have changed in this diff Show more