Revert "goodbye"

This reverts commit 7dd37b53bc.
This commit is contained in:
thororen1234 2026-03-07 08:31:31 -05:00
parent 7dd37b53bc
commit a480ab0517
No known key found for this signature in database
87 changed files with 8742 additions and 162 deletions

View file

@ -0,0 +1,98 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { NavContextMenuPatchCallback } from "@api/ContextMenu";
import { copyToClipboard } from "@utils/clipboard";
import { EquicordDevs } from "@utils/constants";
import definePlugin from "@utils/types";
import { Menu, Toasts, UserProfileStore } from "@webpack/common";
function getProfileColors(userId) {
try {
const profile = UserProfileStore.getUserProfile(userId);
if (!profile || !profile.themeColors || profile.themeColors.length < 2) {
return null;
}
const primaryColor = profile.themeColors[0].toString(16).padStart(6, "0");
const secondaryColor = profile.themeColors[1].toString(16).padStart(6, "0");
return { primaryColor, secondaryColor };
} catch (e) {
console.error("Failed to get profile colors:", e);
return null;
}
}
function copyProfileColors(userId) {
const colors = getProfileColors(userId);
if (!colors) {
Toasts.show({
type: Toasts.Type.FAILURE,
message: "No profile colors found!",
id: Toasts.genId()
});
return;
}
const { primaryColor, secondaryColor } = colors;
// Formatting
const formattedColors = `Primary-color #${primaryColor}, Secondary-Color #${secondaryColor}`;
try {
copyToClipboard(formattedColors);
Toasts.show({
type: Toasts.Type.SUCCESS,
message: "Profile colors copied to clipboard!",
id: Toasts.genId()
});
} catch (e) {
console.error("Failed to copy to clipboard:", e);
Toasts.show({
type: Toasts.Type.FAILURE,
message: "Error copying profile colors!",
id: Toasts.genId()
});
}
}
export function ColorIcon() {
return (
<svg
viewBox="0 0 24 24"
width="20"
height="20"
xmlns="http://www.w3.org/2000/svg"
>
<path fill="currentColor" d="M17,4H15.82A3,3,0,0,0,13,2H11A3,3,0,0,0,8.18,4H7A3,3,0,0,0,4,7V19a3,3,0,0,0,3,3H17a3,3,0,0,0,3-3V7A3,3,0,0,0,17,4ZM10,5a1,1,0,0,1,1-1h2a1,1,0,0,1,1,1V6H10Zm8,14a1,1,0,0,1-1,1H7a1,1,0,0,1-1-1V7A1,1,0,0,1,7,6H8V7A1,1,0,0,0,9,8h6a1,1,0,0,0,1-1V6h1a1,1,0,0,1,1,1Z" />
</svg>
);
}
// spawn in the context menu
const userContextMenuPatch: NavContextMenuPatchCallback = (children, { user }) => {
if (!user) return;
children.push(
<Menu.MenuItem
id="CopyProfileColors"
icon={ColorIcon}
label="Copy Profile Colors"
action={() => copyProfileColors(user.id)}
/>
);
};
export default definePlugin({
name: "CopyProfileColors",
description: "A plugin to copy people's profile gradient colors to clipboard.",
authors: [EquicordDevs.Crxa, EquicordDevs.Cortex],
contextMenus: {
"user-context": userContextMenuPatch,
"user-profile-actions": userContextMenuPatch
}
});

View file

@ -0,0 +1,47 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { AudioPlayerInterface, createAudioPlayer } from "@api/AudioPlayer";
import { definePluginSettings } from "@api/Settings";
import { Devs, EquicordDevs } from "@utils/constants";
import definePlugin, { OptionType } from "@utils/types";
let clickCount = 0;
let croissant: AudioPlayerInterface | null = null;
function play() {
const triggerAmount = settings.store.amount;
clickCount++;
if (clickCount % triggerAmount === 0) {
croissant?.play();
clickCount = 0;
}
}
const settings = definePluginSettings({
amount: {
type: OptionType.NUMBER,
description: "amount of clicks to trigger crossant",
default: 10,
}
});
export default definePlugin({
name: "Equissant",
description: "Crossant every specified amount of clicks :trolley:",
authors: [EquicordDevs.SomeAspy, Devs.thororen],
dependencies: ["AudioPlayerAPI"],
settings,
start() {
croissant = createAudioPlayer("https://github.com/Equicord/Equibored/raw/main/sounds/equissant/croissant.mp3", { persistent: true });
document.addEventListener("click", play);
},
stop() {
croissant?.delete();
document.removeEventListener("click", play);
}
});

View file

@ -0,0 +1,147 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import "./styles.css";
import { playAudio } from "@api/AudioPlayer";
import { NavContextMenuPatchCallback } from "@api/ContextMenu";
import { definePluginSettings } from "@api/Settings";
import { Button } from "@components/Button";
import { Devs } from "@utils/constants";
import { classNameFactory } from "@utils/css";
import definePlugin, { OptionType } from "@utils/types";
import { findComponentByCodeLazy } from "@webpack";
import { ChannelActions, Constants, GuildStore, IconUtils, MediaEngineStore, Menu, RestAPI, SearchableSelect, SelectedChannelStore, TextInput, Toasts } from "@webpack/common";
const cl = classNameFactory("vc-exitsounds-");
const PlayIcon = findComponentByCodeLazy("4.96v14.08c0");
function GuildSelector() {
const { soundGuildId } = settings.use(["soundGuildId"]);
const options = Object.values(GuildStore.getGuilds()).map(g => ({
value: g.id,
label: g.name
}));
return (
<SearchableSelect
options={options}
value={options.find(o => o.value === soundGuildId)?.value}
placeholder="Select a server..."
maxVisibleItems={6}
closeOnSelect={true}
onChange={v => settings.store.soundGuildId = v}
renderOptionPrefix={o => {
const guild = GuildStore.getGuild(o?.value);
if (!guild?.icon) return null;
return (
<img
className={cl("guild-icon")}
src={IconUtils.getGuildIconURL({ id: guild.id, icon: guild.icon, size: 32 })!}
/>
);
}}
/>
);
}
function SoundIdInput() {
const { soundId } = settings.use(["soundId"]);
return (
<div className={cl("input-row")}>
<div className={cl("input-wrapper")}>
<TextInput
value={soundId}
onChange={v => settings.store.soundId = v}
placeholder="Enter sound ID..."
/>
</div>
<Button
onClick={() => playAudio(`https://${window.GLOBAL_ENV.CDN_HOST}/soundboard-sounds/${soundId}`, { volume: 50 })}
disabled={!soundId}
>
<PlayIcon color="currentColor" />
</Button>
</div>
);
}
const settings = definePluginSettings({
soundGuildId: {
type: OptionType.COMPONENT,
description: "Select the server containing the sound.",
component: GuildSelector
},
soundId: {
type: OptionType.COMPONENT,
description: "Enter the ID of the sound you want to play.",
component: SoundIdInput
}
});
const SoundButtonContext: NavContextMenuPatchCallback = (children, { sound }: { sound: { soundId: string; guildId: string; }; }) => {
children.splice(1, 0,
<Menu.MenuGroup>
<Menu.MenuItem
id="set-global-exit-sound"
label="Set as global exit sound"
action={() => {
settings.store.soundGuildId = sound.guildId;
settings.store.soundId = sound.soundId;
}}
/>
</Menu.MenuGroup>
);
};
let original: typeof ChannelActions.selectVoiceChannel;
export default definePlugin({
name: "ExitSounds",
description: "Play soundboard sounds when you disconnect from voice.",
authors: [Devs.prism],
dependencies: ["AudioPlayerAPI"],
settings,
contextMenus: {
"sound-button-context": SoundButtonContext
},
start() {
original = ChannelActions.selectVoiceChannel;
ChannelActions.selectVoiceChannel = async (id: string | null, ...args: unknown[]) => {
const { soundGuildId, soundId } = settings.store;
const voiceId = SelectedChannelStore.getVoiceChannelId();
if (soundGuildId && soundId && voiceId !== id && !MediaEngineStore.isDeaf()) {
try {
await RestAPI.post({
url: Constants.Endpoints.SEND_SOUNDBOARD_SOUND(voiceId),
body: {
sound_id: soundId,
source_guild_id: soundGuildId
}
});
await new Promise(r => setTimeout(r, 500));
} catch {
Toasts.show({
message: "Oops! Something went wrong.",
id: Toasts.genId(),
type: Toasts.Type.FAILURE
});
}
}
return original(id, ...args);
};
},
stop() {
ChannelActions.selectVoiceChannel = original;
}
});

View file

@ -0,0 +1,17 @@
.vc-exitsounds-input-row {
display: flex;
gap: 8px;
align-items: center;
}
.vc-exitsounds-input-wrapper {
flex: 1;
}
.vc-exitsounds-guild-icon {
width: 24px;
height: 24px;
border-radius: 4px;
margin-right: 8px;
vertical-align: middle;
}

View file

@ -0,0 +1,128 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { definePluginSettings } from "@api/Settings";
import { Devs } from "@utils/constants";
import definePlugin, { OptionType } from "@utils/types";
import { Constants, PermissionsBits, PermissionStore, React, RestAPI, useCallback, useEffect, useState } from "@webpack/common";
const validKeycodes = [
"Backspace", "Tab", "Enter", "ShiftLeft", "ShiftRight", "ControlLeft", "ControlRight", "AltLeft", "AltRight", "Pause", "CapsLock",
"Escape", "Space", "PageUp", "PageDown", "End", "Home", "ArrowLeft", "ArrowUp", "ArrowRight", "ArrowDown", "PrintScreen", "Insert",
"Delete", "Digit0", "Digit1", "Digit2", "Digit3", "Digit4", "Digit5", "Digit6", "Digit7", "Digit8", "Digit9", "KeyA", "KeyB", "KeyC",
"KeyD", "KeyE", "KeyF", "KeyG", "KeyH", "KeyI", "KeyJ", "KeyK", "KeyL", "KeyM", "KeyN", "KeyO", "KeyP", "KeyQ", "KeyR", "KeyS", "KeyT",
"KeyU", "KeyV", "KeyW", "KeyX", "KeyY", "KeyZ", "MetaLeft", "MetaRight", "ContextMenu", "Numpad0", "Numpad1", "Numpad2", "Numpad3",
"Numpad4", "Numpad5", "Numpad6", "Numpad7", "Numpad8", "Numpad9", "NumpadMultiply", "NumpadAdd", "NumpadSubtract", "NumpadDecimal",
"NumpadDivide", "F1", "F2", "F3", "F4", "F5", "F6", "F7", "F8", "F9", "F10", "F11", "F12", "NumLock", "ScrollLock",
];
function showIcon() {
const [show, setShow] = useState(false);
const { keyBind, reqCtrl, reqShift, reqAlt } = settings.store;
const handleKeys = useCallback((e: KeyboardEvent) => {
const isMatchingKey =
e.code === keyBind &&
(!reqCtrl || e.ctrlKey) &&
(!reqShift || e.shiftKey) &&
(!reqAlt || e.altKey);
setShow(isMatchingKey);
}, [keyBind, reqCtrl, reqShift, reqAlt]);
useEffect(() => {
window.addEventListener("keydown", handleKeys);
window.addEventListener("keyup", handleKeys);
return () => {
window.removeEventListener("keydown", handleKeys);
window.removeEventListener("keyup", handleKeys);
};
}, [handleKeys]);
return show;
}
// TY ToggleVideoBind
const settings = definePluginSettings({
keyBind: {
description: "The key to toggle trash when pressed.",
type: OptionType.STRING,
default: "KeyZ",
isValid: (value: string) => validKeycodes.includes(value),
},
reqCtrl: {
description: "Require control to be held.",
type: OptionType.BOOLEAN,
default: true,
},
reqShift: {
description: "Require shift to be held.",
type: OptionType.BOOLEAN,
default: true,
},
reqAlt: {
description: "Require alt to be held.",
type: OptionType.BOOLEAN,
default: false,
},
});
export default definePlugin({
name: "FastDeleteChannels",
description: "Adds a trash icon to delete channels",
authors: [Devs.thororen],
settings,
patches: [
// TY TypingIndicator
// Normal Channels
{
find: "UNREAD_IMPORTANT:",
replacement: {
match: /\.Children\.count.+?:null(?<=,channel:(\i).+?)/,
replace: "$&,$self.TrashIcon($1)"
}
},
// Threads
{
find: "18V16H9v2H6Zm3",
replacement: {
match: /mentionsCount:\i.+?null(?<=channel:(\i).+?)/,
replace: "$&,$self.TrashIcon($1)"
}
}
],
TrashIcon: channel => {
const show = showIcon();
if (!show || !PermissionStore.can(PermissionsBits.MANAGE_CHANNELS, channel)) return null;
return (
<span
onClick={() => RestAPI.del({ url: Constants.Endpoints.CHANNEL(channel.id) })}
>
<svg
width="16"
height="16"
fill="none"
viewBox="0 0 24 24"
color="#ed4245"
>
<path
fill="currentColor"
d="M14.25 1c.41 0 .75.34.75.75V3h5.25c.41 0 .75.34.75.75v.5c0 .41-.34.75-.75.75H3.75A.75.75 0 0 1 3 4.25v-.5c0-.41.34-.75.75-.75H9V1.75c0-.41.34-.75.75-.75h4.5Z"
/>
<path
fill="currentColor"
fillRule="evenodd"
clipRule="evenodd"
d="M5.06 7a1 1 0 0 0-1 1.06l.76 12.13a3 3 0 0 0 3 2.81h8.36a3 3 0 0 0 3-2.81l.75-12.13a1 1 0 0 0-1-1.06H5.07ZM11 12a1 1 0 1 0-2 0v6a1 1 0 1 0 2 0v-6Zm3-1a1 1 0 1 1 1 1v6a1 1 0 1 1-2 0v-6a1 1 0 0 1 1-1Z"
/>
</svg>
</span>
);
}
});

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 { NavContextMenuPatchCallback } from "@api/ContextMenu";
import { definePluginSettings } from "@api/Settings";
import { Notice } from "@components/Notice";
import { EquicordDevs } from "@utils/constants";
import definePlugin, { OptionType } from "@utils/types";
import { Channel, User, VoiceState } from "@vencord/discord-types";
import { findByPropsLazy, findStoreLazy } from "@webpack";
import { Menu, React, VoiceStateStore } from "@webpack/common";
type TFollowedUserInfo = {
lastChannelId: string;
userId: string;
} | null;
interface UserContextProps {
channel: Channel;
user: User;
guildId?: string;
}
let followedUserInfo: TFollowedUserInfo = null;
const voiceChannelAction = findByPropsLazy("selectVoiceChannel");
const UserStore = findStoreLazy("UserStore");
const RelationshipStore = findStoreLazy("RelationshipStore");
const settings = definePluginSettings({
onlyWhenInVoice: {
type: OptionType.BOOLEAN,
default: true,
description: "Only follow the user when you are in a voice channel"
},
leaveWhenUserLeaves: {
type: OptionType.BOOLEAN,
default: false,
description: "Leave the voice channel when the user leaves. (That can cause you to sometimes enter infinite leave/join loop)"
}
});
const UserContextMenuPatch: NavContextMenuPatchCallback = (children, { channel, user }: UserContextProps) => {
if (UserStore.getCurrentUser().id === user.id || !RelationshipStore.getFriendIDs().includes(user.id)) return;
const [checked, setChecked] = React.useState(followedUserInfo?.userId === user.id);
children.push(
<Menu.MenuSeparator />,
<Menu.MenuCheckboxItem
id="fvu-follow-user"
label="Follow User"
checked={checked}
action={() => {
if (followedUserInfo?.userId === user.id) {
followedUserInfo = null;
setChecked(false);
return;
}
followedUserInfo = {
lastChannelId: UserStore.getCurrentUser().id,
userId: user.id
};
setChecked(true);
}}
></Menu.MenuCheckboxItem>
);
};
export default definePlugin({
name: "FollowVoiceUser",
description: "Follow a friend in voice chat.",
authors: [EquicordDevs.TheArmagan],
settings,
settingsAboutComponent: () => (
<Notice.Info>
This Plugin is used to follow a Friend/Friends into voice chat(s).
</Notice.Info>
),
flux: {
async VOICE_STATE_UPDATES({ voiceStates }: { voiceStates: VoiceState[]; }) {
if (!followedUserInfo) return;
if (!RelationshipStore.getFriendIDs().includes(followedUserInfo.userId)) return;
if (
settings.store.onlyWhenInVoice
&& !VoiceStateStore.getVoiceStateForUser(UserStore.getCurrentUser().id)
) return;
voiceStates.forEach(voiceState => {
if (
voiceState.userId === followedUserInfo!.userId
&& voiceState.channelId
&& voiceState.channelId !== followedUserInfo!.lastChannelId
) {
followedUserInfo!.lastChannelId = voiceState.channelId;
voiceChannelAction.selectVoiceChannel(followedUserInfo!.lastChannelId);
} else if (
voiceState.userId === followedUserInfo!.userId
&& !voiceState.channelId
&& settings.store.leaveWhenUserLeaves
) {
voiceChannelAction.selectVoiceChannel(null);
}
});
}
},
contextMenus: {
"user-context": UserContextMenuPatch
}
});

View file

@ -0,0 +1,219 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import "./styles.css";
import { definePluginSettings, migratePluginSetting } from "@api/Settings";
import { Card } from "@components/Card";
import { HeadingSecondary, HeadingTertiary } from "@components/Heading";
import { Paragraph } from "@components/Paragraph";
import { debounce } from "@shared/debounce";
import { EquicordDevs } from "@utils/constants";
import { Margins } from "@utils/margins";
import { classes } from "@utils/misc";
import definePlugin, { OptionType } from "@utils/types";
import { React, TextInput } from "@webpack/common";
interface GoogleFontMetadata {
family: string;
displayName: string;
authors: string[];
category?: number;
popularity?: number;
variants: Array<{
axes: Array<{
tag: string;
min: number;
max: number;
}>;
}>;
}
const createGoogleFontUrl = (family: string, options = "") =>
`https://fonts.googleapis.com/css2?family=${encodeURIComponent(family)}${options}&display=swap`;
const loadFontStyle = (url: string) => {
document.head.insertAdjacentHTML("beforeend", `<link rel="stylesheet" href="${url}">`);
return document.createElement("style");
};
async function searchGoogleFonts(query: string) {
try {
const response = await fetch("https://fonts.google.com/$rpc/fonts.fe.catalog.actions.metadata.MetadataService/FontSearch", {
method: "POST",
headers: {
"content-type": "application/json+protobuf",
"x-user-agent": "grpc-web-javascript/0.1"
},
body: JSON.stringify([[query, null, null, null, null, null, 1], [5], null, 16])
});
const data = await response.json();
if (!data?.[1]) return [];
return data[1].map(([_, fontData]: [string, any[]]) => ({
family: fontData[0],
displayName: fontData[1],
authors: fontData[2],
category: fontData[3],
variants: fontData[6].map((variant: any[]) => ({
axes: variant[0].map(([tag, min, max]: [string, number, number]) => ({
tag, min, max
}))
}))
}));
} catch (err) {
console.error("Failed to fetch fonts:", err);
return [];
}
}
const preloadFont = (family: string) =>
loadFontStyle(createGoogleFontUrl(family, "&text=The quick brown fox jumps over the lazy dog"));
let styleElement: HTMLStyleElement | null = null;
const applyFont = async (fontFamily: string) => {
if (!fontFamily) {
styleElement?.remove();
styleElement = null;
return;
}
try {
if (!styleElement) {
styleElement = document.createElement("style");
document.head.appendChild(styleElement);
}
loadFontStyle(createGoogleFontUrl(fontFamily, ":wght@300;400;500;600;700"));
styleElement.textContent = `
* {
--font-primary: '${fontFamily}', sans-serif !important;
--font-display: '${fontFamily}', sans-serif !important;
--font-headline: '${fontFamily}', sans-serif !important;
${settings.store.applyOnCodeBlocks ? "--font-code: '${fontFamily}', monospace !important;" : ""}
}
`;
} catch (err) {
console.error("Failed to load font:", err);
}
};
function GoogleFontSearch({ onSelect }: { onSelect: (font: GoogleFontMetadata) => void; }) {
const [query, setQuery] = React.useState("");
const [results, setResults] = React.useState<GoogleFontMetadata[]>([]);
const [loading, setLoading] = React.useState(false);
const previewStyles = React.useRef<HTMLStyleElement[]>([]);
React.useEffect(() => () => {
previewStyles.current.forEach(style => style.remove());
}, []);
const debouncedSearch = debounce(async (value: string) => {
setLoading(true);
if (!value) {
setResults([]);
setLoading(false);
return;
}
const fonts = await searchGoogleFonts(value);
previewStyles.current.forEach(style => style.remove());
previewStyles.current = await Promise.all(fonts.map(f => preloadFont(f.family)));
setResults(fonts);
setLoading(false);
}, 300);
const handleSearch = (e: string) => {
setQuery(e);
debouncedSearch(e);
};
return (
<section>
<HeadingSecondary>Search Google Fonts</HeadingSecondary>
<Paragraph>Click on any font to apply it.</Paragraph>
<TextInput
value={query}
onChange={e => handleSearch(e)}
placeholder="Search fonts..."
disabled={loading}
className={Margins.bottom16}
/>
{results.length > 0 && (
<div className={classes(Margins.top8, "eq-googlefonts-results")}>
{results.map(font => (
<Card
key={font.family}
className={classes("eq-googlefonts-card", Margins.bottom8)}
onClick={() => onSelect(font)}
>
<div className="eq-googlefonts-preview" style={{ fontFamily: font.family }}>
<HeadingTertiary>{font.displayName}</HeadingTertiary>
<Paragraph>The quick brown fox jumps over the lazy dog</Paragraph>
</div>
{font.authors?.length && (
<Paragraph className={Margins.top8} style={{ opacity: 0.7 }}>
by {font.authors.join(", ")}
</Paragraph>
)}
</Card>
))}
</div>
)}
</section>
);
}
migratePluginSetting("FontLoader", "applyOnCodeBlocks", "applyOnClodeBlocks");
const settings = definePluginSettings({
selectedFont: {
type: OptionType.STRING,
description: "Currently selected font",
default: "",
hidden: true
},
fontSearch: {
type: OptionType.COMPONENT,
description: "Search and select Google Fonts",
component: () => (
<GoogleFontSearch
onSelect={font => {
settings.store.selectedFont = font.family;
applyFont(font.family);
}}
/>
)
},
applyOnCodeBlocks: {
type: OptionType.BOOLEAN,
description: "Apply the font to code blocks",
default: false
}
});
export default definePlugin({
name: "FontLoader",
description: "Loads any font from Google Fonts",
authors: [EquicordDevs.vmohammad],
settings,
async start() {
const savedFont = settings.store.selectedFont;
if (savedFont) {
await applyFont(savedFont);
}
},
stop() {
if (styleElement) {
styleElement.remove();
styleElement = null;
}
}
});

View file

@ -0,0 +1,23 @@
.eq-googlefonts-results {
max-height: 400px;
overflow-y: auto;
}
.eq-googlefonts-card {
padding: 16px;
cursor: pointer !important;
background: var(--background-secondary-alt);
border: 1px solid var(--background-base-low);
}
.eq-googlefonts-card:hover {
background: var(--background-mod-subtle);
cursor: pointer !important;
}
.eq-googlefonts-preview {
display: flex;
flex-direction: column;
gap: 8px;
cursor: pointer !important;
}

View file

@ -0,0 +1,51 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { Devs } from "@utils/constants";
import definePlugin from "@utils/types";
import { ChannelStore, UserSettingsActionCreators } from "@webpack/common";
function generateSearchResults(query) {
const frequentChannelsWithQuery = Object.entries(UserSettingsActionCreators.FrecencyUserSettingsActionCreators.getCurrentValue().guildAndChannelFrecency.guildAndChannels)
.map(([key, value]) => key)
.filter(id => ChannelStore.getChannel(id) != null)
.filter(id => ChannelStore.getChannel(id).name.includes(query))
.sort((id1, id2) => {
const channel1 = UserSettingsActionCreators.FrecencyUserSettingsActionCreators.getCurrentValue().guildAndChannelFrecency.guildAndChannels[id1];
const channel2 = UserSettingsActionCreators.FrecencyUserSettingsActionCreators.getCurrentValue().guildAndChannelFrecency.guildAndChannels[id2];
return channel2.totalUses - channel1.totalUses;
})
.slice(0, 20);
return frequentChannelsWithQuery.map(channelID => {
const channel = ChannelStore.getChannel(channelID);
return (
{
"type": "TEXT_CHANNEL",
"record": channel,
"score": 20,
"comparator": query,
"sortable": query
}
);
});
}
export default definePlugin({
name: "FrequentQuickSwitcher",
description: "Rewrites and filters the quick switcher results to be your most frequent channels",
authors: [Devs.Samwich],
generateSearchResults: generateSearchResults,
patches: [
{
find: "#{intl::QUICKSWITCHER_PLACEHOLDER}",
replacement: {
match: /let{selectedIndex:\i,results:\i}/,
replace: "this.props.results = $self.generateSearchResults(this.state.query);$&"
},
}
]
});

View file

@ -0,0 +1,230 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import "styles.css?managed";
import { NavContextMenuPatchCallback } from "@api/ContextMenu";
import { DataStore } from "@api/index";
import { definePluginSettings } from "@api/Settings";
import { BaseText } from "@components/BaseText";
import { Divider } from "@components/Divider";
import { Devs } from "@utils/constants";
import { useForceUpdater } from "@utils/react";
import definePlugin, { OptionType } from "@utils/types";
import { Button, ChannelStore, Menu, RelationshipStore, TextInput, useEffect, UserStore, useState } from "@webpack/common";
interface UserTagData {
tagName: string;
userIds: string[];
}
let SavedData: UserTagData[] = [];
const tagStoreName = "vc-friendtags-tags";
function parseUsertags(text: string): string[] {
const matches = text.match(/&([^&]+)/g);
if (!matches) return [];
const tags = matches.map(match => match.substring(1).trim());
return tags.filter(tag => tag !== "");
}
function queryFriendTags(query) {
const tags = parseUsertags(query);
if (!tags.length) return [];
const filteredTagObjects = SavedData
.filter(data => data.tagName.length && data.userIds.length)
.filter(data => tags.some(tag => tag.toLowerCase() === data.tagName.toLowerCase()));
if (!filteredTagObjects.length) return [];
const users = Array.from(new Set([...ChannelStore.getDMUserIds(), ...RelationshipStore.getFriendIDs()]))
.filter(user => filteredTagObjects.some(tag => tag.userIds.includes(user)));
return users.map(user => {
const userObject: any = UserStore.getUser(user);
return {
type: "USER",
record: userObject,
score: 20,
comparator: userObject.globalName || userObject.username,
sortable: userObject.globalName || userObject.username
};
});
}
async function SetData() {
const fetchData = await DataStore.get(tagStoreName);
if (SavedData !== fetchData) {
await DataStore.set(tagStoreName, JSON.stringify(SavedData));
}
return true;
}
async function GetData() {
const fetchData = await DataStore.get(tagStoreName);
if (!fetchData) {
DataStore.set(tagStoreName, JSON.stringify([]));
SavedData = [];
return;
}
SavedData = JSON.parse(fetchData);
}
function TagConfigCard(props) {
const { tag } = props;
const [tagName, setTagName] = useState(tag.tagName);
const [userIds, setUserIDs] = useState(tag.userIds.join(", "));
const update = useForceUpdater();
useEffect(() => {
const dataTag = SavedData.find(obj => obj.tagName === tag.tagName);
if (dataTag) {
dataTag.tagName = tagName;
}
SetData();
update();
}, [tagName]);
useEffect(() => {
const dataTag = SavedData.find(obj => obj.userIds === tag.userIds);
if (dataTag) {
dataTag.userIds = userIds.split(", ");
}
SetData();
update();
}, [userIds]);
return (
<>
<BaseText size="md" tag="h5">Name</BaseText>
<TextInput value={tagName} onChange={setTagName}></TextInput>
<BaseText size="md" tag="h5">Users (Seperated by comma)</BaseText>
<TextInput value={userIds} onChange={setUserIDs}></TextInput>
<div className={"vc-friend-tags-user-header-container"}>
<BaseText>User List (Click A User To Remove)</BaseText>
<div className={"vc-friend-tags-user-header-btns"}>
{
userIds.split(", ").map(user => {
const userData: any = UserStore.getUser(user);
if (!userData) return null;
return (
<div style={{ display: "flex" }} key={user.id}>
<img src={userData.getAvatarURL()} style={{ height: "20px", borderRadius: "50%", marginRight: "5px" }}></img>
<BaseText style={{ cursor: "pointer" }} size="md" onClick={() => setUserIDs(userIds.replace(`, ${user}`, "").replace(user, ""))}>{userData.globalName || userData.username}</BaseText>
</div>
);
})
}
</div>
</div>
<Button
onClick={async () => {
SavedData = SavedData.filter(data => (data.tagName !== tagName));
await SetData();
update();
}}
color={Button.Colors.RED}
>
Remove
</Button>
</>
);
}
function TagConfigurationComponent() {
const update = useForceUpdater();
return (
<>
<Divider />
{
SavedData?.map(e => (
<>
<TagConfigCard tag={e} />
<Divider />
</>
))
}
<Button onClick={() => {
SavedData.push(
{
tagName: "",
userIds: []
});
SetData();
update();
}}>Add</Button>
</>
);
}
const settings = definePluginSettings({
tagConfiguration: {
type: OptionType.COMPONENT,
description: "The tag configuration component",
component: () => {
return (
<TagConfigurationComponent />
);
}
}
});
function UserToTagID(user, tag, remove) {
if (remove) {
SavedData.filter(e => e.tagName === tag)[0].userIds = SavedData.filter(e => e.tagName === tag)[0].userIds.filter(e => e !== user);
}
else {
SavedData.filter(e => e.tagName === tag)[0]?.userIds.push(user);
}
SetData();
}
const userPatch: NavContextMenuPatchCallback = (children, { user }) => {
const buttonElement =
<Menu.MenuItem
id="vc-tag-group"
label="Tag"
>
{SavedData.map(tag => {
const isTagged = SavedData.filter(e => e.tagName === tag.tagName)[0].userIds.includes(user.id);
return (
<Menu.MenuItem
label={`${isTagged ? "Remove from" : "Add to"} ${tag.tagName}`}
key={`vc-tag-${tag.tagName}`}
id={`vc-tag-${tag.tagName}`}
action={() => { UserToTagID(user.id, tag.tagName, isTagged); }}
/>
);
})}
</Menu.MenuItem>;
children.push({ ...buttonElement });
};
export default definePlugin({
name: "FriendTags",
description: "Allows you to filter by custom tags in the quick switcher by starting a search with &",
authors: [Devs.Samwich],
settings,
contextMenus: {
"user-context": userPatch
},
patches: [
{
find: "#{intl::QUICKSWITCHER_PLACEHOLDER}",
replacement: {
match: /let{selectedIndex:\i,results:\i}/,
replace: "if(this.state.query.includes(\"&\")){ this.props.results = $self.queryFriendTags(this.state.query); }$&"
},
}
],
async start() {
GetData();
},
queryFriendTags,
});

View file

@ -0,0 +1,11 @@
.vc-permviewer-user-header-container {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.vc-permviewer-user-header-btns {
display: flex;
gap: 8px;
}

View file

@ -0,0 +1,154 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import "./styles.css";
import { BadgePosition, BadgeUserArgs, ProfileBadge } from "@api/Badges";
import { Badges } from "@api/index";
import ErrorBoundary from "@components/ErrorBoundary";
import { Flex } from "@components/Flex";
import { Paragraph } from "@components/Paragraph";
import { Devs } from "@utils/constants";
import { classNameFactory } from "@utils/css";
import { ModalContent, ModalHeader, ModalRoot, ModalSize, openModal } from "@utils/modal";
import definePlugin from "@utils/types";
import { Forms, RelationshipStore } from "@webpack/common";
interface rankInfo {
title: string;
description: string;
requirement: number;
iconSrc: string;
}
const cl = classNameFactory("vc-friendship-ranks-");
function daysSince(dateString: string): number {
const date = new Date(dateString);
const currentDate = new Date();
const differenceInMs = currentDate.getTime() - date.getTime();
const days = differenceInMs / (1000 * 60 * 60 * 24);
return Math.floor(days);
}
const ranks: rankInfo[] =
[
{
title: "Sprout",
description: "Your friendship is just starting",
requirement: 0,
iconSrc: "https://equicord.org/assets/plugins/friendshipRanks/sprout.png"
},
{
title: "Blooming",
description: "Your friendship is getting there! (1 Month)",
requirement: 30,
iconSrc: "https://equicord.org/assets/plugins/friendshipRanks/blooming.png"
},
{
title: "Burning",
description: "Your friendship has reached terminal velocity (3 Months)",
requirement: 90,
iconSrc: "https://equicord.org/assets/plugins/friendshipRanks/burning.png"
},
{
title: "Fighter",
description: "Your friendship is strong (6 Months)",
requirement: 182.5,
iconSrc: "https://equicord.org/assets/plugins/friendshipRanks/fighter.png"
},
{
title: "Star",
description: "Your friendship has been going on for a WHILE (1 Year)",
requirement: 365,
iconSrc: "https://equicord.org/assets/plugins/friendshipRanks/star.png"
},
{
title: "Royal",
description: "Your friendship has gone through thick and thin- a whole 2 years!",
requirement: 730,
iconSrc: "https://equicord.org/assets/plugins/friendshipRanks/royal.png"
},
{
title: "Besties",
description: "How do you even manage this??? (5 Years)",
requirement: 1826.25,
iconSrc: "https://equicord.org/assets/plugins/friendshipRanks/besties.png"
}
];
function openRankModal(rank: rankInfo) {
openModal(props => (
<ErrorBoundary>
<ModalRoot {...props} size={ModalSize.DYNAMIC}>
<ModalHeader>
<Flex className={cl("flex")}>
<Forms.FormTitle
className={cl("img")}
tag="h2"
>
<img src={rank.iconSrc} alt="rank icon" />
{rank.title}
</Forms.FormTitle>
</Flex>
</ModalHeader>
<ModalContent>
<div className={cl("text")}>
<Paragraph>
{rank.description}
</Paragraph>
</div>
</ModalContent>
</ModalRoot>
</ErrorBoundary >
));
}
function shouldShowBadge(userId: string, requirement: number, index: number) {
if (!RelationshipStore.isFriend(userId)) return false;
const days = daysSince(RelationshipStore.getSince(userId));
if (ranks[index + 1] == null) return days > requirement;
return (days > requirement && days < ranks[index + 1].requirement);
}
function getBadgesToApply() {
const badgesToApply: ProfileBadge[] = ranks.map((rank, index) => {
return ({
description: rank.title,
iconSrc: rank.iconSrc,
position: BadgePosition.END,
onClick: () => openRankModal(rank),
shouldShow: (info: BadgeUserArgs) => shouldShowBadge(info.userId, rank.requirement, index),
props: {
style: {
borderRadius: "50%",
transform: "scale(0.9)"
}
},
});
});
return badgesToApply;
}
export default definePlugin({
name: "FriendshipRanks",
description: "Adds badges showcasing how long you have been friends with a user for",
authors: [Devs.Samwich],
start() {
getBadgesToApply().forEach(b => Badges.addProfileBadge(b));
},
stop() {
getBadgesToApply().forEach(b => Badges.removeProfileBadge(b));
},
});

View file

@ -0,0 +1,19 @@
.vc-friendship-ranks-flex {
width: 100%;
justify-content: center;
height: 18px;
}
.vc-friendship-ranks-img {
display: flex;
width: 100%;
text-align: center;
place-content: center center;
margin: 0;
gap: 4px;
}
.vc-friendship-ranks-text {
padding: 1em;
text-align: center;
}

View file

@ -7,197 +7,179 @@
import "./styles.css";
import { findGroupChildrenByChildId } from "@api/ContextMenu";
import {
addServerListElement,
removeServerListElement,
ServerListRenderPosition,
} from "@api/ServerList";
import { addServerListElement, removeServerListElement, ServerListRenderPosition } from "@api/ServerList";
import { definePluginSettings } from "@api/Settings";
import ErrorBoundary from "@components/ErrorBoundary";
import { Devs, EquicordDevs } from "@utils/constants";
import { classNameFactory } from "@utils/css";
import { closeModal, openModal } from "@utils/modal";
import definePlugin, { OptionType } from "@utils/types";
import type { Channel } from "@vencord/discord-types";
import { Channel } from "@vencord/discord-types";
import { Menu, Tooltip, useEffect, useState } from "@webpack/common";
import {
Boo,
clearChannelFromGhost,
getBooCount,
getGhostedChannels,
onBooCountChange,
} from "./Boo";
import { getChannelDisplayName,GhostedUsersModal } from "./GhostedUsersModal";
import { Boo, clearChannelFromGhost, getBooCount, getGhostedChannels, onBooCountChange } from "./Boo";
import { getChannelDisplayName, GhostedUsersModal } from "./GhostedUsersModal";
import { IconGhost } from "./IconGhost";
export const cl = classNameFactory("vc-boo-");
export const settings = definePluginSettings({
showIndicator: {
type: OptionType.BOOLEAN,
description: "Show the ghost counter at the top of the server list",
default: true,
restartNeeded: false,
},
showDmIcons: {
type: OptionType.BOOLEAN,
description: "Show ghost icons next to individual DMs",
default: true,
restartNeeded: false,
},
ignoreGroupDms: {
type: OptionType.BOOLEAN,
description: "Exclude all group dms from ghosting",
default: false,
},
exemptedChannels: {
type: OptionType.STRING,
description:
"Comma-separated list of channel IDs to exempt from ghosting (right-click a DM channel to copy its ID)",
default: "",
restartNeeded: false,
},
ignoreBots: {
type: OptionType.BOOLEAN,
description: "Ignore DMs from bots",
default: true,
restartNeeded: false,
},
showIndicator: {
type: OptionType.BOOLEAN,
description: "Show the ghost counter at the top of the server list",
default: true,
restartNeeded: false
},
showDmIcons: {
type: OptionType.BOOLEAN,
description: "Show ghost icons next to individual DMs",
default: true,
restartNeeded: false
},
ignoreGroupDms: {
type: OptionType.BOOLEAN,
description: "Exclude all group dms from ghosting",
default: false
},
exemptedChannels: {
type: OptionType.STRING,
description: "Comma-separated list of channel IDs to exempt from ghosting (right-click a DM channel to copy its ID)",
default: "",
restartNeeded: false
},
ignoreBots: {
type: OptionType.BOOLEAN,
description: "Ignore DMs from bots",
default: true,
restartNeeded: false
}
});
function BooIndicator() {
const [count, setCount] = useState(getBooCount());
const [count, setCount] = useState(getBooCount());
const [showJumpscare, setShowJumpscare] = useState(false);
useEffect(() => {
const unsubscribe = onBooCountChange(newCount => {
setCount(newCount);
});
useEffect(() => {
const unsubscribe = onBooCountChange(newCount => {
setCount(newCount);
});
return () => {
unsubscribe();
};
}, []);
return () => {
unsubscribe();
};
}, []);
if (!settings.store.showIndicator) return null;
if (!settings.store.showIndicator && !showJumpscare) return null;
const handleClick = () => {
const ghostedChannels = getGhostedChannels();
const modalKey = openModal(modalProps => (
<ErrorBoundary>
<GhostedUsersModal
modalProps={modalProps}
ghostedChannels={ghostedChannels}
onClose={() => closeModal(modalKey)}
onClearGhost={clearChannelFromGhost}
/>
</ErrorBoundary>
));
};
const handleClick = () => {
const ghostedChannels = getGhostedChannels();
const modalKey = openModal(modalProps => (
<ErrorBoundary>
<GhostedUsersModal
modalProps={modalProps}
ghostedChannels={ghostedChannels}
onClose={() => closeModal(modalKey)}
onClearGhost={clearChannelFromGhost}
/>
</ErrorBoundary>
));
};
const getTooltipText = () => {
const ghostedChannels = getGhostedChannels();
if (ghostedChannels.length === 0) {
return "No Ghosted Users";
}
if (ghostedChannels.length <= 5) {
return ghostedChannels.map(id => getChannelDisplayName(id)).join(", ");
}
return `${ghostedChannels.length} Ghosted Users`;
};
const getTooltipText = () => {
const ghostedChannels = getGhostedChannels();
if (ghostedChannels.length === 0) {
return "No Ghosted Users";
}
if (ghostedChannels.length <= 5) {
return ghostedChannels
.map(id => getChannelDisplayName(id))
.join(", ");
}
return `${ghostedChannels.length} Ghosted Users`;
};
return (
<>
{settings.store.showIndicator && getGhostedChannels().length > 0 && (
<div id={cl("container")}>
<Tooltip text={getTooltipText()} position="right">
{({ onMouseEnter, onMouseLeave }) => (
<div
id={cl("container")}
className={cl("clickable")}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onClick={handleClick}
>
{count} <IconGhost fill="currentColor" />
</div>
)}
</Tooltip>
</div>
)}
</>
);
return (
<>
{settings.store.showIndicator && getGhostedChannels().length > 0 && (
<div id={cl("container")}>
<Tooltip text={getTooltipText()} position="right">
{({ onMouseEnter, onMouseLeave }) => (
<div
id={cl("container")}
className={cl("clickable")}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onClick={handleClick}
>
{count} <IconGhost fill="currentColor" />
</div>
)}
</Tooltip>
</div>
)}
</>
);
}
function makeContextItem(props) {
return (
<Menu.MenuItem
id="ec-ghosted-clear"
key="ec-ghosted-clear"
label="unghost"
action={() => {
clearChannelFromGhost(props.channel.id);
}}
/>
);
return <Menu.MenuItem
id="ec-ghosted-clear"
key="ec-ghosted-clear"
label="unghost"
action={() => {
clearChannelFromGhost(props.channel.id);
}}
/>;
}
export default definePlugin({
name: "Ghosted",
description: "A cute ghost will appear if you don't answer their DMs",
authors: [
EquicordDevs.vei,
Devs.sadan,
EquicordDevs.justjxke,
EquicordDevs.iamme,
],
settings,
dependencies: ["AudioPlayerAPI", "ServerListAPI"],
contextMenus: {
"gdm-context": (menuItems, props) => {
const group = findGroupChildrenByChildId("leave", menuItems, true);
group?.unshift(makeContextItem(props));
},
"user-context": (menuItems, props) => {
const group = findGroupChildrenByChildId("close-dm", menuItems);
group?.push(makeContextItem(props));
},
},
name: "Ghosted",
description: "A cute ghost will appear if you don't answer their DMs",
authors: [EquicordDevs.vei, Devs.sadan, EquicordDevs.justjxke, EquicordDevs.iamme],
settings,
dependencies: ["AudioPlayerAPI", "ServerListAPI"],
contextMenus: {
"gdm-context": (menuItems, props) => {
const group = findGroupChildrenByChildId("leave", menuItems, true);
group?.unshift(makeContextItem(props));
},
"user-context": (menuItems, props) => {
const group = findGroupChildrenByChildId("close-dm", menuItems);
group?.push(makeContextItem(props));
}
},
patches: [
{
find: "PrivateChannel.renderAvatar",
replacement: {
match: /\]:i\|\|i.{0,50}children:\[/,
replace: "$&$self.renderBoo(arguments[0]),",
},
},
],
patches: [
{
find: "PrivateChannel.renderAvatar",
replacement: {
match: /\]:\i\|\|\i.{0,50}children:\[/,
replace: "$&$self.renderBoo(arguments[0]),"
}
},
],
renderBoo(props: { channel: Channel }) {
return (
<ErrorBoundary noop>
<Boo {...props} />
</ErrorBoundary>
);
},
renderBoo(props: { channel: Channel; }) {
return (
<ErrorBoundary noop>
<Boo {...props} />
</ErrorBoundary>
);
},
renderIndicator() {
return (
<ErrorBoundary noop>
<BooIndicator />
</ErrorBoundary>
);
},
renderIndicator() {
return (
<ErrorBoundary noop>
<BooIndicator />
</ErrorBoundary>
);
},
start() {
addServerListElement(ServerListRenderPosition.Above, this.renderIndicator);
},
start() {
addServerListElement(ServerListRenderPosition.Above, this.renderIndicator);
},
stop() {
removeServerListElement(
ServerListRenderPosition.Above,
this.renderIndicator,
);
},
stop() {
removeServerListElement(ServerListRenderPosition.Above, this.renderIndicator);
},
});

View file

@ -53,6 +53,93 @@
gap: 2px;
}
/* Jumpscare overlay styling */
.vc-boo-jumpscare-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: rgb(0 0 0 / 50%);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
cursor: pointer;
}
.vc-boo-jumpscare-image {
max-width: 90%;
max-height: 90%;
object-fit: contain;
}
/* job application */
@keyframes boo-jumpscare-fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes boo-jumpscare-fade-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@keyframes boo-jumpscare-shake {
/* stylelint-disable-next-line rule-empty-line-before */
0%,
100% {
transform: translate(0, 0) rotate(0deg);
}
10% {
transform: translate(-5px, -5px) rotate(-2deg);
}
20% {
transform: translate(5px, 5px) rotate(2deg);
}
30% {
transform: translate(-5px, 5px) rotate(-1deg);
}
40% {
transform: translate(5px, -5px) rotate(1deg);
}
50% {
transform: translate(-3px, -3px) rotate(-1deg);
}
60% {
transform: translate(3px, 3px) rotate(1deg);
}
70% {
transform: translate(-3px, 3px) rotate(-0.5deg);
}
80% {
transform: translate(3px, -3px) rotate(0.5deg);
}
90% {
transform: translate(-2px, -2px) rotate(-0.5deg);
}
}
.vc-boo-clear {
margin-right: 16px;
}

View file

@ -0,0 +1,48 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { definePluginSettings } from "@api/Settings";
import { Devs } from "@utils/constants";
import definePlugin, { OptionType } from "@utils/types";
import { Message } from "@vencord/discord-types";
import { RelationshipStore, SelectedChannelStore } from "@webpack/common";
interface IMessageCreate {
type: "MESSAGE_CREATE";
optimistic: boolean;
channelId: string;
message: Message;
}
const settings = definePluginSettings({
regex: {
type: OptionType.STRING,
description: "Regex to trigger on",
default: "hop on (?:fortnite|fn)"
},
url: {
type: OptionType.STRING,
description: "URL to open",
default: "com.epicgames.launcher://apps/fn%3A4fe75bbc5a674f4f9b356b5c90567da5%3AFortnite?action=launch&silent=true"
}
});
export default definePlugin({
name: "HopOn",
description: "Hop on! Opens a configurable URL when a message matches a custom regex in the current channel.",
authors: [Devs.ImLvna],
settings,
flux: {
async MESSAGE_CREATE({ optimistic, type, message, channelId }: IMessageCreate) {
if (optimistic || type !== "MESSAGE_CREATE") return;
if (message.state === "SENDING") return;
if (RelationshipStore.isBlocked(message.author?.id)) return;
if (channelId !== SelectedChannelStore.getChannelId()) return;
if (!message.content?.match(new RegExp(settings.store.regex, "i"))) return;
VencordNative.native.openExternal(settings.store.url);
}
}
});

View file

@ -0,0 +1,122 @@
/* eslint-disable simple-header/header */
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 your mom lol
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { definePluginSettings } from "@api/Settings";
import { Devs } from "@utils/constants";
import { classes } from "@utils/misc";
import definePlugin, { OptionType } from "@utils/types";
import { ChannelStore, EmojiStore, RestAPI } from "@webpack/common";
import type { SVGProps } from "react";
// eslint-disable-next-line no-duplicate-imports
import { PropsWithChildren } from "react";
interface BaseIconProps extends IconProps {
viewBox: string;
}
interface IconProps extends SVGProps<SVGSVGElement> {
className?: string;
height?: string | number;
width?: string | number;
}
function Icon({ height = 24, width = 24, className, children, viewBox, ...svgProps }: PropsWithChildren<BaseIconProps>) {
return (
<svg
className={classes(className, "vc-icon")}
role="img"
width={width}
height={height}
viewBox={viewBox}
{...svgProps}
>
{children}
</svg>
);
}
export function Husk(props: IconProps) {
return (
<Icon
{...props}
className={classes(props.className, "vc-husk")}
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M12,2C6.47,2 2,6.47 2,12C2,17.53 6.47,22 12,22A10,10 0 0,0 22,12C22,6.47 17.5,2 12,2M12,20A8,8 0 0,1 4,12A8,8 0 0,1 12,4A8,8 0 0,1 20,12A8,8 0 0,1 12,20M16.18,7.76L15.12,8.82L14.06,7.76L13,8.82L14.06,9.88L13,10.94L14.06,12L15.12,10.94L16.18,12L17.24,10.94L16.18,9.88L17.24,8.82L16.18,7.76M7.82,12L8.88,10.94L9.94,12L11,10.94L9.94,9.88L11,8.82L9.94,7.76L8.88,8.82L7.82,7.76L6.76,8.82L7.82,9.88L6.76,10.94L7.82,12M12,14C9.67,14 7.69,15.46 6.89,17.5H17.11C16.31,15.46 14.33,14 12,14Z"
/>
</Icon>
);
}
const settings = definePluginSettings({
findInServer: {
description: "Attempt to find emoji of same name in server before using ID in settings (useful if no nitro)",
type: OptionType.BOOLEAN,
default: true
},
emojiName: {
description: "Emoji name (default (from Vencord Server): husk)",
type: OptionType.STRING,
default: "husk"
},
emojiID: {
description: "Emoji ID (default (from Vencord Server): 1026532993923293184)",
type: OptionType.BIGINT,
default: 1026532993923293184n
}
});
function getEmojiIdThatShouldBeUsed(guildId: string) {
if (!settings.store.findInServer || guildId === "") return settings.store.emojiID;
let id = "";
EmojiStore.getGuildEmoji(guildId).forEach(emoji => {
if (emoji.name === settings.store.emojiName) {
id = emoji.id;
}
});
return id !== "" ? id : settings.store.emojiID;
}
export default definePlugin({
name: "Husk",
description: "Adds Husk button (check settings to change used emoji)",
authors: [Devs.nin0dev],
settings,
messagePopoverButton: {
icon: Husk,
render(msg) {
return {
label: "Husk",
icon: Husk,
message: msg,
channel: ChannelStore.getChannel(msg.channel_id),
onClick: () => {
const guildId = ChannelStore.getChannel(msg.channel_id).guild_id !== null ? ChannelStore.getChannel(msg.channel_id).guild_id : "";
RestAPI.put({
url: `/channels/${msg.channel_id}/messages/${msg.id}/reactions/${settings.store.emojiName}:${getEmojiIdThatShouldBeUsed(guildId)}/@me`
}
);
}
};
},
}
});

View file

@ -0,0 +1,217 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { DataStore } from "@api/index";
import { Guild, User } from "@vencord/discord-types";
import { ChannelStore, GuildMemberStore, GuildStore, InviteActions, MessageStore, PermissionsBits, PermissionStore, UserStore, } from "@webpack/common";
export interface IUserExtra {
isOwner?: boolean;
updatedAt?: number;
}
export interface IStorageUser {
id: string;
username: string,
tag: string,
iconURL?: string;
extra?: IUserExtra;
}
export interface GroupData {
id: string;
users: { [key: string]: IStorageUser; };
name: string;
inviteLink?: string;
}
export class Data {
declare usersCollection: Record<string, GroupData>;
declare _storageAutoSaveProtocol_interval;
declare _onMessagePreSend_preSend;
withStart() {
return this;
}
onMessagePreSend(channelId, message, extra) {
const target: Set<{ user: User; source?: Guild, extra: IUserExtra; }> = new Set();
const now = Date.now();
const { replyOptions } = extra;
const guild = (() => {
const channel = ChannelStore.getChannel(channelId);
return GuildStore.getGuild(channel.guild_id) || undefined;
})();
if (replyOptions.messageReference) {
const { channel_id, message_id } = replyOptions.messageReference;
const message = MessageStore.getMessage(channel_id, message_id);
if (!message) {
return;
}
const { author } = message;
target.add({ user: author, source: guild, extra: { updatedAt: now } });
}
if (message.content) {
const { content } = message;
const ids = [...content.matchAll(/<@!?(?<id>\d{17,23})>/g)].map(
({ groups }) => groups.id
);
const users = ids
.map(id => UserStore.getUser(id))
.filter(Boolean);
for (const user of users) {
target.add({ user, source: guild, extra: { updatedAt: now } });
}
}
this.processUsersToCollection([...target]);
}
async processUsersToCollection(
array: { user: User; source?: Guild; extra?: IUserExtra; }[]
) {
const target = this.usersCollection;
const processedGuilds = new Set<string>();
for (const { user, source, extra } of array) {
if (user.bot) {
continue;
}
const groupKey = source?.id ?? "dm";
const group = (target[groupKey] ||= {
name: source?.name || "dm",
id: source?.id || user.id,
users: {},
inviteLink: undefined
});
const usersField = group.users;
const previouExtra = usersField[user.id]?.extra ?? {};
const { id, username } = user;
usersField[id] = {
id,
username,
tag: user.discriminator === "0" ? user.username : user.tag,
extra: { ...previouExtra, ...extra },
iconURL: user.getAvatarURL(),
};
if (source && !processedGuilds.has(source.id) && this.hasInvitePermissions(source.id)) {
this.collectInviteLink(source.id);
processedGuilds.add(source.id);
}
}
}
async updateStorage() {
await DataStore.set("irememberyou.data", this.usersCollection);
}
async initializeUsersCollection() {
const data = await DataStore.get("irememberyou.data");
this.usersCollection = data ?? {};
}
writeMembersFromUserGuildsToCollection() {
const target: Set<{ user: User; source?: Guild, extra: IUserExtra; }> =
new Set();
const now = Date.now();
const LIMIT = 1_000;
const clientId = UserStore.getCurrentUser().id;
if (!clientId) {
return;
}
for (const guild of Object.values(GuildStore.getGuilds())) {
const { ownerId } = guild;
if (ownerId !== clientId) {
continue;
}
const members = GuildMemberStore.getMembers(guild.id);
if (members.length > LIMIT) {
members.length = LIMIT;
}
for (const member of members) {
const user = UserStore.getUser(member.userId);
target.add({ user, source: guild, extra: { updatedAt: now } });
}
this.processUsersToCollection([...target]);
if (guild && this.hasInvitePermissions(guild.id)) {
this.collectInviteLink(guild.id);
}
}
}
writeGuildsOwnersToCollection() {
const target: Set<{ user: User; source?: Guild; extra: IUserExtra; }> =
new Set();
const now = Date.now();
for (const guild of Object.values(GuildStore.getGuilds())) {
const { ownerId } = guild;
const owner = UserStore.getUser(ownerId);
if (!owner) {
continue;
}
target.add({
user: owner,
source: guild,
extra: { isOwner: true, updatedAt: now },
});
}
this.processUsersToCollection([...target]);
}
hasInvitePermissions(guildId: string): boolean {
const guild = GuildStore.getGuild(guildId);
if (!guild) return false;
const currentUser = UserStore.getCurrentUser();
if (!currentUser) return false;
if (guild.ownerId === currentUser.id) return true;
const member = GuildMemberStore.getMember(guildId, currentUser.id);
if (!member) return false;
return PermissionStore.can(PermissionsBits.CREATE_INSTANT_INVITE, guild);
}
async collectInviteLink(guildId: string) {
try {
const invites = await InviteActions.getInvites(guildId);
if (invites && invites.length > 0) {
const invite = invites[0];
const inviteCode = invite.code;
const inviteLink = `https://discord.gg/${inviteCode}`;
if (this.usersCollection[guildId]) {
this.usersCollection[guildId].inviteLink = inviteLink;
}
}
} catch (error) {
console.warn("Failed to collect invite link for guild:", guildId, error);
}
}
storageAutoSaveProtocol() {
this._storageAutoSaveProtocol_interval = setInterval(
this.updateStorage.bind(this),
60_000 * 3
);
}
}

View file

@ -0,0 +1,141 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { BaseText } from "@components/BaseText";
import { Card } from "@components/Card";
import { HeadingPrimary } from "@components/Heading";
import { Paragraph } from "@components/Paragraph";
import { SettingsTab, wrapTab } from "@components/settings";
import { classNameFactory } from "@utils/css";
import { copyWithToast, openUserProfile } from "@utils/discord";
import { Avatar, Clickable, ContextMenuApi, Menu, React, TextInput, Tooltip, UserStore } from "@webpack/common";
import { Data, IStorageUser } from "./data";
const cl = classNameFactory("vc-i-remember-you-");
function tooltipText(user: IStorageUser) {
const { updatedAt } = user.extra || {};
const updatedAtContent = updatedAt ? new Intl.DateTimeFormat().format(updatedAt) : null;
return `${user.username ?? user.tag}, updated at ${updatedAtContent}`;
}
function UsersCollectionRows({ usersCollection }: { usersCollection: Data["usersCollection"]; }) {
if (Object.keys(usersCollection).length === 0) return (
<BaseText>
It's empty right now
</BaseText>
);
return (
<>
{Object.entries(usersCollection)
.map(([_key, { users, name }]) => ({ name, users: Object.values(users) }))
.sort((a, b) => b.users.length - a.users.length)
.map(({ name, users }) => (
<aside key={name}>
<div className={cl("header-container")}>
<HeadingPrimary className={cl("header-name")}>{name}</HeadingPrimary>
<div className={cl("header-btns")}>
{users.map(u => <UserRow key={u.id} user={u} />)}
</div>
</div>
</aside>
))}
</>
);
}
function UserRow({ user, allowOwner = true }: { user: IStorageUser, allowOwner?: boolean; }) {
return (
<div key={user.id} className={cl("user-row")}>
<div className={cl("user")}>
<Clickable onClick={() => openUserProfile(user.id)}>
<span className={cl("user-avatar")}>
<Avatar src={user.iconURL} size="SIZE_24" />
</span>
</Clickable>
<div className={cl("user-tooltip")}>
<Tooltip text={tooltipText(user)}>
{props =>
<Paragraph {...props} className={cl("user-username")}>
{user.tag} {allowOwner && user.extra?.isOwner && "(owner)"}
</Paragraph>
}
</Tooltip>
<span
className={cl("user-id")}
onContextMenu={e => {
e.preventDefault();
const userObj = UserStore.getUser(user.id);
if (userObj) {
ContextMenuApi.openContextMenu(e, () => (
<Menu.Menu
navId="user-context-menu"
onClose={ContextMenuApi.closeContextMenu}
aria-label="User Options"
>
<Menu.MenuItem
id="copy-user-id"
label="Copy User ID"
action={() => copyWithToast(user.id, "User ID copied to clipboard")}
/>
<Menu.MenuItem
id="view-profile"
label="View Profile"
action={() => openUserProfile(user.id)}
/>
</Menu.Menu>
));
}
}}
>
<Clickable onClick={() => copyWithToast(user.id, "User ID copied to clipboard")}>
<Paragraph>
{user.id}
</Paragraph>
</Clickable>
</span>
</div>
</div>
</div>
);
}
function SearchElement({ usersCollection }: { usersCollection: Data["usersCollection"]; }) {
const [current, setCurrent] = React.useState("");
const list = Object.values(usersCollection).flatMap(col => Object.values(col.users)) as IStorageUser[];
return (
<section className={cl("search")}>
<TextInput placeholder="Filter by tag, username" name="Filter" onChange={setCurrent} />
{current && (
<div className={cl("search-user")}>
{list.filter(user => user.tag.includes(current) || user.username.includes(current))
.map(user => <UserRow key={user.id} user={user} allowOwner={false} />)}
</div>
)}
</section>
);
}
export function DataUI({ usersCollection }: { usersCollection: Data["usersCollection"]; }) {
return (
<SettingsTab>
<Card>
<Paragraph>
Provides a list of users you have mentioned or replied to, or those who own the servers you belong to (owner*), or are members of your guild
</Paragraph>
<SearchElement usersCollection={usersCollection} />
</Card>
<div className={cl("rows")}>
<UsersCollectionRows usersCollection={usersCollection} />
</div>
</SettingsTab>
);
}
export default wrapTab(DataUI, "IRememberYouTab");

View file

@ -0,0 +1,55 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import "./styles.css";
import { addMessagePreSendListener, removeMessagePreSendListener } from "@api/MessageEvents";
import { EyeIcon } from "@components/Icons";
import SettingsPlugin from "@plugins/_core/settings";
import { EquicordDevs } from "@utils/constants";
import { removeFromArray } from "@utils/misc";
import definePlugin from "@utils/types";
import { Data } from "./components/data";
import DataUI from "./components/ui";
export default definePlugin({
name: "IRememberYou",
description: "Locally saves everyone you've been communicating with (including servers), in case of lose",
authors: [EquicordDevs.zoodogood, EquicordDevs.keircn],
dependencies: ["MessageEventsAPI"],
patches: [],
async start() {
SettingsPlugin.customEntries.push({
key: "equicord_i_remember_you",
title: "I Remember You",
Component: () => <DataUI usersCollection={data.usersCollection} />,
Icon: EyeIcon
});
SettingsPlugin.settingsSectionMap.push(["EquicordIRememberYou", "equicord_i_remember_you"]);
const data = (this.dataManager = await new Data().withStart());
await data.initializeUsersCollection();
data.writeGuildsOwnersToCollection();
data.writeMembersFromUserGuildsToCollection();
data._onMessagePreSend_preSend = addMessagePreSendListener(
data.onMessagePreSend.bind(data)
);
data.storageAutoSaveProtocol();
},
stop() {
removeFromArray(SettingsPlugin.customEntries, e => e.key === "equicord_i_remember_you");
removeFromArray(SettingsPlugin.settingsSectionMap, entry => entry[1] === "equicord_i_remember_you");
const dataManager = this.dataManager as Data;
removeMessagePreSendListener(dataManager._onMessagePreSend_preSend);
clearInterval(dataManager._storageAutoSaveProtocol_interval);
},
});

View file

@ -0,0 +1,78 @@
.vc-i-remember-you-header-container {
display: contents;
justify-content: space-between;
align-items: center;
flex-direction: column;
}
.vc-i-remember-you-header-name {
margin-top: 16px;
}
.vc-i-remember-you-header-btns {
display: grid;
grid-template-columns: repeat(2, minmax(0, 350px));
gap: 10px;
margin-top: 8px;
}
.vc-i-remember-you-header {
color: var(--text-default);
}
.vc-i-remember-you-rows {
gap: 5px;
flex-direction: column;
}
.vc-i-remember-you-search {
margin-top: 10px;
}
.vc-i-remember-you-search-user {
flex-direction: column;
padding-top: 18px;
}
.vc-i-remember-you-user-avatar {
cursor: pointer;
margin-right: 0;
}
.vc-i-remember-you-user-row {
display: flex;
flex-direction: column;
gap: 6px;
padding: 4px;
align-items: flex-start;
}
.vc-i-remember-you-user-id {
align-self: flex-start;
opacity: 0.75;
height: min-content;
cursor: pointer;
}
.vc-i-remember-you-user-id:hover {
opacity: 1;
}
.vc-i-remember-you-user {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
width: 100%;
margin-top: 0;
}
.vc-i-remember-you-user-username {
margin-top: 2px;
}
.vc-i-remember-you-user-tooltip {
display: flex;
flex-direction: column;
align-items: flex-start;
}

View file

@ -0,0 +1,85 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { definePluginSettings } from "@api/Settings";
import { EquicordDevs } from "@utils/constants";
import { Logger } from "@utils/Logger";
import definePlugin, { OptionType } from "@utils/types";
import { Menu, VoiceStateStore } from "@webpack/common";
const logger = new Logger("IdleAutoRestart");
let lastActivity = 0;
let intervalId: ReturnType<typeof setInterval> | null = null;
const settings = definePluginSettings({
isEnabled: {
description: "Enable automatic restart after idle",
type: OptionType.BOOLEAN,
default: true,
},
idleMinutes: {
description: "Minutes of inactivity before restarting (when not in VC)",
type: OptionType.SLIDER,
markers: [5, 10, 15, 30, 60, 120],
default: 30,
stickToMarkers: false,
},
});
function onActivity() {
lastActivity = Date.now();
}
export default definePlugin({
name: "IdleAutoRestart",
description: "Automatically restarts the client after being idle for a configurable amount of time, but avoids restarting while you are in VC.",
authors: [EquicordDevs.SteelTech],
settings,
toolboxActions() {
return (
<Menu.MenuItem
id="auto-idle-restart-toggle-toolbox"
label={settings.store.isEnabled ? "Disable Auto Idle Restart" : "Enable Auto Idle Restart"}
action={() => {
settings.store.isEnabled = !settings.store.isEnabled;
}}
/>
);
},
start() {
lastActivity = Date.now();
document.addEventListener("mousemove", onActivity);
document.addEventListener("keydown", onActivity);
document.addEventListener("mousedown", onActivity);
document.addEventListener("wheel", onActivity, { passive: true });
if (intervalId) clearInterval(intervalId);
intervalId = setInterval(() => {
if (!settings.store.isEnabled || VoiceStateStore.isCurrentClientInVoiceChannel()) return;
const idleMs = settings.store.idleMinutes * 60_000;
if (Date.now() - lastActivity >= idleMs) {
logger.info("Idle timeout reached, reloading client");
location.reload();
}
}, 30_000);
},
stop() {
if (intervalId) {
clearInterval(intervalId);
intervalId = null;
}
document.removeEventListener("mousemove", onActivity);
document.removeEventListener("keydown", onActivity);
document.removeEventListener("mousedown", onActivity);
document.removeEventListener("wheel", onActivity);
},
});

View file

@ -0,0 +1,44 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import ErrorBoundary from "@components/ErrorBoundary";
import { Paragraph } from "@components/Paragraph";
import { classNameFactory } from "@utils/css";
import { ModalCloseButton, ModalContent, ModalHeader, ModalRoot, ModalSize, openModal } from "@utils/modal";
import { GuildMember } from "@vencord/discord-types";
import { Parser } from "@webpack/common";
const cl = classNameFactory("vc-inrole-");
export function showInRoleModal(members: GuildMember[], roleId: string, channelId: string) {
openModal(props =>
<>
<ErrorBoundary>
<ModalRoot {...props} size={ModalSize.DYNAMIC} fullscreenOnMobile={true} >
<ModalHeader className={cl("header")}>
<Paragraph style={{ fontSize: "1.2rem", fontWeight: "bold", marginRight: "7px" }}>Members of role {
Parser.parse(`<@&${roleId}>`, true, { channelId, viewingChannelId: channelId })
} ({members.length})</Paragraph>
<ModalCloseButton onClick={props.onClose} className={cl("close")} />
</ModalHeader>
<ModalContent>
<div style={{ padding: "13px 20px" }} className={cl("member-list")}>
{
members.length !== 0 ? members.map(member =>
<>
<Paragraph className={cl("modal-member")}>
{Parser.parse(`<@${member.userId}>`, true, { channelId, viewingChannelId: channelId })}
</Paragraph>
</>
) : <Paragraph>Looks like no online cached members with that role were found. Try scrolling down on your member list to cache more users!</Paragraph>
}
</div>
</ModalContent>
</ModalRoot>
</ErrorBoundary>
</>
);
}

View file

@ -0,0 +1,127 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 nin0dev
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import "./style.css";
import { ApplicationCommandInputType, ApplicationCommandOptionType, sendBotMessage } from "@api/Commands";
import { getUserSettingLazy } from "@api/UserSettings";
import { InfoIcon } from "@components/Icons";
import { Paragraph } from "@components/Paragraph";
import { Devs } from "@utils/constants";
import { getCurrentChannel, getCurrentGuild } from "@utils/discord";
import definePlugin from "@utils/types";
import { GuildMember } from "@vencord/discord-types";
import { GuildMemberStore, GuildRoleStore, Menu, Parser } from "@webpack/common";
import { showInRoleModal } from "./RoleMembersModal";
const DeveloperMode = getUserSettingLazy("appearance", "developerMode")!;
function getMembersInRole(roleId: string, guildId: string) {
const members = GuildMemberStore.getMembers(guildId);
const membersInRole: GuildMember[] = [];
members.forEach(member => {
if (member.roles.includes(roleId)) {
membersInRole.push(member);
}
});
return membersInRole;
}
export default definePlugin({
name: "InRole",
description: "Know who is in a role with the role context menu or /inrole command (read plugin info!)",
authors: [Devs.nin0dev],
dependencies: ["UserSettingsAPI"],
start() {
// DeveloperMode needs to be enabled for the context menu to be shown
DeveloperMode.updateSetting(true);
},
settingsAboutComponent: () => {
return (
<>
<Paragraph style={{ fontSize: "1.2rem", marginTop: "15px", fontWeight: "bold" }}>{Parser.parse(":warning:")} Limitations</Paragraph>
<Paragraph style={{ marginTop: "10px", fontWeight: "500" }} >If you don't have mod permissions on the server, and that server is large (over 100 members), the plugin may be limited in the following ways:</Paragraph>
<Paragraph>• Offline members won't be listed</Paragraph>
<Paragraph>• Up to 100 members will be listed by default. To get more, scroll down in the member list to load more members.</Paragraph>
<Paragraph>• However, friends will always be shown regardless of their status.</Paragraph>
</>
);
},
commands: [
{
name: "inrole",
description: "Know who is in a role",
inputType: ApplicationCommandInputType.BUILT_IN,
options: [
{
name: "role",
description: "The role",
type: ApplicationCommandOptionType.ROLE,
required: true
},
],
execute: (args, ctx) => {
// Guild check
if (!ctx.guild) {
return sendBotMessage(ctx.channel.id, { content: "Make sure that you are in a server." });
}
const role = args[0].value;
showInRoleModal(getMembersInRole(role, ctx.guild.id), role, ctx.channel.id);
}
}
],
contextMenus: {
"dev-context"(children, { id }: { id: string; }) {
const guild = getCurrentGuild();
if (!guild) return;
const channel = getCurrentChannel();
if (!channel) return;
const role = GuildRoleStore.getRole(guild.id, id);
if (!role) return;
children.push(
<Menu.MenuItem
id="vc-view-inrole"
label="View Members in Role"
action={() => {
showInRoleModal(getMembersInRole(role.id, guild.id), role.id, channel.id);
}}
icon={InfoIcon}
/>
);
},
"message"(children, { message }: { message: any; }) {
const guild = getCurrentGuild();
if (!guild) return;
const roleMentions = message.content.match(/<@&(\d+)>/g);
if (!roleMentions?.length) return;
const channel = getCurrentChannel();
if (!channel) return;
const roleIds = roleMentions.map(mention => mention.match(/<@&(\d+)>/)![1]);
const role = GuildRoleStore.getRole(guild.id, roleIds);
if (!role) return;
children.push(
<Menu.MenuItem
id="vc-view-inrole"
label="View Members in Role"
action={() => {
showInRoleModal(getMembersInRole(role.id, guild.id), role.id, channel.id);
}}
icon={InfoIcon}
/>
);
}
}
});

View file

@ -0,0 +1,29 @@
.vc-inrole-member-list {
max-height: 400px;
margin-top: 10px;
margin-bottom: 13px;
overflow-x: hidden;
}
.vc-inrole-member-list::-webkit-scrollbar {
background-color: #fff1;
border-radius: 100px;
width: 10px;
}
.vc-inrole-member-list::-webkit-scrollbar-thumb {
background-color: #fff3;
border-radius: 100px;
}
.vc-inrole-modal-member {
margin: 11px 0;
}
.vc-inrole-header {
padding-top: 15px;
}
.vc-inrole-close {
margin-left: auto;
}

View file

@ -0,0 +1,161 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { ChatBarButton, ChatBarButtonFactory } from "@api/ChatButtons";
import { addMessagePreSendListener, removeMessagePreSendListener } from "@api/MessageEvents";
import { definePluginSettings } from "@api/Settings";
import { EquicordDevs } from "@utils/constants";
import definePlugin, { OptionType } from "@utils/types";
import { React } from "@webpack/common";
type WordMatch = {
word: string,
startIndex: number,
};
const settings = definePluginSettings({
showIcon: {
type: OptionType.BOOLEAN,
default: true,
description: "Show a button to toggle the Ingtoninator plugin",
restartNeeded: true
},
isEnabled: {
type: OptionType.BOOLEAN,
default: true,
description: "Enable or disable the Ingtoninator"
}
});
const isLegal = (word: string): boolean => {
if (word === "i" || word === "I") return false;
const lastChar = word.slice(-1).toLowerCase();
if (["a", "e", "o", "u", "y"].includes(lastChar)) return false;
return true;
};
const getWords = (string: string): WordMatch[] => {
const linkRegex = /https?:\/\/[^\s]+\.[^\s]+/g;
const linkRanges = new Set<number>();
for (const match of string.matchAll(linkRegex)) {
const start = match.index!;
const end = start + match[0].length;
for (let i = start; i < end; i++) {
linkRanges.add(i);
}
}
return Array.from(string.matchAll(/[\p{L}]+/gu), match => ({
word: match[0],
startIndex: match.index!
})).filter(match => !linkRanges.has(match.startIndex));
};
const chooseRandomWord = (message: string): WordMatch | null => {
const words: WordMatch[] = getWords(message);
while (words.length > 0) {
const index: number = Math.floor(Math.random() * words.length);
const wordMatch: WordMatch = words[index];
if (!isLegal(wordMatch.word)) {
words.splice(index, 1);
continue;
}
return wordMatch;
}
return null;
};
const ington = (word: string): string => {
if (word.endsWith("INGTON")) return "";
if (word.endsWith("INGTO")) return "N";
if (word.endsWith("INGT")) return "ON";
if (word.endsWith("ING")) return "TON";
if (word.endsWith("IN")) return "GTON";
if (word.endsWith("I")) return "NGTON";
return "INGTON";
};
const handleMessage = ((channelId, message) => {
if (!settings.store.isEnabled) return;
const msg = message.content;
if (!msg || !msg.trim()) return;
const wordMatch: WordMatch | null = chooseRandomWord(msg);
if (wordMatch === null) return;
const { word } = wordMatch;
const wordUpper = word.toUpperCase();
const isUpper = word === wordUpper;
let insertion = ington(wordUpper);
if (!isUpper) {
insertion = insertion.toLowerCase();
}
const idx: number = wordMatch.startIndex + word.length;
message.content = msg.slice(0, idx) + insertion + msg.slice(idx);
});
const IngtoninatorButton: ChatBarButtonFactory = ({ isMainChat }) => {
const { isEnabled, showIcon } = settings.use(["isEnabled", "showIcon"]);
const toggle = () => settings.store.isEnabled = !settings.store.isEnabled;
if (!isMainChat || !showIcon) return null;
return (
<ChatBarButton
tooltip={isEnabled ? "Ingtoninator Enabled" : "Ingtoninator Disabled"}
onClick={toggle}
>
{isEnabled ? (
enabledIcon()
) : (
disabledIcon()
)}
</ChatBarButton>
);
};
function enabledIcon() {
return (
<svg width="20" height="20" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
<path transform="translate(351 -153)" fill="currentcolor" d="M-177.7,334.5c6.3-2.3,12.6-5.2,19.8-8.6c31.9-16.4,51.7-41.7,51.7-41.7s-32.5,0.6-64.4,17 c-4,1.7-7.5,4-10.9,5.7c5.7-7.5,12.1-16.4,18.7-25c25-37.1,31.3-77.3,31.3-77.3s-34.8,21-59.2,58.6c-5.2,7.5-9.8,14.9-13.8,22.7 c1.1-10.3,1.1-22.1,1.1-33.6c0-50-19.8-91.1-19.8-91.1s-19.8,40.5-19.8,91.1c0,12.1,0.6,23.3,1.1,33.6c-4-7.5-8.6-14.9-13.8-22.7 c-25-37.1-59.2-58.6-59.2-58.6s6.3,40,31.3,77.3c6.3,9.2,12.1,17.5,18.7,25c-3.4-2.3-7.5-4-10.9-5.7c-31.9-16.4-64.4-17-64.4-17 s19.8,25.6,51.7,41.7c6.9,3.4,13.2,6.3,19.8,8.6c-4,0.6-8,1.1-12.1,2.3c-30.5,6.4-53.2,23.9-53.2,23.9s27.3,7.5,58.6,1.1 c9.8-2.3,19.8-4.6,27.3-7.5c-1.1,1.1,15.8-8.6,21.6-14.4v60.4h8.6v-61.8c6.3,6.3,22.7,16.4,22.1,14.9c8,2.9,17.5,5.2,27.3,7.5 c30.8,6.3,58.6-1.1,58.6-1.1s-22.1-17.5-53.4-23.8C-169.6,335.7-173.7,335.1-177.7,334.5z" />
</svg>
);
}
function disabledIcon() {
return (
<svg width="20" height="20" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
<path transform="translate(351 -153)" fill="var(--status-danger)" d="M-177.7,334.5c6.3-2.3,12.6-5.2,19.8-8.6c31.9-16.4,51.7-41.7,51.7-41.7s-32.5,0.6-64.4,17 c-4,1.7-7.5,4-10.9,5.7c5.7-7.5,12.1-16.4,18.7-25c25-37.1,31.3-77.3,31.3-77.3s-34.8,21-59.2,58.6c-5.2,7.5-9.8,14.9-13.8,22.7 c1.1-10.3,1.1-22.1,1.1-33.6c0-50-19.8-91.1-19.8-91.1s-19.8,40.5-19.8,91.1c0,12.1,0.6,23.3,1.1,33.6c-4-7.5-8.6-14.9-13.8-22.7 c-25-37.1-59.2-58.6-59.2-58.6s6.3,40,31.3,77.3c6.3,9.2,12.1,17.5,18.7,25c-3.4-2.3-7.5-4-10.9-5.7c-31.9-16.4-64.4-17-64.4-17 s19.8,25.6,51.7,41.7c6.9,3.4,13.2,6.3,19.8,8.6c-4,0.6-8,1.1-12.1,2.3c-30.5,6.4-53.2,23.9-53.2,23.9s27.3,7.5,58.6,1.1 c9.8-2.3,19.8-4.6,27.3-7.5c-1.1,1.1,15.8-8.6,21.6-14.4v60.4h8.6v-61.8c6.3,6.3,22.7,16.4,22.1,14.9c8,2.9,17.5,5.2,27.3,7.5 c30.8,6.3,58.6-1.1,58.6-1.1s-22.1-17.5-53.4-23.8C-169.6,335.7-173.7,335.1-177.7,334.5z" />
</svg>
);
}
export default definePlugin({
name: "Ingtoninator",
description: "Suffixes 'ington' to a random word in your message",
authors: [EquicordDevs.zyqunix, EquicordDevs.BioTomateDE],
settings,
chatBarButton: {
icon: disabledIcon,
render: IngtoninatorButton
},
start() {
addMessagePreSendListener(handleMessage);
},
stop() {
removeMessagePreSendListener(handleMessage);
}
});

View file

@ -0,0 +1,77 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Heading, HeadingTertiary } from "@components/Heading";
import {
ModalContent,
ModalFooter,
ModalHeader,
ModalRoot,
openModal,
} from "@utils/modal";
import { Button, React, TextInput } from "@webpack/common";
import { buildEmbed, decrypt } from "../index";
export function DecModal(props: any) {
const encryptedMessage: string = props?.message?.content;
const [password, setPassword] = React.useState("password");
return (
<ModalRoot {...props}>
<ModalHeader>
<HeadingTertiary>Decrypt Message</HeadingTertiary>
</ModalHeader>
<ModalContent>
<Heading style={{ marginTop: "10px" }}>Message with Encryption</Heading>
<TextInput defaultValue={encryptedMessage} disabled={true}></TextInput>
<Heading style={{ marginTop: "10px" }}>Password</Heading>
<TextInput
style={{ marginBottom: "20px" }}
onChange={setPassword}
/>
</ModalContent>
<ModalFooter>
<Button
color={Button.Colors.GREEN}
onClick={() => {
const toSend = decrypt(encryptedMessage, password, true);
if (!toSend || !props?.message) return;
buildEmbed(props?.message, toSend);
props.onClose();
}}>
Decrypt
</Button>
<Button
color={Button.Colors.TRANSPARENT}
look={Button.Looks.LINK}
style={{ left: 15, position: "absolute" }}
onClick={props.onClose}
>
Cancel
</Button>
</ModalFooter>
</ModalRoot>
);
}
export function buildDecModal(msg: any): any {
openModal((props: any) => <DecModal {...props} {...msg} />);
}

View file

@ -0,0 +1,113 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { FormSwitch } from "@components/FormSwitch";
import { Heading, HeadingTertiary } from "@components/Heading";
import { insertTextIntoChatInputBox } from "@utils/discord";
import {
ModalContent,
ModalFooter,
ModalHeader,
ModalProps,
ModalRoot,
openModal,
} from "@utils/modal";
import { Button, React, TextInput } from "@webpack/common";
import { encrypt } from "../index";
function EncModal(props: ModalProps) {
const [secret, setSecret] = React.useState("");
const [cover, setCover] = React.useState("");
const [password, setPassword] = React.useState("password");
const [noCover, setNoCover] = React.useState(false);
const isValid = secret && (noCover || (cover && cover.trim().split(" ").length > 1));
return (
<ModalRoot {...props}>
<ModalHeader>
<HeadingTertiary>Encrypt Message</HeadingTertiary>
</ModalHeader>
<ModalContent>
<Heading style={{ marginTop: "10px" }}>Secret</Heading>
<TextInput
onChange={(e: string) => {
setSecret(e);
}}
/>
<Heading style={{ marginTop: "10px" }}>Cover (2 or more Words!!)</Heading>
<TextInput
disabled={noCover}
onChange={(e: string) => {
setCover(e);
}}
/>
<Heading style={{ marginTop: "10px" }}>Password</Heading>
<TextInput
style={{ marginBottom: "20px" }}
defaultValue={"password"}
onChange={(e: string) => {
setPassword(e);
}}
/>
<FormSwitch
title="Don't use a Cover"
value={noCover}
onChange={(e: boolean) => {
setNoCover(e);
}}
/>
</ModalContent>
<ModalFooter>
<Button
color={Button.Colors.GREEN}
disabled={!isValid}
onClick={() => {
if (!isValid) return;
const encrypted = encrypt(secret, password, noCover ? "d d" : cover);
const toSend = noCover ? encrypted.replaceAll("d", "") : encrypted;
if (!toSend) return;
insertTextIntoChatInputBox(toSend);
props.onClose();
}}
>
Send
</Button>
<Button
color={Button.Colors.TRANSPARENT}
look={Button.Looks.LINK}
style={{ left: 15, position: "absolute" }}
onClick={() => {
props.onClose();
}}
>
Cancel
</Button>
</ModalFooter>
</ModalRoot>
);
}
export function buildEncModal(): any {
openModal(props => <EncModal {...props} />);
}

View file

@ -0,0 +1,234 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { ChatBarButton, ChatBarButtonFactory } from "@api/ChatButtons";
import { updateMessage } from "@api/MessageUpdater";
import { definePluginSettings } from "@api/Settings";
import ErrorBoundary from "@components/ErrorBoundary";
import { Devs } from "@utils/constants";
import { getStegCloak } from "@utils/dependencies";
import definePlugin, { OptionType, ReporterTestable } from "@utils/types";
import { Message } from "@vencord/discord-types";
import { ChannelStore, Constants, RestAPI, Tooltip } from "@webpack/common";
import { buildDecModal } from "./components/DecryptionModal";
import { buildEncModal } from "./components/EncryptionModal";
let steggo: any;
function PopOverIcon() {
return (
<svg
fill="var(--text-default)"
width={24} height={24}
viewBox={"0 0 64 64"}
>
<path d="M 32 9 C 24.832 9 19 14.832 19 22 L 19 27.347656 C 16.670659 28.171862 15 30.388126 15 33 L 15 49 C 15 52.314 17.686 55 21 55 L 43 55 C 46.314 55 49 52.314 49 49 L 49 33 C 49 30.388126 47.329341 28.171862 45 27.347656 L 45 22 C 45 14.832 39.168 9 32 9 z M 32 13 C 36.963 13 41 17.038 41 22 L 41 27 L 23 27 L 23 22 C 23 17.038 27.037 13 32 13 z" />
</svg>
);
}
function Indicator() {
return (
<Tooltip text="This message has a hidden message! (InvisibleChat)">
{({ onMouseEnter, onMouseLeave }) => (
<img
aria-label="Hidden Message Indicator (InvisibleChat)"
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
src="https://github.com/SammCheese/invisible-chat/raw/NewReplugged/src/assets/lock.png"
width={20}
height={20}
style={{ transform: "translateY(4p)", paddingInline: 4 }}
/>
)}
</Tooltip>
);
}
const ChatBarRender: ChatBarButtonFactory = ({ isMainChat }) => {
if (!isMainChat) return null;
return (
<ChatBarButton
tooltip="Encrypt Message"
onClick={() => buildEncModal()}
buttonProps={{
"aria-haspopup": "dialog",
}}
>
{ChatBarIcon()}
</ChatBarButton>
);
};
function ChatBarIcon() {
return (
<svg
aria-hidden
role="img"
width="20"
height="20"
viewBox={"0 0 64 64"}
style={{ scale: "1.39", translate: "0 -1px" }}
>
<path fill="currentColor" d="M 32 9 C 24.832 9 19 14.832 19 22 L 19 27.347656 C 16.670659 28.171862 15 30.388126 15 33 L 15 49 C 15 52.314 17.686 55 21 55 L 43 55 C 46.314 55 49 52.314 49 49 L 49 33 C 49 30.388126 47.329341 28.171862 45 27.347656 L 45 22 C 45 14.832 39.168 9 32 9 z M 32 13 C 36.963 13 41 17.038 41 22 L 41 27 L 23 27 L 23 22 C 23 17.038 27.037 13 32 13 z" />
</svg>
);
}
const settings = definePluginSettings({
savedPasswords: {
type: OptionType.STRING,
default: "password, Password",
description: "Saved Passwords (Seperated with a , )"
}
});
const EMBED_API_URL = "https://embed.sammcheese.net";
const INV_REGEX = new RegExp(/( \u200c|\u200d |[\u2060-\u2064])[^\u200b]/);
const URL_REGEX = new RegExp(/(http(s)?:\/\/.)?(www\.)?[-a-zA-Z0-9@:%._+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_+.~#?&//=]*)/);
function colorCodeFromNumber(color: number): string {
return `#${[color >> 16, color >> 8, color]
.map(x => (x & 0xFF).toString(16))
.join("")}`;
}
async function getEmbed(url: URL): Promise<Object | {}> {
const { body } = await RestAPI.post({
url: Constants.Endpoints.UNFURL_EMBED_URLS,
body: {
urls: [url]
}
});
// The endpoint returns the color as a number, but Discord expects a string
body.embeds[0].color = colorCodeFromNumber(body.embeds[0].color);
return await body.embeds[0];
}
export async function buildEmbed(message: any, revealed: string): Promise<void> {
const urlCheck = revealed.match(URL_REGEX);
message.embeds.push({
type: "rich",
rawTitle: "Decrypted Message",
color: "#45f5f5",
rawDescription: revealed,
footer: {
text: "Made with ❤️ by c0dine and Sammy!",
},
});
if (urlCheck?.length) {
const embed = await getEmbed(new URL(urlCheck[0]));
if (embed)
message.embeds.push(embed);
}
updateMessage(message.channel_id, message.id, { embeds: message.embeds });
}
export default definePlugin({
name: "InvisibleChat",
description: "Encrypt your Messages in a non-suspicious way!",
authors: [Devs.SammCheese],
dependencies: ["MessageUpdaterAPI"],
reporterTestable: ReporterTestable.Patches,
settings,
chatBarButton: {
icon: ChatBarIcon,
render: ChatBarRender
},
patches: [
{
// Indicator
find: ".SEND_FAILED,",
replacement: {
match: /let\{className:\i,message:\i[^}]*\}=(\i)/,
replace: "try {$1 && $self.INV_REGEX.test($1.message.content) ? $1.content.push($self.indicator()) : null } catch {};$&"
}
},
],
INV_REGEX,
async start() {
const { default: StegCloak } = await getStegCloak();
steggo = new StegCloak(true, false);
},
messagePopoverButton: {
icon: PopOverIcon,
render(message) {
return INV_REGEX.test(message?.content)
? {
label: "Decrypt Message",
icon: PopOverIcon,
message: message,
channel: ChannelStore.getChannel(message.channel_id),
onClick: async () => {
const res = await iteratePasswords(message);
if (res)
buildEmbed(message, res);
else
buildDecModal({ message });
}
}
: null;
}
},
indicator: ErrorBoundary.wrap(Indicator, { noop: true })
});
export function encrypt(secret: string, password: string, cover: string): string {
return steggo.hide(secret + "\u200b", password, cover);
}
export function decrypt(encrypted: string, password: string, removeIndicator: boolean): string {
const decrypted = steggo.reveal(encrypted, password);
return removeIndicator ? decrypted.replace("\u200b", "") : decrypted;
}
export function isCorrectPassword(result: string): boolean {
return result.endsWith("\u200b");
}
export async function iteratePasswords(message: Message): Promise<string | false> {
const passwords = settings.store.savedPasswords.split(",").map(s => s.trim());
if (!message?.content || !passwords?.length) return false;
let { content } = message;
// we use an extra variable so we dont have to edit the message content directly
if (/^\W/.test(message.content)) content = `d ${message.content}d`;
for (let i = 0; i < passwords.length; i++) {
const result = decrypt(content, passwords[i], false);
if (isCorrectPassword(result)) {
return result;
}
}
return false;
}

View file

@ -0,0 +1,90 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import "./styles.css";
import { createAudioPlayer } from "@api/AudioPlayer";
import { definePluginSettings } from "@api/Settings";
import { Devs } from "@utils/constants";
import definePlugin, { OptionType } from "@utils/types";
import { createRoot, FluxDispatcher, useCallback, useEffect, useState } from "@webpack/common";
import { Root } from "react-dom/client";
let jumpscareRoot: Root | undefined;
const settings = definePluginSettings({
imageSource: {
type: OptionType.STRING,
description: "Sets the image url of the jumpscare",
default: "https://github.com/Equicord/Equibored/blob/main/icons/jumpscare/troll.gif?raw=true"
},
audioSource: {
type: OptionType.STRING,
description: "Sets the audio url of the jumpscare",
default: "https://github.com/Equicord/Equibored/raw/main/sounds/jumpscare/trollolol.mp3?raw=true"
},
chance: {
type: OptionType.NUMBER,
description: "The chance of a jumpscare happening (1 in X so: 100 = 1/100 or 1%, 50 = 1/50 or 2%, etc.)",
default: 1000
}
});
function getJumpscareRoot(): Root {
if (!jumpscareRoot) {
const element = document.createElement("div");
element.id = "jumpscare-root";
element.classList.add("jumpscare-root");
document.body.append(element);
jumpscareRoot = createRoot(element);
}
return jumpscareRoot;
}
export default definePlugin({
name: "Jumpscare",
description: "Adds a configurable chance of jumpscaring you whenever you open a channel. Inspired by Geometry Dash Mega Hack",
authors: [Devs.surgedevs],
dependencies: ["AudioPlayerAPI"],
settings,
start() {
getJumpscareRoot().render(
<this.JumpscareComponent />
);
},
stop() {
jumpscareRoot?.unmount();
jumpscareRoot = undefined;
},
JumpscareComponent() {
const [isPlaying, setIsPlaying] = useState(false);
const jumpscareAudio = createAudioPlayer(settings.store.audioSource, { volume: 100, onEnded: () => { setIsPlaying(false); } });
const jumpscare = useCallback(event => {
if (isPlaying) return;
const chance = 1 / settings.store.chance;
if (Math.random() > chance) return;
setIsPlaying(true);
jumpscareAudio.play();
}, [isPlaying]);
useEffect(() => {
FluxDispatcher.subscribe("CHANNEL_SELECT", jumpscare);
return () => {
FluxDispatcher.unsubscribe("CHANNEL_SELECT", jumpscare);
};
}, [jumpscare]);
return <img className={`jumpscare-img ${isPlaying ? "jumpscare-animate" : ""}`} src={settings.store.imageSource} />;
}
});

View file

@ -0,0 +1,33 @@
.jumpscare-root {
pointer-events: none;
}
.jumpscare-img {
position: absolute;
width: 100%;
height: 100%;
background-color: #000;
z-index: 99999;
object-fit: contain;
opacity: 0;
}
.jumpscare-animate {
animation: jumpscare-animation 0.7s;
}
@keyframes jumpscare-animation {
0% {
transform: scale(0);
}
80% {
transform: scale(1);
opacity: 1;
}
100% {
transform: scale(0);
opacity: 0;
}
}

View file

@ -0,0 +1,144 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { NavContextMenuPatchCallback } from "@api/ContextMenu";
import { EquicordDevs } from "@utils/constants";
import definePlugin from "@utils/types";
import { Menu, NavigationRouter, RestAPI, Toasts, UserStore } from "@webpack/common";
async function findLastMessageFromUser(guildId: string, channelId: string, userId: string) {
try {
const res = await RestAPI.get({
url: `/guilds/${guildId}/messages/search?author_id=${userId}&channel_id=${channelId}&sort_by=timestamp&sort_order=desc&offset=0`
});
const allMessages = res.body.messages?.flat() || [];
const newestMessage = allMessages.find(msg => msg && msg.id);
if (newestMessage) return newestMessage.id;
Toasts.show({
type: Toasts.Type.FAILURE,
message: "Couldn't find any recent messages from this user.",
id: Toasts.genId()
});
return null;
} catch (error) {
console.error("Error finding last message:", error);
Toasts.show({
type: Toasts.Type.FAILURE,
message: "Failed to find messages. Check console for details.",
id: Toasts.genId()
});
return null;
}
}
async function jumpToLastActive(channel: any, targetUserId?: string) {
try {
if (!channel) {
Toasts.show({
type: Toasts.Type.FAILURE,
message: "Channel information not available.",
id: Toasts.genId()
});
return;
}
const guildId = channel.guild_id !== null ? channel.guild_id : "@me";
const channelId = channel.id;
let userId: string;
if (targetUserId) {
userId = targetUserId;
} else {
const currentUser = UserStore.getCurrentUser();
userId = currentUser.id;
}
const messageId = await findLastMessageFromUser(guildId, channelId, userId);
if (messageId) {
const url = `/channels/${guildId}/${channelId}/${messageId}`;
NavigationRouter.transitionTo(url);
}
} catch (error) {
console.error("Error in jumpToLastActive:", error);
Toasts.show({
type: Toasts.Type.FAILURE,
message: "Failed to jump to message. Check console for details.",
id: Toasts.genId()
});
}
}
const ChannelContextMenuPatch: NavContextMenuPatchCallback = (children, { channel }) => {
children.push(
<Menu.MenuItem
id="LastActive"
label={<span style={{ color: "#aa6746" }}>Your Last Message</span>}
icon={LastActiveIcon}
action={() => {
jumpToLastActive(channel);
}}
/>
);
};
const UserContextMenuPatch: NavContextMenuPatchCallback = (children, { user, channel }) => {
if (!channel || !user?.id) return;
children.push(
<Menu.MenuItem
id="LastActive"
label={<span style={{ color: "#aa6746" }}>User's Last Message</span>}
icon={UserLastActiveIcon}
action={() => {
jumpToLastActive(channel, user.id);
}}
/>
);
};
export function UserLastActiveIcon() {
return (
<svg
viewBox="0 0 52 52"
width="20"
height="20"
fill="#aa6746"
>
<g>
<path d="M11.4,21.6L24.9,7.9c0.6-0.6,1.6-0.6,2.2,0l13.5,13.7c0.6,0.6,0.6,1.6,0,2.2L38.4,26
c-0.6,0.6-1.6,0.6-2.2,0l-9.1-9.4c-0.6-0.6-1.6-0.6-2.2,0l-9.1,9.3c-0.6,0.6-1.6,0.6-2.2,0l-2.2-2.2C10.9,23.1,10.9,22.2,11.4,21.6
z"/>
<path d="M11.4,39.7L24.9,26c0.6-0.6,1.6-0.6,2.2,0l13.5,13.7c0.6,0.6,0.6,1.6,0,2.2l-2.2,2.2
c-0.6,0.6-1.6,0.6-2.2,0l-9.1-9.4c-0.6-0.6-1.6-0.6-2.2,0L15.8,44c-0.6,0.6-1.6,0.6-2.2,0l-2.2-2.2C10.9,41.2,10.9,40.2,11.4,39.7z
"/>
</g>
</svg>
);
}
export function LastActiveIcon() {
return (
<svg
viewBox="0 0 24 24"
width="20"
height="20"
fill="#aa6746"
xmlns="http://www.w3.org/2000/svg"
>
<path fillRule="evenodd" d="M12,2 C17.5228475,2 22,6.4771525 22,12 C22,17.5228475 17.5228475,22 12,22 C6.4771525,22 2,17.5228475 2,12 C2,6.4771525 6.4771525,2 12,2 Z M12,4 C7.581722,4 4,7.581722 4,12 C4,16.418278 7.581722,20 12,20 C16.418278,20 20,16.418278 20,12 C20,7.581722 16.418278,4 12,4 Z M12,6 C12.5128358,6 12.9355072,6.38604019 12.9932723,6.88337887 L13,7 L13,11.5857864 L14.7071068,13.2928932 C15.0976311,13.6834175 15.0976311,14.3165825 14.7071068,14.7071068 C14.3466228,15.0675907 13.7793918,15.0953203 13.3871006,14.7902954 L13.2928932,14.7071068 L11.2928932,12.7071068 C11.1366129,12.5508265 11.0374017,12.3481451 11.0086724,12.131444 L11,12 L11,7 C11,6.44771525 11.4477153,6 12,6 Z" />
</svg>
);
}
export default definePlugin({
name: "LastActive",
description: "A plugin to jump to last active message from yourself or another user in a channel/server.",
authors: [EquicordDevs.Crxa],
contextMenus: {
"channel-context": ChannelContextMenuPatch,
"user-context": UserContextMenuPatch,
"thread-context": ChannelContextMenuPatch
}
});

View file

@ -0,0 +1,96 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { definePluginSettings } from "@api/Settings";
import { EquicordDevs } from "@utils/constants";
import definePlugin, { OptionType } from "@utils/types";
import { Channel, Message } from "@vencord/discord-types";
import { ChannelStore, MessageActions, MessageStore, UserStore } from "@webpack/common";
function shouldEdit(channel: Channel, message: Message, timePeriod: number, shouldMergeWithAttachment: boolean) {
let should = true;
if (channel.isGroupDM()) {
if (channel.name === message.content) {
should = false;
}
}
if (message.author.id !== UserStore.getCurrentUser().id) {
should = false;
}
if (document.querySelector('[class*="replyBar"]')) {
should = false;
}
if (message.attachments.length > 0 && !shouldMergeWithAttachment) {
should = false;
}
// @ts-ignore
const timestamp = new Date(message.timestamp);
const now = new Date();
if ((now.getTime() - timestamp.getTime()) > (timePeriod * 1000)) {
should = false;
}
return {
should: should,
content: message.content
};
}
const settings = definePluginSettings({
timePeriod: {
type: OptionType.NUMBER,
description: "The duration of bursts (in seconds).",
default: 3
},
shouldMergeWithAttachment: {
type: OptionType.BOOLEAN,
description: "Should the message be merged if the last message has an attachment?",
default: false
},
useSpace: {
type: OptionType.BOOLEAN,
description: "Whether to add a space between messages when merging instead of new lines.",
default: false
}
});
export default definePlugin({
name: "MessageBurst",
description: "Merges messages sent within a time period with your previous sent message if no one else sends a message before you.",
authors: [EquicordDevs.port22exposed],
settings,
onBeforeMessageSend(channelId, message) {
const messages = MessageStore.getMessages(channelId)._map;
if (!messages) {
return;
}
const entries = Object.entries(messages);
const [lastMessageId, lastMessage] = entries[entries.length - 1];
const channel = ChannelStore.getChannel(channelId);
const { should, content } = shouldEdit(channel, lastMessage as Message, this.settings.store.timePeriod, this.settings.store.shouldMergeWithAttachment);
if (should) {
const separator = settings.store.useSpace ? " " : "\n";
const newContent = content + separator + message.content;
MessageActions.editMessage(channelId, lastMessageId, {
content: newContent,
});
message.content = "";
}
},
});

View file

@ -0,0 +1,178 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { ChatBarButton, ChatBarButtonFactory } from "@api/ChatButtons";
import { definePluginSettings } from "@api/Settings";
import { EquicordDevs } from "@utils/constants";
import { getCurrentChannel } from "@utils/discord";
import definePlugin, { OptionType } from "@utils/types";
import { FluxDispatcher, React } from "@webpack/common";
interface FetchTiming {
channelId: string;
startTime: number;
endTime?: number;
duration?: number;
timestamp?: Date;
}
let currentFetch: FetchTiming | null = null;
let currentChannelId: string | null = null;
const channelTimings: Map<string, { time: number; timestamp: Date; }> = new Map();
const settings = definePluginSettings({
showIcon: {
type: OptionType.BOOLEAN,
description: "Show fetch time icon in message bar",
default: true,
},
showMs: {
type: OptionType.BOOLEAN,
description: "Show milliseconds in timing",
default: true,
},
iconColor: {
type: OptionType.STRING,
description: "Icon color (CSS color value)",
default: "#00d166",
}
});
const FetchTimeButton: ChatBarButtonFactory = ({ isMainChat }) => {
const { showMs, iconColor } = settings.use(["showMs", "iconColor"]);
if (!isMainChat || !settings.store.showIcon || !currentChannelId) {
return null;
}
const channelData = channelTimings.get(currentChannelId);
if (!channelData) {
return null;
}
const { time, timestamp } = channelData;
const displayTime = showMs ? `${Math.round(time)}ms` : `${Math.round(time / 1000)}s`;
if (!showMs && Math.round(time / 1000) === 0) {
return null;
}
const timeAgo = formatTimeAgo(timestamp);
return (
<ChatBarButton
tooltip={`Messages loaded in ${Math.round(time)}ms (${timeAgo})`}
onClick={() => { }}
>
<div style={{
display: "flex",
alignItems: "center",
gap: "4px"
}}>
{FetchTimeIcon()}
<span style={{
fontSize: "12px",
color: iconColor,
fontWeight: "500"
}}>
{displayTime}
</span>
</div>
</ChatBarButton>
);
};
function FetchTimeIcon() {
return (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M12,4A8,8 0 0,1 20,12A8,8 0 0,1 12,20A8,8 0 0,1 4,12A8,8 0 0,1 12,4M12.5,7V12.25L17,14.92L16.25,16.15L11,13V7H12.5Z" />
</svg>
);
}
function formatTimeAgo(timestamp: Date): string {
const now = new Date();
const diff = now.getTime() - timestamp.getTime();
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) {
return `${days} day${days > 1 ? "s" : ""} ago`;
} else if (hours > 0) {
return `${hours} hour${hours > 1 ? "s" : ""} ago`;
} else if (minutes > 0) {
return `${minutes} minute${minutes > 1 ? "s" : ""} ago`;
} else {
return "just now";
}
}
function handleChannelSelect(data: any) {
if (data.channelId && data.channelId !== currentChannelId) {
currentChannelId = data.channelId;
currentFetch = {
channelId: data.channelId,
startTime: performance.now()
};
}
}
function handleMessageLoad(data: any) {
if (!currentFetch || data.channelId !== currentFetch.channelId) return;
const existing = channelTimings.get(currentFetch.channelId);
if (existing) return;
const endTime = performance.now();
const duration = endTime - currentFetch.startTime;
channelTimings.set(currentFetch.channelId, {
time: duration,
timestamp: new Date()
});
currentFetch = null;
}
export default definePlugin({
name: "MessageFetchTimer",
description: "Shows how long it took to fetch messages for the current channel",
authors: [EquicordDevs.GroupXyz],
settings,
chatBarButton: {
icon: FetchTimeIcon,
render: FetchTimeButton
},
start() {
FluxDispatcher.subscribe("CHANNEL_SELECT", handleChannelSelect);
FluxDispatcher.subscribe("LOAD_MESSAGES_SUCCESS", handleMessageLoad);
FluxDispatcher.subscribe("MESSAGE_CREATE", handleMessageLoad);
const currentChannel = getCurrentChannel();
if (currentChannel) {
currentChannelId = currentChannel.id;
}
},
stop() {
FluxDispatcher.unsubscribe("CHANNEL_SELECT", handleChannelSelect);
FluxDispatcher.unsubscribe("LOAD_MESSAGES_SUCCESS", handleMessageLoad);
FluxDispatcher.unsubscribe("MESSAGE_CREATE", handleMessageLoad);
currentFetch = null;
channelTimings.clear();
currentChannelId = null;
}
});

View file

@ -0,0 +1,63 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { Notifications } from "@api/index";
import { definePluginSettings } from "@api/Settings";
import { EquicordDevs } from "@utils/constants";
import { getCurrentChannel } from "@utils/discord";
import definePlugin, { OptionType } from "@utils/types";
import { MessageJSON } from "@vencord/discord-types";
import { MessageType } from "@vencord/discord-types/enums";
import { ChannelStore, GuildStore, NavigationRouter, RelationshipStore } from "@webpack/common";
interface MessageCreatePayload {
guildId: string;
channelId: string;
message: MessageJSON;
}
const settings = definePluginSettings({
users: {
type: OptionType.STRING,
description: "Comma separated list of user ids to get message toasts for",
default: "",
isValid(value: string) {
if (value === "") return true;
const userIds = value.split(",").map(id => id.trim());
for (const id of userIds)
if (!/\d+/.test(id)) return `${id} isn't a valid user id`;
return true;
},
},
});
export default definePlugin({
authors: [EquicordDevs.cassie, EquicordDevs.mochienya],
name: "MessageNotifier",
description: "Get toasts for when chosen users send a message",
settings,
flux: {
MESSAGE_CREATE({ message, channelId, guildId }: MessageCreatePayload) {
if (message.type !== MessageType.DEFAULT || getCurrentChannel()?.id === channelId) return;
const userIds = settings.store.users.split(",").map(id => id.trim());
if (!userIds.includes(message.author.id)) return;
const username = RelationshipStore.getNickname(message.author.id) ?? message.author.globalName ?? message.author.username;
const guild = GuildStore.getGuild(guildId);
const channel = ChannelStore.getChannel(channelId);
const locationName = guild ? `${guild.name}#${channel.name}` : channel?.name ?? "their dms";
Notifications.showNotification({
title: `${username} sent a message`,
body: `Click to jump to ${locationName}`,
onClick() {
NavigationRouter.transitionTo(`/channels/${guild?.id ?? "@me"}/${channel.id}/${message.id}`);
},
});
},
},
});

View file

@ -0,0 +1,174 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2022 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { definePluginSettings } from "@api/Settings";
import { Devs } from "@utils/constants";
import { sleep } from "@utils/misc";
import definePlugin, { makeRange, OptionType } from "@utils/types";
import { Message, ReactionEmoji } from "@vencord/discord-types";
import { RelationshipStore, SelectedChannelStore, UserStore } from "@webpack/common";
interface IMessageCreate {
type: "MESSAGE_CREATE";
optimistic: boolean;
isPushNotification: boolean;
channelId: string;
message: Message;
}
interface IReactionAdd {
type: "MESSAGE_REACTION_ADD";
optimistic: boolean;
channelId: string;
messageId: string;
messageAuthorId: string;
userId: "195136840355807232";
emoji: ReactionEmoji;
}
interface IVoiceChannelEffectSendEvent {
type: string;
emoji?: ReactionEmoji; // Just in case...
channelId: string;
userId: string;
animationType: number;
animationId: number;
}
const MOYAI = "🗿";
const MOYAI_URL = "https://github.com/Equicord/Equibored/raw/main/sounds/moyai/moyai.mp3";
const MOYAI_URL_HD = "https://github.com/Equicord/Equibored/raw/main/sounds/moyai/moyai.wav";
const settings = definePluginSettings({
volume: {
description: "Volume of the 🗿🗿🗿",
type: OptionType.SLIDER,
markers: makeRange(0, 1, 0.1),
default: 0.5,
stickToMarkers: false
},
quality: {
description: "Quality of the 🗿🗿🗿",
type: OptionType.SELECT,
options: [
{ label: "Normal", value: "Normal", default: true },
{ label: "HD", value: "HD" }
],
},
triggerWhenUnfocused: {
description: "Trigger the 🗿 even when the window is unfocused",
type: OptionType.BOOLEAN,
default: true
},
ignoreBots: {
description: "Ignore bots",
type: OptionType.BOOLEAN,
default: true
},
ignoreBlocked: {
description: "Ignore blocked users",
type: OptionType.BOOLEAN,
default: true
}
});
export default definePlugin({
name: "Moyai",
authors: [Devs.Megu, Devs.Nuckyz],
description: "Plays a 🗿 sound effect whenever a moyai emoji is sent, reacted, or used as a voice effect in your current channel.",
settings,
flux: {
async MESSAGE_CREATE({ optimistic, type, message, channelId }: IMessageCreate) {
if (optimistic || type !== "MESSAGE_CREATE") return;
if (message.state === "SENDING") return;
if (settings.store.ignoreBots && message.author?.bot) return;
if (settings.store.ignoreBlocked && RelationshipStore.isBlocked(message.author?.id)) return;
if (!message.content) return;
if (channelId !== SelectedChannelStore.getChannelId()) return;
const moyaiCount = getMoyaiCount(message.content);
for (let i = 0; i < moyaiCount; i++) {
boom();
await sleep(300);
}
},
MESSAGE_REACTION_ADD({ optimistic, type, channelId, userId, messageAuthorId, emoji }: IReactionAdd) {
if (optimistic || type !== "MESSAGE_REACTION_ADD") return;
if (settings.store.ignoreBots && UserStore.getUser(userId)?.bot) return;
if (settings.store.ignoreBlocked && RelationshipStore.isBlocked(messageAuthorId)) return;
if (channelId !== SelectedChannelStore.getChannelId()) return;
const name = emoji.name.toLowerCase();
if (name !== MOYAI && !name.includes("moyai") && !name.includes("moai")) return;
boom();
},
VOICE_CHANNEL_EFFECT_SEND({ emoji }: IVoiceChannelEffectSendEvent) {
if (!emoji?.name) return;
const name = emoji.name.toLowerCase();
if (name !== MOYAI && !name.includes("moyai") && !name.includes("moai")) return;
boom();
}
}
});
function countOccurrences(sourceString: string, subString: string) {
let i = 0;
let lastIdx = 0;
while ((lastIdx = sourceString.indexOf(subString, lastIdx) + 1) !== 0)
i++;
return i;
}
function countMatches(sourceString: string, pattern: RegExp) {
if (!pattern.global)
throw new Error("pattern must be global");
let i = 0;
while (pattern.test(sourceString))
i++;
return i;
}
const customMoyaiRe = /<a?:\w*moy?ai\w*:\d{17,20}>/gi;
function getMoyaiCount(message: string) {
const count = countOccurrences(message, MOYAI)
+ countMatches(message, customMoyaiRe);
return Math.min(count, 10);
}
function boom() {
if (!settings.store.triggerWhenUnfocused && !document.hasFocus()) return;
const audioElement = document.createElement("audio");
audioElement.src = settings.store.quality === "HD"
? MOYAI_URL_HD
: MOYAI_URL;
audioElement.volume = settings.store.volume;
audioElement.play();
}

View file

@ -0,0 +1,23 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { Devs } from "@utils/constants";
import definePlugin from "@utils/types";
export default definePlugin({
name: "NoRoleHeaders",
description: "We are all equal!! Removes the role headers in the member list.",
authors: [Devs.Samwich],
patches: [
{
find: "._areActivitiesExperimentallyHidden=(",
replacement: {
match: /return \i===\i\.\i\.UNKNOWN/,
replace: "return null;$&"
}
}
]
});

View file

@ -0,0 +1,67 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { Devs } from "@utils/constants";
import { getIntlMessage } from "@utils/discord";
import definePlugin from "@utils/types";
import { findByCodeLazy, findByPropsLazy } from "@webpack";
import { ChannelStore, GuildStore, RelationshipStore, UserStore } from "@webpack/common";
const { getName } = findByPropsLazy("getName", "useName", "getNickname");
const computeChannelName = findByCodeLazy(".isThread())return`\"");
const ChannelTypes = findByPropsLazy("DM", "GUILD_TEXT", "PUBLIC_THREAD", "UNKNOWN");
const ChannelTypesSets = findByPropsLazy("THREADS", "GUILD_TEXTUAL", "ALL_DMS");
const MessageTypes = findByPropsLazy("REPLY", "STAGE_RAISE_HAND", "CHANNEL_NAME_CHANGE");
export default definePlugin({
name: "NotificationTitle",
description: "Makes desktop notifications more informative",
authors: [Devs.Kyuuhachi],
patches: [
{
find: '"SystemMessageUtils.stringify(...) could not convert"',
replacement: {
match: /{icon:.{0,50}emoji:\i}/,
replace: "($self.makeTitle($&,...arguments))",
}
},
],
makeTitle(result, channel, message, user) {
const username = getName(channel.guild_id, channel.id, user);
let title = username;
if (message.type === MessageTypes.REPLY && message.referenced_message?.author) {
const replyUser = UserStore.getUser(message.referenced_message.author.id);
const replyUsername = getName(channel.guild_id, channel.id, replyUser);
title = getIntlMessage("CHANNEL_MESSAGE_REPLY_A11Y_LABEL", {
author: username,
repliedAuthor: replyUsername,
});
}
const guild = GuildStore.getGuild(channel.guild_id);
const parent = ChannelStore.getChannel(channel.parent_id);
if (channel.type !== ChannelTypes.DM) {
let where = ChannelTypesSets.THREADS.has(channel.type)
? `${channelName(channel)} in ${channelName(parent, true)}`
: `${channelName(channel, true)}`;
if (guild != null)
where += `, ${guild.name}`;
title += `\n(${where})`;
}
result.title = title;
return result;
}
});
function channelName(channel, withPrefix = false) {
return computeChannelName(channel, UserStore, RelationshipStore, withPrefix);
}

View file

@ -0,0 +1,109 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { definePluginSettings } from "@api/Settings";
import { Devs } from "@utils/constants";
import definePlugin, { OptionType, ReporterTestable } from "@utils/types";
import { FluxDispatcher } from "@webpack/common";
const enum Intensity {
Normal,
Better,
ProjectX,
}
const settings = definePluginSettings({
superIntensePartyMode: {
description: "Party intensity",
type: OptionType.SELECT,
options: [
{ label: "Normal", value: Intensity.Normal, default: true },
{ label: "Better", value: Intensity.Better },
{ label: "Project X", value: Intensity.ProjectX },
],
restartNeeded: false,
onChange: setSettings
},
});
export default definePlugin({
name: "PartyMode",
description: "Allows you to use party mode cause the party never ends ✨",
authors: [Devs.UwUDev],
reporterTestable: ReporterTestable.None,
settings,
start() {
setPoggerState(true);
setSettings(settings.store.superIntensePartyMode);
},
stop() {
setPoggerState(false);
},
});
function setPoggerState(state: boolean) {
FluxDispatcher.dispatch({
type: "POGGERMODE_SETTINGS_UPDATE",
settings: {
enabled: state,
settingsVisible: state
}
});
}
function setSettings(intensity: Intensity) {
const state = {
screenshakeEnabledLocations: { 0: true, 1: true, 2: true },
shakeIntensity: 1,
confettiSize: 16,
confettiCount: 5,
combosRequiredCount: 1
};
switch (intensity) {
case Intensity.Normal: {
Object.assign(state, {
screenshakeEnabledLocations: { 0: true, 1: false, 2: false },
combosRequiredCount: 5
});
break;
}
case Intensity.Better: {
Object.assign(state, {
confettiSize: 12,
confettiCount: 8,
});
break;
}
case Intensity.ProjectX: {
Object.assign(state, {
shakeIntensity: 20,
confettiSize: 25,
confettiCount: 15,
});
break;
}
}
FluxDispatcher.dispatch({
type: "POGGERMODE_SETTINGS_UPDATE",
settings: state
});
}

View file

@ -0,0 +1,145 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { showNotification } from "@api/Notifications";
import { definePluginSettings } from "@api/Settings";
import { EquicordDevs } from "@utils/constants";
import definePlugin, { OptionType } from "@utils/types";
import { findStoreLazy } from "@webpack";
import {
ChannelStore,
GuildStore,
NavigationRouter,
PresenceStore,
RelationshipStore,
SelectedChannelStore,
UserStore
} from "@webpack/common";
const UserGuildSettingsStore = findStoreLazy("UserGuildSettingsStore");
const settings = definePluginSettings({
friends: {
type: OptionType.BOOLEAN,
default: false,
description: "Notify when friends send messages in servers"
},
mentions: {
type: OptionType.BOOLEAN,
default: true,
description: "Notify when someone @mentions you directly"
},
dms: {
type: OptionType.BOOLEAN,
default: true,
description: "Notify for direct messages (DMs)"
},
showInActive: {
type: OptionType.BOOLEAN,
default: false,
description: "Show notifications even for currently active channel"
},
ignoreMuted: {
type: OptionType.BOOLEAN,
default: true,
description: "Skip notifications from muted servers, channels, or users"
}
});
function formatContent(message) {
let content = message.content || "";
message.mentions?.forEach(user => {
content = content.replace(new RegExp(`<@!?${user.id}>`, "g"), `@${user.username}`);
});
return content.slice(0, 200) + (content.length > 200 ? "..." : "");
}
function checkIfMuted(channel) {
if (!settings.store.ignoreMuted) return false;
if (!channel) return false;
if (channel.isMuted?.()) return true;
const isDM = [1, 3].includes(channel.type);
if (isDM) {
const recipientIds = channel.recipients || [];
for (const userId of recipientIds) {
if (RelationshipStore.isBlocked(userId)) return true;
}
if (UserGuildSettingsStore?.getMutedChannels?.()?.includes?.(channel.id)) return true;
}
if (channel.guild_id) {
const guild = GuildStore.getGuild(channel.guild_id);
if (UserGuildSettingsStore.isMuted(channel.guild_id)) return true;
if (UserGuildSettingsStore?.isMuted?.(channel.guild_id)) return true;
if (UserGuildSettingsStore?.isChannelMuted?.(channel.guild_id, channel.id)) return true;
if (UserGuildSettingsStore?.isCategoryMuted?.(channel.guild_id, channel.id)) return true;
}
return false;
}
function isUserBlocked(userId) {
return settings.store.ignoreMuted && RelationshipStore.isBlocked?.(userId);
}
export default definePlugin({
name: "PingNotifications",
description: "Customizable notifications with improved mention formatting",
authors: [EquicordDevs.smuki],
settings,
flux: {
MESSAGE_CREATE({ message }) {
try {
if (!message?.channel_id || message.state === "SENDING") return;
const channel = ChannelStore.getChannel(message.channel_id);
const currentUser = UserStore.getCurrentUser();
if (!channel || !currentUser) return;
if (message.author?.id === currentUser.id) return;
const isDM = [1, 3].includes(channel.type);
if (checkIfMuted(channel)) return;
if (isUserBlocked(message.author.id)) return;
if (!settings.store.showInActive && channel.id === SelectedChannelStore.getChannelId()) return;
if (PresenceStore.getStatus(currentUser.id) === "dnd") return;
const author = UserStore.getUser(message.author.id) || { username: "Unknown" };
const channelName = channel.name || (isDM ? "DM" : "Group");
const body = formatContent(message);
let shouldNotify = false;
if (settings.store.mentions && message.mentions?.some(u => u.id === currentUser.id)) {
shouldNotify = true;
} else if (settings.store.friends && RelationshipStore.isFriend(message.author.id) && !isDM) {
shouldNotify = true;
} else if (isDM && settings.store.dms) {
shouldNotify = true;
}
if (shouldNotify) {
showNotification({
title: `${author.username} in ${channelName}`,
body,
icon: author.getAvatarURL?.(undefined, 128),
onClick: () => NavigationRouter.transitionTo(
`/channels/${channel.guild_id || "@me"}/${channel.id}/${message.id}`
)
});
}
} catch (err) {
console.error("[PingNotifications] Error:", err);
}
}
}
});

View file

@ -0,0 +1,81 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { Button } from "@components/Button";
import { Heading } from "@components/Heading";
import { Paragraph } from "@components/Paragraph";
import { ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot } from "@utils/modal";
interface ConfirmModalProps extends ModalProps {
title: string;
message: string;
confirmText: string;
cancelText: string;
onConfirm: () => void;
onCancel: () => void;
}
interface ImportProfilesModalProps extends ModalProps {
title: string;
message: string;
onOverride: () => void;
onMerge: () => void;
onCancel: () => void;
}
export function ConfirmModal({ title, message, confirmText, cancelText, onConfirm, onCancel, onClose, transitionState }: ConfirmModalProps) {
const closeAfter = (action: () => void) => () => {
action();
onClose();
};
return (
<ModalRoot transitionState={transitionState}>
<ModalHeader>
<Heading tag="h2">{title}</Heading>
</ModalHeader>
<ModalContent>
<Paragraph>{message}</Paragraph>
</ModalContent>
<ModalFooter>
<Button onClick={closeAfter(onConfirm)}>
{confirmText}
</Button>
<Button variant="secondary" onClick={closeAfter(onCancel)}>
{cancelText}
</Button>
</ModalFooter>
</ModalRoot>
);
}
export function ImportProfilesModal({ title, message, onOverride, onMerge, onCancel, onClose, transitionState }: ImportProfilesModalProps) {
const closeAfter = (action: () => void) => () => {
action();
onClose();
};
return (
<ModalRoot transitionState={transitionState}>
<ModalHeader>
<Heading tag="h2">{title}</Heading>
</ModalHeader>
<ModalContent>
<Paragraph>{message}</Paragraph>
</ModalContent>
<ModalFooter>
<Button onClick={closeAfter(onOverride)}>
Override
</Button>
<Button onClick={closeAfter(onMerge)}>
Merge
</Button>
<Button variant="secondary" onClick={closeAfter(onCancel)}>
Cancel
</Button>
</ModalFooter>
</ModalRoot>
);
}

View file

@ -0,0 +1,220 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { isNonNullish } from "@utils/guards";
import { classes } from "@utils/misc";
import { ProfilePreset } from "@vencord/discord-types";
import { ContextMenuApi, Menu, React, TextInput } from "@webpack/common";
import { cl } from "..";
import { deletePreset, movePreset, renamePreset, updatePresetField } from "../utils/actions";
import { getCurrentProfile } from "../utils/profile";
import { PresetSection, type ProfilePresetEx } from "../utils/storage";
interface PresetListProps {
presets: ProfilePresetEx[];
allPresets: ProfilePresetEx[];
avatarSize: number;
selectedPreset: number;
onLoad: (index: number) => void;
onUpdate: () => void;
guildId?: string;
section: PresetSection;
currentPage: number;
onPageChange: (page: number) => void;
}
export function PresetList({
presets,
allPresets,
avatarSize,
selectedPreset,
onLoad,
onUpdate,
guildId,
section,
currentPage,
onPageChange
}: PresetListProps) {
type EditableProfile = Omit<ProfilePreset, "name" | "timestamp">;
const [renaming, setRenaming] = React.useState<number>(-1);
const [renameText, setRenameText] = React.useState("");
const isGuildProfile = section === "server";
return (
<div className={cl("list-container")}>
{presets.map(preset => {
const actualIndex = allPresets.indexOf(preset);
const isRenaming = renaming === actualIndex;
const isSelected = !isRenaming && selectedPreset === actualIndex;
const date = new Date(preset.timestamp);
const formattedDate = date.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric"
});
const formattedTime = date.toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit"
});
const commitRename = () => {
const nextName = renameText.trim();
if (!nextName) return;
renamePreset(actualIndex, nextName, section, guildId);
onUpdate();
};
return (
<div
key={actualIndex}
tabIndex={isRenaming ? -1 : 0}
role="button"
onClick={() => {
if (!isRenaming) {
onLoad(actualIndex);
}
}}
onKeyDown={e => {
if (!isRenaming && (e.key === "Enter" || e.key === " ")) {
e.preventDefault();
onLoad(actualIndex);
}
}}
className={classes(cl("row"), isSelected ? "selected" : "")}
>
<div className={cl("avatar-url")}>
{preset.avatarDataUrl && (
<img
src={preset.avatarDataUrl}
alt=""
className={cl("avatar")}
style={{ width: `${avatarSize}px`, height: `${avatarSize}px` }}
/>
)}
<div className={cl("rename")}>
{isRenaming ? (
<TextInput
value={renameText}
onChange={setRenameText}
onBlur={() => {
commitRename();
setRenaming(-1);
}}
onKeyDown={e => {
if (e.key === "Enter") {
commitRename();
setRenaming(-1);
} else if (e.key === "Escape") {
setRenaming(-1);
}
e.stopPropagation();
}}
onClick={e => e.stopPropagation()}
autoFocus
/>
) : (
<>
<div className={cl("name")}>
{preset.name}
</div>
<div className={cl("timestamp")}>
{formattedDate} at {formattedTime}
</div>
</>
)}
</div>
</div>
<div className={cl("updated")}>
<svg
width="20"
height="20"
viewBox="0 0 20 20"
className={cl("menu-icon")}
onClick={e => {
e.stopPropagation();
const target = e.currentTarget;
ContextMenuApi.openContextMenu(e, () => (
<Menu.Menu navId="preset-options" onClose={ContextMenuApi.closeContextMenu}>
<Menu.MenuItem
id="rename"
label="Rename"
action={() => {
setRenaming(actualIndex);
setRenameText(preset.name);
}}
/>
<Menu.MenuItem
id="update"
label="Update"
action={async () => {
const profile = await getCurrentProfile(guildId, { isGuildProfile });
await Promise.all(
(Object.entries(profile) as [keyof EditableProfile, EditableProfile[keyof EditableProfile]][])
.filter(([, value]) => isNonNullish(value))
.map(([key, value]) => updatePresetField(actualIndex, key, value, section, guildId))
);
onUpdate();
}}
/>
<Menu.MenuSeparator />
{actualIndex > 0 && (
<Menu.MenuItem
id="move-up"
label="Move Up"
action={() => {
movePreset(actualIndex, actualIndex - 1, section, guildId);
onUpdate();
}}
/>
)}
{actualIndex < allPresets.length - 1 && (
<Menu.MenuItem
id="move-down"
label="Move Down"
action={() => {
movePreset(actualIndex, actualIndex + 1, section, guildId);
onUpdate();
}}
/>
)}
{currentPage > 1 && (
<Menu.MenuItem
id="move-to-page-1"
label="Move to Page 1"
action={() => {
movePreset(actualIndex, 0, section, guildId);
onPageChange(1);
onUpdate();
}}
/>
)}
<Menu.MenuSeparator />
<Menu.MenuItem
id="delete"
label="Delete"
color="danger"
action={async () => {
await deletePreset(actualIndex, section, guildId);
onUpdate();
}}
/>
</Menu.Menu>
));
}}
>
<path
fill="currentColor"
d="M10 3a1.5 1.5 0 110 3 1.5 1.5 0 010-3zm0 5a1.5 1.5 0 110 3 1.5 1.5 0 010-3zm0 5a1.5 1.5 0 110 3 1.5 1.5 0 010-3z"
/>
</svg>
</div>
</div>
);
})}
</div>
);
}

View file

@ -0,0 +1,290 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { Button } from "@components/Button";
import { Heading } from "@components/Heading";
import { classes } from "@utils/misc";
import { openModal } from "@utils/modal";
import { React, SelectedGuildStore, TextInput, useStateFromStores } from "@webpack/common";
import { cl, settings } from "../index";
import { exportPresets, ImportDecision, importPresets, savePreset } from "../utils/actions";
import { loadPresetAsPending } from "../utils/profile";
import { loadPresets, presets, PresetSection, setCurrentPresetIndex } from "../utils/storage";
import { ImportProfilesModal } from "./confirmModal";
import { PresetList } from "./presetList";
const PRESETS_PER_PAGE = 5;
type PresetManagerProps = {
section?: PresetSection;
guildId?: string;
};
export function PresetManager({ section, guildId }: PresetManagerProps) {
const [presetName, setPresetName] = React.useState("");
const [, forceUpdate] = React.useReducer(x => x + 1, 0);
const [isSaving, setIsSaving] = React.useState(false);
const [currentPage, setCurrentPage] = React.useState(1);
const [pageInput, setPageInput] = React.useState("1");
const [selectedPreset, setSelectedPreset] = React.useState<number>(-1);
const [searchMode, setSearchMode] = React.useState(false);
const lastRandomIndexRef = React.useRef<number>(-1);
const resolvedSection: PresetSection = section ?? "main";
const isServerSection = resolvedSection === "server";
const lastSelectedGuildId = useStateFromStores(
[SelectedGuildStore],
() => SelectedGuildStore.getLastSelectedGuildId() ?? SelectedGuildStore.getGuildId()
);
const resolvedGuildId = isServerSection ? (guildId ?? lastSelectedGuildId ?? undefined) : undefined;
const canUseGuild = !isServerSection || Boolean(resolvedGuildId);
React.useEffect(() => {
let isActive = true;
(async () => {
await loadPresets(resolvedSection);
if (!isActive) return;
setSelectedPreset(-1);
setCurrentPage(1);
setPageInput("1");
forceUpdate();
})();
return () => {
isActive = false;
};
}, [resolvedGuildId, resolvedSection]);
const filteredPresets = !searchMode
? presets
: presets.filter(preset => preset.name.toLowerCase().includes(presetName.toLowerCase()));
const totalPages = Math.ceil(filteredPresets.length / PRESETS_PER_PAGE);
const startIndex = (currentPage - 1) * PRESETS_PER_PAGE;
const currentPresets = filteredPresets.slice(startIndex, startIndex + PRESETS_PER_PAGE);
const handlePageChange = (newPage: number) => {
if (newPage >= 1 && newPage <= totalPages) {
setCurrentPage(newPage);
setPageInput(String(newPage));
}
};
const handleSavePreset = async () => {
if (!canUseGuild) return;
const trimmedName = presetName.trim();
if (!trimmedName) return;
setIsSaving(true);
await savePreset(trimmedName, resolvedSection, resolvedGuildId);
setPresetName("");
setIsSaving(false);
const newTotalPages = Math.ceil(presets.length / PRESETS_PER_PAGE);
handlePageChange(newTotalPages);
forceUpdate();
};
const applyPreset = (index: number) => {
setSelectedPreset(index);
setCurrentPresetIndex(index);
loadPresetAsPending(presets[index], resolvedGuildId, {
isGuildProfile: resolvedSection === "server"
});
forceUpdate();
};
const handleLoadPreset = (index: number) => {
if (!canUseGuild) return;
applyPreset(index);
};
const selectRandomPreset = (sectionType: PresetSection) => {
const availablePresets = presets;
if (!availablePresets.length) return null;
const randomIndex = Math.floor(Math.random() * availablePresets.length);
return { preset: availablePresets[randomIndex], index: randomIndex, section: sectionType };
};
const handleRandomPreset = () => {
if (!canUseGuild) return;
const selection = selectRandomPreset(resolvedSection);
if (!selection) return;
let nextIndex = selection.index;
if (presets.length > 1 && nextIndex === lastRandomIndexRef.current) {
let attempts = 0;
while (attempts < 5 && nextIndex === lastRandomIndexRef.current) {
nextIndex = Math.floor(Math.random() * presets.length);
attempts++;
}
}
lastRandomIndexRef.current = nextIndex;
applyPreset(nextIndex);
};
const showImportPrompt = (existingCount: number): Promise<ImportDecision> => {
return new Promise(resolve => {
openModal(props => (
<ImportProfilesModal
{...props}
title="Import Profiles"
message={`You have ${existingCount} existing profiles in this section. Do you want to override them or merge with imported profiles?`}
onOverride={() => resolve("override")}
onMerge={() => resolve("merge")}
onCancel={() => resolve("cancel")}
/>
));
});
};
const { avatarSize } = settings.store;
const hasPresets = presets.length > 0;
const shouldShowPagination = filteredPresets.length > PRESETS_PER_PAGE;
return (
<div className={classes(cl("section"), isServerSection ? cl("section-server") : "")} >
<Heading tag="h3" className={cl("heading")}>
Saved Profiles
</Heading>
<div className={cl("text")}>
<TextInput
placeholder={searchMode ? "Search profiles..." : "Profile Name"}
value={presetName}
onChange={setPresetName}
className={cl("text-input")}
/>
</div>
<div className={cl("search")}>
{!searchMode && (
<Button
size="small"
disabled={isSaving || !presetName.trim() || !canUseGuild}
onClick={handleSavePreset}
className={cl("search-button")}
>
{isSaving ? "Saving..." : "Save Profile"}
</Button>
)}
{hasPresets && (
<Button
size="small"
variant={searchMode ? "primary" : "secondary"}
onClick={() => {
setSearchMode(!searchMode);
handlePageChange(1);
}}
>
{searchMode ? "Cancel Search" : "Search"}
</Button>
)}
</div>
{hasPresets && (
<>
<PresetList
presets={currentPresets}
allPresets={presets}
avatarSize={avatarSize}
selectedPreset={selectedPreset}
onLoad={handleLoadPreset}
onUpdate={() => {
const newTotal = Math.ceil(presets.length / PRESETS_PER_PAGE);
if (newTotal === 0) {
handlePageChange(1);
} else if (currentPage > newTotal) {
handlePageChange(newTotal);
}
forceUpdate();
}}
guildId={resolvedGuildId}
section={resolvedSection}
currentPage={currentPage}
onPageChange={handlePageChange}
/>
{shouldShowPagination && (
<div className={cl("pagination")}>
<Button
size="small"
variant="secondary"
disabled={currentPage === 1}
onClick={() => handlePageChange(currentPage - 1)}
>
←
</Button>
<div className={cl("page")}>
<input
type="text"
value={pageInput}
onChange={e => {
const { value } = e.target;
setPageInput(value);
const num = parseInt(value);
if (!isNaN(num) && num >= 1 && num <= totalPages) {
setCurrentPage(num);
}
}}
className={cl("page-input")}
/>
<span className={cl("page-of")}>
/ {totalPages}
</span>
</div>
<Button
size="small"
variant="secondary"
disabled={currentPage === totalPages}
onClick={() => handlePageChange(currentPage + 1)}
>
→
</Button>
</div>
)}
<hr className={cl("block")} />
</>
)}
<div className={cl("import")}>
<Button
size="small"
variant="secondary"
onClick={handleRandomPreset}
className={cl("random")}
disabled={!presets.length || !canUseGuild}
>
<span className={cl("random-content")}>
<svg className={cl("random-icon")} viewBox="0 0 24 24" aria-hidden="true">
<path
fill="currentColor"
d="M20.5 4h-5a1 1 0 0 0 0 2h2.586l-4.293 4.293a1 1 0 0 0 1.414 1.414L19.5 7.414V10a1 1 0 1 0 2 0V5a1 1 0 0 0-1-1Zm-16 1a1 1 0 0 0 0 2h2.586l4.293 4.293a1 1 0 1 0 1.414-1.414L8.414 5H4.5ZM5 15a1 1 0 0 0-1 1v5a1 1 0 1 0 2 0v-2.586l4.293 4.293a1 1 0 0 0 1.414-1.414L7.414 17H10a1 1 0 1 0 0-2H5Zm14.5 0a1 1 0 0 0 0 2h-2.586l-4.293 4.293a1 1 0 1 0 1.414 1.414L18.586 17H21a1 1 0 1 0 0-2h-1.5Z"
/>
</svg>
Random
</span>
</Button>
<Button
size="small"
variant="secondary"
onClick={() => importPresets(forceUpdate, showImportPrompt, resolvedSection, resolvedGuildId)}
disabled={!canUseGuild}
>
Import
</Button>
<Button
size="small"
variant="secondary"
onClick={() => exportPresets(resolvedSection)}
>
Export All
</Button>
</div>
<hr className={cl("block")} />
{resolvedSection === "server" && (
<hr className={cl("block")} />
)}
</div>
);
}

View file

@ -0,0 +1,56 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import "./styles.css";
import { definePluginSettings } from "@api/Settings";
import { EquicordDevs } from "@utils/constants";
import { classNameFactory } from "@utils/css";
import definePlugin, { OptionType } from "@utils/types";
import { React } from "@webpack/common";
import { PresetManager } from "./components/presetManager";
import { loadPresets, PresetSection } from "./utils/storage";
export const cl = classNameFactory("vc-profile-presets-");
export const settings = definePluginSettings({
avatarSize: {
type: OptionType.SLIDER,
description: "Avatar size in preset list.",
markers: [56, 64, 72, 80, 88, 96],
default: 56,
stickToMarkers: true
},
});
export default definePlugin({
name: "ProfileSets",
description: "Allows you to save and load different profile presets, via the Profile Section in Settings.",
authors: [EquicordDevs.omaw, EquicordDevs.justjxke],
settings,
patches: [
{
find: "DefaultCustomizationSections: user cannot be undefined",
replacement: {
match: /return.{0,50}children:\[(?<=\.getLegacyUsername\(\).*?)/,
replace: "$&$self.renderPresetSection(\"main\"),"
}
},
{
find: "USER_SETTINGS_GUILD_PROFILE)",
replacement: {
match: /guildId:(\i\.id),onChange:(\i)\}\)(?=.{0,25}profilePreviewTitle:)/,
replace: 'guildId:$1,onChange:$2}),$self.renderPresetSection("server",$1)'
}
}
],
start() {
loadPresets("main");
},
renderPresetSection(section: PresetSection, guildId?: string) {
return <PresetManager section={section} guildId={guildId} />;
}
});

View file

@ -0,0 +1,209 @@
.vc-profile-presets-section {
margin-bottom: 20px;
padding: 0;
background-color: transparent;
max-width: 660px;
width: 100%;
min-width: 0;
align-self: flex-start;
}
.vc-profile-presets-section-server {
max-width: 660px;
}
.vc-profile-presets-heading {
margin-bottom: 8px;
font-size: 16px;
font-weight: 600;
}
.vc-profile-presets-text {
margin-bottom: 8px;
}
.vc-profile-presets-text-input {
width: 100%;
max-width: 500px;
min-width: 0;
}
.vc-profile-presets-search {
display: flex;
gap: 8px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.vc-profile-presets-search-button {
min-width: 120px;
}
.vc-profile-presets-pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
margin-top: 12px;
}
.vc-profile-presets-section-server .vc-profile-presets-pagination {
width: 300px;
max-width: 100%;
}
.vc-profile-presets-page {
display: flex;
align-items: center;
gap: 4px;
}
.vc-profile-presets-page-of {
font-size: 14px;
color: var(--text-normal);
font-weight: 500;
}
.vc-profile-presets-import {
display: flex;
gap: 8px;
margin-top: 12px;
margin-bottom: 16px;
}
.vc-profile-presets-random {
background: linear-gradient(135deg, #ff5f6d 0%, #ffc371 50%, #8a7bff 100%);
color: #fff;
border: none;
}
.vc-profile-presets-random:hover {
filter: brightness(1.05);
}
.vc-profile-presets-random-content {
display: inline-flex;
align-items: center;
gap: 6px;
}
.vc-profile-presets-random-icon {
width: 16px;
height: 16px;
display: inline-block;
}
.vc-profile-presets-block {
margin: 16px 0;
border: none;
border-top: 1px solid var(--background-modifier-accent);
}
.vc-profile-presets-avatar-url {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
min-width: 0;
}
.vc-profile-presets-rename {
min-width: 0;
flex: 1;
}
.vc-profile-presets-updated {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.vc-profile-presets-list-container {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 12px;
padding: 12px;
background-color: var(--background-secondary);
border-radius: 8px;
border: 2px solid var(--background-modifier-accent);
min-width: 0;
width: 100%;
max-width: 660px;
align-self: flex-start;
}
.vc-profile-presets-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 12px;
background-color: var(--background-primary);
border-radius: 6px;
cursor: pointer;
transition: all 0.15s ease;
border: 2px solid transparent;
outline: none;
min-width: 0;
width: 100%;
max-width: 300px;
align-self: flex-start;
}
.vc-profile-presets-row:hover {
background-color: var(--background-modifier-hover) !important;
border-color: var(--background-modifier-accent) !important;
}
.vc-profile-presets-row:focus-visible {
border-color: var(--brand-experiment) !important;
box-shadow: 0 0 0 2px var(--brand-experiment-30a);
}
.vc-profile-presets-row.selected {
background-color: var(--background-modifier-selected);
}
.vc-profile-presets-avatar {
border-radius: 50%;
border: 2px solid var(--background-tertiary);
flex-shrink: 0;
}
.vc-profile-presets-name {
font-size: 15px;
font-weight: 600;
color: var(--header-primary);
margin-bottom: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.vc-profile-presets-timestamp {
font-size: 12px;
color: var(--text-normal);
font-weight: 500;
opacity: 0.8;
}
.vc-profile-presets-menu-icon {
opacity: 0.7;
cursor: pointer;
transition: opacity 0.15s ease;
}
.vc-profile-presets-menu-icon:hover {
opacity: 1;
}
.vc-profile-presets-page-input {
width: 40px;
text-align: center;
padding: 4px;
background-color: var(--background-secondary);
border: 2px solid var(--background-modifier-accent);
border-radius: 4px;
color: var(--text-normal);
}

View file

@ -0,0 +1,152 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { isNonNullish } from "@utils/guards";
import { ProfilePreset } from "@vencord/discord-types";
import { findStoreLazy } from "@webpack";
import { showToast, Toasts } from "@webpack/common";
import { getCurrentProfile } from "./profile";
import { addPreset, movePresetInArray, presets, PresetSection, type ProfilePresetEx, removePreset, replaceAllPresets, savePresetsData, updatePreset } from "./storage";
const UserProfileSettingsStore = findStoreLazy("UserProfileSettingsStore");
function isImageInput(value: unknown): value is string | { imageUri: string; } {
if (typeof value === "string") return value.length > 0;
return typeof value === "object" && isNonNullish(value) && "imageUri" in value && typeof (value as { imageUri: unknown }).imageUri === "string";
}
function getFreshPendingAvatar(section: PresetSection, guildId?: string): string | null {
const pending = (section === "server" && guildId
? UserProfileSettingsStore.getPendingChanges?.(guildId)
: UserProfileSettingsStore.getPendingChanges?.()) ?? {};
const pendingObj = pending as Record<string, unknown>;
const candidates = [
pendingObj.selectedAvatarRaw,
pendingObj.presetAvatarRaw,
pendingObj.pendingAvatar,
pendingObj.avatar,
pendingObj.selectedAvatarProcessed,
pendingObj.presetAvatarProcessed
];
const selected = candidates.find(isImageInput);
if (!selected) return null;
return typeof selected === "string" ? selected : selected.imageUri;
}
export async function savePreset(name: string, section: PresetSection, guildId?: string) {
const profile = await getCurrentProfile(guildId, { isGuildProfile: section === "server" });
const freshPendingAvatar = getFreshPendingAvatar(section, guildId);
const effectiveAvatar = freshPendingAvatar ?? profile.avatarDataUrl ?? null;
const newPreset: ProfilePresetEx = {
name,
timestamp: Date.now(),
...profile,
avatarDataUrl: effectiveAvatar,
};
addPreset(newPreset);
await savePresetsData(section);
}
export async function updatePresetField<K extends keyof Omit<ProfilePreset, "name" | "timestamp">>(
index: number,
field: K,
value: Omit<ProfilePreset, "name" | "timestamp">[K],
section: PresetSection,
guildId?: string
) {
if (index < 0 || index >= presets.length) return;
void guildId;
const updatedPreset = {
...presets[index],
[field]: value,
timestamp: Date.now()
};
updatePreset(index, updatedPreset);
await savePresetsData(section);
}
export async function deletePreset(index: number, section: PresetSection, guildId?: string) {
if (index < 0 || index >= presets.length) return;
removePreset(index);
await savePresetsData(section);
}
export async function movePreset(fromIndex: number, toIndex: number, section: PresetSection, guildId?: string) {
if (fromIndex < 0 || fromIndex >= presets.length || toIndex < 0 || toIndex >= presets.length) return;
movePresetInArray(fromIndex, toIndex);
await savePresetsData(section);
}
export async function renamePreset(index: number, newName: string, section: PresetSection, guildId?: string) {
if (index < 0 || index >= presets.length || !newName.trim()) return;
const updatedPreset = { ...presets[index], name: newName.trim() };
updatePreset(index, updatedPreset);
await savePresetsData(section);
}
export function exportPresets(section: PresetSection) {
const dataStr = JSON.stringify(presets, null, 2);
const dataBlob = new Blob([dataStr], { type: "application/json" });
const url = URL.createObjectURL(dataBlob);
const link = document.createElement("a");
link.href = url;
link.download = `profile-presets-${section}-${Date.now()}.json`;
link.click();
URL.revokeObjectURL(url);
}
export type ImportDecision = "override" | "merge" | "cancel";
export async function importPresets(
forceUpdate: () => void,
onImportPrompt: (existingCount: number) => Promise<ImportDecision>,
section: PresetSection,
guildId?: string
) {
const input = document.createElement("input");
input.type = "file";
input.accept = "application/json";
input.onchange = async (event: Event) => {
try {
const target = event.currentTarget as HTMLInputElement | null;
const file = target?.files?.[0];
if (!file) return;
const text = await file.text();
const importedPresets = JSON.parse(text);
if (!Array.isArray(importedPresets)) {
return;
}
if (presets.length > 0) {
const decision = await onImportPrompt(presets.length);
if (decision === "cancel") return;
if (decision === "override") {
replaceAllPresets(importedPresets);
} else {
const combined = [...presets, ...importedPresets];
replaceAllPresets(combined);
}
} else {
replaceAllPresets(importedPresets);
}
await savePresetsData(section);
forceUpdate();
} catch {
showToast("Failed to import presets. The file might be invalid.", Toasts.Type.FAILURE);
}
};
input.click();
}

View file

@ -0,0 +1,415 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { getUserSettingLazy } from "@api/UserSettings";
import { CustomStatus, DisplayNameStyles, Nameplate, ProfileEffect, ProfilePreset } from "@vencord/discord-types";
import { findStoreLazy } from "@webpack";
import { FluxDispatcher, GuildMemberStore, IconUtils, UserProfileStore, UserStore } from "@webpack/common";
const UserProfileSettingsStore = findStoreLazy("UserProfileSettingsStore");
const CustomStatusSettings = getUserSettingLazy("status", "customStatus")!;
type PendingChanges = Record<string, unknown> & {
selectedAvatarRaw?: ImageInput;
presetAvatarRaw?: ImageInput;
selectedAvatarProcessed?: ImageInput;
presetAvatarProcessed?: ImageInput;
pendingAvatar?: ImageInput;
avatar?: ImageInput;
pendingBanner?: ImageInput;
banner?: ImageInput;
pendingAvatarDecoration?: { asset: string; skuId: string; } | null;
pendingProfileEffect?: ProfileEffect | null;
pendingNameplate?: Nameplate | null;
pendingDisplayNameStyles?: DisplayNameStyles | null;
pendingAccentColor?: number | null;
pendingThemeColors?: number[] | null;
pendingBio?: string | null;
pendingPronouns?: string | null;
pendingNickname?: string | null;
pendingGlobalName?: string | null;
pendingPrimaryGuildId?: string | null;
};
type ImageInput = string | { imageUri: string; } | null | undefined;
type DisplayNameStylesLike = DisplayNameStyles & {
fontId?: number;
effectId?: number;
};
type CurrentProfileOptions = {
isGuildProfile?: boolean;
};
type LoadPresetOptions = {
skipGlobalName?: boolean;
skipBio?: boolean;
skipPronouns?: boolean;
isGuildProfile?: boolean;
};
function dispatch(type: string, payload: Record<string, unknown>) {
FluxDispatcher.dispatch({ type, ...payload });
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.length > 0;
}
function hasImageInput(value: ImageInput): boolean {
if (!value) return false;
if (typeof value === "string") return value.length > 0;
return typeof value === "object" && isNonEmptyString(value?.imageUri);
}
function normalizeDisplayNameStyles(value: DisplayNameStylesLike | null | undefined): DisplayNameStylesLike | null {
if (!value) return null;
const fontId = value.fontId ?? value.font_id;
const effectId = value.effectId ?? value.effect_id;
if (typeof fontId !== "number" || typeof effectId !== "number") return null;
const colors = Array.isArray(value.colors) ? [...value.colors] : [];
return {
fontId,
effectId,
font_id: fontId,
effect_id: effectId,
colors
};
}
export async function imageUrlToBase64(url: string): Promise<string | null> {
try {
const response = await fetch(url);
const blob = await response.blob();
return await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
} catch {
return null;
}
}
async function processImage(imageData: ImageInput, userId: string, type: "avatar" | "banner", guildId?: string, useGuildPath?: boolean): Promise<string | null> {
if (!imageData) return null;
if (typeof imageData === "object" && isNonEmptyString(imageData?.imageUri)) {
return imageData.imageUri;
}
if (typeof imageData === "string") {
if (imageData.startsWith("data:")) return imageData;
if (/^https?:\/\//.test(imageData)) {
return await imageUrlToBase64(imageData);
}
const isAnimated = imageData.startsWith("a_");
const size = type === "banner" ? 1024 : 512;
const urlPath = type === "banner" ? "banners" : "avatars";
const guildPath = guildId ? `guilds/${guildId}/users/${userId}/${type === "banner" ? "banners" : "avatars"}` : urlPath;
const guildUrl = `https://cdn.discordapp.com/${guildPath}/${imageData}.${isAnimated ? "gif" : "png"}?size=${size}`;
const globalUrl = `https://cdn.discordapp.com/${urlPath}/${userId}/${imageData}.${isAnimated ? "gif" : "png"}?size=${size}`;
if (useGuildPath && guildId) {
const guildResult = await imageUrlToBase64(guildUrl);
if (guildResult) return guildResult;
}
return await imageUrlToBase64(globalUrl);
}
return null;
}
export async function getCurrentProfile(guildId?: string, options: CurrentProfileOptions = {}): Promise<Omit<ProfilePreset, "name" | "timestamp">> {
const currentUser = UserStore.getCurrentUser();
const baseProfile = UserProfileStore.getUserProfile(currentUser.id);
const isGuildProfile = options.isGuildProfile ?? Boolean(guildId);
const effectiveGuildId = isGuildProfile ? guildId : undefined;
const guildProfile = effectiveGuildId ? UserProfileStore.getGuildMemberProfile(currentUser.id, effectiveGuildId) : null;
const userProfile = guildProfile ?? baseProfile;
const userAny = currentUser;
const guildMember = effectiveGuildId ? GuildMemberStore.getMember(effectiveGuildId, currentUser.id) : null;
const pendingChangesDefault: PendingChanges = UserProfileSettingsStore.getPendingChanges() ?? {};
const pendingChangesForGuild: PendingChanges = UserProfileSettingsStore.getPendingChanges(effectiveGuildId) ?? {};
const pendingChanges: PendingChanges = isGuildProfile && Object.keys(pendingChangesForGuild).length > 0
? pendingChangesForGuild
: pendingChangesDefault;
const customStatusSetting = CustomStatusSettings.getSetting();
const customStatus = isGuildProfile
? null
: {
text: customStatusSetting?.text ?? "",
emojiId: customStatusSetting?.emojiId ?? "0",
emojiName: customStatusSetting?.emojiName ?? "",
expiresAtMs: customStatusSetting?.expiresAtMs ?? "0"
};
const avatarDecorationSource = pendingChanges.pendingAvatarDecoration ?? userAny.avatarDecorationData;
const avatarDecoration = avatarDecorationSource ? {
asset: avatarDecorationSource.asset,
skuId: avatarDecorationSource.skuId
} : null;
let profileEffect: ProfileEffect | null = null;
const effectToUse = pendingChanges.pendingProfileEffect ?? userProfile?.profileEffect;
if (effectToUse) {
if (effectToUse.skuId && effectToUse.effects) {
profileEffect = {
skuId: effectToUse.skuId,
title: effectToUse.title,
description: effectToUse.description,
accessibilityLabel: effectToUse.accessibilityLabel,
reducedMotionSrc: effectToUse.reducedMotionSrc,
thumbnailPreviewSrc: effectToUse.thumbnailPreviewSrc,
effects: effectToUse.effects,
animationType: effectToUse.animationType,
staticFrameSrc: effectToUse.staticFrameSrc,
type: effectToUse.type || 1
};
} else if (effectToUse.skuId) {
const collectibles = userProfile?.collectibles;
const collectible = collectibles?.find(c => c?.skuId === effectToUse.skuId);
if (collectible) {
profileEffect = {
skuId: collectible.skuId,
title: collectible.title,
description: collectible.description,
accessibilityLabel: collectible.accessibilityLabel,
reducedMotionSrc: collectible.reducedMotionSrc,
thumbnailPreviewSrc: collectible.thumbnailPreviewSrc,
effects: collectible.effects,
animationType: collectible.animationType,
staticFrameSrc: collectible.staticFrameSrc,
type: collectible.type || 1
};
}
}
}
const nameplateToUse = pendingChanges.pendingNameplate ?? userAny.collectibles?.nameplate;
const nameplate = nameplateToUse ? {
skuId: nameplateToUse.skuId,
asset: nameplateToUse.asset,
label: nameplateToUse.label,
palette: typeof nameplateToUse.palette === "string" ? nameplateToUse.palette : undefined,
type: nameplateToUse.type || 2
} : null;
const savedDisplayNameStyles = isGuildProfile
? (guildMember?.displayNameStyles ?? userAny.displayNameStyles)
: userAny.displayNameStyles;
const displayNameStylesToUse = pendingChanges.pendingDisplayNameStyles ?? savedDisplayNameStyles;
const displayNameStyles = normalizeDisplayNameStyles(displayNameStylesToUse);
const pendingAvatar = pendingChanges.pendingAvatar ?? pendingChanges.avatar;
const avatarToUse: ImageInput = hasImageInput(pendingAvatar)
? pendingAvatar
: (isGuildProfile ? (guildMember?.avatar ?? currentUser.avatar ?? null) : (currentUser.avatar ?? null));
const useGuildAvatar = !!(effectiveGuildId && isGuildProfile && guildMember?.avatar && avatarToUse === guildMember.avatar);
const avatarInput: ImageInput = hasImageInput(avatarToUse)
? avatarToUse
: IconUtils.getUserAvatarURL(currentUser, true, 512);
const avatarDataUrl = await processImage(avatarInput, currentUser.id, "avatar", effectiveGuildId, useGuildAvatar);
const resolvedAvatarDataUrl = avatarDataUrl ?? IconUtils.getDefaultAvatarURL(currentUser.id);
const pendingBanner = pendingChanges.pendingBanner ?? pendingChanges.banner;
const bannerToUse: ImageInput = hasImageInput(pendingBanner)
? pendingBanner
: (isGuildProfile ? (guildProfile?.banner ?? baseProfile?.banner) : baseProfile?.banner);
const useGuildBanner = !!(effectiveGuildId && isGuildProfile && guildProfile?.banner && bannerToUse === guildProfile?.banner);
const bannerDataUrl = await processImage(bannerToUse, currentUser.id, "banner", effectiveGuildId, useGuildBanner);
return {
avatarDataUrl: resolvedAvatarDataUrl,
bannerDataUrl,
bio: pendingChanges.pendingBio ?? userProfile?.bio ?? null,
accentColor: pendingChanges.pendingAccentColor ?? userProfile?.accentColor ?? null,
themeColors: pendingChanges.pendingThemeColors ?? userProfile?.themeColors ?? null,
globalName: isGuildProfile
? (pendingChanges.pendingNickname ?? guildMember?.nick ?? null)
: (pendingChanges.pendingGlobalName ?? currentUser.globalName ?? null),
pronouns: isGuildProfile ? null : (pendingChanges.pendingPronouns ?? userProfile?.pronouns ?? null),
avatarDecoration,
profileEffect,
nameplate,
primaryGuildId: isGuildProfile
? null
: (pendingChanges.pendingPrimaryGuildId ?? userAny.primaryGuild?.identityGuildId ?? null),
customStatus,
displayNameStyles
};
}
function jsonEq(a: unknown, b: unknown): boolean {
if (a === b) return true;
return JSON.stringify(a) === JSON.stringify(b);
}
function customStatusEq(a: CustomStatus | null | undefined, b: CustomStatus | null | undefined): boolean {
if (a == null || b == null) return a == null && b == null;
return a.text === b.text
&& String(a.emojiId ?? "") === String(b.emojiId ?? "")
&& a.emojiName === b.emojiName
&& String(a.expiresAtMs ?? "0") === String(b.expiresAtMs ?? "0");
}
function resolvePendingAvatar(pendingChanges: PendingChanges | null): ImageInput {
if (!pendingChanges) return null;
const candidates: ImageInput[] = [
pendingChanges.selectedAvatarRaw,
pendingChanges.presetAvatarRaw,
pendingChanges.pendingAvatar,
pendingChanges.avatar,
pendingChanges.selectedAvatarProcessed,
pendingChanges.presetAvatarProcessed
];
return candidates.find(hasImageInput) ?? null;
}
function normalizeImageValue(value: unknown): string | null {
if (typeof value === "string") return value;
if (value && typeof value === "object" && "imageUri" in value) {
const { imageUri } = value as { imageUri: unknown; };
return typeof imageUri === "string" ? imageUri : null;
}
return null;
}
function collectibleEqBySku(a: { skuId?: string | number | null; } | null | undefined, b: { skuId?: string | number | null; } | null | undefined): boolean {
if (a == null || b == null) return a == null && b == null;
return String(a.skuId ?? "") === String(b.skuId ?? "");
}
function avatarDecorationEq(a: { skuId?: string | number | null; asset?: string | null; } | null | undefined, b: { skuId?: string | number | null; asset?: string | null; } | null | undefined): boolean {
if (a == null || b == null) return a == null && b == null;
return String(a.skuId ?? "") === String(b.skuId ?? "") && String(a.asset ?? "") === String(b.asset ?? "");
}
function nameplateEq(a: { skuId?: string | number | null; asset?: string | null; } | null | undefined, b: { skuId?: string | number | null; asset?: string | null; } | null | undefined): boolean {
if (a == null || b == null) return a == null && b == null;
return String(a.skuId ?? "") === String(b.skuId ?? "") && String(a.asset ?? "") === String(b.asset ?? "");
}
export async function loadPresetAsPending(preset: ProfilePreset, guildId?: string, options: LoadPresetOptions = {}) {
try {
const isGuild = options.isGuildProfile ?? Boolean(guildId);
if (isGuild && !guildId) return;
const currentUser = UserStore.getCurrentUser();
const baseProfile = UserProfileStore.getUserProfile(currentUser.id);
const guildProfile = isGuild && guildId ? UserProfileStore.getGuildMemberProfile(currentUser.id, guildId) : null;
const equippedProfile = guildProfile ?? baseProfile;
const current = await getCurrentProfile(guildId, {
isGuildProfile: isGuild
});
const pendingChanges = (isGuild && guildId
? UserProfileSettingsStore.getPendingChanges(guildId)
: UserProfileSettingsStore.getPendingChanges());
const equippedAvatarDecoration = currentUser.avatarDecorationData ?? null;
const equippedProfileEffect = equippedProfile?.profileEffect ?? null;
const equippedNameplate = currentUser.collectibles?.nameplate ?? null;
const pendingAvatarDecoration = pendingChanges?.pendingAvatarDecoration ?? null;
const pendingProfileEffect = pendingChanges?.pendingProfileEffect ?? null;
const pendingNameplate = pendingChanges?.pendingNameplate ?? null;
const setPending = (field: string, payload: Record<string, unknown>) => {
const cleanPayload = Object.fromEntries(Object.entries(payload).filter(([, v]) => v !== undefined));
if (!Object.keys(cleanPayload).length) return;
dispatch(`USER_PROFILE_SETTINGS_SET_PENDING_${field}`, isGuild ? { ...cleanPayload, guildId } : cleanPayload);
};
if ("avatarDataUrl" in preset) {
const avatarValue = preset.avatarDataUrl;
const presetAvatar = normalizeImageValue(avatarValue);
const currentAvatar = normalizeImageValue(current.avatarDataUrl);
const pendingAvatar = normalizeImageValue(resolvePendingAvatar(pendingChanges));
if (isGuild || (presetAvatar !== currentAvatar && presetAvatar !== pendingAvatar)) {
const avatarPayload =
typeof avatarValue === "string" && avatarValue.startsWith("data:")
? {
assetOrigin: "NEW_ASSET",
imageUri: avatarValue,
description: `profilesets-${preset.name ?? "preset"}`
}
: avatarValue;
setPending("AVATAR", { avatar: avatarPayload });
}
}
if ("bannerDataUrl" in preset && preset.bannerDataUrl !== current.bannerDataUrl) {
setPending("BANNER", { banner: preset.bannerDataUrl });
}
if (!options.skipBio && preset?.bio !== current?.bio) {
const bioValue = preset.bio === null ? "" : preset.bio;
setPending("BIO", { bio: bioValue });
}
if (!isGuild && !options.skipPronouns && preset?.pronouns !== current?.pronouns) {
const pronounsValue = preset.pronouns === null ? "" : preset.pronouns;
setPending("PRONOUNS", { pronouns: pronounsValue });
}
if (!options.skipGlobalName && preset?.globalName !== current?.globalName) {
if (isGuild) {
setPending("NICKNAME", { nickname: preset.globalName });
} else {
setPending("GLOBAL_NAME", { globalName: preset.globalName });
}
}
if (preset.avatarDecoration && ![pendingAvatarDecoration, equippedAvatarDecoration, current.avatarDecoration].some(d => avatarDecorationEq(preset.avatarDecoration, d))) {
setPending("COLLECTIBLES_ITEM", {
item: { type: 0, value: preset.avatarDecoration }
});
}
if (preset.profileEffect && ![pendingProfileEffect, equippedProfileEffect, current.profileEffect].some(d => collectibleEqBySku(preset.profileEffect, d))) {
setPending("COLLECTIBLES_ITEM", {
item: { type: 1, value: preset.profileEffect }
});
}
if (preset.nameplate && ![pendingNameplate, equippedNameplate, current.nameplate].some(d => nameplateEq(preset.nameplate, d))) {
setPending("COLLECTIBLES_ITEM", {
item: { type: 2, value: preset.nameplate }
});
}
if (preset.displayNameStyles) {
const presetDisplayNameStyles = normalizeDisplayNameStyles(preset.displayNameStyles);
if (!jsonEq(presetDisplayNameStyles, current.displayNameStyles)) {
setPending("DISPLAY_NAME_STYLES", { displayNameStyles: presetDisplayNameStyles });
}
}
if (preset.themeColors && !jsonEq(preset.themeColors, current.themeColors)) {
setPending("THEME_COLORS", { themeColors: preset.themeColors });
}
if (preset.primaryGuildId && !isGuild && preset.primaryGuildId !== current.primaryGuildId) {
dispatch("USER_SETTINGS_SET_PENDING_PRIMARY_GUILD_ID", { primaryGuildId: preset.primaryGuildId });
}
if (preset.customStatus && !isGuild && !customStatusEq(preset.customStatus, current.customStatus)) {
CustomStatusSettings.updateSetting({
text: preset.customStatus?.text ?? "",
expiresAtMs: preset.customStatus?.expiresAtMs ?? "0",
emojiId: preset.customStatus?.emojiId ?? "0",
emojiName: preset.customStatus?.emojiName ?? ""
});
}
} catch (err) {
throw err;
}
}

View file

@ -0,0 +1,120 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { DataStore } from "@api/index";
import { Logger } from "@utils/Logger";
import { ProfilePreset } from "@vencord/discord-types";
import { UserStore } from "@webpack/common";
const logger = new Logger("ProfilePresets");
const LEGACY_PRESETS_KEY = "ProfileDataset";
const MAIN_PRESETS_KEY = "ProfilePresets_v2_Main";
const SERVER_PRESETS_KEY = "ProfilePresets_v2_Server";
export type PresetSection = "main" | "server";
export type ProfilePresetEx = ProfilePreset & {
avatarRaw?: string | null;
};
export let presets: ProfilePresetEx[] = [];
export let currentPresetIndex = -1;
let activeScopeKey: string | null = null;
function resetPresets(nextPresets: ProfilePresetEx[] = []) {
presets = nextPresets;
currentPresetIndex = -1;
}
function getPresetsKey(section: PresetSection, userId: string) {
const baseKey = section === "main" ? MAIN_PRESETS_KEY : SERVER_PRESETS_KEY;
return `${baseKey}:${userId}`;
}
function getLegacyKey(userId: string) {
return `${LEGACY_PRESETS_KEY}:${userId}:main`;
}
export async function loadPresets(section: PresetSection) {
try {
const currentUser = UserStore.getCurrentUser();
const userId = currentUser!.id;
const key = getPresetsKey(section, userId);
activeScopeKey = key;
const stored = await DataStore.get(key);
if (stored && Array.isArray(stored)) {
resetPresets(stored);
return;
}
if (section === "main") {
const legacyKey = getLegacyKey(userId);
const legacyStored = await DataStore.get(legacyKey);
const legacyBaseStored = await DataStore.get(LEGACY_PRESETS_KEY);
const legacyToUse = Array.isArray(legacyStored)
? legacyStored
: (Array.isArray(legacyBaseStored) ? legacyBaseStored : null);
if (legacyToUse) {
resetPresets(legacyToUse);
await DataStore.set(key, legacyToUse);
await DataStore.del(legacyKey);
await DataStore.del(LEGACY_PRESETS_KEY);
return;
}
}
resetPresets();
} catch (err) {
logger.error("Failed to load presets", err);
resetPresets();
}
}
export async function savePresetsData(section?: PresetSection) {
try {
if (!activeScopeKey && !section) return;
const currentUser = UserStore.getCurrentUser();
const userId = currentUser!.id;
const key = section ? getPresetsKey(section, userId) : activeScopeKey!;
await DataStore.set(key, presets);
} catch (err) {
logger.error("Failed to save presets", err);
}
}
export function setCurrentPresetIndex(index: number) {
currentPresetIndex = index;
}
export function addPreset(preset: ProfilePresetEx) {
presets.push(preset);
}
export function updatePreset(index: number, preset: ProfilePresetEx) {
if (index >= 0 && index < presets.length) {
presets[index] = preset;
}
}
export function removePreset(index: number) {
if (index >= 0 && index < presets.length) {
presets.splice(index, 1);
if (currentPresetIndex === index) {
currentPresetIndex = -1;
} else if (currentPresetIndex > index) {
currentPresetIndex--;
}
}
}
export function movePresetInArray(fromIndex: number, toIndex: number) {
if (fromIndex < 0 || fromIndex >= presets.length || toIndex < 0 || toIndex >= presets.length) return;
const [preset] = presets.splice(fromIndex, 1);
presets.splice(toIndex, 0, preset);
}
export function replaceAllPresets(newPresets: ProfilePresetEx[]) {
presets = newPresets;
}

View file

@ -0,0 +1,46 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { addMessagePreSendListener, removeMessagePreSendListener } from "@api/MessageEvents";
import { definePluginSettings } from "@api/Settings";
import { Devs } from "@utils/constants";
import definePlugin, { OptionType } from "@utils/types";
const settings = definePluginSettings({
replace: {
type: OptionType.STRING,
description: "Replace with",
default: ":face_with_monocle:"
},
});
function replaceQuestionMarks(content: string): string {
const allQuestionMarks = content.split("").every(char => char === "?");
if (allQuestionMarks) {
return content.replace(/\?/g, settings.store.replace);
} else {
return content;
}
}
export default definePlugin({
name: "QuestionMarkReplacement",
description: "Replace all question marks with chosen string, if message only contains question marks.",
authors: [Devs.nyx],
settings,
start() {
this.preSend = addMessagePreSendListener((_, msg) => {
msg.content = replaceQuestionMarks(msg.content);
});
},
stop() {
removeMessagePreSendListener(this.preSend);
}
});

View file

@ -0,0 +1,66 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { addContextMenuPatch, removeContextMenuPatch } from "@api/ContextMenu";
import { ScreenshareIcon } from "@components/Icons";
import { Devs } from "@utils/constants";
import definePlugin from "@utils/types";
import { Menu, UploadHandler } from "@webpack/common";
let recoder: MediaRecorder;
export default definePlugin({
name: "ScreenRecorder",
description: "epic screen recorder lol",
authors: [Devs.AutumnVN],
contextMenus: {
"channel-attach": startRecording
}
});
function startRecording(children) {
children.push(
<Menu.MenuItem
id="start-recording"
label={
<div>
<ScreenshareIcon height={24} width={24} />
<div>Start Recording</div>
</div>
}
action={async () => {
const stream = await navigator.mediaDevices.getDisplayMedia({ audio: true, video: { frameRate: { ideal: 60 } } });
recoder = new MediaRecorder(stream);
recoder.start();
removeContextMenuPatch("channel-attach", startRecording);
addContextMenuPatch("channel-attach", stopRecording);
}}
/>
);
}
function stopRecording(children, props) {
children.push(
<Menu.MenuItem
id="stop-recording"
label={
<div>
<ScreenshareIcon height={24} width={24} />
<div>Stop Recording</div>
</div>
}
action={() => {
recoder.addEventListener("dataavailable", e => {
const file = new File([e.data], "watch if cute.webm", { type: "video/webm" });
UploadHandler.promptToUpload([file], props.channel, 0);
});
recoder.stop();
removeContextMenuPatch("channel-attach", stopRecording);
addContextMenuPatch("channel-attach", startRecording);
}}
/>
);
}

View file

@ -0,0 +1,64 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated, camila314, and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import "./styles.css";
import { addServerListElement, removeServerListElement, ServerListRenderPosition } from "@api/ServerList";
import ErrorBoundary from "@components/ErrorBoundary";
import { EquicordDevs } from "@utils/constants";
import definePlugin from "@utils/types";
import { FluxDispatcher } from "@webpack/common";
import { Tooltip } from "webpack/common/components";
function SearchIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24" id="vc-searchbutton-icon">
<path
fill="currentColor"
fillRule="evenodd"
d="M15.62 17.03a9 9 0 1 1 1.41-1.41l4.68 4.67a1 1 0 0 1-1.421.42l-4.67-4.68ZM17 10a7 7 0 1 1-14 0 7 7 0 0 1 14 0Z"
clipRule="evenodd"></path>
</svg>
);
}
export default definePlugin({
name: "ServerSearch",
authors: [EquicordDevs.camila314],
description: "Navigate your servers better with a quick search button",
renderButton() {
return <ErrorBoundary noop>
<div id="vc-searchbutton-container">
<Tooltip text="Search" position="right">
{({ onMouseEnter, onMouseLeave }) => (
<div
id="vc-searchbutton"
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onClick={() =>
FluxDispatcher.dispatch({
type: "QUICKSWITCHER_SHOW",
query: "",
queryMode: null
})
}>
<SearchIcon />
</div>
)}
</Tooltip>
</div>
</ErrorBoundary>;
},
start() {
addServerListElement(ServerListRenderPosition.Above, this.renderButton);
},
stop() {
removeServerListElement(ServerListRenderPosition.Above, this.renderButton);
}
});

View file

@ -0,0 +1,33 @@
#vc-searchbutton-container {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 8px;
}
#vc-searchbutton {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: var(--background-primary);
height: 48px;
width: 48px;
border-radius: 100%;
transition: border-radius 0.15s ease-out, background-color 0.15s ease-out;
}
#vc-searchbutton-icon {
color: var(--text-default);
}
#vc-searchbutton:hover {
border-radius: 34%;
background-color: var(--green-360);
cursor: pointer;
}
#vc-searchbutton:hover #vc-searchbutton-icon {
color: var(--white-500);
}

View file

@ -0,0 +1,155 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { ChatBarButton, ChatBarButtonFactory } from "@api/ChatButtons";
import { ApplicationCommandInputType, ApplicationCommandOptionType, findOption, sendBotMessage } from "@api/Commands";
import { findGroupChildrenByChildId, NavContextMenuPatchCallback } from "@api/ContextMenu";
import { definePluginSettings } from "@api/Settings";
import { Devs, EquicordDevs } from "@utils/constants";
import definePlugin, { OptionType } from "@utils/types";
import { Menu, React } from "@webpack/common";
// Big thank you too slientTyping
const settings = definePluginSettings(
{
name: {
type: OptionType.STRING,
description: "The signature that will be added to the end of your messages",
default: "a chronic discord user"
},
textHeader: {
description: "What header to preface text with",
type: OptionType.STRING,
default: ">",
},
showIcon: {
type: OptionType.BOOLEAN,
default: true,
description: "Show an icon for toggling the plugin in the chat bar",
restartNeeded: true,
},
contextMenu: {
type: OptionType.BOOLEAN,
description: "Add option to toggle the functionality in the chat input context menu",
default: true
},
isEnabled: {
type: OptionType.BOOLEAN,
description: "Toggle functionality",
default: true,
},
});
const SignatureToggle: ChatBarButtonFactory = ({ isMainChat }) => {
const { isEnabled, showIcon } = settings.use(["isEnabled", "showIcon"]);
const toggle = () => settings.store.isEnabled = !settings.store.isEnabled;
if (!isMainChat || !showIcon) return null;
return (
<ChatBarButton
tooltip={isEnabled ? "Disable Signature" : "Enable Signature"}
onClick={toggle}
>
<svg width="20" height="20" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 21.333">
<path fill="currentColor" mask="url(#signature-msg-mask)" d="M2 4.621a.5.5 0 0 1 .854-.353l6.01 6.01c.126.126.17.31.15.487a2 2 0 1 0 1.751-1.751a.59.59 0 0 1-.487-.15l-6.01-6.01A.5.5 0 0 1 4.62 2H11a9 9 0 0 1 8.468 12.054l2.24 2.239a1 1 0 0 1 0 1.414l-4 4a1 1 0 0 1-1.415 0l-2.239-2.239A9 9 0 0 1 2 11z" />
{isEnabled && (
<>
<mask id="signature-msg-mask"> <path fill="#fff" d="M0 0h24v24H0Z"></path>
<path stroke="#000" strokeWidth="5.99068" d="M0 24 24 0"></path> </mask>
<path fill="var(--status-danger)" d="m21.178 1.70703 1.414 1.414L4.12103 21.593l-1.414-1.415L21.178 1.70703Z" />
</>
)}
</svg>
</ChatBarButton>
);
};
function SignatureIcon() {
return (
<svg width="20" height="20" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 21.333">
<path fill="currentColor" d="M2 4.621a.5.5 0 0 1 .854-.353l6.01 6.01c.126.126.17.31.15.487a2 2 0 1 0 1.751-1.751a.59.59 0 0 1-.487-.15l-6.01-6.01A.5.5 0 0 1 4.62 2H11a9 9 0 0 1 8.468 12.054l2.24 2.239a1 1 0 0 1 0 1.414l-4 4a1 1 0 0 1-1.415 0l-2.239-2.239A9 9 0 0 1 2 11z" />
</svg>
);
}
const ChatBarContextCheckbox: NavContextMenuPatchCallback = children => {
const { isEnabled, contextMenu } = settings.use(["isEnabled", "contextMenu"]);
if (!contextMenu) return;
const group = findGroupChildrenByChildId("submit-button", children);
if (!group) return;
const idx = group.findIndex(c => c?.props?.id === "submit-button");
group.splice(idx + 1, 0,
<Menu.MenuCheckboxItem
id="vc-Signature"
label="Enable Signature"
checked={isEnabled}
action={() => settings.store.isEnabled = !settings.store.isEnabled}
/>
);
};
export default definePlugin({
name: "Signature",
description: "Automated fingerprint/end text",
authors: [Devs.Ven, Devs.Rini, Devs.ImBanana, EquicordDevs.KrystalSkull],
onBeforeMessageSend(channelId, msg) {
if (!settings.store.isEnabled) {
msg.content = msg.content;
} else {
msg.content = textProcessing(msg.content);
}
},
start: () => {
if (settings.store.isEnabled) true;
},
stop: () => {
if (settings.store.isEnabled) false;
},
chatBarButton: {
icon: SignatureIcon,
render: SignatureToggle
},
settings,
contextMenus: {
"textarea-context": ChatBarContextCheckbox
},
commands: [
{
name: "signature",
description: "Toggle your signature",
inputType: ApplicationCommandInputType.BUILT_IN,
options: [
{
name: "value",
description: "Toggle your signature (default is toggle)",
required: false,
type: ApplicationCommandOptionType.BOOLEAN,
},
],
execute: async (args, ctx) => {
settings.store.isEnabled = !!findOption(args, "value", !settings.store.isEnabled);
sendBotMessage(ctx.channel.id, {
content: settings.store.isEnabled ? "Signature enabled!" : "Signature disabled!",
});
},
}
],
});
// text processing injection processor
function textProcessing(input: string) {
return `${input}\n${settings.store.textHeader ? settings.store.textHeader + " " : ""}${settings.store.name}`;
}

View file

@ -0,0 +1,490 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { definePluginSettings } from "@api/Settings";
import { Heading } from "@components/Heading";
import { Paragraph } from "@components/Paragraph";
import { EquicordDevs } from "@utils/constants";
import definePlugin, { OptionType } from "@utils/types";
import { createRoot, React } from "@webpack/common";
import type { Root } from "react-dom/client";
const SnowfallCSS = `
#snowfield {
pointer-events: none;
user-select: none;
z-index: 100000;
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
}
.snowflake {
position: absolute;
color: #fff;
line-height: 1;
-webkit-font-smoothing: antialiased;
}
.snowflake-solid {
border-radius: 50%;
background: #ffffff;
}
.snowflake-image {
filter: drop-shadow(0 0 2px rgba(255, 255, 255, 0.5));
}
`;
// SVG snowflake images as data URIs
const SNOWFLAKE_SVGS = [
// 6-pointed snowflake
"data:image/svg+xml," + encodeURIComponent(`
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<path fill="white" d="M50,10 L55,45 L50,50 L45,45 Z M50,90 L55,55 L50,50 L45,55 Z M10,50 L45,55 L50,50 L45,45 Z M90,50 L55,55 L50,50 L55,45 Z M25,25 L45,45 L50,40 L40,30 Z M75,75 L55,55 L50,60 L60,70 Z M75,25 L55,45 L60,50 L70,40 Z M25,75 L45,55 L40,50 L30,60 Z"/>
<circle cx="50" cy="50" r="8" fill="white"/>
</svg>
`),
// 8-pointed star snowflake
"data:image/svg+xml," + encodeURIComponent(`
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<g fill="white">
<rect x="47" y="5" width="6" height="90" rx="2"/>
<rect x="5" y="47" width="90" height="6" rx="2"/>
<rect x="47" y="5" width="6" height="90" rx="2" transform="rotate(45 50 50)"/>
<rect x="47" y="5" width="6" height="90" rx="2" transform="rotate(-45 50 50)"/>
<circle cx="50" cy="50" r="10" fill="white"/>
</g>
</svg>
`),
// Discord snowflake
"data:image/svg+xml," + encodeURIComponent(`
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<defs>
<symbol id="discord" viewBox="0 0 24 24">
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z"/>
</symbol>
</defs>
<!-- snowflake arms -->
<line x1="100" y1="100" x2="150" y2="100" stroke="#FFF" stroke-width="1.5" stroke-linecap="round"/>
<line x1="100" y1="100" x2="125" y2="143.3" stroke="#FFF" stroke-width="1.5" stroke-linecap="round"/>
<line x1="100" y1="100" x2="75" y2="143.3" stroke="#FFF" stroke-width="1.5" stroke-linecap="round"/>
<line x1="100" y1="100" x2="50" y2="100" stroke="#FFF" stroke-width="1.5" stroke-linecap="round"/>
<line x1="100" y1="100" x2="75" y2="56.7" stroke="#FFF" stroke-width="1.5" stroke-linecap="round"/>
<line x1="100" y1="100" x2="125" y2="56.7" stroke="#FFF" stroke-width="1.5" stroke-linecap="round"/>
<!-- circle mask -->
<mask id="logoMask">
<rect width="200" height="200" fill="white"/>
<circle cx="150" cy="100" r="18" fill="black"/> <!-- right -->
<circle cx="125" cy="143.3" r="18" fill="black"/> <!-- top-right -->
<circle cx="75" cy="143.3" r="18" fill="black"/> <!-- top-left -->
<circle cx="50" cy="100" r="18" fill="black"/> <!-- left -->
<circle cx="75" cy="56.7" r="18" fill="black"/> <!-- bottom-left -->
<circle cx="125" cy="56.7" r="18" fill="black"/> <!-- bottom-right -->
</mask>
<!-- circle behind all elements -->
<circle cx="100" cy="100" r="50" stroke="#FFF" stroke-width="1.5" fill="none" stroke-linecap="round" mask="url(#logoMask)" />
<use href="#discord" fill="#FFF" x="135" y="85" width="30" height="30" transform="rotate(90, 150, 100)"/>
<use href="#discord" fill="#FFF" x="110" y="128.3" width="30" height="30" transform="rotate(150, 125, 143.3)"/>
<use href="#discord" fill="#FFF" x="60" y="128.3" width="30" height="30" transform="rotate(210, 75, 143.3)"/>
<use href="#discord" fill="#FFF" x="35" y="85" width="30" height="30" transform="rotate(270, 50, 100)"/>
<use href="#discord" fill="#FFF" x="60" y="41.7" width="30" height="30" transform="rotate(330, 75, 56.7)"/>
<use href="#discord" fill="#FFF" x="110" y="41.7" width="30" height="30" transform="rotate(30, 125, 56.7)"/>
</svg>
`),
];
const settings = definePluginSettings({
typeOfSnow: {
description: "Change the type of snow displayed (Affects performance).",
type: OptionType.SELECT,
options: [
{ label: "Solid (Highest Performance)", value: "solid" },
{ label: "Text (Medium Performance)", value: "text", default: true },
{ label: "Image (Lowest Performance)", value: "image" }
],
},
maxSize: {
description: "Maximum snowflake size",
type: OptionType.SLIDER,
default: 30,
markers: [10, 20, 30, 40, 50]
},
speed: {
description: "Snowfall speed (higher = faster fall)",
type: OptionType.SLIDER,
default: 50,
markers: [50, 100, 200, 300, 400, 500]
},
flakesPerSecond: {
description: "Snowflakes per second (higher = denser snowfall)",
type: OptionType.SLIDER,
default: 5,
markers: [1, 5, 10, 20, 40, 60],
min: 1,
max: 60,
}
});
class CopleSnow {
private static winWidth = window.innerWidth;
private static winHeight = window.innerHeight;
static readonly defaultOptions = {
minSize: 10,
maxSize: 30,
type: "text" as "text" | "solid" | "image",
content: "❄" as string | string[],
fadeOut: true,
autoplay: true,
interval: 200
};
private static cssPrefix(propertyName: string): string | null {
const capitalize = propertyName.charAt(0).toUpperCase() + propertyName.slice(1);
const tempDiv = document.createElement("div");
const { style } = tempDiv;
const prefixes = ["Webkit", "Moz", "ms", "O"];
if (propertyName in style) return propertyName;
for (const prefix of prefixes) {
const name = prefix + capitalize;
if (name in style) return name;
}
return null;
}
private static readonly cssPrefixedNames = {
transform: this.cssPrefix("transform"),
transition: this.cssPrefix("transition")
};
private static readonly transitionEndEvent =
{ WebkitTransition: "webkitTransitionEnd", OTransition: "oTransitionEnd", Moztransition: "transitionend", transition: "transitionend" }[
this.cssPrefixedNames.transition ?? "transition"
] ?? "transitionend";
private static random(min: number, max: number, deviation?: number): number {
if (deviation !== undefined) {
deviation *= max;
max += deviation;
min = max - deviation;
} else {
min = min || 0;
}
return Math.floor(Math.random() * (max - min + 1) + min);
}
private static setStyle(element: HTMLElement, rules: Record<string, string | number>) {
for (const [name, value] of Object.entries(rules)) {
const cssName = CopleSnow.cssPrefixedNames[name as keyof typeof CopleSnow.cssPrefixedNames] || name;
(element.style as any)[cssName] = value;
}
}
private options = { ...CopleSnow.defaultOptions };
private queue: HTMLElement[] = [];
private $snowfield: HTMLDivElement;
private timer: number | null = null;
public playing = false;
constructor(newOptions: Partial<typeof CopleSnow.defaultOptions> = {}) {
Object.assign(this.options, newOptions);
this.$snowfield = document.createElement("div");
this.$snowfield.id = "snowfield";
document.body.appendChild(this.$snowfield);
const updateSize = () => {
CopleSnow.winHeight = window.innerHeight;
CopleSnow.winWidth = window.innerWidth;
};
window.addEventListener("resize", updateSize);
(this as any)._resizeHandler = updateSize;
this.$snowfield.addEventListener(CopleSnow.transitionEndEvent, e => {
const snowflake = e.target as HTMLElement;
if (snowflake.classList.contains("snowflake")) {
this.$snowfield.removeChild(snowflake);
this.queue.push(snowflake);
}
});
const handleVisibilityChange = () => {
if (document.hidden) this.stop();
else this.play();
};
document.addEventListener("visibilitychange", handleVisibilityChange);
(this as any)._visibilityHandler = handleVisibilityChange;
if (this.options.autoplay) this.play();
}
private createSnowflake(): HTMLElement {
const { type, content } = this.options as { type: "text" | "image" | "solid"; content: string | string[]; };
const cntLength = Array.isArray(content) ? content.length : 1;
let snowflake: HTMLElement;
if (type === "image") {
snowflake = document.createElement("img");
const src = typeof content === "string"
? content
: content[Math.floor(Math.random() * cntLength)];
(snowflake as HTMLImageElement).src = src;
snowflake.setAttribute("draggable", "false");
} else {
snowflake = document.createElement("div");
if (type === "text") {
const textContent = typeof content === "string"
? content
: content[Math.floor(Math.random() * cntLength)];
snowflake.textContent = textContent;
}
// if type is solid we don't need to set content
}
snowflake.className = `snowflake snowflake-${type}`;
(snowflake as any).dataset.type = type;
return snowflake;
}
private animateSnowflake() {
const { winWidth, winHeight } = CopleSnow;
const size = CopleSnow.random(this.options.minSize, this.options.maxSize);
const top = -2 * size;
const left = CopleSnow.random(0, winWidth - size);
const opacity = CopleSnow.random(5, 10) / 10;
const angle = CopleSnow.random(0, winHeight * 0.8, 1);
const translateX = CopleSnow.random(-100, 100);
const translateY = winHeight + size * 2;
const baseSpeed = settings.store.speed;
const duration = (winHeight * 10) / (baseSpeed / 50);
let snowflake: HTMLElement;
if (this.queue.length > 0) {
const reused = this.queue.shift()!;
if ((reused.dataset.type as string) !== this.options.type) {
snowflake = this.createSnowflake();
} else {
snowflake = reused;
if (this.options.type === "text") {
const { content } = this.options;
if (Array.isArray(content)) {
snowflake.textContent = content[Math.floor(Math.random() * content.length)];
}
} else if (this.options.type === "image") {
const { content } = this.options;
if (Array.isArray(content)) {
(snowflake as HTMLImageElement).src = content[Math.floor(Math.random() * content.length)];
}
}
}
} else {
snowflake = this.createSnowflake();
}
const styleRules: Record<string, string | number> = {
top: `${top}px`,
left: `${left}px`,
opacity: opacity,
transform: "none",
transition: `${duration}ms linear`
};
switch (this.options.type) {
case "solid":
styleRules.width = styleRules.height = `${size}px`;
break;
case "text":
styleRules.fontSize = `${size}px`;
break;
case "image":
styleRules.width = `${size}px`;
styleRules.height = "auto";
break;
}
CopleSnow.setStyle(snowflake, styleRules);
this.$snowfield.appendChild(snowflake);
setTimeout(() => {
CopleSnow.setStyle(snowflake, {
transform: `translate(${translateX}px, ${translateY}px) rotate(${angle}deg)`,
opacity: this.options.fadeOut ? 0 : opacity
});
}, 100);
}
play() {
if (this.playing) return;
this.timer = window.setInterval(() => this.animateSnowflake(), this.options.interval);
this.playing = true;
}
stop() {
if (!this.playing) return;
if (this.timer !== null) {
clearInterval(this.timer);
this.timer = null;
}
this.playing = false;
}
destroy() {
this.stop();
if (this.$snowfield.parentNode) {
this.$snowfield.remove();
}
window.removeEventListener("resize", (this as any)._resizeHandler);
document.removeEventListener("visibilitychange", (this as any)._visibilityHandler);
}
updateOptions(newOptions: Partial<typeof CopleSnow.defaultOptions>) {
Object.assign(this.options, newOptions);
}
}
const SnowfallManager: React.FC = () => {
const snowRef = React.useRef<CopleSnow | null>(null);
const styleRef = React.useRef<HTMLStyleElement | null>(null);
React.useEffect(() => {
// Inject CSS
const styleEl = document.createElement("style");
styleEl.id = "snowfall-styles";
styleEl.textContent = SnowfallCSS;
document.head.appendChild(styleEl);
styleRef.current = styleEl;
// Get initial snow type from settings
const snowType = settings.store.typeOfSnow as "text" | "solid" | "image";
const snowOptions: Partial<typeof CopleSnow.defaultOptions> = {
autoplay: false,
type: snowType,
maxSize: settings.store.maxSize,
interval: 1000 / settings.store.flakesPerSecond
};
snowOptions.interval = settings.store.speed;
// Set content based on type
if (snowType === "image") {
snowOptions.content = SNOWFLAKE_SVGS;
}
// Create snow instance with settings
const snow = new CopleSnow(snowOptions);
snowRef.current = snow;
const blurHandler = () => snow.stop();
const focusHandler = () => {
if (document.hasFocus()) snow.play();
};
window.addEventListener("blur", blurHandler);
window.addEventListener("focus", focusHandler);
if (document.hasFocus()) {
snow.play();
}
// Custom listener for settings changes (we don't have a built-in one)
let lastSettings = {
type: settings.store.typeOfSnow as "text" | "solid" | "image",
maxSize: settings.store.maxSize,
speed: settings.store.speed,
flakesPerSecond: settings.store.flakesPerSecond
};
const settingsInterval = setInterval(() => {
const newSettings = {
type: settings.store.typeOfSnow as "text" | "solid" | "image",
maxSize: settings.store.maxSize,
speed: settings.store.speed,
flakesPerSecond: settings.store.flakesPerSecond
};
if (Object.keys(newSettings).some(k => newSettings[k as keyof typeof newSettings] !== lastSettings[k as keyof typeof lastSettings])) {
lastSettings = newSettings;
const updateOptions: Partial<typeof CopleSnow.defaultOptions> = {
type: newSettings.type,
maxSize: newSettings.maxSize,
interval: 1000 / newSettings.flakesPerSecond
};
if (newSettings.type === "image") updateOptions.content = SNOWFLAKE_SVGS;
else if (newSettings.type === "text") updateOptions.content = "❄";
snow.updateOptions(updateOptions);
}
}, 5000);
return () => {
snow.destroy();
snowRef.current = null;
window.removeEventListener("blur", blurHandler);
window.removeEventListener("focus", focusHandler);
clearInterval(settingsInterval);
if (styleEl.parentNode) {
styleEl.remove();
}
styleRef.current = null;
const snowfield = document.getElementById("snowfield");
if (snowfield) snowfield.remove();
};
}, []);
return null;
};
let snowRoot: Root | null = null;
let container: HTMLDivElement | null = null;
export default definePlugin({
name: "Snowfall",
description: "Let it snow on Discord! Ported from the BetterDiscord plugin by square.",
authors: [EquicordDevs.ZcraftElite, EquicordDevs.square],
settingsAboutComponent: () => (
<>
<Heading>Information</Heading>
<Paragraph>
This plugin adds a christmas-y snowfall effect on top of Discord's interface.
You can change the type of snow in the settings below.
<br /><br />
NOTE: While on most computers this plugin will not impact performance any more than your average Equicord extension,
it may cause some lag on lower end systems.
</Paragraph>
</>
),
settings,
start() {
container = document.createElement("div");
container.id = "snowfall-plugin-container";
document.body.appendChild(container);
snowRoot = createRoot(container);
snowRoot.render(<SnowfallManager />);
},
stop() {
if (snowRoot) {
snowRoot.unmount();
snowRoot = null;
}
if (container) {
if (container.parentNode) container.remove();
container = null;
}
}
});

View file

@ -0,0 +1,167 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { AudioPlayerInterface, createAudioPlayer } from "@api/AudioPlayer";
import { HeaderBarButton } from "@api/HeaderBar";
import { definePluginSettings } from "@api/Settings";
import { EquicordDevs } from "@utils/constants";
import { ModalProps, ModalRoot, openModal } from "@utils/modal";
import definePlugin, { OptionType } from "@utils/types";
import { React } from "@webpack/common";
let boopSound: AudioPlayerInterface;
let song: AudioPlayerInterface;
function assignSong(url: string, volume: number) {
song?.delete();
song = createAudioPlayer(url, { volume, preload: true, persistent: true });
song.load();
}
function assignBoop(url: string, volume: number) {
boopSound?.delete();
boopSound = createAudioPlayer(url, { volume, preload: true, persistent: true });
boopSound.load();
}
function SoggyModal(props: ModalProps) {
if (settings.store.songVolume !== 0) {
React.useEffect(() => {
song?.loop();
return () => {
song?.stop();
};
}, []);
}
const boop = (e: React.MouseEvent<HTMLImageElement>) => {
const { offsetX, offsetY } = e.nativeEvent;
const region = { x: 155, y: 220, width: 70, height: 70 };
if (
settings.store.boopVolume !== 0 &&
offsetX >= region.x &&
offsetX <= region.x + region.width &&
offsetY >= region.y &&
offsetY <= region.y + region.height
) {
boopSound?.play();
}
};
return (
<ModalRoot {...props}>
<img
src={settings.store.imageLink}
onClick={boop}
style={{ display: "block" }}
/>
</ModalRoot >
);
}
function buildSoggyModal(): any {
openModal(props => <SoggyModal {...props} />);
}
function SoggyButton() {
return (
<HeaderBarButton
className="soggy-button"
tooltip={settings.store.tooltipText}
icon={() => (
<img
alt=""
src={settings.store.imageLink}
width={24}
height={24}
draggable={false}
style={{ pointerEvents: "none" }}
/>
)}
onClick={() => buildSoggyModal()}
selected={false}
/>
);
}
const settings = definePluginSettings({
songVolume: {
description: "Volume of the song. 0 to disable",
type: OptionType.SLIDER,
default: 0.25,
markers: [0, 0.25, 0.5, 0.75, 1],
stickToMarkers: false,
},
boopVolume: {
description: "Volume of the boop sound",
type: OptionType.SLIDER,
default: 0.2,
markers: [0, 0.25, 0.5, 0.75, 1],
stickToMarkers: false,
},
tooltipText: {
description: "The text shown when hovering over the button",
type: OptionType.STRING,
default: "the soggy",
},
imageLink: {
description: "URL for the image (button and modal)",
type: OptionType.STRING,
default: "https://equicord.org/assets/plugins/soggy/cat.png",
},
songLink: {
description: "URL for the song to play",
type: OptionType.STRING,
default: "https://github.com/Equicord/Equibored/raw/main/sounds/soggy/song.mp3?raw=true",
onChange(newValue) {
assignSong(newValue, settings.store.songVolume * 100);
},
},
boopLink: {
description: "URL for the boop sound",
type: OptionType.STRING,
default: "https://github.com/Equicord/Equibored/raw/main/sounds/soggy/honk.wav?raw=true",
onChange(newValue) {
assignBoop(newValue, settings.store.boopVolume * 100);
}
}
});
export default definePlugin({
name: "Soggy",
description: "Adds a soggy button to the toolbox",
authors: [EquicordDevs.sliwka],
settings,
dependencies: ["AudioPlayerAPI"],
headerBarButton: {
icon: () => (
<img
alt=""
src={settings.store.imageLink}
width={24}
height={24}
draggable={false}
style={{ pointerEvents: "none" }}
/>
),
render: SoggyButton
},
start() {
assignBoop(settings.store.boopLink, settings.store.boopVolume * 100);
assignSong(settings.store.songLink, settings.store.songVolume * 100);
},
stop() {
boopSound?.delete();
song?.delete();
},
});

View file

@ -0,0 +1,74 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { ChatBarButton, ChatBarButtonFactory } from "@api/ChatButtons";
import { addMessagePreSendListener, MessageSendListener, removeMessagePreSendListener } from "@api/MessageEvents";
import { EquicordDevs } from "@utils/constants";
import definePlugin from "@utils/types";
import { React, useEffect, useState } from "@webpack/common";
let lastState = false;
const ReverseMessageToggle: ChatBarButtonFactory = ({ isMainChat }) => {
const [enabled, setEnabled] = useState(lastState);
function setEnabledValue(value: boolean) {
lastState = value;
setEnabled(value);
}
useEffect(() => {
const listener: MessageSendListener = async (_, message) => {
if (enabled && message.content) message.content = message.content.split("").reverse().join("");
};
addMessagePreSendListener(listener);
return () => void removeMessagePreSendListener(listener);
}, [enabled]);
if (!isMainChat) return null;
return (
<ChatBarButton
tooltip={enabled ? "Disable Reverse Message" : "Enable Reverse Message"}
onClick={() => setEnabledValue(!enabled)}
>
<svg xmlns="http://www.w3.org/2000/svg" height="20" viewBox="0 -960 960 960" width="20"><path fill={enabled ? "var(--green-360)" : "currentColor"} d="M482-160q-134 0-228-93t-94-227v-7l-36 36q-11 11-28 11t-28-11q-11-11-11-28t11-28l104-104q12-12 28-12t28 12l104 104q11 11 11 28t-11 28q-11 11-28 11t-28-11l-36-36v7q0 100 70.5 170T482-240q16 0 31.5-2t30.5-7q17-5 32 1t23 21q8 16 1.5 31.5T577-175q-23 8-47 11.5t-48 3.5Zm-4-560q-16 0-31.5 2t-30.5 7q-17 5-32.5-1T360-733q-8-15-1.5-30.5T381-784q24-8 48-12t49-4q134 0 228 93t94 227v7l36-36q11-11 28-11t28 11q11 11 11 28t-11 28L788-349q-12 12-28 12t-28-12L628-453q-11-11-11-28t11-28q11-11 28-11t28 11l36 36v-7q0-100-70.5-170T478-720Z" /></svg>
</ChatBarButton>
);
};
function ReverseMessageIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" height="20" viewBox="0 -960 960 960" width="20">
<path fill={"currentColor"} d="M482-160q-134 0-228-93t-94-227v-7l-36 36q-11 11-28 11t-28-11q-11-11-11-28t11-28l104-104q12-12 28-12t28 12l104 104q11 11 11 28t-11 28q-11 11-28 11t-28-11l-36-36v7q0 100 70.5 170T482-240q16 0 31.5-2t30.5-7q17-5 32 1t23 21q8 16 1.5 31.5T577-175q-23 8-47 11.5t-48 3.5Zm-4-560q-16 0-31.5 2t-30.5 7q-17 5-32.5-1T360-733q-8-15-1.5-30.5T381-784q24-8 48-12t49-4q134 0 228 93t94 227v7l36-36q11-11 28-11t28 11q11 11 11 28t-11 28L788-349q-12 12-28 12t-28-12L628-453q-11-11-11-28t11-28q11-11 28-11t28 11l36 36v-7q0-100-70.5-170T478-720Z" />
</svg>
);
}
export default definePlugin({
name: "TalkInReverse",
authors: [EquicordDevs.Tolgchu],
description: "Reverses the message content before sending it.",
dependencies: ["MessageEventsAPI"],
chatBarButton: {
icon: ReverseMessageIcon,
render: ReverseMessageToggle
}
});

View file

@ -0,0 +1,70 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { definePluginSettings } from "@api/Settings";
import { EquicordDevs } from "@utils/constants";
import definePlugin, { OptionType } from "@utils/types";
import { findByPropsLazy } from "@webpack";
import { FluxDispatcher } from "@webpack/common";
const validKeycodes = [
"Backspace", "Tab", "Enter", "ShiftLeft", "ShiftRight", "ControlLeft", "ControlRight", "AltLeft", "AltRight", "Pause", "CapsLock",
"Escape", "Space", "PageUp", "PageDown", "End", "Home", "ArrowLeft", "ArrowUp", "ArrowRight", "ArrowDown", "PrintScreen", "Insert",
"Delete", "Digit0", "Digit1", "Digit2", "Digit3", "Digit4", "Digit5", "Digit6", "Digit7", "Digit8", "Digit9", "KeyA", "KeyB", "KeyC",
"KeyD", "KeyE", "KeyF", "KeyG", "KeyH", "KeyI", "KeyJ", "KeyK", "KeyL", "KeyM", "KeyN", "KeyO", "KeyP", "KeyQ", "KeyR", "KeyS", "KeyT",
"KeyU", "KeyV", "KeyW", "KeyX", "KeyY", "KeyZ", "MetaLeft", "MetaRight", "ContextMenu", "Numpad0", "Numpad1", "Numpad2", "Numpad3",
"Numpad4", "Numpad5", "Numpad6", "Numpad7", "Numpad8", "Numpad9", "NumpadMultiply", "NumpadAdd", "NumpadSubtract", "NumpadDecimal",
"NumpadDivide", "F1", "F2", "F3", "F4", "F5", "F6", "F7", "F8", "F9", "F10", "F11", "F12", "NumLock", "ScrollLock",
];
const settings = definePluginSettings({
keyBind: {
description: "The key to toggle webcam when pressed.",
type: OptionType.STRING,
default: "KeyX",
isValid: (value: string) => validKeycodes.includes(value),
},
reqCtrl: {
description: "Require control to be held.",
type: OptionType.BOOLEAN,
default: true,
},
reqShift: {
description: "Require shift to be held.",
type: OptionType.BOOLEAN,
default: true,
},
reqAlt: {
description: "Require alt to be held.",
type: OptionType.BOOLEAN,
default: false,
},
});
const { isVideoEnabled } = findByPropsLazy("isVideoEnabled");
function handleKeydown({ code, ctrlKey, shiftKey, altKey }: KeyboardEvent) {
const { keyBind, reqCtrl, reqShift, reqAlt } = settings.store;
if (keyBind !== code || ctrlKey !== reqCtrl || shiftKey !== reqShift || altKey !== reqAlt) { return; } // please don't think about it
FluxDispatcher.dispatch({
type: "MEDIA_ENGINE_SET_VIDEO_ENABLED",
enabled: !isVideoEnabled(),
});
}
export default definePlugin({
name: "ToggleVideoBind",
description: "Adds a customizable bind to toggle webcam.",
authors: [EquicordDevs.mochienya],
settings,
start() {
document.addEventListener("keydown", handleKeydown);
},
stop() {
document.removeEventListener("keydown", handleKeydown);
},
});

View file

@ -0,0 +1,36 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { Tooltip } from "@webpack/common";
import type { ReactElement } from "react";
export interface ToneIndicatorProps {
prefix: string;
indicator: string;
desc: string;
}
export default function ToneIndicator({
prefix,
indicator,
desc,
}: ToneIndicatorProps): ReactElement {
return (
<Tooltip text={desc}>
{tooltipProps => (
<span
{...tooltipProps}
style={{
color: "var(--text-normal)",
userSelect: "text",
}}
>
{prefix}{indicator}
</span>
)}
</Tooltip>
);
}

View file

@ -0,0 +1,188 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { definePluginSettings } from "@api/Settings";
import { EquicordDevs } from "@utils/constants";
import definePlugin, { OptionType } from "@utils/types";
import { React } from "@webpack/common";
import { type ReactNode } from "react";
import indicatorsDefault from "./indicators";
import ToneIndicator from "./ToneIndicator";
const settings = definePluginSettings({
prefix: {
type: OptionType.STRING,
description: "Prefix character(s) for tone indicators.",
default: "/",
},
customIndicators: {
type: OptionType.STRING,
description: "Custom tone indicators (format: jk=Joking; srs=Serious)",
default: "",
},
});
function getCustomIndicators(): Record<string, string> {
const raw = settings.store.customIndicators || "";
const result: Record<string, string> = {};
raw.split("; ").forEach(entry => {
const [key, ...rest] = entry.split("=");
if (key && rest.length > 0) {
result[key.trim().toLowerCase()] = rest.join("=").trim();
}
});
return result;
}
function getIndicator(text: string): string | null {
text = text.toLowerCase();
const customIndicators = getCustomIndicators();
return (
customIndicators[text] ||
customIndicators[`_${text}`] ||
indicatorsDefault.get(text) ||
indicatorsDefault.get(`_${text}`) ||
null
);
}
function buildIndicatorRegex(): RegExp {
const customIndicators = getCustomIndicators();
const allIndicators = new Set<string>();
indicatorsDefault.forEach((_, key) => {
allIndicators.add(key.replace(/^_/, "")); // remove underscore prefix for aliases
});
Object.keys(customIndicators).forEach(key => {
allIndicators.add(key.replace(/^_/, "")); // remove underscore prefix for aliases
});
// escape special regex characters and sort by length (longest first)
const escaped = Array.from(allIndicators)
.map(ind => ind.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
.sort((a, b) => b.length - a.length); // longest first to avoid partial matches (should fix some edge cases)
const prefix = settings.store.prefix || "/";
let escapedPrefix = prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
// if prefix is a markdown character, also match escaped version
const isMarkdown = /[*_~`|]/.test(prefix);
if (isMarkdown) {
escapedPrefix = `(?:\\\\${escapedPrefix}|${escapedPrefix})`;
}
// exclude forward slash from punctuation to prevent sed syntax conflicts (s/find/replace)
const pattern = `(?:^|\\s)${escapedPrefix}(${escaped.join("|")})(?=\\s|$|[^\\s\\w/])`;
return new RegExp(pattern, "giu"); // 'i' = case-insensitive, 'u' = unicode
}
function splitTextWithIndicators(text: string): ReactNode[] {
const nodes: ReactNode[] = [];
let lastIndex = 0;
const regex = buildIndicatorRegex();
const prefix = settings.store.prefix || "/";
let match: RegExpExecArray | null;
while ((match = regex.exec(text))) {
const indicator = match[1];
const desc = getIndicator(indicator);
const fullMatch = match[0];
const leadingWhitespace = fullMatch.match(/^(\s*)/)?.[1] ?? "";
const matchStart = match.index;
const matchEnd = regex.lastIndex;
if (matchStart > lastIndex) {
nodes.push(text.slice(lastIndex, matchStart));
}
if (desc) {
if (leadingWhitespace) nodes.push(leadingWhitespace);
nodes.push(
<ToneIndicator
key={`ti-${matchStart}`}
prefix={prefix}
indicator={indicator}
desc={desc}
/>,
);
}
lastIndex = matchEnd;
}
if (lastIndex < text.length) nodes.push(text.slice(lastIndex));
return nodes;
}
function patchChildrenTree(children: any): any {
const transform = (node: any): any => {
if (node == null) return node;
if (typeof node === "string") {
const prefix = settings.store.prefix || "/";
let escapedPrefix = prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
// if prefix is markdown character, also check for escaped version
const isMarkdown = /[*_~`|]/.test(prefix);
if (isMarkdown) {
escapedPrefix = `(?:\\\\${escapedPrefix}|${escapedPrefix})`;
}
if (!new RegExp(`${escapedPrefix}[\\p{L}_]+`, "iu").test(node)) return node;
const parts = splitTextWithIndicators(node);
return parts.length === 1 ? parts[0] : parts;
}
if (node?.props?.children != null) {
const c = node.props.children;
if (Array.isArray(c)) {
node.props.children = c.map(transform).flat();
} else {
node.props.children = transform(c);
}
return node;
}
return node;
};
if (Array.isArray(children)) return children.map(transform).flat();
return transform(children);
}
export default definePlugin({
name: "ToneIndicators",
description: "Show tooltips for tone indicators like /srs, /gen, etc. in sent messages.",
authors: [EquicordDevs.justjxke],
settings,
patches: [
{
find: '["strong","em","u","text","inlineCode","s","spoiler"]',
replacement: [
{
match: /(?=return\{hasSpoilerEmbeds:\i,.{0,15}content:(\i))/,
replace: "$1=$self.patchToneIndicators($1);",
},
],
},
],
patchToneIndicators(content: any): any {
try {
return patchChildrenTree(content);
} catch {
return content;
}
},
});

View file

@ -0,0 +1,62 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
const defaultIndicators = new Map(
Object.entries({
j: "Joking",
hj: "Half Joking",
s: "Sarcastic",
_sarc: "Sarcastic", // Alias for s
gen: "Genuine",
_g: "Genuine", // Alias for gen,
genq: "Genuine Question",
_gq: "Genuine Question", // Alias for genq
srs: "Serious",
nsrs: "Non-Serious",
pos: "Positive Connotation",
_pc: "Positive Connotation", // Alias for pos
neu: "Neutral Connotation",
neg: "Negative Connotation",
_nc: "Negative Connotation", // Alias for neg
p: "Platonic",
r: "Romantic",
a: "Alterous",
c: "Copypasta",
l: "Lyrics",
_ly: "Lyrics", // Alias for l
lh: "Light-Hearted",
nm: "Not Mad",
lu: "A Little Upset",
nbh: "Not Directed At Anybody Here",
nay: "Not Directed At You",
ay: "Directed At You",
nsb: "Not Subtweeting",
x: "Sexual Intent",
_sx: "Sexual Intent", // Alias for x
nx: "Non-Sexual Intent",
_nsx: "Non-Sexual Intent", // Alias for nx
rh: "Rhetorical Question",
_rt: "Rhetorical Question", // Alias for rh
t: "Teasing",
ij: "Inside Joke",
m: "Metaphorically",
li: "Literally",
hyp: "Hyperbole",
ex: "Exaggeration",
f: "Fake",
q: "Quote",
th: "Threat",
cb: "Clickbait",
ref: "Reference",
nf: "Not Forced",
nbr: "Not Being Rude",
ot: "Off-Topic",
nj: "Not Joking",
naj: "Not A Joke",
}),
);
export default defaultIndicators;

View file

@ -0,0 +1,68 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import "./style.css";
import { findGroupChildrenByChildId, NavContextMenuPatchCallback } from "@api/ContextMenu";
import { Devs, EquicordDevs } from "@utils/constants";
import definePlugin from "@utils/types";
import { ChannelStore, Menu } from "@webpack/common";
import { settings } from "./settings";
import { Accessory, handleTranslate } from "./utils/accessory";
import { Icon } from "./utils/icon";
const messageCtxPatch: NavContextMenuPatchCallback = (children, { message }) => {
if (!message.content) return;
const group = findGroupChildrenByChildId("copy-text", children);
if (!group) return;
group.splice(group.findIndex(c => c?.props?.id === "copy-text") + 1, 0, (
<Menu.MenuItem
id="ec-trans"
label="Translate"
icon={Icon}
action={() => handleTranslate(message)}
/>
));
};
export default definePlugin({
name: "Translate+",
description: "Vencord's translate plugin but with support for artistic languages!",
authors: [Devs.Ven, EquicordDevs.Prince527],
settings,
contextMenus: {
"message": messageCtxPatch
},
renderMessageAccessory: props => <Accessory message={props.message} />,
messagePopoverButton: {
icon: Icon,
render(message) {
if (!message.content) return null;
return {
label: "Translate",
icon: Icon,
message: message,
channel: ChannelStore.getChannel(message.channel_id),
onClick: () => handleTranslate(message),
};
}
}
});

View file

@ -0,0 +1,173 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/*
To generate:
- Visit https://translate.google.com/?sl=auto&tl=en&op=translate
- Open Language dropdown
- Open Devtools and use the element picker to pick the root of the language picker
- Right click on the element in devtools and click "Store as global variable"
copy(Object.fromEntries(
Array.from(
temp1.querySelectorAll("[data-language-code]"),
e => [e.dataset.languageCode, e.children[1].textContent]
).sort((a, b) => a[1] === "Detect language" ? -1 : b[1] === "Detect language" ? 1 : a[1].localeCompare(b[1]))
))
*/
export type languages = keyof typeof languages;
export const languages = {
"auto": "Detect language",
"af": "Afrikaans",
"sq": "Albanian",
"am": "Amharic",
"ar": "Arabic",
"hy": "Armenian",
"as": "Assamese",
"ay": "Aymara",
"az": "Azerbaijani",
"bm": "Bambara",
"eu": "Basque",
"be": "Belarusian",
"bn": "Bengali",
"bho": "Bhojpuri",
"bs": "Bosnian",
"bg": "Bulgarian",
"ca": "Catalan",
"ceb": "Cebuano",
"ny": "Chichewa",
"zh-CN": "Chinese (Simplified)",
"zh-TW": "Chinese (Traditional)",
"co": "Corsican",
"hr": "Croatian",
"cs": "Czech",
"da": "Danish",
"dv": "Dhivehi",
"doi": "Dogri",
"nl": "Dutch",
"en": "English",
"eo": "Esperanto",
"et": "Estonian",
"ee": "Ewe",
"tl": "Filipino",
"tp": "Toki Pona",
"sh": "Shavian",
"fi": "Finnish",
"fr": "French",
"fy": "Frisian",
"gl": "Galician",
"ka": "Georgian",
"de": "German",
"el": "Greek",
"gn": "Guarani",
"gu": "Gujarati",
"ht": "Haitian Creole",
"ha": "Hausa",
"haw": "Hawaiian",
"iw": "Hebrew",
"hi": "Hindi",
"hmn": "Hmong",
"hu": "Hungarian",
"is": "Icelandic",
"ig": "Igbo",
"ilo": "Ilocano",
"id": "Indonesian",
"ga": "Irish",
"it": "Italian",
"ja": "Japanese",
"jw": "Javanese",
"kn": "Kannada",
"kk": "Kazakh",
"km": "Khmer",
"rw": "Kinyarwanda",
"gom": "Konkani",
"ko": "Korean",
"kri": "Krio",
"ku": "Kurdish (Kurmanji)",
"ckb": "Kurdish (Sorani)",
"ky": "Kyrgyz",
"lo": "Lao",
"la": "Latin",
"lv": "Latvian",
"ln": "Lingala",
"lt": "Lithuanian",
"lg": "Luganda",
"lb": "Luxembourgish",
"mk": "Macedonian",
"mai": "Maithili",
"mg": "Malagasy",
"ms": "Malay",
"ml": "Malayalam",
"mt": "Maltese",
"mi": "Maori",
"mr": "Marathi",
"mni-Mtei": "Meiteilon (Manipuri)",
"lus": "Mizo",
"mn": "Mongolian",
"my": "Myanmar (Burmese)",
"ne": "Nepali",
"no": "Norwegian",
"or": "Odia (Oriya)",
"om": "Oromo",
"ps": "Pashto",
"fa": "Persian",
"pl": "Polish",
"pt": "Portuguese",
"pa": "Punjabi",
"qu": "Quechua",
"ro": "Romanian",
"ru": "Russian",
"sm": "Samoan",
"sa": "Sanskrit",
"gd": "Scots Gaelic",
"nso": "Sepedi",
"sr": "Serbian",
"st": "Sesotho",
"sn": "Shona",
"sd": "Sindhi",
"si": "Sinhala",
"sk": "Slovak",
"sl": "Slovenian",
"so": "Somali",
"es": "Spanish",
"su": "Sundanese",
"sw": "Swahili",
"sv": "Swedish",
"tg": "Tajik",
"ta": "Tamil",
"tt": "Tatar",
"te": "Telugu",
"th": "Thai",
"ti": "Tigrinya",
"ts": "Tsonga",
"tr": "Turkish",
"tk": "Turkmen",
"ak": "Twi",
"uk": "Ukrainian",
"ur": "Urdu",
"ug": "Uyghur",
"uz": "Uzbek",
"vi": "Vietnamese",
"cy": "Welsh",
"xh": "Xhosa",
"yi": "Yiddish",
"yo": "Yoruba",
"zu": "Zulu"
} as const;

View file

@ -0,0 +1,19 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { classNameFactory } from "@utils/css";
export const cl = classNameFactory("eq-trans-");
export interface Translation {
text: string;
src: string;
}
export type IconProps = {
width?: number;
height?: number;
};

View file

@ -0,0 +1,35 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { definePluginSettings } from "@api/Settings";
import { OptionType } from "@utils/types";
export const settings = definePluginSettings({
target: {
type: OptionType.STRING,
description: "Target language",
default: "en",
restartNeeded: true
},
toki: {
type: OptionType.BOOLEAN,
description: "Enable Toki Pona",
default: true,
restartNeeded: true
},
sitelen: {
type: OptionType.BOOLEAN,
description: "Enable Sitelen Pona",
default: true,
restartNeeded: true
},
shavian: {
type: OptionType.BOOLEAN,
description: "Enable Shavian",
default: true,
restartNeeded: true
}
});

View file

@ -0,0 +1,22 @@
.eq-trans-accessory {
color: var(--text-muted);
margin-top: 0.5em;
font-style: italic;
font-weight: 400;
display: block;
white-space: pre-wrap;
}
.eq-trans-accessory svg {
margin-right: 0.25em;
}
.eq-trans-dismiss {
all: unset;
cursor: pointer;
color: var(--text-link);
}
.eq-trans-dismiss:is(:hover, :focus) {
text-decoration: underline;
}

View file

@ -0,0 +1,42 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { languages } from "@equicordplugins/translatePlus/misc/languages";
import { cl, Translation } from "@equicordplugins/translatePlus/misc/types";
import { Message } from "@vencord/discord-types";
import { Parser, useEffect, useState } from "@webpack/common";
import { SmallIcon } from "./icon";
import { translate } from "./translator";
const setters = new Map();
export function Accessory({ message }: { message: Message; }) {
const [translation, setTranslation] = useState<Translation | undefined>(undefined);
useEffect(() => {
if ((message as any).vencordEmbeddedBy) return;
setters.set(message.id, setTranslation);
return () => void setters.delete(message.id);
}, []);
if (!translation) return null;
return (
<div className={cl("accessory")}>
<SmallIcon />
{Parser.parse(translation.text)}
{" "}
(translated from {languages[translation.src] ?? translation.src} - <button onClick={() => setTranslation(undefined)} className={cl("dismiss")}>Dismiss</button>)
</div>
);
}
export async function handleTranslate(message: Message) {
setters.get(message.id)!(await translate(message.content));
}

View file

@ -0,0 +1,27 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { cl } from "@equicordplugins/translatePlus/misc/types";
export function Icon() {
return (
<svg viewBox="0 96 960 960" height={24} width={24} className={cl("icon")}>
<path fill="currentColor" d="m475 976 181-480h82l186 480h-87l-41-126H604l-47 126h-82Zm151-196h142l-70-194h-2l-70 194Zm-466 76-55-55 204-204q-38-44-67.5-88.5T190 416h87q17 33 37.5 62.5T361 539q45-47 75-97.5T487 336H40v-80h280v-80h80v80h280v80H567q-22 69-58.5 135.5T419 598l98 99-30 81-127-122-200 200Z" />
<path fill="currentColor" d="m 830.17456,136.43701 c -11.54729,0 -20.84473,8.71252 -20.84473,19.53369 v 66.21826 h -66.21826 c -10.82122,0 -19.53369,9.29373 -19.53369,20.84107 0,11.54734 8.71247,20.84473 19.53369,20.84473 h 66.21826 v 66.21826 c 0,10.8212 9.29742,19.53369 20.84473,19.53369 11.54731,0 20.84106,-8.71249 20.84106,-19.53369 v -66.21826 h 66.21827 c 10.82124,0 19.53369,-9.29737 19.53369,-20.84473 0,-11.54736 -8.71245,-20.84107 -19.53369,-20.84107 H 851.01562 V 155.9707 c 0,-10.82117 -9.29377,-19.53369 -20.84106,-19.53369 z" />
<rect fill="currentColor" width="0.42110577" height="2.1055288" x="848.52814" y="112.42313" ry="0.2105529" />
</svg>
);
}
export function SmallIcon() {
return (
<svg viewBox="0 96 960 960" height={16} width={16} className={cl("icon")}>
<path fill="currentColor" d="m475 976 181-480h82l186 480h-87l-41-126H604l-47 126h-82Zm151-196h142l-70-194h-2l-70 194Zm-466 76-55-55 204-204q-38-44-67.5-88.5T190 416h87q17 33 37.5 62.5T361 539q45-47 75-97.5T487 336H40v-80h280v-80h80v80h280v80H567q-22 69-58.5 135.5T419 598l98 99-30 81-127-122-200 200Z" />
<path fill="currentColor" d="m 830.17456,136.43701 c -11.54729,0 -20.84473,8.71252 -20.84473,19.53369 v 66.21826 h -66.21826 c -10.82122,0 -19.53369,9.29373 -19.53369,20.84107 0,11.54734 8.71247,20.84473 19.53369,20.84473 h 66.21826 v 66.21826 c 0,10.8212 9.29742,19.53369 20.84473,19.53369 11.54731,0 20.84106,-8.71249 20.84106,-19.53369 v -66.21826 h 66.21827 c 10.82124,0 19.53369,-9.29737 19.53369,-20.84473 0,-11.54736 -8.71245,-20.84107 -19.53369,-20.84107 H 851.01562 V 155.9707 c 0,-10.82117 -9.29377,-19.53369 -20.84106,-19.53369 z" />
<rect fill="currentColor" width="0.42110577" height="2.1055288" x="848.52814" y="112.42313" ry="0.2105529" />
</svg>
);
}

View file

@ -0,0 +1,143 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { settings } from "@equicordplugins/translatePlus/settings";
function isTokiPona(text: string) {
const dictionary = /\b(?:leko|weka|pan|lete|linja|lipu|suli|nimi|akesi|misikeke|selo|ike|sijelo|sona|lili|pimeja|ante|jo|loje|telo|walo|kijetesantakalu|kasi|waso|wile|utala|lukin|sina|lape|ma|pilin|jasima|la|olin|pipi|meso|lawa|pi|pakala|oko|tan|ken|jaki|unpa|esun|seme|sitelen|len|kule|soko|open|ala|tenpo|lon|sinpin|pini|kokosila|mama|musi|monsi|mewika|taso|ona|mun|kiwen|tomo|mute|mi|nena|palisa|meli|laso|wawa|ale|kipisi|kulupu|ilo|lupa|nanpa|en|mu|jelo|kili|tonsi|moku|ni|kama|pu|poki|monsuta|sin|lasina|poka|soweli|sewi|elena|epiku|moli|pona|lanpan|alasa|anu|kute|uta|luka|suno|sama|awen|namako|suwi|noka|seli|mije|sike|jan|pali|tawa|inli|nasa|mani|wan|insa|nijon|nasin|kalama|ijo|toki|anpa|kala|kepeken|ko|kon|pana|tu|supa|kin|usawi|yupekosi)\b/gm;
return (text.match(dictionary) || []).length >= text.split(/\s+/).length * 0.5;
}
function isSitelen(text: string) {
const dictionary = /(?:󱤀|󱤁|󱤂|󱤃|󱤄|󱤅|󱤆|󱤇|󱤈|󱤉|󱤊|󱤋|󱤌|󱤍|󱤎|󱤏|󱤐|󱤑|󱤒|󱤓|󱤔|󱤕|󱤖|󱤗|󱤘|󱤙|󱤚|󱤛|󱤜|󱤝|󱤞|󱤟|󱤠|󱤡|󱤢|󱤣|󱤤|󱤥|󱤦|󱤧|󱤨|󱤩|󱤪|󱤫|󱤬|󱤭|󱤮|󱤯|󱤰|󱤱|󱤲|󱤳|󱤴|󱤵|󱤶|󱤷|󱤸|󱤹|󱤺|󱤻|󱤼|󱤽|󱤾|󱤿|󱥀|󱥁|󱥂|󱥃|󱥄|󱥅|󱥆|󱥇|󱥈|󱥉|󱥊|󱥋|󱥌|󱥍|󱥎|󱥏|󱥐|󱥑|󱥒|󱥓|󱥔|󱥕|󱥖|󱥗|󱥘|󱥙|󱥚|󱥛|󱥜|󱥝|󱥞|󱥟|󱥠|󱥡|󱥢|󱥣|󱥤|󱥥|󱥦|󱥧|󱥨|󱥩|󱥪|󱥫|󱥬|󱥭|󱥮|󱥯|󱥰|󱥱|󱥲|󱥳|󱥴|󱥵|󱥶|󱥷|󱦠|󱦡|󱦢|󱦣|󱥸|󱥹|󱥺|󱥻|󱥼|󱥽|󱥾|󱥿|󱦀|󱦁|󱦂|󱦃|󱦄|󱦅|󱦆|󱦇|󱦈|󱦐|󱦑|󱦒|󱦓|󱦔|󱦕|󱦖|󱦗|󱦘|󱦙|󱦚|󱦛|󱦜|󱦝)/gm;
return dictionary.test(text);
}
function isShavian(text: string) {
const shavianRegex = /[\u{10450}-\u{1047F}]+/u;
return shavianRegex.test(text);
}
async function translateShavian(message: string) {
const dictionary = await (await fetch("https://raw.githubusercontent.com/ForkPrince/TranslatePlus/322199d5fdb1a9506591c9f4a2826338b5d67e38/shavian.json")).json();
const punctuationMap = {
'"': "\"",
"«": "\"",
"»": "\"",
",": ",",
"!": "!",
"?": "?",
".": ".",
"(": "(",
")": ")",
"/": "/",
";": ";",
":": ":"
};
let translated = "";
const words = message.split(/\s+/);
for (let word of words) {
let punctuationBefore = "", punctuationAfter = "";
if (word[0] in punctuationMap) {
punctuationBefore = punctuationMap[word[0]];
word = word.slice(1);
}
if (word[word.length - 1] in punctuationMap) {
punctuationAfter = punctuationMap[word[word.length - 1]];
word = word.slice(0, -1);
}
translated += punctuationBefore;
if (word in dictionary) translated += dictionary[word];
else translated += word;
translated += punctuationAfter + " ";
}
return translated.trim();
}
async function translateSitelen(message: string) {
message = Array.from(message).join(" ");
const dictionary = await (await fetch("https://raw.githubusercontent.com/ForkPrince/TranslatePlus/5ca152b134ea11433971f21b2ef8d556d4306717/sitelen-pona.json")).json();
const sorted = Object.keys(dictionary).sort((a, b) => b.length - a.length);
const pattern = new RegExp(`(${sorted.join("|")})`, "g");
const translate = message.replace(pattern, match => dictionary[match]);
return translate;
}
async function google(target: string, text: string) {
if (!text) return { src: "", text: "" };
try {
const res = await fetch(`https://translate.googleapis.com/translate_a/single?${new URLSearchParams({ client: "gtx", sl: "auto", tl: target, dt: "t", dj: "1", source: "input", q: text })}`);
if (!res.ok) throw new Error(`Request failed with status ${res.status}`);
const translate = await res.json();
return {
src: translate.src,
text: translate.sentences?.map(s => s.trans).filter(Boolean).join("\n")
};
} catch (error) {
console.error("[TranslatePlus] Google Translate request failed:", error);
return { src: "en", text: "Translation failed due to an error." };
}
}
export async function translate(text: string): Promise<any> {
const { target, toki, sitelen, shavian } = settings.store;
const output = { src: "", text: "" };
if ((isTokiPona(text) || isSitelen(text)) && (toki || sitelen)) {
if (isSitelen(text) && sitelen) text = await translateSitelen(text);
console.log(text);
const translate = await (await fetch("https://aiapi.serversmp.xyz/toki", {
method: "POST",
headers: {
"Accept": "application/json",
"Content-Type": "application/json"
},
body: JSON.stringify({
text: text,
src: "tl",
target: "en"
})
})).json();
console.log(translate);
output.src = "tp";
output.text = target === "en" ? translate.translation[0] : (await google(target, translate.translation[0])).text;
} else if (isShavian(text) && shavian) {
const translate = await translateShavian(text);
output.src = "sh";
output.text = target === "en" ? translate : (await google(target, translate)).text;
} else {
const translate = await google(target, text);
output.src = translate.src;
output.text = translate.text;
}
return output;
}

View file

@ -0,0 +1,66 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { findGroupChildrenByChildId, NavContextMenuPatchCallback } from "@api/ContextMenu";
import { EquicordDevs } from "@utils/constants";
import definePlugin, { IconComponent } from "@utils/types";
import { Message } from "@vencord/discord-types";
import { findExportedComponentLazy } from "@webpack";
import { ChannelStore, Menu } from "@webpack/common";
import { settings } from "./settings";
import { getResponse, handleResponse, parseMessageContent } from "./utils";
const RobotIconLazy = findExportedComponentLazy("RobotIcon");
const RobotIcon: IconComponent = props => <RobotIconLazy {...props} />;
const messageCtxPatch: NavContextMenuPatchCallback = (children, { message }: { message: Message; }) => {
const payload = parseMessageContent(message);
if (!payload) return;
const group = findGroupChildrenByChildId("copy-text", children);
if (!group) return;
group.splice(group.findIndex(c => c?.props?.id === "copy-text") + 1, 0, (
<Menu.MenuItem
id="vc-trivia-ai"
label="Answer With AI"
icon={RobotIcon}
action={async () => {
const ans = await getResponse(payload);
handleResponse(message, ans);
}}
/>
));
};
export default definePlugin({
name: "TriviaAI",
description: "A plugin that helps you answer trivia questions using AI.",
authors: [EquicordDevs.yash],
settings,
contextMenus: {
"message": messageCtxPatch
},
messagePopoverButton: {
icon: RobotIcon,
render(message: Message) {
const payload = parseMessageContent(message);
if (!payload) return null;
return {
label: "Answer With AI",
icon: RobotIcon,
message,
channel: ChannelStore.getChannel(message.channel_id),
onClick: async () => {
const ans = await getResponse(payload);
handleResponse(message, ans);
}
};
}
}
});

View file

@ -0,0 +1,53 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { definePluginSettings } from "@api/Settings";
import { OptionType } from "@utils/types";
export const settings = definePluginSettings({
apiKey: {
type: OptionType.STRING,
description: "API Key.",
default: "",
placeholder: "Enter API Key here for your AI endpoint.",
componentProps: {
type: "password"
}
},
model: {
type: OptionType.STRING,
description: "AI Model to use.",
default: "google/gemini-3-flash-preview",
placeholder: "e.g. google/gemini-3-flash-preview, inception/mercury, openai/gpt-5.2-chat, etc."
},
systemPrompt: {
type: OptionType.STRING,
description: "System Prompt for the AI.",
default: "You are a helpful assistant who answers questions for the user in a concise and short way while using the least amount of words and punctuation.",
placeholder: "Enter system prompt."
},
maxTokens: {
type: OptionType.NUMBER,
description: "Maximum number of tokens in the response.",
default: 500
},
endpoint: {
type: OptionType.STRING,
description: "OpenAI Compatible AI Endpoint.",
default: "https://openrouter.ai/api/v1/chat/completions",
placeholder: "Enter your OpenAI compatible AI endpoint here."
},
autoRespond: {
type: OptionType.BOOLEAN,
description: "Automatically respond to messages on receiving a response.",
default: false
},
supportImages: {
type: OptionType.BOOLEAN,
description: "Pass images to the AI for context (if any). This is not supported by all models.",
default: true
}
});

View file

@ -0,0 +1,188 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { insertTextIntoChatInputBox, sendMessage } from "@utils/discord";
import { Logger } from "@utils/Logger";
import { Message } from "@vencord/discord-types";
import { showToast, Toasts } from "@webpack/common";
import { settings } from "./settings";
const logger = new Logger("TriviaAI");
type TextPart = {
type: "text";
text: string;
};
type ImagePart = {
type: "image_url";
image_url: {
url: string;
detail?: "auto" | "high" | "low";
};
};
export type ContentPayload = string | (TextPart | ImagePart)[];
export function parseMessageContent(message: Message): ContentPayload | null {
const textParts: string[] = [];
if (message.content && message.content.trim().length > 0) {
textParts.push(message.content);
}
message.embeds.forEach(embed => {
const embedBuffer: string[] = [];
const parts = [
embed.provider?.name ? `> ${embed.provider.name}` : null,
embed.author?.name ? `**${embed.author.name}**` : null,
embed.rawTitle ? `## ${embed.rawTitle}` : null,
embed.rawDescription ?? null,
...(embed.fields?.map(f => (f.rawName && f.rawValue) ? `**${f.rawName}**: ${f.rawValue}` : null) ?? []),
embed.footer?.text ? `_${embed.footer.text}_` : null,
];
parts.forEach(p => {
if (p) embedBuffer.push(p);
});
if (embedBuffer.length > 0) textParts.push(embedBuffer.join("\n"));
});
const combinedText = textParts.join("\n\n");
if (!settings.store.supportImages) {
return combinedText || null;
}
const imageUrls = new Set<string>();
message.attachments
.filter(att => att.content_type?.startsWith("image/"))
.forEach(att => imageUrls.add(att.url));
message.embeds.forEach(embed => {
const potentialUrls = [
embed.image?.url,
embed.thumbnail?.url,
...(embed.images?.map(img => img.url) ?? [])
];
potentialUrls.forEach(url => {
if (url) imageUrls.add(url);
});
});
if (imageUrls.size === 0) {
return combinedText || null;
}
const payload: (TextPart | ImagePart)[] = [];
if (combinedText.length > 0) {
payload.push({
type: "text",
text: combinedText
});
}
imageUrls.forEach(url => {
payload.push({
type: "image_url",
image_url: { url }
});
});
return payload;
}
export async function handleResponse(message: Message, response: string): Promise<string> {
if (settings.store.autoRespond) {
sendMessage(
message.channel_id,
{ content: response },
true,
{ messageReference: { channel_id: message.channel_id, message_id: message.id } }
);
return response;
} else {
insertTextIntoChatInputBox(response);
return response;
}
}
export async function getResponse(payload: ContentPayload): Promise<string> {
if (!settings.store.apiKey) {
showToast("TriviaAI: API Key is not set.", Toasts.Type.FAILURE);
return "";
}
if (!settings.store.endpoint) {
showToast("TriviaAI: Endpoint is not set.", Toasts.Type.FAILURE);
return "";
}
if (!settings.store.model) {
showToast("TriviaAI: Model is not set.", Toasts.Type.FAILURE);
return "";
}
try {
const req = await fetch(settings.store.endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${settings.store.apiKey}`
},
body: JSON.stringify({
model: settings.store.model,
messages: [
{
role: "system",
content: settings.store.systemPrompt
},
{
role: "user",
content: payload
}
],
max_tokens: settings.store.maxTokens,
})
});
if (!req.ok) {
const errorBody = await req.text();
const errorMessage = `API request failed with status ${req.status}: ${errorBody}`;
logger.error(errorMessage);
showToast(errorMessage, Toasts.Type.FAILURE);
return "";
}
const data = await req.json();
if (data.error) {
const errorMessage = data.error.message ?? "An unknown error occurred";
logger.error(`API Error: ${errorMessage}`);
showToast(errorMessage, Toasts.Type.FAILURE);
return "";
}
const response = data.choices[0].message.content;
if (!response?.trim()) {
logger.warn("no response from AI model");
return "";
}
return response;
} catch (e) {
logger.error("Error getting response from AI model", e);
showToast("Error getting response from AI model", Toasts.Type.FAILURE);
return "";
}
}

View file

@ -0,0 +1,80 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { definePluginSettings } from "@api/Settings";
import ErrorBoundary from "@components/ErrorBoundary";
import { Devs } from "@utils/constants";
import definePlugin, { OptionType } from "@utils/types";
import { Channel } from "@vencord/discord-types";
import { findComponentByCodeLazy, findStoreLazy } from "@webpack";
import { ReadStateStore, useStateFromStores } from "@webpack/common";
const UserGuildSettingsStore = findStoreLazy("UserGuildSettingsStore");
const JoinedThreadsStore = findStoreLazy("JoinedThreadsStore");
const NumberBadge = findComponentByCodeLazy("BADGE_NOTIFICATION_BACKGROUND", "let{count:");
const settings = definePluginSettings({
showOnMutedChannels: {
description: "Show unread count on muted channels",
type: OptionType.BOOLEAN,
default: false,
},
notificationCountLimit: {
description: "Show +99 instead of true amount",
type: OptionType.BOOLEAN,
default: false,
},
});
export default definePlugin({
name: "UnreadCountBadge",
authors: [Devs.Joona],
description: "Shows unread message count badges on channels in the channel list",
settings,
patches: [
// Kanged from typingindicators
{
find: "UNREAD_IMPORTANT:",
replacement: [
{
match: /\.Children\.count.+?:null(?<=,channel:(\i).+?)/,
replace: "$&,$self.CountBadge({channel: arguments[0].channel})",
},
]
},
// Threads
{
// This is the thread "spine" that shows in the left
find: "M0 15H2c0 1.6569",
replacement: [
{
match: /mentionsCount:\i.{0,50}?null/,
replace: "$&,$self.CountBadge({channel: arguments[0].thread})",
},
]
},
],
CountBadge: ErrorBoundary.wrap(({ channel }: { channel: Channel; }) => {
const unreadCount = useStateFromStores([ReadStateStore], () => ReadStateStore.getUnreadCount(channel.id));
if (!unreadCount) return null;
if (!settings.store.showOnMutedChannels && (UserGuildSettingsStore.isChannelMuted(channel.guild_id, channel.id) || JoinedThreadsStore.isMuted(channel.id)))
return null;
return (
<NumberBadge
color="var(--brand-500)"
count={
unreadCount > 99 && settings.store.notificationCountLimit
? "+99"
: unreadCount
}
/>
);
}, { noop: true }),
});

View file

@ -0,0 +1,39 @@
.vc-unreadCountBadge {
box-sizing: border-box;
height: 16px;
min-height: 16px;
min-width: 16px;
display: flex;
justify-content: center;
align-items: center;
text-align: center;
background-color: var(--brand-500);
color: var(--white);
font-size: 12px;
font-family: var(--font-display);
border-radius: 8px;
margin-left: 4px;
line-height: 1.33;
font-weight: 700;
letter-spacing: 0.02em;
}
.vc-unreadCountBadge-dot {
position: absolute;
top: 25%;
margin-left: -3%;
scale: 0.9;
z-index: 1;
}
.vc-unreadCountBadge-dot-thread {
position: absolute;
top: 20%;
margin-left: -18%;
scale: 0.9;
z-index: 1;
}
[class*="modeMuted"] .unreadCountBadge {
display: none;
}

View file

@ -0,0 +1,42 @@
.vc-voice-channel-log {
list-style-type: none;
height: 50px;
display: grid;
grid-template-columns: 2.25rem 24px 40px max-content;
gap: 4px;
width: 4px;
background-color: var(--border-subtle);
}
.vc-voice-channel-log-date-separator,
.vc-voice-channel-log-timestamp {
margin-left: 4px;
height: 48px;
line-height: 48px;
font-size: .75rem;
text-align: center;
display: inline-block;
vertical-align: middle;
color: var(--text-default, white);
}
.vc-voice-channel-log-avatar {
height: 40px;
width: 100%;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
font-weight: 600;
overflow: hidden;
text-align: center;
margin-top: 10px;
padding-left: 10px;
padding-right: 10px;
cursor: pointer;
}
.vc-voice-channel-log-icon {
margin: auto 3px;
margin-left: 15px;
}

View file

@ -0,0 +1,37 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import "./VoiceChannelLogEntryComponent.css";
import { VoiceChannelLogEntry } from "@equicordplugins/voiceChannelLog.dev/logs";
import { classes } from "@utils/misc";
import { Channel } from "@vencord/discord-types";
import { React, Timestamp, Tooltip, UserStore } from "@webpack/common";
import { Util } from "Vencord";
import { cl } from "..";
import Icon from "./VoiceChannelLogEntryIcons";
export function VoiceChannelLogEntryComponent({ logEntry, channel }: { logEntry: VoiceChannelLogEntry; channel: Channel; }) {
const user = UserStore.getUser(logEntry.userId);
return <li className="vc-voice-channel-log">
<Timestamp className={cl("timestamp")} timestamp={new Date(logEntry.timestamp)} compact isInline={false} cozyAlt></Timestamp>
<Icon logEntry={logEntry} channel={channel} className={cl("icon")} />
<Tooltip text={logEntry.username}>
{(tooltipProps: any) => (
<img
{...tooltipProps}
className={classes(cl("avatar"))}
onClick={() => Util.openUserProfile(logEntry.userId)}
src={user.getAvatarURL(channel.getGuildId())}
/>
)}
</Tooltip>
<div className={cl("content")}>
{ }
</div>
</li>;
}

View file

@ -0,0 +1,29 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { VoiceChannelLogEntry } from "@equicordplugins/voiceChannelLog.dev/logs";
import { classes } from "@utils/misc";
import { Channel } from "@vencord/discord-types";
import { React } from "@webpack/common";
import { cl } from "..";
export default function Icon({ logEntry, channel, className }: { logEntry: VoiceChannelLogEntry; channel: Channel; className: string; }) {
// Taken from /assets/7378a83d74ce97d83380.svg
const Join = <svg xmlns="http://www.w3.org/2000/svg" height="18" width="18"><g fill="none" fillRule="evenodd"><path d="m18 0h-18v18h18z" /><path d="m0 8h14.2l-3.6-3.6 1.4-1.4 6 6-6 6-1.4-1.4 3.6-3.6h-14.2" fill="#3ba55c" /></g></svg>;
// Taken from /assets/192510ade1abc3149b46.svg
const Leave = <svg xmlns="http://www.w3.org/2000/svg" height="18" width="18" ><g fill="none" fillRule="evenodd"><path d="m18 0h-18v18h18z" /><path d="m3.8 8 3.6-3.6-1.4-1.4-6 6 6 6 1.4-1.4-3.6-3.6h14.2v-2" fill="#ed4245" /></g></svg>;
// For other contributors, please DO make specific designs for these instead of how I just copied the join/leave icons and making them orange
const MovedTo = <svg xmlns="http://www.w3.org/2000/svg" height="18" width="18"><g fill="none" fillRule="evenodd"><path d="m18 0h-18v18h18z" /><path d="m3.8 8 3.6-3.6-1.4-1.4-6 6 6 6 1.4-1.4-3.6-3.6h14.2v-2" fill="#faa61a" /></g></svg>;
const MovedFrom = <svg xmlns="http://www.w3.org/2000/svg" height="18" width="18"><g fill="none" fillRule="evenodd"><path d="m18 0h-18v18h18z" /><path d="m0 8h14.2l-3.6-3.6 1.4-1.4 6 6-6 6-1.4-1.4 3.6-3.6h-14.2" fill="#faa61a" /></g></svg>;
if (logEntry.newChannel && !logEntry.oldChannel) return React.cloneElement(Join, { className: classes(className, cl("join")) });
if (!logEntry.newChannel && logEntry.oldChannel) return React.cloneElement(Leave, { className: classes(className, cl("leave")) });
if (logEntry.newChannel === channel.id && logEntry.oldChannel) return React.cloneElement(MovedFrom, { className: classes(className, cl("moved-from")) });
if (logEntry.newChannel && logEntry.oldChannel === channel.id) return React.cloneElement(MovedTo, { className: classes(className, cl("moved-to")) });
// we should never get here, this is just here to shut up the type checker
return <svg></svg>;
}

View file

@ -0,0 +1,68 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { BaseText } from "@components/BaseText";
import { getVcLogs, vcLogSubscribe } from "@equicordplugins/voiceChannelLog.dev/logs";
import { classes } from "@utils/misc";
import { ModalCloseButton, ModalContent, ModalHeader, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal";
import { Channel } from "@vencord/discord-types";
import { findStoreLazy } from "@webpack";
import { React, ScrollerThin } from "@webpack/common";
import { cl } from "..";
import { VoiceChannelLogEntryComponent } from "./VoiceChannelLogEntryComponent";
const AccessibilityStore = findStoreLazy("AccessibilityStore");
export function openVoiceChannelLog(channel: Channel) {
return openModal(props => (
<VoiceChannelLogModal
props={props}
channel={channel}
/>
));
}
export function VoiceChannelLogModal({ channel, props }: { channel: Channel; props: ModalProps; }) {
React.useSyncExternalStore(vcLogSubscribe, () => getVcLogs(channel.id));
const vcLogs = getVcLogs(channel.id);
const logElements: (React.ReactNode)[] = [];
if (vcLogs.length > 0) {
for (let i = 0; i < vcLogs.length; i++) {
const logEntry = vcLogs[i];
if (i === 0 || logEntry.timestamp.toDateString() !== vcLogs[i - 1].timestamp.toDateString()) {
logElements.push(<div className={classes(cl("date-separator"))} role="separator" aria-label={logEntry.timestamp.toDateString()}>
<span>
{logEntry.timestamp.toDateString()}
</span>
</div>);
} else {
logElements.push(<VoiceChannelLogEntryComponent logEntry={logEntry} channel={channel} />);
}
}
} else {
logElements.push(<div className={cl("empty")}>No logs to display.</div>);
}
return (
<ModalRoot
{...props}
size={ModalSize.LARGE}
>
<ModalHeader>
<BaseText size="lg" weight="semibold" className={cl("header")} style={{ flexGrow: 1 }}>{channel.name} logs</BaseText>
<ModalCloseButton onClick={props.onClose} />
</ModalHeader>
<ModalContent>
<ScrollerThin fade className={classes(cl("scroller"), `group-spacing-${AccessibilityStore.messageGroupSpacing}`)}>
{logElements}
</ScrollerThin>
</ModalContent>
</ModalRoot >
);
}

View file

@ -0,0 +1,164 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { findGroupChildrenByChildId, NavContextMenuPatchCallback } from "@api/ContextMenu";
import { definePluginSettings } from "@api/Settings";
import { Devs, EquicordDevs } from "@utils/constants";
import { classNameFactory } from "@utils/css";
import definePlugin, { OptionType } from "@utils/types";
import { Message, User } from "@vencord/discord-types";
import { findByCodeLazy } from "@webpack";
import { FluxDispatcher, Menu, MessageActions, MessageStore, RelationshipStore, SelectedChannelStore, UserStore } from "@webpack/common";
import { openVoiceChannelLog } from "./components/VoiceChannelLogModal";
import { addLogEntry } from "./logs";
export const cl = classNameFactory("vc-voice-channel-log-");
const createBotMessage = findByCodeLazy('username:"Clyde"');
const settings = definePluginSettings({
mode: {
type: OptionType.SELECT,
description: "How to show the voice channel log",
options: [
{ label: "Log menu", value: 1, default: true },
{ label: "Log to associated chat directly", value: 2 },
{ label: "Log to chat and log menu", value: 3 },
]
},
voiceChannelChatSelf: {
type: OptionType.BOOLEAN,
description: "Log your own voice channel events in the voice channels",
default: true
},
voiceChannelChatSilent: {
type: OptionType.BOOLEAN,
description: "Join/leave/move messages in voice channel chats will be silent",
default: true
},
voiceChannelChatSilentSelf: {
type: OptionType.BOOLEAN,
description: "Join/leave/move messages in voice channel chats will be silent if you are in the voice channel",
default: false
},
ignoreBlockedUsers: {
type: OptionType.BOOLEAN,
description: "Do not log blocked users",
default: false
},
});
interface VoiceState {
guildId?: string;
channelId?: string;
oldChannelId?: string;
user: User;
userId: string;
}
function getMessageFlags(selfInChannel: boolean) {
let flags = 1 << 6;
if (selfInChannel ? settings.store.voiceChannelChatSilentSelf : settings.store.voiceChannelChatSilent) flags += 1 << 12;
return flags;
}
function sendVoiceStatusMessage(channelId: string, content: string, userId: string, selfInChannel: boolean): Message | null {
if (!channelId) return null;
const message: Message = createBotMessage({ channelId, content, embeds: [] });
message.flags = getMessageFlags(selfInChannel);
message.author = UserStore.getUser(userId);
// If we try to send a message into an unloaded channel, the client-sided messages get overwritten when the channel gets loaded
// This might be messy but It Works:tm:
const messagesLoaded: Promise<any> = MessageStore.hasPresent(channelId) ? new Promise<void>(r => r()) : MessageActions.fetchMessages({ channelId });
messagesLoaded.then(() => {
FluxDispatcher.dispatch({
type: "MESSAGE_CREATE",
channelId,
message,
optimistic: true,
sendMessageOptions: {},
isPushNotification: false
});
});
return message;
}
const patchChannelContextMenu: NavContextMenuPatchCallback = (children, { channel }) => {
const group = findGroupChildrenByChildId("mark-channel-read", children) ?? children;
group.push(
<Menu.MenuItem
id="vc-view-voice-channel-logs"
label="View Channel Logs"
action={() => { openVoiceChannelLog(channel); }}
/>
);
};
// Blatantly stolen from VcNarrator plugin
// For every user, channelId and oldChannelId will differ when moving channel.
// Only for the local user, channelId and oldChannelId will be the same when moving channel,
// for some ungodly reason
let clientOldChannelId: string | undefined;
export default definePlugin({
name: "VoiceChannelLog",
description: "Logs who joins and leaves voice channels",
authors: [Devs.Sqaaakoi, Devs.thororen, EquicordDevs.nyx],
contextMenus: {
"channel-context": patchChannelContextMenu
},
settings,
flux: {
VOICE_STATE_UPDATES({ voiceStates }: { voiceStates: VoiceState[]; }) {
const clientUserId = UserStore.getCurrentUser().id;
voiceStates.forEach(state => {
// mmmm hacky workaround
const { userId, channelId } = state;
const user = UserStore.getUser(userId) as User & { globalName: string; };
const username = user.globalName || user.username;
let { oldChannelId } = state;
if (userId === clientUserId && channelId !== clientOldChannelId) {
oldChannelId = clientOldChannelId;
clientOldChannelId = channelId;
}
if (settings.store.ignoreBlockedUsers && RelationshipStore.isBlocked(userId)) return;
// Ignore events from same channel
if (oldChannelId === channelId) return;
const logEntry = {
userId,
username,
oldChannel: oldChannelId || null,
newChannel: channelId || null,
timestamp: new Date()
};
if (settings.store.mode !== 2) {
addLogEntry(logEntry, oldChannelId);
addLogEntry(logEntry, channelId);
}
if (!settings.store.voiceChannelChatSelf && userId === clientUserId) return;
if (settings.store.mode === 1) return;
// Join / Leave
if ((!oldChannelId && channelId) || (oldChannelId && !channelId)) {
// empty string is to make type checker shut up
const targetChannelId = oldChannelId || channelId || "";
const selfInChannel = SelectedChannelStore.getVoiceChannelId() === targetChannelId;
sendVoiceStatusMessage(targetChannelId, `${(channelId ? "Joined" : "Left")} <#${targetChannelId}>`, userId, selfInChannel);
}
// Move between channels
if (oldChannelId && channelId) {
sendVoiceStatusMessage(oldChannelId, `Moved to <#${channelId}>`, userId, SelectedChannelStore.getVoiceChannelId() === oldChannelId);
sendVoiceStatusMessage(channelId, `Moved from <#${oldChannelId}>`, userId, SelectedChannelStore.getVoiceChannelId() === channelId);
}
});
},
}
});

View file

@ -0,0 +1,41 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
export interface VoiceChannelLogEntry {
userId: string;
username: string;
oldChannel: string | null;
newChannel: string | null;
timestamp: Date;
}
export const vcLogs = new Map<string, VoiceChannelLogEntry[]>();
let vcLogSubscriptions: (() => void)[] = [];
export function getVcLogs(channel?: string): VoiceChannelLogEntry[] {
if (!channel) return [];
if (!vcLogs.has(channel)) vcLogs.set(channel, []);
return vcLogs.get(channel) || [];
}
export function addLogEntry(logEntry: VoiceChannelLogEntry, channel?: string) {
if (!channel) return;
vcLogs.set(channel, [...getVcLogs(channel), logEntry]);
vcLogSubscriptions.forEach(u => u());
}
export function clearLogs(channel?: string) {
if (!channel) return;
vcLogs.set(channel, []);
vcLogSubscriptions.forEach(u => u());
}
export function vcLogSubscribe(listener: () => void) {
vcLogSubscriptions = [...vcLogSubscriptions, listener];
return () => {
vcLogSubscriptions = vcLogSubscriptions.filter(l => l !== listener);
};
}

View file

@ -0,0 +1,199 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { definePluginSettings } from "@api/Settings";
import { BaseText } from "@components/BaseText";
import { EquicordDevs } from "@utils/constants";
import definePlugin, { makeRange, OptionType } from "@utils/types";
import { ReactNode } from "react";
import ExampleWiggle from "./ui/components/ExampleWiggle";
const settings = definePluginSettings({
intensity: {
type: OptionType.SLIDER,
description: "Animation intensity in px",
markers: makeRange(1, 10, 1),
default: 4,
stickToMarkers: true,
onChange: () => updateStyles()
}
});
const dirMap = {
x: "0.6s wiggle-wavy-x alternate ease-in-out infinite",
y: "1.2s wiggle-wavy-y linear infinite"
};
const classMap = [
{
chars: ["<", ">"],
className: "wiggle-inner-x",
},
{
chars: ["^", "^"],
className: "wiggle-inner-y",
},
{
chars: [")", "("],
className: "wiggle-inner-xy"
}
];
let styles: HTMLStyleElement;
const updateStyles = () => {
const inten = settings.store.intensity + "px";
styles.textContent = `
.wiggle-example {
list-style-type: disc;
list-style-position: outside;
margin: 4px 0 0 16px;
}
.wiggle-example li {
white-space: break-spaces;
margin-bottom: 4px;
}
.wiggle-inner {
position: relative;
top: 0;
left: 0;
&.wiggle-inner-x {
animation: ${dirMap.x};
}
&.wiggle-inner-y {
animation: ${dirMap.y};
}
&.wiggle-inner-xy {
animation: ${dirMap.x}, ${dirMap.y};
}
}
@keyframes wiggle-wavy-x {
from {
left: -${inten};
}
to {
left: ${inten};
}
}
@keyframes wiggle-wavy-y {
0% {
top: 0;
animation-timing-function: ease-out;
}
25% {
top: -${inten};
animation-timing-function: ease-in;
}
50% {
top: 0;
animation-timing-function: ease-out;
}
75% {
top: ${inten};
animation-timing-function: ease-in;
}
}`;
};
export default definePlugin({
name: "WigglyText",
description: "Adds a new markdown formatting that makes text wiggly.",
authors: [EquicordDevs.nexpid],
settings,
settingsAboutComponent: () => (
<BaseText>
You can make text wiggle with the following:<br />
<ul className="wiggle-example">
<li><ExampleWiggle wiggle="x">left and right</ExampleWiggle> by typing <code>&lt;~text~&gt;</code></li>
<li><ExampleWiggle wiggle="y">up and down</ExampleWiggle> by typing <code>^~text~^</code></li>
<li><ExampleWiggle wiggle="xy">in a circle</ExampleWiggle> by typing <code>)~text~(</code></li>
</ul>
</BaseText>
),
patches: [
{
find: "parseToAST:",
replacement: {
match: /(parse[\w]*):(.*?)\((\i)\),/g,
replace: "$1:$2({...$3,wiggly:$self.wigglyRule}),",
},
},
],
wigglyRule: {
order: 24,
match: (source: string) => classMap.map(({ chars }) => source.match(new RegExp(`^(\\${chars[0]})~([\\s\\S]+?)~(\\${chars[1]})(?!_)`))).find(x => x !== null),
parse: (
capture: RegExpMatchArray,
transform: (...args: any[]) => any,
state: any
) => {
const className = classMap.find(({ chars }) => chars[0] === capture[1] && chars[1] === capture[3])?.className ?? "";
return {
content: transform(capture[2], state),
className
};
},
react: (
data: { content: any[]; className: string; },
output: (...args: any[]) => ReactNode[]
) => {
let offset = 0;
const traverse = (raw: any) => {
const children = !Array.isArray(raw) ? [raw] : raw;
let modified = false;
let j = -1;
for (const child of children) {
j++;
if (typeof child === "string") {
modified = true;
children[j] = child.split("").map((x, i) => (
<span key={i}>
<span
className={`wiggle-inner ${data.className}`}
style={{
animationDelay: `${((offset++) * 25) % 1200}ms`,
}}
>
{x}
</span>
</span>
));
} else if (child?.props?.children)
child.props.children = traverse(child.props.children);
}
return modified ? children : raw;
};
return traverse(output(data.content));
},
},
start: () => {
styles = document.createElement("style");
styles.id = "WigglyText";
document.head.appendChild(styles);
updateStyles();
},
stop: () => styles.remove()
});

View file

@ -0,0 +1,20 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
export default function ExampleWiggle({ wiggle, children }: { wiggle: "x" | "y" | "xy", children: string; }) {
return children.split("").map((x, i) => (
<span key={i}>
<span
className={`wiggle-inner wiggle-inner-${wiggle}`}
style={{
animationDelay: `${(i * 25) % 1200}ms`,
}}
>
{x}
</span>
</span>
));
}

View file

@ -0,0 +1,49 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { addMessagePreSendListener, MessageSendListener, removeMessagePreSendListener } from "@api/MessageEvents";
import { definePluginSettings } from "@api/Settings";
import { Devs, EquicordDevs } from "@utils/constants";
import definePlugin, { OptionType } from "@utils/types";
const settings = definePluginSettings(
{
blockedWords: {
type: OptionType.STRING,
description: "Strings not to capitilise (seperate with a comma)",
default: "http, https, ok"
}
}
);
const presendObject: MessageSendListener = (_, msg) => {
const blockedWordsArray: string[] = settings.store.blockedWords.split(", ");
const sentences = msg.content.split(/(?<=[.!?]+['")\]]*)(\s+)/);
msg.content = sentences.map((element, i) => {
if (i % 2 === 1) return element;
if (!blockedWordsArray.some(word => element.toLowerCase().startsWith(word.toLocaleLowerCase()))) {
return element.charAt(0).toUpperCase() + element.slice(1);
} else {
return element;
}
}).join("");
};
export default definePlugin({
name: "WriteUpperCase",
description: "Changes the first Letter of each Sentence in Message Inputs to Uppercase",
authors: [Devs.Samwich, EquicordDevs.KrystalSkull],
settings,
start() {
addMessagePreSendListener(presendObject);
},
stop() {
removeMessagePreSendListener(presendObject);
}
});

View file

@ -156,8 +156,9 @@ async function generateDebugInfoMessage() {
}
const potentiallyProblematicPlugins = ([
"NoRPC", "NoProfileThemes", "NoMosaic", "NoSystemBadge",
"AlwaysAnimate", "ClientTheme", "SoundTroll", "NeverPausePreviews",
"NoRPC", "NoProfileThemes", "NoMosaic", "NoRoleHeaders", "NoSystemBadge",
"AlwaysAnimate", "ClientTheme", "SoundTroll", "Ingtoninator", "NeverPausePreviews",
"IdleAutoRestart",
].filter(isPluginEnabled) ?? []).sort();
if (isPluginEnabled(customIdle.name) && customIdle.settings.store.idleTimeout === 0) {