Merge branch 'Vendicated:main' into main

This commit is contained in:
catcraft 2024-09-25 12:06:25 +09:30 committed by GitHub
commit 5c5582cfbc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
102 changed files with 1673 additions and 1038 deletions

View file

@ -1,7 +1,7 @@
{ {
"name": "vencord", "name": "vencord",
"private": "true", "private": "true",
"version": "1.9.8", "version": "1.10.2",
"description": "The cutest Discord client mod", "description": "The cutest Discord client mod",
"homepage": "https://github.com/Vendicated/Vencord#readme", "homepage": "https://github.com/Vendicated/Vencord#readme",
"bugs": { "bugs": {

View file

@ -334,5 +334,6 @@ export const commonRendererPlugins = [
banImportPlugin(builtinModuleRegex, "Cannot import node inbuilt modules in browser code. You need to use a native.ts file"), banImportPlugin(builtinModuleRegex, "Cannot import node inbuilt modules in browser code. You need to use a native.ts file"),
banImportPlugin(/^react$/, "Cannot import from react. React and hooks should be imported from @webpack/common"), banImportPlugin(/^react$/, "Cannot import from react. React and hooks should be imported from @webpack/common"),
banImportPlugin(/^electron(\/.*)?$/, "Cannot import electron in browser code. You need to use a native.ts file"), banImportPlugin(/^electron(\/.*)?$/, "Cannot import electron in browser code. You need to use a native.ts file"),
banImportPlugin(/^ts-pattern$/, "Cannot import from ts-pattern. match and P should be imported from @webpack/common"),
...commonOpts.plugins ...commonOpts.plugins
]; ];

View file

@ -16,6 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { Logger } from "@utils/Logger";
import { makeCodeblock } from "@utils/text"; import { makeCodeblock } from "@utils/text";
import { sendBotMessage } from "./commandHelpers"; import { sendBotMessage } from "./commandHelpers";
@ -46,10 +47,10 @@ export let RequiredMessageOption: Option = ReqPlaceholder;
export const _init = function (cmds: Command[]) { export const _init = function (cmds: Command[]) {
try { try {
BUILT_IN = cmds; BUILT_IN = cmds;
OptionalMessageOption = cmds.find(c => c.name === "shrug")!.options![0]; OptionalMessageOption = cmds.find(c => (c.untranslatedName || c.displayName) === "shrug")!.options![0];
RequiredMessageOption = cmds.find(c => c.name === "me")!.options![0]; RequiredMessageOption = cmds.find(c => (c.untranslatedName || c.displayName) === "me")!.options![0];
} catch (e) { } catch (e) {
console.error("Failed to load CommandsApi"); new Logger("CommandsAPI").error("Failed to load CommandsApi", e, " - cmds is", cmds);
} }
return cmds; return cmds;
} as never; } as never;
@ -138,6 +139,8 @@ export function registerCommand<C extends Command>(command: C, plugin: string) {
throw new Error(`Command '${command.name}' already exists.`); throw new Error(`Command '${command.name}' already exists.`);
command.isVencordCommand = true; command.isVencordCommand = true;
command.untranslatedName ??= command.name;
command.untranslatedDescription ??= command.description;
command.id ??= `-${BUILT_IN.length + 1}`; command.id ??= `-${BUILT_IN.length + 1}`;
command.applicationId ??= "-1"; // BUILT_IN; command.applicationId ??= "-1"; // BUILT_IN;
command.type ??= ApplicationCommandType.CHAT_INPUT; command.type ??= ApplicationCommandType.CHAT_INPUT;

View file

@ -93,8 +93,10 @@ export interface Command {
isVencordCommand?: boolean; isVencordCommand?: boolean;
name: string; name: string;
untranslatedName?: string;
displayName?: string; displayName?: string;
description: string; description: string;
untranslatedDescription?: string;
displayDescription?: string; displayDescription?: string;
options?: Option[]; options?: Option[];

View file

@ -90,19 +90,20 @@ export function removeGlobalContextMenuPatch(patch: GlobalContextMenuPatchCallba
* A helper function for finding the children array of a group nested inside a context menu based on the id(s) of its children * A helper function for finding the children array of a group nested inside a context menu based on the id(s) of its children
* @param id The id of the child. If an array is specified, all ids will be tried * @param id The id of the child. If an array is specified, all ids will be tried
* @param children The context menu children * @param children The context menu children
* @param matchSubstring Whether to check if the id is a substring of the child id
*/ */
export function findGroupChildrenByChildId(id: string | string[], children: Array<ReactElement | null>): Array<ReactElement | null> | null { export function findGroupChildrenByChildId(id: string | string[], children: Array<ReactElement | null | undefined>, matchSubstring = false): Array<ReactElement | null | undefined> | null {
for (const child of children) { for (const child of children) {
if (child == null) continue; if (child == null) continue;
if (Array.isArray(child)) { if (Array.isArray(child)) {
const found = findGroupChildrenByChildId(id, child); const found = findGroupChildrenByChildId(id, child, matchSubstring);
if (found !== null) return found; if (found !== null) return found;
} }
if ( if (
(Array.isArray(id) && id.some(id => child.props?.id === id)) (Array.isArray(id) && id.some(id => matchSubstring ? child.props?.id?.includes(id) : child.props?.id === id))
|| child.props?.id === id || (matchSubstring ? child.props?.id?.includes(id) : child.props?.id === id)
) return children; ) return children;
let nextChildren = child.props?.children; let nextChildren = child.props?.children;
@ -112,7 +113,7 @@ export function findGroupChildrenByChildId(id: string | string[], children: Arra
child.props.children = nextChildren; child.props.children = nextChildren;
} }
const found = findGroupChildrenByChildId(id, nextChildren); const found = findGroupChildrenByChildId(id, nextChildren, matchSubstring);
if (found !== null) return found; if (found !== null) return found;
} }
} }

View file

@ -16,6 +16,8 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { Margins } from "@utils/margins";
import { wordsFromCamel, wordsToTitle } from "@utils/text";
import { OptionType, PluginOptionNumber } from "@utils/types"; import { OptionType, PluginOptionNumber } from "@utils/types";
import { Forms, React, TextInput } from "@webpack/common"; import { Forms, React, TextInput } from "@webpack/common";
@ -54,7 +56,8 @@ export function SettingNumericComponent({ option, pluginSettings, definedSetting
return ( return (
<Forms.FormSection> <Forms.FormSection>
<Forms.FormTitle>{option.description}</Forms.FormTitle> <Forms.FormTitle>{wordsToTitle(wordsFromCamel(id))}</Forms.FormTitle>
<Forms.FormText className={Margins.bottom20} type="description">{option.description}</Forms.FormText>
<TextInput <TextInput
type="number" type="number"
pattern="-?[0-9]+" pattern="-?[0-9]+"

View file

@ -16,6 +16,8 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { Margins } from "@utils/margins";
import { wordsFromCamel, wordsToTitle } from "@utils/text";
import { PluginOptionSelect } from "@utils/types"; import { PluginOptionSelect } from "@utils/types";
import { Forms, React, Select } from "@webpack/common"; import { Forms, React, Select } from "@webpack/common";
@ -44,7 +46,8 @@ export function SettingSelectComponent({ option, pluginSettings, definedSettings
return ( return (
<Forms.FormSection> <Forms.FormSection>
<Forms.FormTitle>{option.description}</Forms.FormTitle> <Forms.FormTitle>{wordsToTitle(wordsFromCamel(id))}</Forms.FormTitle>
<Forms.FormText className={Margins.bottom16} type="description">{option.description}</Forms.FormText>
<Select <Select
isDisabled={option.disabled?.call(definedSettings) ?? false} isDisabled={option.disabled?.call(definedSettings) ?? false}
options={option.options} options={option.options}

View file

@ -16,6 +16,8 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { Margins } from "@utils/margins";
import { wordsFromCamel, wordsToTitle } from "@utils/text";
import { PluginOptionSlider } from "@utils/types"; import { PluginOptionSlider } from "@utils/types";
import { Forms, React, Slider } from "@webpack/common"; import { Forms, React, Slider } from "@webpack/common";
@ -50,7 +52,8 @@ export function SettingSliderComponent({ option, pluginSettings, definedSettings
return ( return (
<Forms.FormSection> <Forms.FormSection>
<Forms.FormTitle>{option.description}</Forms.FormTitle> <Forms.FormTitle>{wordsToTitle(wordsFromCamel(id))}</Forms.FormTitle>
<Forms.FormText className={Margins.bottom20} type="description">{option.description}</Forms.FormText>
<Slider <Slider
disabled={option.disabled?.call(definedSettings) ?? false} disabled={option.disabled?.call(definedSettings) ?? false}
markers={option.markers} markers={option.markers}

View file

@ -16,6 +16,8 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { Margins } from "@utils/margins";
import { wordsFromCamel, wordsToTitle } from "@utils/text";
import { PluginOptionString } from "@utils/types"; import { PluginOptionString } from "@utils/types";
import { Forms, React, TextInput } from "@webpack/common"; import { Forms, React, TextInput } from "@webpack/common";
@ -41,7 +43,8 @@ export function SettingTextComponent({ option, pluginSettings, definedSettings,
return ( return (
<Forms.FormSection> <Forms.FormSection>
<Forms.FormTitle>{option.description}</Forms.FormTitle> <Forms.FormTitle>{wordsToTitle(wordsFromCamel(id))}</Forms.FormTitle>
<Forms.FormText className={Margins.bottom20} type="description">{option.description}</Forms.FormText>
<TextInput <TextInput
type="text" type="text"
value={state} value={state}

View file

@ -292,10 +292,10 @@ export default function PluginSettings() {
if (!pluginFilter(p)) continue; if (!pluginFilter(p)) continue;
const isRequired = p.required || depMap[p.name]?.some(d => settings.plugins[d].enabled); const isRequired = p.required || p.isDependency || depMap[p.name]?.some(d => settings.plugins[d].enabled);
if (isRequired) { if (isRequired) {
const tooltipText = p.required const tooltipText = p.required || !depMap[p.name]
? "This plugin is required for Vencord to function." ? "This plugin is required for Vencord to function."
: makeDependencyList(depMap[p.name]?.filter(d => settings.plugins[d].enabled)); : makeDependencyList(depMap[p.name]?.filter(d => settings.plugins[d].enabled));

View file

@ -77,8 +77,16 @@ function Validators({ themeLinks }: { themeLinks: string[]; }) {
<Forms.FormTitle className={Margins.top20} tag="h5">Validator</Forms.FormTitle> <Forms.FormTitle className={Margins.top20} tag="h5">Validator</Forms.FormTitle>
<Forms.FormText>This section will tell you whether your themes can successfully be loaded</Forms.FormText> <Forms.FormText>This section will tell you whether your themes can successfully be loaded</Forms.FormText>
<div> <div>
{themeLinks.map(link => ( {themeLinks.map(rawLink => {
<Card style={{ const { label, link } = (() => {
const match = /^@(light|dark) (.*)/.exec(rawLink);
if (!match) return { label: rawLink, link: rawLink };
const [, mode, link] = match;
return { label: `[${mode} mode only] ${link}`, link };
})();
return <Card style={{
padding: ".5em", padding: ".5em",
marginBottom: ".5em", marginBottom: ".5em",
marginTop: ".5em" marginTop: ".5em"
@ -86,11 +94,11 @@ function Validators({ themeLinks }: { themeLinks: string[]; }) {
<Forms.FormTitle tag="h5" style={{ <Forms.FormTitle tag="h5" style={{
overflowWrap: "break-word" overflowWrap: "break-word"
}}> }}>
{link} {label}
</Forms.FormTitle> </Forms.FormTitle>
<Validator link={link} /> <Validator link={link} />
</Card> </Card>;
))} })}
</div> </div>
</> </>
); );
@ -296,6 +304,7 @@ function ThemesTab() {
<Card className="vc-settings-card vc-text-selectable"> <Card className="vc-settings-card vc-text-selectable">
<Forms.FormTitle tag="h5">Paste links to css files here</Forms.FormTitle> <Forms.FormTitle tag="h5">Paste links to css files here</Forms.FormTitle>
<Forms.FormText>One link per line</Forms.FormText> <Forms.FormText>One link per line</Forms.FormText>
<Forms.FormText>You can prefix lines with @light or @dark to toggle them based on your Discord theme</Forms.FormText>
<Forms.FormText>Make sure to use direct links to files (raw or github.io)!</Forms.FormText> <Forms.FormText>Make sure to use direct links to files (raw or github.io)!</Forms.FormText>
</Card> </Card>

View file

@ -1,3 +0,0 @@
[class*="profileBadges"] {
flex: none;
}

View file

@ -16,8 +16,6 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import "./fixBadgeOverflow.css";
import { _getBadges, BadgePosition, BadgeUserArgs, ProfileBadge } from "@api/Badges"; import { _getBadges, BadgePosition, BadgeUserArgs, ProfileBadge } from "@api/Badges";
import DonateButton from "@components/DonateButton"; import DonateButton from "@components/DonateButton";
import ErrorBoundary from "@components/ErrorBoundary"; import ErrorBoundary from "@components/ErrorBoundary";
@ -79,7 +77,7 @@ export default definePlugin({
replace: "...$1.props,$& $1.image??" replace: "...$1.props,$& $1.image??"
}, },
{ {
match: /(?<=text:(\i)\.description,.{0,50})children:/, match: /(?<=text:(\i)\.description,.{0,200})children:/,
replace: "children:$1.component ? $self.renderBadgeComponent({ ...$1 }) :" replace: "children:$1.component ? $self.renderBadgeComponent({ ...$1 }) :"
}, },
// conditionally override their onClick with badge.onClick if it exists // conditionally override their onClick with badge.onClick if it exists

View file

@ -34,7 +34,7 @@ export default definePlugin({
{ {
find: "Messages.SERVERS,children", find: "Messages.SERVERS,children",
replacement: { replacement: {
match: /(?<=Messages\.SERVERS,children:).+?default:return null\}\}\)/, match: /(?<=Messages\.SERVERS,children:)\i\.map\(\i\)/,
replace: "Vencord.Api.ServerList.renderAll(Vencord.Api.ServerList.ServerListRenderPosition.In).concat($&)" replace: "Vencord.Api.ServerList.renderAll(Vencord.Api.ServerList.ServerListRenderPosition.In).concat($&)"
} }
} }

View file

@ -48,7 +48,7 @@ export default definePlugin({
}, },
}, },
{ {
find: ".METRICS,", find: ".METRICS",
replacement: [ replacement: [
{ {
match: /this\._intervalId=/, match: /this\._intervalId=/,

View file

@ -142,7 +142,7 @@ export default definePlugin({
required: true, required: true,
description: "Helps us provide support to you", description: "Helps us provide support to you",
authors: [Devs.Ven], authors: [Devs.Ven],
dependencies: ["CommandsAPI", "UserSettingsAPI", "MessageAccessoriesAPI"], dependencies: ["UserSettingsAPI", "MessageAccessoriesAPI"],
settings, settings,

View file

@ -0,0 +1,7 @@
# AccountPanelServerProfile
Right click your account panel in the bottom left to view your profile in the current server
![](https://github.com/user-attachments/assets/3228497d-488f-479c-93d2-a32ccdb08f0f)
![](https://github.com/user-attachments/assets/6fc45363-d95f-4810-812f-2f9fb28b41b5)

View file

@ -0,0 +1,134 @@
/*
* 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 { getCurrentChannel } from "@utils/discord";
import definePlugin, { OptionType } from "@utils/types";
import { findByPropsLazy, findComponentByCodeLazy } from "@webpack";
import { ContextMenuApi, Menu, useEffect, useRef } from "@webpack/common";
import { User } from "discord-types/general";
interface UserProfileProps {
popoutProps: Record<string, any>;
currentUser: User;
originalPopout: () => React.ReactNode;
}
const UserProfile = findComponentByCodeLazy("UserProfilePopoutWrapper: user cannot be undefined");
const styles = findByPropsLazy("accountProfilePopoutWrapper");
let openAlternatePopout = false;
let accountPanelRef: React.MutableRefObject<Record<PropertyKey, any> | null> = { current: null };
const AccountPanelContextMenu = ErrorBoundary.wrap(() => {
const { prioritizeServerProfile } = settings.use(["prioritizeServerProfile"]);
return (
<Menu.Menu
navId="vc-ap-server-profile"
onClose={ContextMenuApi.closeContextMenu}
>
<Menu.MenuItem
id="vc-ap-view-alternate-popout"
label={prioritizeServerProfile ? "View Account Profile" : "View Server Profile"}
disabled={getCurrentChannel()?.getGuildId() == null}
action={e => {
openAlternatePopout = true;
accountPanelRef.current?.props.onMouseDown();
accountPanelRef.current?.props.onClick(e);
}}
/>
<Menu.MenuCheckboxItem
id="vc-ap-prioritize-server-profile"
label="Prioritize Server Profile"
checked={prioritizeServerProfile}
action={() => settings.store.prioritizeServerProfile = !prioritizeServerProfile}
/>
</Menu.Menu>
);
}, { noop: true });
const settings = definePluginSettings({
prioritizeServerProfile: {
type: OptionType.BOOLEAN,
description: "Prioritize Server Profile when left clicking your account panel",
default: false
}
});
export default definePlugin({
name: "AccountPanelServerProfile",
description: "Right click your account panel in the bottom left to view your profile in the current server",
authors: [Devs.Nuckyz, Devs.relitrix],
settings,
patches: [
{
find: ".Messages.ACCOUNT_SPEAKING_WHILE_MUTED",
group: true,
replacement: [
{
match: /(?<=\.SIZE_32\)}\);)/,
replace: "$self.useAccountPanelRef();"
},
{
match: /(\.AVATAR,children:.+?renderPopout:(\i)=>){(.+?)}(?=,position)(?<=currentUser:(\i).+?)/,
replace: (_, rest, popoutProps, originalPopout, currentUser) => `${rest}$self.UserProfile({popoutProps:${popoutProps},currentUser:${currentUser},originalPopout:()=>{${originalPopout}}})`
},
{
match: /\.AVATAR,children:.+?(?=renderPopout:)/,
replace: "$&onRequestClose:$self.onPopoutClose,"
},
{
match: /(?<=.avatarWrapper,)/,
replace: "ref:$self.accountPanelRef,onContextMenu:$self.openAccountPanelContextMenu,"
}
]
}
],
get accountPanelRef() {
return accountPanelRef;
},
useAccountPanelRef() {
useEffect(() => () => {
accountPanelRef.current = null;
}, []);
return (accountPanelRef = useRef(null));
},
openAccountPanelContextMenu(event: React.UIEvent) {
ContextMenuApi.openContextMenu(event, AccountPanelContextMenu);
},
onPopoutClose() {
openAlternatePopout = false;
},
UserProfile: ErrorBoundary.wrap(({ popoutProps, currentUser, originalPopout }: UserProfileProps) => {
if (
(settings.store.prioritizeServerProfile && openAlternatePopout) ||
(!settings.store.prioritizeServerProfile && !openAlternatePopout)
) {
return originalPopout();
}
const currentChannel = getCurrentChannel();
if (currentChannel?.getGuildId() == null) {
return originalPopout();
}
return (
<div className={styles.accountProfilePopoutWrapper}>
<UserProfile {...popoutProps} userId={currentUser.id} guildId={currentChannel.getGuildId()} channelId={currentChannel.id} />
</div>
);
}, { noop: true })
});

View file

@ -24,7 +24,7 @@ interface ActivityButton {
} }
interface Activity { interface Activity {
state: string; state?: string;
details?: string; details?: string;
timestamps?: { timestamps?: {
start?: number; start?: number;
@ -52,8 +52,8 @@ const enum ActivityFlag {
export interface TrackData { export interface TrackData {
name: string; name: string;
album: string; album?: string;
artist: string; artist?: string;
appleMusicLink?: string; appleMusicLink?: string;
songLink?: string; songLink?: string;
@ -61,8 +61,8 @@ export interface TrackData {
albumArtwork?: string; albumArtwork?: string;
artistArtwork?: string; artistArtwork?: string;
playerPosition: number; playerPosition?: number;
duration: number; duration?: number;
} }
const enum AssetImageType { const enum AssetImageType {
@ -120,7 +120,7 @@ const settings = definePluginSettings({
stateString: { stateString: {
type: OptionType.STRING, type: OptionType.STRING,
description: "Activity state format string", description: "Activity state format string",
default: "{artist}" default: "{artist} · {album}"
}, },
largeImageType: { largeImageType: {
type: OptionType.SELECT, type: OptionType.SELECT,
@ -155,8 +155,8 @@ const settings = definePluginSettings({
function customFormat(formatStr: string, data: TrackData) { function customFormat(formatStr: string, data: TrackData) {
return formatStr return formatStr
.replaceAll("{name}", data.name) .replaceAll("{name}", data.name)
.replaceAll("{album}", data.album) .replaceAll("{album}", data.album ?? "")
.replaceAll("{artist}", data.artist); .replaceAll("{artist}", data.artist ?? "");
} }
function getImageAsset(type: AssetImageType, data: TrackData) { function getImageAsset(type: AssetImageType, data: TrackData) {
@ -212,14 +212,16 @@ export default definePlugin({
const assets: ActivityAssets = {}; const assets: ActivityAssets = {};
const isRadio = Number.isNaN(trackData.duration) && (trackData.playerPosition === 0);
if (settings.store.largeImageType !== AssetImageType.Disabled) { if (settings.store.largeImageType !== AssetImageType.Disabled) {
assets.large_image = largeImageAsset; assets.large_image = largeImageAsset;
assets.large_text = customFormat(settings.store.largeTextString, trackData); if (!isRadio) assets.large_text = customFormat(settings.store.largeTextString, trackData);
} }
if (settings.store.smallImageType !== AssetImageType.Disabled) { if (settings.store.smallImageType !== AssetImageType.Disabled) {
assets.small_image = smallImageAsset; assets.small_image = smallImageAsset;
assets.small_text = customFormat(settings.store.smallTextString, trackData); if (!isRadio) assets.small_text = customFormat(settings.store.smallTextString, trackData);
} }
const buttons: ActivityButton[] = []; const buttons: ActivityButton[] = [];
@ -243,17 +245,17 @@ export default definePlugin({
name: customFormat(settings.store.nameString, trackData), name: customFormat(settings.store.nameString, trackData),
details: customFormat(settings.store.detailsString, trackData), details: customFormat(settings.store.detailsString, trackData),
state: customFormat(settings.store.stateString, trackData), state: isRadio ? undefined : customFormat(settings.store.stateString, trackData),
timestamps: (settings.store.enableTimestamps ? { timestamps: (trackData.playerPosition && trackData.duration && settings.store.enableTimestamps) ? {
start: Date.now() - (trackData.playerPosition * 1000), start: Date.now() - (trackData.playerPosition * 1000),
end: Date.now() - (trackData.playerPosition * 1000) + (trackData.duration * 1000), end: Date.now() - (trackData.playerPosition * 1000) + (trackData.duration * 1000),
} : undefined), } : undefined,
assets, assets,
buttons: buttons.length ? buttons.map(v => v.label) : undefined, buttons: !isRadio && buttons.length ? buttons.map(v => v.label) : undefined,
metadata: { button_urls: buttons.map(v => v.url) || undefined, }, metadata: !isRadio && buttons.length ? { button_urls: buttons.map(v => v.url) } : undefined,
type: settings.store.activityType, type: settings.store.activityType,
flags: ActivityFlag.INSTANCE, flags: ActivityFlag.INSTANCE,

View file

@ -11,37 +11,11 @@ import type { TrackData } from ".";
const exec = promisify(execFile); const exec = promisify(execFile);
// function exec(file: string, args: string[] = []) {
// return new Promise<{ code: number | null, stdout: string | null, stderr: string | null; }>((resolve, reject) => {
// const process = spawn(file, args, { stdio: [null, "pipe", "pipe"] });
// let stdout: string | null = null;
// process.stdout.on("data", (chunk: string) => { stdout ??= ""; stdout += chunk; });
// let stderr: string | null = null;
// process.stderr.on("data", (chunk: string) => { stdout ??= ""; stderr += chunk; });
// process.on("exit", code => { resolve({ code, stdout, stderr }); });
// process.on("error", err => reject(err));
// });
// }
async function applescript(cmds: string[]) { async function applescript(cmds: string[]) {
const { stdout } = await exec("osascript", cmds.map(c => ["-e", c]).flat()); const { stdout } = await exec("osascript", cmds.map(c => ["-e", c]).flat());
return stdout; return stdout;
} }
function makeSearchUrl(type: string, query: string) {
const url = new URL("https://tools.applemediaservices.com/api/apple-media/music/US/search.json");
url.searchParams.set("types", type);
url.searchParams.set("limit", "1");
url.searchParams.set("term", query);
return url;
}
const requestOptions: RequestInit = {
headers: { "user-agent": "Mozilla/5.0 (Windows NT 10.0; rv:125.0) Gecko/20100101 Firefox/125.0" },
};
interface RemoteData { interface RemoteData {
appleMusicLink?: string, appleMusicLink?: string,
songLink?: string, songLink?: string,
@ -51,6 +25,24 @@ interface RemoteData {
let cachedRemoteData: { id: string, data: RemoteData; } | { id: string, failures: number; } | null = null; let cachedRemoteData: { id: string, data: RemoteData; } | { id: string, failures: number; } | null = null;
const APPLE_MUSIC_BUNDLE_REGEX = /<script type="module" crossorigin src="([a-zA-Z0-9.\-/]+)"><\/script>/;
const APPLE_MUSIC_TOKEN_REGEX = /\w+="([A-Za-z0-9-_]*\.[A-Za-z0-9-_]*\.[A-Za-z0-9-_]*)",\w+="x-apple-jingle-correlation-key"/;
let cachedToken: string | undefined = undefined;
const getToken = async () => {
if (cachedToken) return cachedToken;
const html = await fetch("https://music.apple.com/").then(r => r.text());
const bundleUrl = new URL(html.match(APPLE_MUSIC_BUNDLE_REGEX)![1], "https://music.apple.com/");
const bundle = await fetch(bundleUrl).then(r => r.text());
const token = bundle.match(APPLE_MUSIC_TOKEN_REGEX)![1];
cachedToken = token;
return token;
};
async function fetchRemoteData({ id, name, artist, album }: { id: string, name: string, artist: string, album: string; }) { async function fetchRemoteData({ id, name, artist, album }: { id: string, name: string, artist: string, album: string; }) {
if (id === cachedRemoteData?.id) { if (id === cachedRemoteData?.id) {
if ("data" in cachedRemoteData) return cachedRemoteData.data; if ("data" in cachedRemoteData) return cachedRemoteData.data;
@ -58,21 +50,39 @@ async function fetchRemoteData({ id, name, artist, album }: { id: string, name:
} }
try { try {
const [songData, artistData] = await Promise.all([ const dataUrl = new URL("https://amp-api-edge.music.apple.com/v1/catalog/us/search");
fetch(makeSearchUrl("songs", artist + " " + album + " " + name), requestOptions).then(r => r.json()), dataUrl.searchParams.set("platform", "web");
fetch(makeSearchUrl("artists", artist.split(/ *[,&] */)[0]), requestOptions).then(r => r.json()) dataUrl.searchParams.set("l", "en-US");
]); dataUrl.searchParams.set("limit", "1");
dataUrl.searchParams.set("with", "serverBubbles");
dataUrl.searchParams.set("types", "songs");
dataUrl.searchParams.set("term", `${name} ${artist} ${album}`);
dataUrl.searchParams.set("include[songs]", "artists");
const appleMusicLink = songData?.songs?.data[0]?.attributes.url; const token = await getToken();
const songLink = songData?.songs?.data[0]?.id ? `https://song.link/i/${songData?.songs?.data[0]?.id}` : undefined;
const albumArtwork = songData?.songs?.data[0]?.attributes.artwork.url.replace("{w}", "512").replace("{h}", "512"); const songData = await fetch(dataUrl, {
const artistArtwork = artistData?.artists?.data[0]?.attributes.artwork.url.replace("{w}", "512").replace("{h}", "512"); headers: {
"accept": "*/*",
"accept-language": "en-US,en;q=0.9",
"authorization": `Bearer ${token}`,
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36",
"origin": "https://music.apple.com",
},
})
.then(r => r.json())
.then(data => data.results.song.data[0]);
cachedRemoteData = { cachedRemoteData = {
id, id,
data: { appleMusicLink, songLink, albumArtwork, artistArtwork } data: {
appleMusicLink: songData.attributes.url,
songLink: `https://song.link/i/${songData.id}`,
albumArtwork: songData.attributes.artwork.url.replace("{w}x{h}", "512x512"),
artistArtwork: songData.relationships.artists.data[0].attributes.artwork.url.replace("{w}x{h}", "512x512"),
}
}; };
return cachedRemoteData.data; return cachedRemoteData.data;
} catch (e) { } catch (e) {
console.error("[AppleMusicRichPresence] Failed to fetch remote data:", e); console.error("[AppleMusicRichPresence] Failed to fetch remote data:", e);

View file

@ -0,0 +1,11 @@
# Better Folders
Better Folders offers a variety of options to improve your folder experience
Always show the folder icon, regardless of if the folder is open or not
Only have one folder open at a time
Open folders in a sidebar:
![A folder open in a separate sidebar](https://github.com/user-attachments/assets/432d3146-8091-4bae-9c1e-c19046c72947)

View file

@ -30,9 +30,9 @@ enum FolderIconDisplay {
MoreThanOneFolderExpanded MoreThanOneFolderExpanded
} }
const GuildsTree = findLazy(m => m.prototype?.moveNextTo);
const SortedGuildStore = findStoreLazy("SortedGuildStore");
export const ExpandedGuildFolderStore = findStoreLazy("ExpandedGuildFolderStore"); export const ExpandedGuildFolderStore = findStoreLazy("ExpandedGuildFolderStore");
const SortedGuildStore = findStoreLazy("SortedGuildStore");
const GuildsTree = findLazy(m => m.prototype?.moveNextTo);
const FolderUtils = findByPropsLazy("move", "toggleGuildFolderExpand"); const FolderUtils = findByPropsLazy("move", "toggleGuildFolderExpand");
let lastGuildId = null as string | null; let lastGuildId = null as string | null;
@ -118,22 +118,22 @@ export default definePlugin({
// If we are rendering the Better Folders sidebar, we filter out guilds that are not in folders and unexpanded folders // If we are rendering the Better Folders sidebar, we filter out guilds that are not in folders and unexpanded folders
{ {
match: /\[(\i)\]=(\(0,\i\.\i\).{0,40}getGuildsTree\(\).+?}\))(?=,)/, match: /\[(\i)\]=(\(0,\i\.\i\).{0,40}getGuildsTree\(\).+?}\))(?=,)/,
replace: (_, originalTreeVar, rest) => `[betterFoldersOriginalTree]=${rest},${originalTreeVar}=$self.getGuildTree(!!arguments[0].isBetterFolders,betterFoldersOriginalTree,arguments[0].betterFoldersExpandedIds)` replace: (_, originalTreeVar, rest) => `[betterFoldersOriginalTree]=${rest},${originalTreeVar}=$self.getGuildTree(!!arguments[0]?.isBetterFolders,betterFoldersOriginalTree,arguments[0]?.betterFoldersExpandedIds)`
}, },
// If we are rendering the Better Folders sidebar, we filter out everything but the servers and folders from the GuildsBar Guild List children // If we are rendering the Better Folders sidebar, we filter out everything but the servers and folders from the GuildsBar Guild List children
{ {
match: /lastTargetNode:\i\[\i\.length-1\].+?Fragment.+?\]}\)\]/, match: /lastTargetNode:\i\[\i\.length-1\].+?Fragment.+?\]}\)\]/,
replace: "$&.filter($self.makeGuildsBarGuildListFilter(!!arguments[0].isBetterFolders))" replace: "$&.filter($self.makeGuildsBarGuildListFilter(!!arguments[0]?.isBetterFolders))"
}, },
// If we are rendering the Better Folders sidebar, we filter out everything but the scroller for the guild list from the GuildsBar Tree children // If we are rendering the Better Folders sidebar, we filter out everything but the scroller for the guild list from the GuildsBar Tree children
{ {
match: /unreadMentionsIndicatorBottom,.+?}\)\]/, match: /unreadMentionsIndicatorBottom,.+?}\)\]/,
replace: "$&.filter($self.makeGuildsBarTreeFilter(!!arguments[0].isBetterFolders))" replace: "$&.filter($self.makeGuildsBarTreeFilter(!!arguments[0]?.isBetterFolders))"
}, },
// Export the isBetterFolders variable to the folders component // Export the isBetterFolders variable to the folders component
{ {
match: /(?<=\.Messages\.SERVERS.+?switch\((\i)\.type\){case \i\.\i\.FOLDER:.+?folderNode:\i,)/, match: /switch\(\i\.type\){case \i\.\i\.FOLDER:.+?folderNode:\i,/,
replace: 'isBetterFolders:typeof isBetterFolders!=="undefined"?isBetterFolders:false,' replace: '$&isBetterFolders:typeof isBetterFolders!=="undefined"?isBetterFolders:false,'
} }
] ]
}, },
@ -167,31 +167,31 @@ export default definePlugin({
{ {
predicate: () => settings.store.keepIcons, predicate: () => settings.store.keepIcons,
match: /(?<=let{folderNode:\i,setNodeRef:\i,.+?expanded:(\i),.+?;)(?=let)/, match: /(?<=let{folderNode:\i,setNodeRef:\i,.+?expanded:(\i),.+?;)(?=let)/,
replace: (_, isExpanded) => `${isExpanded}=!!arguments[0].isBetterFolders&&${isExpanded};` replace: (_, isExpanded) => `${isExpanded}=!!arguments[0]?.isBetterFolders&&${isExpanded};`
}, },
// Disable expanding and collapsing folders transition in the normal GuildsBar sidebar // Disable expanding and collapsing folders transition in the normal GuildsBar sidebar
{ {
predicate: () => !settings.store.keepIcons, predicate: () => !settings.store.keepIcons,
match: /(?<=\.Messages\.SERVER_FOLDER_PLACEHOLDER.+?useTransition\)\()/, match: /(?<=\.Messages\.SERVER_FOLDER_PLACEHOLDER.+?useTransition\)\()/,
replace: "!!arguments[0].isBetterFolders&&" replace: "$self.shouldShowTransition(arguments[0])&&"
}, },
// If we are rendering the normal GuildsBar sidebar, we avoid rendering guilds from folders that are expanded // If we are rendering the normal GuildsBar sidebar, we avoid rendering guilds from folders that are expanded
{ {
predicate: () => !settings.store.keepIcons, predicate: () => !settings.store.keepIcons,
match: /expandedFolderBackground,.+?,(?=\i\(\(\i,\i,\i\)=>{let{key.{0,45}ul)(?<=selected:\i,expanded:(\i),.+?)/, match: /expandedFolderBackground,.+?,(?=\i\(\(\i,\i,\i\)=>{let{key.{0,45}ul)(?<=selected:\i,expanded:(\i),.+?)/,
replace: (m, isExpanded) => `${m}!arguments[0].isBetterFolders&&${isExpanded}?null:` replace: (m, isExpanded) => `${m}$self.shouldRenderContents(arguments[0],${isExpanded})?null:`
}, },
{ {
// Decide if we should render the expanded folder background if we are rendering the Better Folders sidebar // Decide if we should render the expanded folder background if we are rendering the Better Folders sidebar
predicate: () => settings.store.showFolderIcon !== FolderIconDisplay.Always, predicate: () => settings.store.showFolderIcon !== FolderIconDisplay.Always,
match: /(?<=\.wrapper,children:\[)/, match: /(?<=\.wrapper,children:\[)/,
replace: "$self.shouldShowFolderIconAndBackground(!!arguments[0].isBetterFolders,arguments[0].betterFoldersExpandedIds)&&" replace: "$self.shouldShowFolderIconAndBackground(!!arguments[0]?.isBetterFolders,arguments[0]?.betterFoldersExpandedIds)&&"
}, },
{ {
// Decide if we should render the expanded folder icon if we are rendering the Better Folders sidebar // Decide if we should render the expanded folder icon if we are rendering the Better Folders sidebar
predicate: () => settings.store.showFolderIcon !== FolderIconDisplay.Always, predicate: () => settings.store.showFolderIcon !== FolderIconDisplay.Always,
match: /(?<=\.expandedFolderBackground.+?}\),)(?=\i,)/, match: /(?<=\.expandedFolderBackground.+?}\),)(?=\i,)/,
replace: "!$self.shouldShowFolderIconAndBackground(!!arguments[0].isBetterFolders,arguments[0].betterFoldersExpandedIds)?null:" replace: "!$self.shouldShowFolderIconAndBackground(!!arguments[0]?.isBetterFolders,arguments[0]?.betterFoldersExpandedIds)?null:"
} }
] ]
}, },
@ -200,8 +200,8 @@ export default definePlugin({
predicate: () => settings.store.sidebar, predicate: () => settings.store.sidebar,
replacement: { replacement: {
// Render the Better Folders sidebar // Render the Better Folders sidebar
match: /(?<=({className:\i\.guilds,themeOverride:\i})\))/, match: /(container.{0,50}({className:\i\.guilds,themeOverride:\i})\))/,
replace: ",$self.FolderSideBar($1)" replace: "$1,$self.FolderSideBar({...$2})"
} }
}, },
{ {
@ -306,7 +306,20 @@ export default definePlugin({
} }
}, },
FolderSideBar: guildsBarProps => <FolderSideBar {...guildsBarProps} />, shouldShowTransition(props: any) {
// Pending guilds
if (props?.folderNode?.id === 1) return true;
closeFolders return !!props?.isBetterFolders;
},
shouldRenderContents(props: any, isExpanded: boolean) {
// Pending guilds
if (props?.folderNode?.id === 1) return false;
return !props?.isBetterFolders && isExpanded;
},
FolderSideBar,
closeFolders,
}); });

View file

@ -0,0 +1,68 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { openPluginModal } from "@components/PluginSettings/PluginModal";
import { isObjectEmpty } from "@utils/misc";
import { Alerts, i18n, Menu, useMemo, useState } from "@webpack/common";
import Plugins from "~plugins";
function onRestartNeeded() {
Alerts.show({
title: "Restart required",
body: <p>You have changed settings that require a restart.</p>,
confirmText: "Restart now",
cancelText: "Later!",
onConfirm: () => location.reload()
});
}
export default function PluginsSubmenu() {
const sortedPlugins = useMemo(() => Object.values(Plugins)
.sort((a, b) => a.name.localeCompare(b.name)), []);
const [query, setQuery] = useState("");
const search = query.toLowerCase();
const include = (p: typeof Plugins[keyof typeof Plugins]) => (
Vencord.Plugins.isPluginEnabled(p.name)
&& p.options && !isObjectEmpty(p.options)
&& (
p.name.toLowerCase().includes(search)
|| p.description.toLowerCase().includes(search)
|| p.tags?.some(t => t.toLowerCase().includes(search))
)
);
const plugins = sortedPlugins.filter(include);
return (
<>
<Menu.MenuControlItem
id="vc-plugins-search"
control={(props, ref) => (
<Menu.MenuSearchControl
{...props}
query={query}
onChange={setQuery}
ref={ref}
placeholder={i18n.Messages.SEARCH}
/>
)}
/>
{!!plugins.length && <Menu.MenuSeparator />}
{plugins.map(p => (
<Menu.MenuItem
key={p.name}
id={p.name}
label={p.name}
action={() => openPluginModal(p, onRestartNeeded)}
/>
))}
</>
);
}

View file

@ -13,6 +13,8 @@ import { waitFor } from "@webpack";
import { ComponentDispatch, FocusLock, i18n, Menu, useEffect, useRef } from "@webpack/common"; import { ComponentDispatch, FocusLock, i18n, Menu, useEffect, useRef } from "@webpack/common";
import type { HTMLAttributes, ReactElement } from "react"; import type { HTMLAttributes, ReactElement } from "react";
import PluginsSubmenu from "./PluginsSubmenu";
type SettingsEntry = { section: string, label: string; }; type SettingsEntry = { section: string, label: string; };
const cl = classNameFactory(""); const cl = classNameFactory("");
@ -118,13 +120,21 @@ export default definePlugin({
}, },
{ // Settings cog context menu { // Settings cog context menu
find: "Messages.USER_SETTINGS_ACTIONS_MENU_LABEL", find: "Messages.USER_SETTINGS_ACTIONS_MENU_LABEL",
replacement: { replacement: [
match: /(EXPERIMENTS:.+?)(\(0,\i.\i\)\(\))(?=\.filter\(\i=>\{let\{section:\i\}=)/, {
replace: "$1$self.wrapMenu($2)" match: /(EXPERIMENTS:.+?)(\(0,\i.\i\)\(\))(?=\.filter\(\i=>\{let\{section:\i\}=)/,
} replace: "$1$self.wrapMenu($2)"
} },
{
match: /case \i\.\i\.DEVELOPER_OPTIONS:return \i;/,
replace: "$&case 'VencordPlugins':return $self.PluginsSubmenu();"
}
]
},
], ],
PluginsSubmenu,
// This is the very outer layer of the entire ui, so we can't wrap this in an ErrorBoundary // This is the very outer layer of the entire ui, so we can't wrap this in an ErrorBoundary
// without possibly also catching unrelated errors of children. // without possibly also catching unrelated errors of children.
// //

View file

@ -155,4 +155,5 @@ export const defaultRules = [
"igshid", "igshid",
"igsh", "igsh",
"share_id@reddit.com", "share_id@reddit.com",
"si@soundcloud.com",
]; ];

View file

@ -12,7 +12,7 @@ import { Margins } from "@utils/margins";
import { classes } from "@utils/misc"; import { classes } from "@utils/misc";
import definePlugin, { OptionType, StartAt } from "@utils/types"; import definePlugin, { OptionType, StartAt } from "@utils/types";
import { findByCodeLazy, findComponentByCodeLazy, findStoreLazy } from "@webpack"; import { findByCodeLazy, findComponentByCodeLazy, findStoreLazy } from "@webpack";
import { Button, Forms, useStateFromStores } from "@webpack/common"; import { Button, Forms, ThemeStore, useStateFromStores } from "@webpack/common";
const ColorPicker = findComponentByCodeLazy(".Messages.USER_SETTINGS_PROFILE_COLOR_SELECT_COLOR", ".BACKGROUND_PRIMARY)"); const ColorPicker = findComponentByCodeLazy(".Messages.USER_SETTINGS_PROFILE_COLOR_SELECT_COLOR", ".BACKGROUND_PRIMARY)");
@ -36,7 +36,6 @@ function setTheme(theme: string) {
saveClientTheme({ theme }); saveClientTheme({ theme });
} }
const ThemeStore = findStoreLazy("ThemeStore");
const NitroThemeStore = findStoreLazy("ClientThemesBackgroundStore"); const NitroThemeStore = findStoreLazy("ClientThemesBackgroundStore");
function ThemeSettings() { function ThemeSettings() {

View file

@ -1,5 +1,5 @@
# ConsoleJanitor # ConsoleJanitor
Disables annoying console messages/errors. This plugin mainly removes errors/warnings that happen all the time and noisy/spammy logging messages. Disables annoying console messages/errors. This plugin mainly removes errors/warnings that happen all the time and Discord logger messages.
Some of the disabled messages include the "notosans-400-normalitalic" error and MessageActionCreators, Routing/Utils loggers. One of the disabled messages is the "Window state not initialized" warning, for example.

View file

@ -6,7 +6,7 @@
import { definePluginSettings } from "@api/Settings"; import { definePluginSettings } from "@api/Settings";
import { Devs } from "@utils/constants"; import { Devs } from "@utils/constants";
import definePlugin, { OptionType } from "@utils/types"; import definePlugin, { OptionType, StartAt } from "@utils/types";
const Noop = () => { }; const Noop = () => { };
const NoopLogger = { const NoopLogger = {
@ -22,10 +22,12 @@ const NoopLogger = {
fileOnly: Noop fileOnly: Noop
}; };
const logAllow = new Set();
const settings = definePluginSettings({ const settings = definePluginSettings({
disableNoisyLoggers: { disableLoggers: {
type: OptionType.BOOLEAN, type: OptionType.BOOLEAN,
description: "Disable noisy loggers like the MessageActionCreators", description: "Disables Discords loggers",
default: false, default: false,
restartNeeded: true restartNeeded: true
}, },
@ -34,16 +36,34 @@ const settings = definePluginSettings({
description: "Disable the Spotify logger, which leaks account information and access token", description: "Disable the Spotify logger, which leaks account information and access token",
default: true, default: true,
restartNeeded: true restartNeeded: true
},
whitelistedLoggers: {
type: OptionType.STRING,
description: "Semi colon separated list of loggers to allow even if others are hidden",
default: "GatewaySocket; Routing/Utils",
onChange(newVal: string) {
logAllow.clear();
newVal.split(";").map(x => x.trim()).forEach(logAllow.add.bind(logAllow));
}
} }
}); });
export default definePlugin({ export default definePlugin({
name: "ConsoleJanitor", name: "ConsoleJanitor",
description: "Disables annoying console messages/errors", description: "Disables annoying console messages/errors",
authors: [Devs.Nuckyz], authors: [Devs.Nuckyz, Devs.sadan],
settings, settings,
startAt: StartAt.Init,
start() {
logAllow.clear();
this.settings.store.whitelistedLoggers?.split(";").map(x => x.trim()).forEach(logAllow.add.bind(logAllow));
},
NoopLogger: () => NoopLogger, NoopLogger: () => NoopLogger,
shouldLog(logger: string) {
return logAllow.has(logger);
},
patches: [ patches: [
{ {
@ -103,34 +123,13 @@ export default definePlugin({
replace: "" replace: ""
} }
}, },
...[ // Patches discords generic logger function
'("MessageActionCreators")', '("ChannelMessages")',
'("Routing/Utils")', '("RTCControlSocket")',
'("ConnectionEventFramerateReducer")', '("RTCLatencyTestManager")',
'("OverlayBridgeStore")', '("RPCServer:WSS")', '("RPCServer:IPC")'
].map(logger => ({
find: logger,
predicate: () => settings.store.disableNoisyLoggers,
all: true,
replacement: {
match: new RegExp(String.raw`new \i\.\i${logger.replace(/([()])/g, "\\$1")}`),
replace: `$self.NoopLogger${logger}`
}
})),
{ {
find: '"Experimental codecs: "', find: "Σ:",
predicate: () => settings.store.disableNoisyLoggers, predicate: () => settings.store.disableLoggers,
replacement: { replacement: {
match: /new \i\.\i\("Connection\("\.concat\(\i,"\)"\)\)/, match: /(?<=&&)(?=console)/,
replace: "$self.NoopLogger()" replace: "$self.shouldLog(arguments[0])&&"
}
},
{
find: '"Handling ping: "',
predicate: () => settings.store.disableNoisyLoggers,
replacement: {
match: /new \i\.\i\("RTCConnection\("\.concat.+?\)\)(?=,)/,
replace: "$self.NoopLogger()"
} }
}, },
{ {
@ -141,5 +140,5 @@ export default definePlugin({
replace: "$self.NoopLogger()" replace: "$self.NoopLogger()"
} }
} }
] ],
}); });

View file

@ -1,6 +1,7 @@
.vc-cfc-button { .vc-cfc-button {
color: var(--interactive-normal); color: var(--interactive-normal);
cursor: pointer; cursor: pointer;
padding-left: 4px;
} }
.vc-cfc-button:hover { .vc-cfc-button:hover {

View file

@ -175,7 +175,7 @@ export default definePlugin({
} }
if (settings.store.attemptToNavigateToHome) { if (settings.store.attemptToNavigateToHome) {
try { try {
NavigationRouter.transitionTo("/channels/@me"); NavigationRouter.transitionToGuild("@me");
} catch (err) { } catch (err) {
CrashHandlerLogger.debug("Failed to navigate to home", err); CrashHandlerLogger.debug("Failed to navigate to home", err);
} }

View file

@ -26,12 +26,11 @@ import { Margins } from "@utils/margins";
import { classes } from "@utils/misc"; import { classes } from "@utils/misc";
import { useAwaiter } from "@utils/react"; import { useAwaiter } from "@utils/react";
import definePlugin, { OptionType } from "@utils/types"; import definePlugin, { OptionType } from "@utils/types";
import { findByCodeLazy, findByPropsLazy, findComponentByCodeLazy } from "@webpack"; import { findByCodeLazy, findComponentByCodeLazy } from "@webpack";
import { ApplicationAssetUtils, Button, FluxDispatcher, Forms, GuildStore, React, SelectedChannelStore, SelectedGuildStore, UserStore } from "@webpack/common"; import { ApplicationAssetUtils, Button, FluxDispatcher, Forms, GuildStore, React, SelectedChannelStore, SelectedGuildStore, UserStore } from "@webpack/common";
const useProfileThemeStyle = findByCodeLazy("profileThemeStyle:", "--profile-gradient-primary-color"); const useProfileThemeStyle = findByCodeLazy("profileThemeStyle:", "--profile-gradient-primary-color");
const ActivityComponent = findComponentByCodeLazy("onOpenGameProfile"); const ActivityComponent = findComponentByCodeLazy("onOpenGameProfile");
const ActivityClassName = findByPropsLazy("activity", "buttonColor");
const ShowCurrentGame = getUserSettingLazy<boolean>("status", "showCurrentGame")!; const ShowCurrentGame = getUserSettingLazy<boolean>("status", "showCurrentGame")!;
@ -436,8 +435,8 @@ export default definePlugin({
<Forms.FormDivider className={Margins.top8} /> <Forms.FormDivider className={Margins.top8} />
<div style={{ width: "284px", ...profileThemeStyle }}> <div style={{ width: "284px", ...profileThemeStyle, padding: 8, marginTop: 8, borderRadius: 8, background: "var(--bg-mod-faint)" }}>
{activity[0] && <ActivityComponent activity={activity[0]} className={ActivityClassName.activity} channelId={SelectedChannelStore.getChannelId()} {activity[0] && <ActivityComponent activity={activity[0]} channelId={SelectedChannelStore.getChannelId()}
guild={GuildStore.getGuild(SelectedGuildStore.getLastSelectedGuildId())} guild={GuildStore.getGuild(SelectedGuildStore.getLastSelectedGuildId())}
application={{ id: settings.store.appID }} application={{ id: settings.store.appID }}
user={UserStore.getCurrentUser()} />} user={UserStore.getCurrentUser()} />}

View file

@ -46,7 +46,7 @@ const embedUrlRe = /https:\/\/www\.youtube\.com\/embed\/([a-zA-Z0-9_-]{11})/;
async function embedDidMount(this: Component<Props>) { async function embedDidMount(this: Component<Props>) {
try { try {
const { embed } = this.props; const { embed } = this.props;
const { replaceElements } = settings.store; const { replaceElements, dearrowByDefault } = settings.store;
if (!embed || embed.dearrow || embed.provider?.name !== "YouTube" || !embed.video?.url) return; if (!embed || embed.dearrow || embed.provider?.name !== "YouTube" || !embed.video?.url) return;
@ -63,18 +63,22 @@ async function embedDidMount(this: Component<Props>) {
if (!hasTitle && !hasThumb) return; if (!hasTitle && !hasThumb) return;
embed.dearrow = { embed.dearrow = {
enabled: true enabled: dearrowByDefault
}; };
if (hasTitle && replaceElements !== ReplaceElements.ReplaceThumbnailsOnly) { if (hasTitle && replaceElements !== ReplaceElements.ReplaceThumbnailsOnly) {
embed.dearrow.oldTitle = embed.rawTitle; const replacementTitle = titles[0].title.replace(/(^|\s)>(\S)/g, "$1$2");
embed.rawTitle = titles[0].title.replace(/(^|\s)>(\S)/g, "$1$2");
}
embed.dearrow.oldTitle = dearrowByDefault ? embed.rawTitle : replacementTitle;
if (dearrowByDefault) embed.rawTitle = replacementTitle;
}
if (hasThumb && replaceElements !== ReplaceElements.ReplaceTitlesOnly) { if (hasThumb && replaceElements !== ReplaceElements.ReplaceTitlesOnly) {
embed.dearrow.oldThumb = embed.thumbnail.proxyURL; const replacementProxyURL = `https://dearrow-thumb.ajay.app/api/v1/getThumbnail?videoID=${videoId}&time=${thumbnails[0].timestamp}`;
embed.thumbnail.proxyURL = `https://dearrow-thumb.ajay.app/api/v1/getThumbnail?videoID=${videoId}&time=${thumbnails[0].timestamp}`;
embed.dearrow.oldThumb = dearrowByDefault ? embed.thumbnail.proxyURL : replacementProxyURL;
if (dearrowByDefault) embed.thumbnail.proxyURL = replacementProxyURL;
} }
this.forceUpdate(); this.forceUpdate();
@ -96,6 +100,7 @@ function DearrowButton({ component }: { component: Component<Props>; }) {
className={"vc-dearrow-toggle-" + (embed.dearrow.enabled ? "on" : "off")} className={"vc-dearrow-toggle-" + (embed.dearrow.enabled ? "on" : "off")}
onClick={() => { onClick={() => {
const { enabled, oldThumb, oldTitle } = embed.dearrow; const { enabled, oldThumb, oldTitle } = embed.dearrow;
settings.store.dearrowByDefault = !enabled;
embed.dearrow.enabled = !enabled; embed.dearrow.enabled = !enabled;
if (oldTitle) { if (oldTitle) {
embed.dearrow.oldTitle = embed.rawTitle; embed.dearrow.oldTitle = embed.rawTitle;
@ -153,6 +158,12 @@ const settings = definePluginSettings({
{ label: "Titles", value: ReplaceElements.ReplaceTitlesOnly }, { label: "Titles", value: ReplaceElements.ReplaceTitlesOnly },
{ label: "Thumbnails", value: ReplaceElements.ReplaceThumbnailsOnly }, { label: "Thumbnails", value: ReplaceElements.ReplaceThumbnailsOnly },
], ],
},
dearrowByDefault: {
description: "Dearrow videos automatically",
type: OptionType.BOOLEAN,
default: true,
restartNeeded: false
} }
}); });

View file

@ -23,12 +23,13 @@ import { ErrorCard } from "@components/ErrorCard";
import { Devs } from "@utils/constants"; import { Devs } from "@utils/constants";
import { Margins } from "@utils/margins"; import { Margins } from "@utils/margins";
import definePlugin, { OptionType } from "@utils/types"; import definePlugin, { OptionType } from "@utils/types";
import { findByPropsLazy } from "@webpack"; import { findByPropsLazy, findLazy } from "@webpack";
import { Forms, React } from "@webpack/common"; import { Forms, React } from "@webpack/common";
import hideBugReport from "./hideBugReport.css?managed"; import hideBugReport from "./hideBugReport.css?managed";
const KbdStyles = findByPropsLazy("key", "combo"); const KbdStyles = findByPropsLazy("key", "combo");
const BugReporterExperiment = findLazy(m => m?.definition?.id === "2024-09_bug_reporter");
const settings = definePluginSettings({ const settings = definePluginSettings({
toolbarDevMenu: { toolbarDevMenu: {
@ -78,8 +79,8 @@ export default definePlugin({
{ {
find: "toolbar:function", find: "toolbar:function",
replacement: { replacement: {
match: /\i\.isStaff\(\)/, match: /hasBugReporterAccess:(\i)/,
replace: "true" replace: "_hasBugReporterAccess:$1=true"
}, },
predicate: () => settings.store.toolbarDevMenu predicate: () => settings.store.toolbarDevMenu
}, },
@ -91,10 +92,18 @@ export default definePlugin({
match: /\i\.isDM\(\)\|\|\i\.isThread\(\)/, match: /\i\.isDM\(\)\|\|\i\.isThread\(\)/,
replace: "false", replace: "false",
} }
},
// enable option to always record clips even if you are not streaming
{
find: "isDecoupledGameClippingEnabled(){",
replacement: {
match: /\i\.isStaff\(\)/,
replace: "true"
}
} }
], ],
start: () => enableStyle(hideBugReport), start: () => !BugReporterExperiment.getCurrentConfig().hasBugReporterAccess && enableStyle(hideBugReport),
stop: () => disableStyle(hideBugReport), stop: () => disableStyle(hideBugReport),
settingsAboutComponent: () => { settingsAboutComponent: () => {

View file

@ -203,6 +203,15 @@ export default definePlugin({
settings, settings,
patches: [ patches: [
// Patch the emoji picker in voice calls to not be bypassed by fake nitro
{
find: "emojiItemDisabled]",
predicate: () => settings.store.enableEmojiBypass,
replacement: {
match: /CHAT/,
replace: "STATUS"
}
},
{ {
find: ".PREMIUM_LOCKED;", find: ".PREMIUM_LOCKED;",
group: true, group: true,

View file

@ -27,7 +27,6 @@ export default definePlugin({
name: "FriendInvites", name: "FriendInvites",
description: "Create and manage friend invite links via slash commands (/create friend invite, /view friend invites, /revoke friend invites).", description: "Create and manage friend invite links via slash commands (/create friend invite, /view friend invites, /revoke friend invites).",
authors: [Devs.afn, Devs.Dziurwa], authors: [Devs.afn, Devs.Dziurwa],
dependencies: ["CommandsAPI"],
commands: [ commands: [
{ {
name: "create friend invite", name: "create friend invite",

View file

@ -7,16 +7,15 @@
import ErrorBoundary from "@components/ErrorBoundary"; import ErrorBoundary from "@components/ErrorBoundary";
import { Devs } from "@utils/constants"; import { Devs } from "@utils/constants";
import { getCurrentChannel } from "@utils/discord"; import { getCurrentChannel } from "@utils/discord";
import { Logger } from "@utils/Logger";
import definePlugin from "@utils/types"; import definePlugin from "@utils/types";
import { findByCodeLazy, findByPropsLazy, findLazy } from "@webpack"; import { findByCodeLazy, findByPropsLazy, findComponentByCodeLazy } from "@webpack";
import { Heading, RelationshipStore, Text } from "@webpack/common"; import { RelationshipStore, Text } from "@webpack/common";
const containerWrapper = findByPropsLazy("memberSinceWrapper"); const containerWrapper = findByPropsLazy("memberSinceWrapper");
const container = findByPropsLazy("memberSince"); const container = findByPropsLazy("memberSince");
const getCreatedAtDate = findByCodeLazy('month:"short",day:"numeric"'); const getCreatedAtDate = findByCodeLazy('month:"short",day:"numeric"');
const locale = findByPropsLazy("getLocale"); const locale = findByPropsLazy("getLocale");
const section = findLazy((m: any) => m.section !== void 0 && m.heading !== void 0 && Object.values(m).length === 2); const Section = findComponentByCodeLazy('"auto":"smooth"', ".section");
export default definePlugin({ export default definePlugin({
name: "FriendsSince", name: "FriendsSince",
@ -28,7 +27,7 @@ export default definePlugin({
find: ".PANEL}),nicknameIcons", find: ".PANEL}),nicknameIcons",
replacement: { replacement: {
match: /USER_PROFILE_MEMBER_SINCE,.{0,100}userId:(\i\.id)}\)}\)/, match: /USER_PROFILE_MEMBER_SINCE,.{0,100}userId:(\i\.id)}\)}\)/,
replace: "$&,$self.friendsSinceNew({userId:$1,isSidebar:true})" replace: "$&,$self.FriendsSinceComponent({userId:$1,isSidebar:true})"
} }
}, },
// User Profile Modal // User Profile Modal
@ -36,34 +35,19 @@ export default definePlugin({
find: "action:\"PRESS_APP_CONNECTION\"", find: "action:\"PRESS_APP_CONNECTION\"",
replacement: { replacement: {
match: /USER_PROFILE_MEMBER_SINCE,.{0,100}userId:(\i\.id),.{0,100}}\)}\),/, match: /USER_PROFILE_MEMBER_SINCE,.{0,100}userId:(\i\.id),.{0,100}}\)}\),/,
replace: "$&,$self.friendsSinceNew({userId:$1,isSidebar:false})," replace: "$&,$self.FriendsSinceComponent({userId:$1,isSidebar:false}),"
} }
} }
], ],
getFriendSince(userId: string) { FriendsSinceComponent: ErrorBoundary.wrap(({ userId, isSidebar }: { userId: string; isSidebar: boolean; }) => {
try {
if (!RelationshipStore.isFriend(userId)) return null;
return RelationshipStore.getSince(userId);
} catch (err) {
new Logger("FriendsSince").error(err);
return null;
}
},
friendsSinceNew: ErrorBoundary.wrap(({ userId, isSidebar }: { userId: string; isSidebar: boolean; }) => {
if (!RelationshipStore.isFriend(userId)) return null; if (!RelationshipStore.isFriend(userId)) return null;
const friendsSince = RelationshipStore.getSince(userId); const friendsSince = RelationshipStore.getSince(userId);
if (!friendsSince) return null; if (!friendsSince) return null;
return ( return (
<section className={section.section}> <Section heading="Friends Since">
<Heading variant="text-xs/semibold" style={isSidebar ? {} : { color: "var(--header-secondary)" }}>
Friends Since
</Heading>
{ {
isSidebar ? ( isSidebar ? (
<Text variant="text-sm/normal"> <Text variant="text-sm/normal">
@ -91,8 +75,7 @@ export default definePlugin({
</div> </div>
) )
} }
</Section>
</section>
); );
}, { noop: true }), }, { noop: true }),
}); });

View file

@ -0,0 +1,5 @@
# FullSearchContext
Makes the message context menu in message search results have all options you'd expect.
![](https://github.com/user-attachments/assets/472d1327-3935-44c7-b7c4-0978b5348550)

View file

@ -0,0 +1,111 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { findGroupChildrenByChildId, NavContextMenuPatchCallback } from "@api/ContextMenu";
import { migratePluginSettings } from "@api/Settings";
import { Devs } from "@utils/constants";
import { NoopComponent } from "@utils/react";
import definePlugin from "@utils/types";
import { filters, findByPropsLazy, waitFor } from "@webpack";
import { ChannelStore, ContextMenuApi, i18n, UserStore } from "@webpack/common";
import { Message } from "discord-types/general";
const { useMessageMenu } = findByPropsLazy("useMessageMenu");
interface CopyIdMenuItemProps {
id: string;
label: string;
}
let CopyIdMenuItem: (props: CopyIdMenuItemProps) => React.ReactElement | null = NoopComponent;
waitFor(filters.componentByCode('"devmode-copy-id-".concat'), m => CopyIdMenuItem = m);
function MessageMenu({ message, channel, onHeightUpdate }) {
const canReport = message.author &&
!(message.author.id === UserStore.getCurrentUser().id || message.author.system);
return useMessageMenu({
navId: "message-actions",
ariaLabel: i18n.Messages.MESSAGE_UTILITIES_A11Y_LABEL,
message,
channel,
canReport,
onHeightUpdate,
onClose: () => ContextMenuApi.closeContextMenu(),
textSelection: "",
favoriteableType: null,
favoriteableId: null,
favoriteableName: null,
itemHref: void 0,
itemSrc: void 0,
itemSafeSrc: void 0,
itemTextContent: void 0,
isFullSearchContextMenu: true
});
}
interface MessageActionsProps {
message: Message;
isFullSearchContextMenu?: boolean;
}
const contextMenuPatch: NavContextMenuPatchCallback = (children, props: MessageActionsProps) => {
if (props?.isFullSearchContextMenu == null) return;
const group = findGroupChildrenByChildId("devmode-copy-id", children, true);
group?.push(
CopyIdMenuItem({ id: props.message.author.id, label: i18n.Messages.COPY_ID_AUTHOR })
);
};
migratePluginSettings("FullSearchContext", "SearchReply");
export default definePlugin({
name: "FullSearchContext",
description: "Makes the message context menu in message search results have all options you'd expect",
authors: [Devs.Ven, Devs.Aria],
patches: [{
find: "onClick:this.handleMessageClick,",
replacement: {
match: /this(?=\.handleContextMenu\(\i,\i\))/,
replace: "$self"
}
}],
handleContextMenu(event: React.MouseEvent, message: Message) {
const channel = ChannelStore.getChannel(message.channel_id);
if (!channel) return;
event.stopPropagation();
ContextMenuApi.openContextMenu(event, contextMenuProps =>
<MessageMenu
message={message}
channel={channel}
onHeightUpdate={contextMenuProps.onHeightUpdate}
/>
);
},
contextMenus: {
"message-actions": contextMenuPatch
}
});

View file

@ -0,0 +1,13 @@
# IgnoreActivities
Ignore activities from showing up on your status ONLY. You can configure which ones are specifically ignored from the Registered Games and Activities tabs, or use the general settings.
![](https://github.com/user-attachments/assets/f0c19060-0ecf-4f1c-8165-a5aa40143c82)
![](https://github.com/user-attachments/assets/73c3fa7a-5b90-41ee-a4d6-91fa76458b74)
![](https://github.com/user-attachments/assets/1ab3fe73-3911-48d1-8a08-e976af614b41)
The activity stays showing as a detected game even if ignored, differently from the stock Toggle Detection button from Discord:
![](https://github.com/user-attachments/assets/08ea60c3-3a31-42de-ae4c-7535fbf1b45a)

View file

@ -237,7 +237,7 @@ function isActivityTypeIgnored(type: number, id?: string) {
export default definePlugin({ export default definePlugin({
name: "IgnoreActivities", name: "IgnoreActivities",
authors: [Devs.Nuckyz, Devs.Kylie], authors: [Devs.Nuckyz, Devs.Kylie],
description: "Ignore activities from showing up on your status ONLY. You can configure which ones are specifically ignored from the Registered Games and Activities tabs, or use the general settings below.", description: "Ignore activities from showing up on your status ONLY. You can configure which ones are specifically ignored from the Registered Games and Activities tabs, or use the general settings below",
dependencies: ["UserSettingsAPI"], dependencies: ["UserSettingsAPI"],
settings, settings,
@ -266,6 +266,7 @@ export default definePlugin({
replace: (m, props, nowPlaying) => `${m}$self.renderToggleGameActivityButton(${props},${nowPlaying}),` replace: (m, props, nowPlaying) => `${m}$self.renderToggleGameActivityButton(${props},${nowPlaying}),`
} }
}, },
// Discord has 2 different components for activities. Currently, the last is the one being used
{ {
find: ".activityTitleText,variant", find: ".activityTitleText,variant",
replacement: { replacement: {
@ -274,9 +275,9 @@ export default definePlugin({
}, },
}, },
{ {
find: ".activityCardDetails,children", find: ".promotedLabelWrapperNonBanner,children",
replacement: { replacement: {
match: /\.activityCardDetails.+?children:(\i\.application)\.name.*?}\),/, match: /\.appDetailsHeaderContainer.+?children:\i.*?}\),(?<=application:(\i).+?)/,
replace: (m, props) => `${m}$self.renderToggleActivityButton(${props}),` replace: (m, props) => `${m}$self.renderToggleActivityButton(${props}),`
} }
} }

View file

@ -66,14 +66,14 @@ export function addPatch(newPatch: Omit<Patch, "plugin">, pluginName: string) {
patch.replacement = [patch.replacement]; patch.replacement = [patch.replacement];
} }
patch.replacement = patch.replacement.filter(({ predicate }) => !predicate || predicate());
if (IS_REPORTER) { if (IS_REPORTER) {
patch.replacement.forEach(r => { patch.replacement.forEach(r => {
delete r.predicate; delete r.predicate;
}); });
} }
patch.replacement = patch.replacement.filter(({ predicate }) => !predicate || predicate());
patches.push(patch); patches.push(patch);
} }
@ -105,6 +105,11 @@ for (const p of pluginsValues) if (isPluginEnabled(p.name)) {
settings[d].enabled = true; settings[d].enabled = true;
dep.isDependency = true; dep.isDependency = true;
}); });
if (p.commands?.length) {
Plugins.CommandsAPI.isDependency = true;
settings.CommandsAPI.enabled = true;
}
} }
for (const p of pluginsValues) { for (const p of pluginsValues) {

View file

@ -19,7 +19,7 @@
import * as DataStore from "@api/DataStore"; import * as DataStore from "@api/DataStore";
import { Devs } from "@utils/constants"; import { Devs } from "@utils/constants";
import definePlugin from "@utils/types"; import definePlugin from "@utils/types";
import { ChannelStore, NavigationRouter, SelectedChannelStore, SelectedGuildStore } from "@webpack/common"; import { ChannelRouter, SelectedChannelStore, SelectedGuildStore } from "@webpack/common";
export interface LogoutEvent { export interface LogoutEvent {
type: "LOGOUT"; type: "LOGOUT";
@ -40,11 +40,6 @@ interface PreviousChannel {
let isSwitchingAccount = false; let isSwitchingAccount = false;
let previousCache: PreviousChannel | undefined; let previousCache: PreviousChannel | undefined;
function attemptToNavigateToChannel(guildId: string | null, channelId: string) {
if (!ChannelStore.hasChannel(channelId)) return;
NavigationRouter.transitionTo(`/channels/${guildId ?? "@me"}/${channelId}`);
}
export default definePlugin({ export default definePlugin({
name: "KeepCurrentChannel", name: "KeepCurrentChannel",
description: "Attempt to navigate to the channel you were in before switching accounts or loading Discord.", description: "Attempt to navigate to the channel you were in before switching accounts or loading Discord.",
@ -59,8 +54,9 @@ export default definePlugin({
if (!isSwitchingAccount) return; if (!isSwitchingAccount) return;
isSwitchingAccount = false; isSwitchingAccount = false;
if (previousCache?.channelId) if (previousCache?.channelId) {
attemptToNavigateToChannel(previousCache.guildId, previousCache.channelId); ChannelRouter.transitionToChannel(previousCache.channelId);
}
}, },
async CHANNEL_SELECT({ guildId, channelId }: ChannelSelectEvent) { async CHANNEL_SELECT({ guildId, channelId }: ChannelSelectEvent) {
@ -84,7 +80,7 @@ export default definePlugin({
await DataStore.set("KeepCurrentChannel_previousData", previousCache); await DataStore.set("KeepCurrentChannel_previousData", previousCache);
} else if (previousCache.channelId) { } else if (previousCache.channelId) {
attemptToNavigateToChannel(previousCache.guildId, previousCache.channelId); ChannelRouter.transitionToChannel(previousCache.channelId);
} }
} }
}); });

View file

@ -14,7 +14,7 @@ import { OnlineMemberCountStore } from "./OnlineMemberCountStore";
export function MemberCount({ isTooltip, tooltipGuildId }: { isTooltip?: true; tooltipGuildId?: string; }) { export function MemberCount({ isTooltip, tooltipGuildId }: { isTooltip?: true; tooltipGuildId?: string; }) {
const currentChannel = useStateFromStores([SelectedChannelStore], () => getCurrentChannel()); const currentChannel = useStateFromStores([SelectedChannelStore], () => getCurrentChannel());
const guildId = isTooltip ? tooltipGuildId! : currentChannel.guild_id; const guildId = isTooltip ? tooltipGuildId! : currentChannel?.guild_id;
const totalCount = useStateFromStores( const totalCount = useStateFromStores(
[GuildMemberCountStore], [GuildMemberCountStore],
@ -33,7 +33,7 @@ export function MemberCount({ isTooltip, tooltipGuildId }: { isTooltip?: true; t
const threadGroups = useStateFromStores( const threadGroups = useStateFromStores(
[ThreadMemberListStore], [ThreadMemberListStore],
() => ThreadMemberListStore.getMemberListSections(currentChannel.id) () => ThreadMemberListStore.getMemberListSections(currentChannel?.id)
); );
if (!isTooltip && (groups.length >= 1 || groups[0].id !== "unknown")) { if (!isTooltip && (groups.length >= 1 || groups[0].id !== "unknown")) {

View file

@ -15,8 +15,8 @@ export const OnlineMemberCountStore = proxyLazy(() => {
const onlineMemberMap = new Map<string, number>(); const onlineMemberMap = new Map<string, number>();
class OnlineMemberCountStore extends Flux.Store { class OnlineMemberCountStore extends Flux.Store {
getCount(guildId: string) { getCount(guildId?: string) {
return onlineMemberMap.get(guildId); return onlineMemberMap.get(guildId!);
} }
async _ensureCount(guildId: string) { async _ensureCount(guildId: string) {
@ -25,8 +25,8 @@ export const OnlineMemberCountStore = proxyLazy(() => {
await PrivateChannelsStore.preload(guildId, GuildChannelStore.getDefaultChannel(guildId).id); await PrivateChannelsStore.preload(guildId, GuildChannelStore.getDefaultChannel(guildId).id);
} }
ensureCount(guildId: string) { ensureCount(guildId?: string) {
if (onlineMemberMap.has(guildId)) return; if (!guildId || onlineMemberMap.has(guildId)) return;
preloadQueue.push(() => preloadQueue.push(() =>
this._ensureCount(guildId) this._ensureCount(guildId)

View file

@ -28,12 +28,12 @@ import { FluxStore } from "@webpack/types";
import { MemberCount } from "./MemberCount"; import { MemberCount } from "./MemberCount";
export const GuildMemberCountStore = findStoreLazy("GuildMemberCountStore") as FluxStore & { getMemberCount(guildId: string): number | null; }; export const GuildMemberCountStore = findStoreLazy("GuildMemberCountStore") as FluxStore & { getMemberCount(guildId?: string): number | null; };
export const ChannelMemberStore = findStoreLazy("ChannelMemberStore") as FluxStore & { export const ChannelMemberStore = findStoreLazy("ChannelMemberStore") as FluxStore & {
getProps(guildId: string, channelId: string): { groups: { count: number; id: string; }[]; }; getProps(guildId?: string, channelId?: string): { groups: { count: number; id: string; }[]; };
}; };
export const ThreadMemberListStore = findStoreLazy("ThreadMemberListStore") as FluxStore & { export const ThreadMemberListStore = findStoreLazy("ThreadMemberListStore") as FluxStore & {
getMemberListSections(channelId: string): { [sectionId: string]: { sectionId: string; userIds: string[]; }; }; getMemberListSections(channelId?: string): { [sectionId: string]: { sectionId: string; userIds: string[]; }; };
}; };

View file

@ -9,3 +9,8 @@
.vc-mentionAvatars-role-icon { .vc-mentionAvatars-role-icon {
margin: 0 2px 0.2rem 4px; margin: 0 2px 0.2rem 4px;
} }
/** don't display inside the ServerInfo modal owner mention */
.vc-gp-owner .vc-mentionAvatars-icon {
display: none;
}

View file

@ -82,7 +82,6 @@ export default definePlugin({
default: true default: true
} }
}, },
dependencies: ["CommandsAPI"],
async start() { async start() {
for (const tag of await getTags()) createTagCommand(tag); for (const tag of await getTags()) createTagCommand(tag);

View file

@ -33,7 +33,6 @@ export default definePlugin({
name: "MoreCommands", name: "MoreCommands",
description: "echo, lenny, mock", description: "echo, lenny, mock",
authors: [Devs.Arjix, Devs.echo, Devs.Samu], authors: [Devs.Arjix, Devs.echo, Devs.Samu],
dependencies: ["CommandsAPI"],
commands: [ commands: [
{ {
name: "echo", name: "echo",

View file

@ -24,7 +24,6 @@ export default definePlugin({
name: "MoreKaomoji", name: "MoreKaomoji",
description: "Adds more Kaomoji to discord. ヽ(´▽`)/", description: "Adds more Kaomoji to discord. ヽ(´▽`)/",
authors: [Devs.JacobTm], authors: [Devs.JacobTm],
dependencies: ["CommandsAPI"],
commands: [ commands: [
{ name: "dissatisfaction", description: " " }, { name: "dissatisfaction", description: " " },
{ name: "smug", description: "ಠ_ಠ" }, { name: "smug", description: "ಠ_ಠ" },

View file

@ -20,7 +20,7 @@ import ErrorBoundary from "@components/ErrorBoundary";
import { Devs } from "@utils/constants"; import { Devs } from "@utils/constants";
import { isNonNullish } from "@utils/guards"; import { isNonNullish } from "@utils/guards";
import definePlugin from "@utils/types"; import definePlugin from "@utils/types";
import { findByPropsLazy } from "@webpack"; import { findByPropsLazy, findComponentByCodeLazy } from "@webpack";
import { Avatar, ChannelStore, Clickable, IconUtils, RelationshipStore, ScrollerThin, useMemo, UserStore } from "@webpack/common"; import { Avatar, ChannelStore, Clickable, IconUtils, RelationshipStore, ScrollerThin, useMemo, UserStore } from "@webpack/common";
import { Channel, User } from "discord-types/general"; import { Channel, User } from "discord-types/general";
@ -28,6 +28,7 @@ const SelectedChannelActionCreators = findByPropsLazy("selectPrivateChannel");
const UserUtils = findByPropsLazy("getGlobalName"); const UserUtils = findByPropsLazy("getGlobalName");
const ProfileListClasses = findByPropsLazy("emptyIconFriends", "emptyIconGuilds"); const ProfileListClasses = findByPropsLazy("emptyIconFriends", "emptyIconGuilds");
const ExpandableList = findComponentByCodeLazy(".mutualFriendItem]");
const GuildLabelClasses = findByPropsLazy("guildNick", "guildAvatarWithoutIcon"); const GuildLabelClasses = findByPropsLazy("guildNick", "guildAvatarWithoutIcon");
function getGroupDMName(channel: Channel) { function getGroupDMName(channel: Channel) {
@ -50,6 +51,29 @@ function getMutualGDMCountText(user: User) {
return `${count === 0 ? "No" : count} Mutual Group${count !== 1 ? "s" : ""}`; return `${count === 0 ? "No" : count} Mutual Group${count !== 1 ? "s" : ""}`;
} }
function renderClickableGDMs(mutualDms: Channel[], onClose: () => void) {
return mutualDms.map(c => (
<Clickable
className={ProfileListClasses.listRow}
onClick={() => {
onClose();
SelectedChannelActionCreators.selectPrivateChannel(c.id);
}}
>
<Avatar
src={IconUtils.getChannelIconURL({ id: c.id, icon: c.icon, size: 32 })}
size="SIZE_40"
className={ProfileListClasses.listAvatar}
>
</Avatar>
<div className={ProfileListClasses.listRowContent}>
<div className={ProfileListClasses.listName}>{getGroupDMName(c)}</div>
<div className={GuildLabelClasses.guildNick}>{c.recipients.length + 1} Members</div>
</div>
</Clickable>
));
}
const IS_PATCHED = Symbol("MutualGroupDMs.Patched"); const IS_PATCHED = Symbol("MutualGroupDMs.Patched");
export default definePlugin({ export default definePlugin({
@ -70,6 +94,13 @@ export default definePlugin({
replace: "$1==='MUTUAL_GDMS'?$self.renderMutualGDMs(arguments[0]):$&" replace: "$1==='MUTUAL_GDMS'?$self.renderMutualGDMs(arguments[0]):$&"
} }
] ]
},
{
find: 'section:"MUTUAL_FRIENDS"',
replacement: {
match: /\.openUserProfileModal.+?\)}\)}\)(?<=(\(0,\i\.jsxs?\)\(\i\.\i,{className:(\i)\.divider}\)).+?)/,
replace: "$&,$self.renderDMPageList({user: arguments[0].user, Divider: $1, listStyle: $2.list})"
}
} }
], ],
@ -84,28 +115,9 @@ export default definePlugin({
}, },
renderMutualGDMs: ErrorBoundary.wrap(({ user, onClose }: { user: User, onClose: () => void; }) => { renderMutualGDMs: ErrorBoundary.wrap(({ user, onClose }: { user: User, onClose: () => void; }) => {
const mutualDms = useMemo(() => getMutualGroupDms(user.id), [user.id]); const mutualGDms = useMemo(() => getMutualGroupDms(user.id), [user.id]);
const entries = mutualDms.map(c => ( const entries = renderClickableGDMs(mutualGDms, onClose);
<Clickable
className={ProfileListClasses.listRow}
onClick={() => {
onClose();
SelectedChannelActionCreators.selectPrivateChannel(c.id);
}}
>
<Avatar
src={IconUtils.getChannelIconURL({ id: c.id, icon: c.icon, size: 32 })}
size="SIZE_40"
className={ProfileListClasses.listAvatar}
>
</Avatar>
<div className={ProfileListClasses.listRowContent}>
<div className={ProfileListClasses.listName}>{getGroupDMName(c)}</div>
<div className={GuildLabelClasses.guildNick}>{c.recipients.length + 1} Members</div>
</div>
</Clickable>
));
return ( return (
<ScrollerThin <ScrollerThin
@ -124,5 +136,24 @@ export default definePlugin({
} }
</ScrollerThin> </ScrollerThin>
); );
}),
renderDMPageList: ErrorBoundary.wrap(({ user, Divider, listStyle }: { user: User, Divider: JSX.Element, listStyle: string; }) => {
const mutualGDms = getMutualGroupDms(user.id);
if (mutualGDms.length === 0) return null;
const header = getMutualGDMCountText(user);
return (
<>
{Divider}
<ExpandableList
className={listStyle}
header={header}
isLoadingHeader={false}
children={renderClickableGDMs(mutualGDms, () => { })}
/>
</>
);
}) })
}); });

View file

@ -1,5 +0,0 @@
# NoDefaultHangStatus
Disable the default hang status when joining voice channels
![Visualization](https://github.com/Vendicated/Vencord/assets/24937357/329a9742-236f-48f7-94ff-c3510eca505a)

View file

@ -1,24 +0,0 @@
/*
* 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: "NoDefaultHangStatus",
description: "Disable the default hang status when joining voice channels",
authors: [Devs.D3SOX],
patches: [
{
find: ".CHILLING)",
replacement: {
match: /{enableHangStatus:(\i),/,
replace: "{_enableHangStatus:$1=false,"
}
}
]
});

View file

@ -62,16 +62,7 @@ export default definePlugin({
replace: "return 0;" replace: "return 0;"
} }
}, },
// New message requests hook // Message requests hook
{
find: 'location:"use-message-requests-count"',
predicate: () => settings.store.hideMessageRequestsCount,
replacement: {
match: /getNonChannelAckId\(\i\.\i\.MESSAGE_REQUESTS\).+?return /,
replace: "$&0;"
}
},
// Old message requests hook
{ {
find: "getMessageRequestsCount(){", find: "getMessageRequestsCount(){",
predicate: () => settings.store.hideMessageRequestsCount, predicate: () => settings.store.hideMessageRequestsCount,

View file

@ -36,7 +36,7 @@ export default definePlugin({
} }
], ],
shouldSkip(guildId: string, emoji: any) { shouldSkip(guildId: string, emoji: any) {
if (emoji.type !== "GUILD_EMOJI") { if (emoji.type !== 1) {
return false; return false;
} }
if (settings.store.shownEmojis === "onlyUnicode") { if (settings.store.shownEmojis === "onlyUnicode") {

View file

@ -33,7 +33,7 @@ interface URLReplacementRule {
// Do not forget to add protocols to the ALLOWED_PROTOCOLS constant // Do not forget to add protocols to the ALLOWED_PROTOCOLS constant
const UrlReplacementRules: Record<string, URLReplacementRule> = { const UrlReplacementRules: Record<string, URLReplacementRule> = {
spotify: { spotify: {
match: /^https:\/\/open\.spotify\.com\/(?:intl-[a-z]{2}\/)?(track|album|artist|playlist|user|episode)\/(.+)(?:\?.+?)?$/, match: /^https:\/\/open\.spotify\.com\/(?:intl-[a-z]{2}\/)?(track|album|artist|playlist|user|episode|prerelease)\/(.+)(?:\?.+?)?$/,
replace: (_, type, id) => `spotify://${type}/${id}`, replace: (_, type, id) => `spotify://${type}/${id}`,
description: "Open Spotify links in the Spotify app", description: "Open Spotify links in the Spotify app",
shortlinkMatch: /^https:\/\/spotify\.link\/.+$/, shortlinkMatch: /^https:\/\/spotify\.link\/.+$/,
@ -100,6 +100,20 @@ export default definePlugin({
replace: "true$1VencordNative.native.openExternal" replace: "true$1VencordNative.native.openExternal"
} }
}, },
{
find: "no artist ids in metadata",
predicate: () => !IS_DISCORD_DESKTOP && pluginSettings.store.spotify,
replacement: [
{
match: /\i\.\i\.isProtocolRegistered\(\)/g,
replace: "true"
},
{
match: /!\(0,\i\.isDesktop\)\(\)/,
replace: "false"
}
]
},
{ {
find: ".CONNECTED_ACCOUNT_VIEWED,", find: ".CONNECTED_ACCOUNT_VIEWED,",
replacement: { replacement: {

View file

@ -21,8 +21,10 @@ import { Flex } from "@components/Flex";
import { InfoIcon, OwnerCrownIcon } from "@components/Icons"; import { InfoIcon, OwnerCrownIcon } from "@components/Icons";
import { getUniqueUsername } from "@utils/discord"; import { getUniqueUsername } from "@utils/discord";
import { ModalCloseButton, ModalContent, ModalHeader, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal"; import { ModalCloseButton, ModalContent, ModalHeader, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal";
import { Clipboard, ContextMenuApi, FluxDispatcher, GuildMemberStore, GuildStore, i18n, Menu, PermissionsBits, Text, Tooltip, useEffect, UserStore, useState, useStateFromStores } from "@webpack/common"; import { findByCodeLazy } from "@webpack";
import type { Guild } from "discord-types/general"; import { Clipboard, ContextMenuApi, FluxDispatcher, GuildMemberStore, GuildStore, i18n, Menu, PermissionsBits, ScrollerThin, Text, Tooltip, useEffect, UserStore, useState, useStateFromStores } from "@webpack/common";
import { UnicodeEmoji } from "@webpack/types";
import type { Guild, Role, User } from "discord-types/general";
import { settings } from ".."; import { settings } from "..";
import { cl, getPermissionDescription, getPermissionString } from "../utils"; import { cl, getPermissionDescription, getPermissionString } from "../utils";
@ -42,15 +44,15 @@ export interface RoleOrUserPermission {
overwriteDeny?: bigint; overwriteDeny?: bigint;
} }
function openRolesAndUsersPermissionsModal(permissions: Array<RoleOrUserPermission>, guild: Guild, header: string) { type GetRoleIconData = (role: Role, size: number) => { customIconSrc?: string; unicodeEmoji?: UnicodeEmoji; };
return openModal(modalProps => ( const getRoleIconData: GetRoleIconData = findByCodeLazy("convertSurrogateToName", "customIconSrc", "unicodeEmoji");
<RolesAndUsersPermissions
modalProps={modalProps} function getRoleIconSrc(role: Role) {
permissions={permissions} const icon = getRoleIconData(role, 20);
guild={guild} if (!icon) return;
header={header}
/> const { customIconSrc, unicodeEmoji } = icon;
)); return customIconSrc ?? unicodeEmoji?.url;
} }
function RolesAndUsersPermissionsComponent({ permissions, guild, modalProps, header }: { permissions: Array<RoleOrUserPermission>; guild: Guild; modalProps: ModalProps; header: string; }) { function RolesAndUsersPermissionsComponent({ permissions, guild, modalProps, header }: { permissions: Array<RoleOrUserPermission>; guild: Guild; modalProps: ModalProps; header: string; }) {
@ -86,31 +88,34 @@ function RolesAndUsersPermissionsComponent({ permissions, guild, modalProps, hea
size={ModalSize.LARGE} size={ModalSize.LARGE}
> >
<ModalHeader> <ModalHeader>
<Text className={cl("perms-title")} variant="heading-lg/semibold">{header} permissions:</Text> <Text className={cl("modal-title")} variant="heading-lg/semibold">{header} permissions:</Text>
<ModalCloseButton onClick={modalProps.onClose} /> <ModalCloseButton onClick={modalProps.onClose} />
</ModalHeader> </ModalHeader>
<ModalContent> <ModalContent className={cl("modal-content")}>
{!selectedItem && ( {!selectedItem && (
<div className={cl("perms-no-perms")}> <div className={cl("modal-no-perms")}>
<Text variant="heading-lg/normal">No permissions to display!</Text> <Text variant="heading-lg/normal">No permissions to display!</Text>
</div> </div>
)} )}
{selectedItem && ( {selectedItem && (
<div className={cl("perms-container")}> <div className={cl("modal-container")}>
<div className={cl("perms-list")}> <ScrollerThin className={cl("modal-list")} orientation="auto">
{permissions.map((permission, index) => { {permissions.map((permission, index) => {
const user = UserStore.getUser(permission.id ?? ""); const user: User | undefined = UserStore.getUser(permission.id ?? "");
const role = roles[permission.id ?? ""]; const role: Role | undefined = roles[permission.id ?? ""];
const roleIconSrc = role != null ? getRoleIconSrc(role) : undefined;
return ( return (
<button <div
className={cl("perms-list-item-btn")} className={cl("modal-list-item-btn")}
onClick={() => selectItem(index)} onClick={() => selectItem(index)}
role="button"
tabIndex={0}
> >
<div <div
className={cl("perms-list-item", { "perms-list-item-active": selectedItemIndex === index })} className={cl("modal-list-item", { "modal-list-item-active": selectedItemIndex === index })}
onContextMenu={e => { onContextMenu={e => {
if (permission.type === PermissionType.Role) if (permission.type === PermissionType.Role)
ContextMenuApi.openContextMenu(e, () => ( ContextMenuApi.openContextMenu(e, () => (
@ -124,7 +129,6 @@ function RolesAndUsersPermissionsComponent({ permissions, guild, modalProps, hea
ContextMenuApi.openContextMenu(e, () => ( ContextMenuApi.openContextMenu(e, () => (
<UserContextMenu <UserContextMenu
userId={permission.id!} userId={permission.id!}
onClose={modalProps.onClose}
/> />
)); ));
} }
@ -132,13 +136,19 @@ function RolesAndUsersPermissionsComponent({ permissions, guild, modalProps, hea
> >
{(permission.type === PermissionType.Role || permission.type === PermissionType.Owner) && ( {(permission.type === PermissionType.Role || permission.type === PermissionType.Owner) && (
<span <span
className={cl("perms-role-circle")} className={cl("modal-role-circle")}
style={{ backgroundColor: role?.colorString ?? "var(--primary-300)" }} style={{ backgroundColor: role?.colorString ?? "var(--primary-300)" }}
/> />
)} )}
{permission.type === PermissionType.User && user !== undefined && ( {permission.type === PermissionType.Role && roleIconSrc != null && (
<img <img
className={cl("perms-user-img")} className={cl("modal-role-image")}
src={roleIconSrc}
/>
)}
{permission.type === PermissionType.User && user != null && (
<img
className={cl("modal-user-img")}
src={user.getAvatarURL(void 0, void 0, false)} src={user.getAvatarURL(void 0, void 0, false)}
/> />
)} )}
@ -147,28 +157,25 @@ function RolesAndUsersPermissionsComponent({ permissions, guild, modalProps, hea
permission.type === PermissionType.Role permission.type === PermissionType.Role
? role?.name ?? "Unknown Role" ? role?.name ?? "Unknown Role"
: permission.type === PermissionType.User : permission.type === PermissionType.User
? (user && getUniqueUsername(user)) ?? "Unknown User" ? (user != null && getUniqueUsername(user)) ?? "Unknown User"
: ( : (
<Flex style={{ gap: "0.2em", justifyItems: "center" }}> <Flex style={{ gap: "0.2em", justifyItems: "center" }}>
@owner @owner
<OwnerCrownIcon <OwnerCrownIcon height={18} width={18} aria-hidden="true" />
height={18}
width={18}
aria-hidden="true"
/>
</Flex> </Flex>
) )
} }
</Text> </Text>
</div> </div>
</button> </div>
); );
})} })}
</div> </ScrollerThin>
<div className={cl("perms-perms")}> <div className={cl("modal-divider")} />
<ScrollerThin className={cl("modal-perms")} orientation="auto">
{Object.entries(PermissionsBits).map(([permissionName, bit]) => ( {Object.entries(PermissionsBits).map(([permissionName, bit]) => (
<div className={cl("perms-perms-item")}> <div className={cl("modal-perms-item")}>
<div className={cl("perms-perms-item-icon")}> <div className={cl("modal-perms-item-icon")}>
{(() => { {(() => {
const { permissions, overwriteAllow, overwriteDeny } = selectedItem; const { permissions, overwriteAllow, overwriteDeny } = selectedItem;
@ -192,11 +199,11 @@ function RolesAndUsersPermissionsComponent({ permissions, guild, modalProps, hea
</Tooltip> </Tooltip>
</div> </div>
))} ))}
</div> </ScrollerThin>
</div> </div>
)} )}
</ModalContent> </ModalContent>
</ModalRoot > </ModalRoot>
); );
} }
@ -208,7 +215,7 @@ function RoleContextMenu({ guild, roleId, onClose }: { guild: Guild; roleId: str
aria-label="Role Options" aria-label="Role Options"
> >
<Menu.MenuItem <Menu.MenuItem
id="vc-copy-role-id" id={cl("copy-role-id")}
label={i18n.Messages.COPY_ID_ROLE} label={i18n.Messages.COPY_ID_ROLE}
action={() => { action={() => {
Clipboard.copy(roleId); Clipboard.copy(roleId);
@ -217,14 +224,13 @@ function RoleContextMenu({ guild, roleId, onClose }: { guild: Guild; roleId: str
{(settings.store as any).unsafeViewAsRole && ( {(settings.store as any).unsafeViewAsRole && (
<Menu.MenuItem <Menu.MenuItem
id="vc-pw-view-as-role" id={cl("view-as-role")}
label={i18n.Messages.VIEW_AS_ROLE} label={i18n.Messages.VIEW_AS_ROLE}
action={() => { action={() => {
const role = GuildStore.getRole(guild.id, roleId); const role = GuildStore.getRole(guild.id, roleId);
if (!role) return; if (!role) return;
onClose(); onClose();
FluxDispatcher.dispatch({ FluxDispatcher.dispatch({
type: "IMPERSONATE_UPDATE", type: "IMPERSONATE_UPDATE",
guildId: guild.id, guildId: guild.id,
@ -235,15 +241,14 @@ function RoleContextMenu({ guild, roleId, onClose }: { guild: Guild; roleId: str
} }
} }
}); });
} }}
}
/> />
)} )}
</Menu.Menu> </Menu.Menu>
); );
} }
function UserContextMenu({ userId, onClose }: { userId: string; onClose: () => void; }) { function UserContextMenu({ userId }: { userId: string; }) {
return ( return (
<Menu.Menu <Menu.Menu
navId={cl("user-context-menu")} navId={cl("user-context-menu")}
@ -251,7 +256,7 @@ function UserContextMenu({ userId, onClose }: { userId: string; onClose: () => v
aria-label="User Options" aria-label="User Options"
> >
<Menu.MenuItem <Menu.MenuItem
id="vc-copy-user-id" id={cl("copy-user-id")}
label={i18n.Messages.COPY_ID_USER} label={i18n.Messages.COPY_ID_USER}
action={() => { action={() => {
Clipboard.copy(userId); Clipboard.copy(userId);
@ -263,4 +268,13 @@ function UserContextMenu({ userId, onClose }: { userId: string; onClose: () => v
const RolesAndUsersPermissions = ErrorBoundary.wrap(RolesAndUsersPermissionsComponent); const RolesAndUsersPermissions = ErrorBoundary.wrap(RolesAndUsersPermissionsComponent);
export default openRolesAndUsersPermissionsModal; export default function openRolesAndUsersPermissionsModal(permissions: Array<RoleOrUserPermission>, guild: Guild, header: string) {
return openModal(modalProps => (
<RolesAndUsersPermissions
modalProps={modalProps}
permissions={permissions}
guild={guild}
header={header}
/>
));
}

View file

@ -29,6 +29,7 @@ import openRolesAndUsersPermissionsModal, { PermissionType, type RoleOrUserPermi
interface UserPermission { interface UserPermission {
permission: string; permission: string;
roleName: string;
roleColor: string; roleColor: string;
rolePosition: number; rolePosition: number;
} }
@ -45,8 +46,48 @@ const { RoleRootClasses, RoleClasses, RoleBorderClasses } = proxyLazyWebpack(()
return { RoleRootClasses, RoleClasses, RoleBorderClasses }; return { RoleRootClasses, RoleClasses, RoleBorderClasses };
}); });
interface FakeRoleProps extends React.HTMLAttributes<HTMLDivElement> {
text: string;
color: string;
}
function FakeRole({ text, color, ...props }: FakeRoleProps) {
return (
<div {...props} className={classes(RoleClasses.role)}>
<div className={RoleClasses.roleRemoveButton}>
<span
className={classes(RoleBorderClasses.roleCircle, RoleClasses.roleCircle)}
style={{ backgroundColor: color }}
/>
</div>
<div className={RoleClasses.roleName}>
<Text
className={RoleClasses.roleNameOverflow}
variant="text-xs/medium"
>
{text}
</Text>
</div>
</div>
);
}
interface GrantedByTooltipProps {
roleName: string;
roleColor: string;
}
function GrantedByTooltip({ roleName, roleColor }: GrantedByTooltipProps) {
return (
<>
<Text variant="text-sm/medium">Granted By</Text>
<FakeRole text={roleName} color={roleColor} />
</>
);
}
function UserPermissionsComponent({ guild, guildMember, forceOpen = false }: { guild: Guild; guildMember: GuildMember; forceOpen?: boolean; }) { function UserPermissionsComponent({ guild, guildMember, forceOpen = false }: { guild: Guild; guildMember: GuildMember; forceOpen?: boolean; }) {
const stns = settings.use(["permissionsSortOrder"]); const { permissionsSortOrder } = settings.use(["permissionsSortOrder"]);
const [rolePermissions, userPermissions] = useMemo(() => { const [rolePermissions, userPermissions] = useMemo(() => {
const userPermissions: UserPermissions = []; const userPermissions: UserPermissions = [];
@ -67,6 +108,7 @@ function UserPermissionsComponent({ guild, guildMember, forceOpen = false }: { g
const OWNER = i18n.Messages.GUILD_OWNER || "Server Owner"; const OWNER = i18n.Messages.GUILD_OWNER || "Server Owner";
userPermissions.push({ userPermissions.push({
permission: OWNER, permission: OWNER,
roleName: "Owner",
roleColor: "var(--primary-300)", roleColor: "var(--primary-300)",
rolePosition: Infinity rolePosition: Infinity
}); });
@ -75,10 +117,11 @@ function UserPermissionsComponent({ guild, guildMember, forceOpen = false }: { g
sortUserRoles(userRoles); sortUserRoles(userRoles);
for (const [permission, bit] of Object.entries(PermissionsBits)) { for (const [permission, bit] of Object.entries(PermissionsBits)) {
for (const { permissions, colorString, position } of userRoles) { for (const { permissions, colorString, position, name } of userRoles) {
if ((permissions & bit) === bit) { if ((permissions & bit) === bit) {
userPermissions.push({ userPermissions.push({
permission: getPermissionString(permission), permission: getPermissionString(permission),
roleName: name,
roleColor: colorString || "var(--primary-300)", roleColor: colorString || "var(--primary-300)",
rolePosition: position rolePosition: position
}); });
@ -91,7 +134,7 @@ function UserPermissionsComponent({ guild, guildMember, forceOpen = false }: { g
userPermissions.sort((a, b) => b.rolePosition - a.rolePosition); userPermissions.sort((a, b) => b.rolePosition - a.rolePosition);
return [rolePermissions, userPermissions]; return [rolePermissions, userPermissions];
}, [stns.permissionsSortOrder]); }, [permissionsSortOrder]);
return ( return (
<ExpandableHeader <ExpandableHeader
@ -108,46 +151,41 @@ function UserPermissionsComponent({ guild, guildMember, forceOpen = false }: { g
onDropDownClick={state => settings.store.defaultPermissionsDropdownState = !state} onDropDownClick={state => settings.store.defaultPermissionsDropdownState = !state}
defaultState={settings.store.defaultPermissionsDropdownState} defaultState={settings.store.defaultPermissionsDropdownState}
buttons={[ buttons={[
(<Tooltip text={`Sorting by ${stns.permissionsSortOrder === PermissionsSortOrder.HighestRole ? "Highest Role" : "Lowest Role"}`}> <Tooltip text={`Sorting by ${permissionsSortOrder === PermissionsSortOrder.HighestRole ? "Highest Role" : "Lowest Role"}`}>
{tooltipProps => ( {tooltipProps => (
<button <div
{...tooltipProps} {...tooltipProps}
className={cl("userperms-sortorder-btn")} className={cl("user-sortorder-btn")}
role="button"
tabIndex={0}
onClick={() => { onClick={() => {
stns.permissionsSortOrder = stns.permissionsSortOrder === PermissionsSortOrder.HighestRole ? PermissionsSortOrder.LowestRole : PermissionsSortOrder.HighestRole; settings.store.permissionsSortOrder = permissionsSortOrder === PermissionsSortOrder.HighestRole ? PermissionsSortOrder.LowestRole : PermissionsSortOrder.HighestRole;
}} }}
> >
<svg <svg
width="20" width="20"
height="20" height="20"
viewBox="0 96 960 960" viewBox="0 96 960 960"
transform={stns.permissionsSortOrder === PermissionsSortOrder.HighestRole ? "scale(1 1)" : "scale(1 -1)"} transform={permissionsSortOrder === PermissionsSortOrder.HighestRole ? "scale(1 1)" : "scale(1 -1)"}
> >
<path fill="var(--text-normal)" d="M440 896V409L216 633l-56-57 320-320 320 320-56 57-224-224v487h-80Z" /> <path fill="var(--text-normal)" d="M440 896V409L216 633l-56-57 320-320 320 320-56 57-224-224v487h-80Z" />
</svg> </svg>
</button> </div>
)} )}
</Tooltip>) </Tooltip>
]}> ]}>
{userPermissions.length > 0 && ( {userPermissions.length > 0 && (
<div className={classes(RoleRootClasses.root)}> <div className={classes(RoleRootClasses.root)}>
{userPermissions.map(({ permission, roleColor }) => ( {userPermissions.map(({ permission, roleColor, roleName }) => (
<div className={classes(RoleClasses.role)}> <Tooltip
<div className={RoleClasses.roleRemoveButton}> text={<GrantedByTooltip roleName={roleName} roleColor={roleColor} />}
<span tooltipClassName={cl("granted-by-container")}
className={classes(RoleBorderClasses.roleCircle, RoleClasses.roleCircle)} tooltipContentClassName={cl("granted-by-content")}
style={{ backgroundColor: roleColor }} >
/> {tooltipProps => (
</div> <FakeRole {...tooltipProps} text={permission} color={roleColor} />
<div className={RoleClasses.roleName}> )}
<Text </Tooltip>
className={RoleClasses.roleNameOverflow}
variant="text-xs/medium"
>
{permission}
</Text>
</div>
</div>
))} ))}
</div> </div>
)} )}

View file

@ -26,7 +26,7 @@ import { Devs } from "@utils/constants";
import { classes } from "@utils/misc"; import { classes } from "@utils/misc";
import definePlugin, { OptionType } from "@utils/types"; import definePlugin, { OptionType } from "@utils/types";
import { findByPropsLazy } from "@webpack"; import { findByPropsLazy } from "@webpack";
import { Button, ChannelStore, Dialog, GuildMemberStore, GuildStore, Menu, PermissionsBits, Popout, TooltipContainer, UserStore } from "@webpack/common"; import { Button, ChannelStore, Dialog, GuildMemberStore, GuildStore, match, Menu, PermissionsBits, Popout, TooltipContainer, UserStore } from "@webpack/common";
import type { Guild, GuildMember } from "discord-types/general"; import type { Guild, GuildMember } from "discord-types/general";
import openRolesAndUsersPermissionsModal, { PermissionType, RoleOrUserPermission } from "./components/RolesAndUsersPermissions"; import openRolesAndUsersPermissionsModal, { PermissionType, RoleOrUserPermission } from "./components/RolesAndUsersPermissions";
@ -54,12 +54,12 @@ export const settings = definePluginSettings({
options: [ options: [
{ label: "Highest Role", value: PermissionsSortOrder.HighestRole, default: true }, { label: "Highest Role", value: PermissionsSortOrder.HighestRole, default: true },
{ label: "Lowest Role", value: PermissionsSortOrder.LowestRole } { label: "Lowest Role", value: PermissionsSortOrder.LowestRole }
], ]
}, },
defaultPermissionsDropdownState: { defaultPermissionsDropdownState: {
description: "Whether the permissions dropdown on user popouts should be open by default", description: "Whether the permissions dropdown on user popouts should be open by default",
type: OptionType.BOOLEAN, type: OptionType.BOOLEAN,
default: false, default: false
} }
}); });
@ -73,14 +73,12 @@ function MenuItem(guildId: string, id?: string, type?: MenuItemParentType) {
action={() => { action={() => {
const guild = GuildStore.getGuild(guildId); const guild = GuildStore.getGuild(guildId);
let permissions: RoleOrUserPermission[]; const { permissions, header } = match(type)
let header: string; .returnType<{ permissions: RoleOrUserPermission[], header: string; }>()
.with(MenuItemParentType.User, () => {
switch (type) {
case MenuItemParentType.User: {
const member = GuildMemberStore.getMember(guildId, id!); const member = GuildMemberStore.getMember(guildId, id!);
permissions = getSortedRoles(guild, member) const permissions: RoleOrUserPermission[] = getSortedRoles(guild, member)
.map(role => ({ .map(role => ({
type: PermissionType.Role, type: PermissionType.Role,
...role ...role
@ -93,37 +91,37 @@ function MenuItem(guildId: string, id?: string, type?: MenuItemParentType) {
}); });
} }
header = member.nick ?? UserStore.getUser(member.userId).username; return {
permissions,
break; header: member.nick ?? UserStore.getUser(member.userId).username
} };
})
case MenuItemParentType.Channel: { .with(MenuItemParentType.Channel, () => {
const channel = ChannelStore.getChannel(id!); const channel = ChannelStore.getChannel(id!);
permissions = sortPermissionOverwrites(Object.values(channel.permissionOverwrites).map(({ id, allow, deny, type }) => ({ const permissions = sortPermissionOverwrites(Object.values(channel.permissionOverwrites).map(({ id, allow, deny, type }) => ({
type: type as PermissionType, type: type as PermissionType,
id, id,
overwriteAllow: allow, overwriteAllow: allow,
overwriteDeny: deny overwriteDeny: deny
})), guildId); })), guildId);
header = channel.name; return {
permissions,
break; header: channel.name
} };
})
default: { .otherwise(() => {
permissions = Object.values(GuildStore.getRoles(guild.id)).map(role => ({ const permissions = Object.values(GuildStore.getRoles(guild.id)).map(role => ({
type: PermissionType.Role, type: PermissionType.Role,
...role ...role
})); }));
header = guild.name; return {
permissions,
break; header: guild.name
} };
} });
openRolesAndUsersPermissionsModal(permissions, guild, header); openRolesAndUsersPermissionsModal(permissions, guild, header);
}} }}
@ -133,32 +131,34 @@ function MenuItem(guildId: string, id?: string, type?: MenuItemParentType) {
function makeContextMenuPatch(childId: string | string[], type?: MenuItemParentType): NavContextMenuPatchCallback { function makeContextMenuPatch(childId: string | string[], type?: MenuItemParentType): NavContextMenuPatchCallback {
return (children, props) => { return (children, props) => {
if (!props) return; if (
if ((type === MenuItemParentType.User && !props.user) || (type === MenuItemParentType.Guild && !props.guild) || (type === MenuItemParentType.Channel && (!props.channel || !props.guild))) !props ||
(type === MenuItemParentType.User && !props.user) ||
(type === MenuItemParentType.Guild && !props.guild) ||
(type === MenuItemParentType.Channel && (!props.channel || !props.guild))
) {
return; return;
}
const group = findGroupChildrenByChildId(childId, children); const group = findGroupChildrenByChildId(childId, children);
const item = (() => { const item = match(type)
switch (type) { .with(MenuItemParentType.User, () => MenuItem(props.guildId, props.user.id, type))
case MenuItemParentType.User: .with(MenuItemParentType.Channel, () => MenuItem(props.guild.id, props.channel.id, type))
return MenuItem(props.guildId, props.user.id, type); .with(MenuItemParentType.Guild, () => MenuItem(props.guild.id))
case MenuItemParentType.Channel: .otherwise(() => null);
return MenuItem(props.guild.id, props.channel.id, type);
case MenuItemParentType.Guild:
return MenuItem(props.guild.id);
default:
return null;
}
})();
if (item == null) return; if (item == null) return;
if (group) if (group) {
group.push(item); return group.push(item);
else if (childId === "roles" && props.guildId) }
// "roles" may not be present due to the member not having any roles. In that case, add it above "Copy ID"
// "roles" may not be present due to the member not having any roles. In that case, add it above "Copy ID"
if (childId === "roles" && props.guildId) {
children.splice(-1, 0, <Menu.MenuGroup>{item}</Menu.MenuGroup>); children.splice(-1, 0, <Menu.MenuGroup>{item}</Menu.MenuGroup>);
}
}; };
} }

View file

@ -1,20 +1,6 @@
/* User Permissions Component */ /* User Permissions Component */
.vc-permviewer-userperms-title-container { .vc-permviewer-user-sortorder-btn {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 10px;
margin-bottom: 6px;
}
.vc-permviewer-userperms-btns-container {
display: flex;
align-items: center;
}
.vc-permviewer-userperms-sortorder-btn {
all: unset;
cursor: pointer; cursor: pointer;
display: flex; display: flex;
align-items: center; align-items: center;
@ -23,27 +9,17 @@
height: 24px; height: 24px;
} }
.vc-permviewer-userperms-permdetails-btn {
all: unset;
cursor: pointer;
display: flex;
align-items: center;
}
.vc-permviewer-userperms-toggleperms-btn {
all: unset;
cursor: pointer;
display: flex;
align-items: center;
}
/* RolesAndUsersPermissions Component */ /* RolesAndUsersPermissions Component */
.vc-permviewer-perms-title { .vc-permviewer-modal-content {
padding: 16px 4px 16px 16px;
}
.vc-permviewer-modal-title {
flex-grow: 1; flex-grow: 1;
} }
.vc-permviewer-perms-no-perms { .vc-permviewer-modal-no-perms {
width: 100%; width: 100%;
height: 100%; height: 100%;
display: flex; display: flex;
@ -52,101 +28,103 @@
text-align: center; text-align: center;
} }
.vc-permviewer-perms-container { .vc-permviewer-modal-container {
display: grid; width: 100%;
grid-template-columns: 1fr 2fr; height: 100%;
grid-template-areas: "list permissions"; display: flex;
padding: 16px 0; gap: 8px;
} }
.vc-permviewer-perms-list { .vc-permviewer-modal-list {
grid-area: list;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px; gap: 2px;
border-right: 2px solid var(--background-modifier-active); padding-right: 8px;
width: 200px;
} }
.vc-permviewer-perms-list-item-btn { .vc-permviewer-modal-list-item-btn {
all: unset;
cursor: pointer; cursor: pointer;
} }
.vc-permviewer-perms-list-item { .vc-permviewer-modal-list-item {
display: flex; display: flex;
align-items: center; align-items: center;
padding: 8px 5px; gap: 8px;
cursor: pointer; padding: 8px;
width: 230px;
border-radius: 5px; border-radius: 5px;
} }
.vc-permviewer-perms-list-item:hover { .vc-permviewer-modal-list-item:hover {
background-color: var(--background-modifier-hover); background-color: var(--background-modifier-hover);
} }
.vc-permviewer-perms-list-item-active { .vc-permviewer-modal-list-item-active {
background-color: var(--background-modifier-selected); background-color: var(--background-modifier-selected);
} }
.vc-permviewer-perms-list-item > div { .vc-permviewer-modal-list-item > div {
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
} }
.vc-permviewer-perms-role-circle { .vc-permviewer-modal-role-circle {
border-radius: 50%; border-radius: 50%;
width: 12px; width: 12px;
height: 12px; height: 12px;
margin-left: 3px;
margin-right: 11px;
flex-shrink: 0; flex-shrink: 0;
} }
.vc-permviewer-perms-user-img { .vc-permviewer-modal-role-image {
width: 20px;
height: 20px;
object-fit: contain;
}
.vc-permviewer-modal-user-img {
border-radius: 50%; border-radius: 50%;
width: 20px; width: 20px;
height: 20px; height: 20px;
margin-right: 6px;
} }
.vc-permviewer-perms-perms { .vc-permviewer-modal-divider {
grid-area: permissions; width: 2px;
background-color: var(--background-modifier-active);
}
.vc-permviewer-modal-perms {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
margin-left: 5px; padding-right: 8px;
} }
.vc-permviewer-perms-perms-item { .vc-permviewer-modal-perms-item {
position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
padding: 10px; gap: 5px;
padding: 10px 2px 10px 10px;
border-bottom: 2px solid var(--background-modifier-active); border-bottom: 2px solid var(--background-modifier-active);
} }
.vc-permviewer-perms-perms-item:last-child { .vc-permviewer-modal-perms-item:last-child {
border: 0; border: 0;
} }
.vc-permviewer-perms-perms-item-icon { .vc-permviewer-modal-perms-item-icon {
border: 1px solid var(--background-modifier-selected); border: 1px solid var(--background-modifier-selected);
width: 24px; width: 24px;
height: 24px; height: 24px;
margin-right: 5px;
} }
.vc-permviewer-perms-perms-item .vc-info-icon { .vc-permviewer-modal-perms-item .vc-info-icon {
color: var(--interactive-muted); color: var(--interactive-muted);
margin-left: auto;
cursor: pointer; cursor: pointer;
position: absolute;
right: 0;
scale: 0.9;
transition: color ease-in 0.1s; transition: color ease-in 0.1s;
} }
.vc-permviewer-perms-perms-item .vc-info-icon:hover { .vc-permviewer-modal-perms-item .vc-info-icon:hover {
color: var(--interactive-active); color: var(--interactive-active);
} }
@ -167,3 +145,14 @@
background: rgb(var(--bg-overlay-color)/var(--bg-overlay-opacity-6)); background: rgb(var(--bg-overlay-color)/var(--bg-overlay-opacity-6));
border-color: var(--profile-body-border-color) border-color: var(--profile-body-border-color)
} }
.vc-permviewer-granted-by-container {
max-width: 300px;
width: auto;
}
.vc-permviewer-granted-by-content {
display: flex;
align-items: center;
gap: 4px;
}

View file

@ -88,7 +88,6 @@ export default definePlugin({
name: "petpet", name: "petpet",
description: "Adds a /petpet slash command to create headpet gifs from any image", description: "Adds a /petpet slash command to create headpet gifs from any image",
authors: [Devs.Ven], authors: [Devs.Ven],
dependencies: ["CommandsAPI"],
commands: [ commands: [
{ {
inputType: ApplicationCommandInputType.BUILT_IN, inputType: ApplicationCommandInputType.BUILT_IN,

View file

@ -0,0 +1,172 @@
/*
* 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 { getCurrentChannel } from "@utils/discord";
import { useAwaiter } from "@utils/react";
import { findStoreLazy } from "@webpack";
import { UserProfileStore, UserStore } from "@webpack/common";
import { settings } from "./settings";
import { PronounMapping, Pronouns, PronounsCache, PronounSets, PronounsFormat, PronounSource, PronounsResponse } from "./types";
const UserSettingsAccountStore = findStoreLazy("UserSettingsAccountStore");
const EmptyPronouns = { pronouns: undefined, source: "", hasPendingPronouns: false } as const satisfies Pronouns;
type RequestCallback = (pronounSets?: PronounSets) => void;
const pronounCache: Record<string, PronounsCache> = {};
const requestQueue: Record<string, RequestCallback[]> = {};
let isProcessing = false;
async function processQueue() {
if (isProcessing) return;
isProcessing = true;
let ids = Object.keys(requestQueue);
while (ids.length > 0) {
const idsChunk = ids.splice(0, 50);
const pronouns = await bulkFetchPronouns(idsChunk);
for (const id of idsChunk) {
const callbacks = requestQueue[id];
for (const callback of callbacks) {
callback(pronouns[id]?.sets);
}
delete requestQueue[id];
}
ids = Object.keys(requestQueue);
await new Promise(r => setTimeout(r, 2000));
}
isProcessing = false;
}
function fetchPronouns(id: string): Promise<string | undefined> {
return new Promise(resolve => {
if (pronounCache[id] != null) {
resolve(extractPronouns(pronounCache[id].sets));
return;
}
function handlePronouns(pronounSets?: PronounSets) {
const pronouns = extractPronouns(pronounSets);
resolve(pronouns);
}
if (requestQueue[id] != null) {
requestQueue[id].push(handlePronouns);
return;
}
requestQueue[id] = [handlePronouns];
processQueue();
});
}
async function bulkFetchPronouns(ids: string[]): Promise<PronounsResponse> {
const params = new URLSearchParams();
params.append("platform", "discord");
params.append("ids", ids.join(","));
try {
const req = await fetch("https://pronoundb.org/api/v2/lookup?" + String(params), {
method: "GET",
headers: {
"Accept": "application/json",
"X-PronounDB-Source": "WebExtension/0.14.5"
}
});
if (!req.ok) throw new Error(`Status ${req.status}`);
const res: PronounsResponse = await req.json();
Object.assign(pronounCache, res);
return res;
} catch (e) {
console.error("PronounDB request failed:", e);
const dummyPronouns: PronounsResponse = Object.fromEntries(ids.map(id => [id, { sets: {} }]));
Object.assign(pronounCache, dummyPronouns);
return dummyPronouns;
}
}
function extractPronouns(pronounSets?: PronounSets): string | undefined {
if (pronounSets == null) return undefined;
if (pronounSets.en == null) return PronounMapping.unspecified;
const pronouns = pronounSets.en;
if (pronouns.length === 0) return PronounMapping.unspecified;
const { pronounsFormat } = settings.store;
if (pronouns.length > 1) {
const pronounString = pronouns.map(p => p[0].toUpperCase() + p.slice(1)).join("/");
return pronounsFormat === PronounsFormat.Capitalized ? pronounString : pronounString.toLowerCase();
}
const pronoun = pronouns[0];
// For capitalized pronouns or special codes (any, ask, avoid), we always return the normal (capitalized) string
if (pronounsFormat === PronounsFormat.Capitalized || ["any", "ask", "avoid", "other", "unspecified"].includes(pronoun)) {
return PronounMapping[pronoun];
} else {
return PronounMapping[pronoun].toLowerCase();
}
}
function getDiscordPronouns(id: string, useGlobalProfile: boolean = false): string | undefined {
const globalPronouns = UserProfileStore.getUserProfile(id)?.pronouns;
if (useGlobalProfile) return globalPronouns;
return UserProfileStore.getGuildMemberProfile(id, getCurrentChannel()?.guild_id)?.pronouns || globalPronouns;
}
export function useFormattedPronouns(id: string, useGlobalProfile: boolean = false): Pronouns {
const discordPronouns = getDiscordPronouns(id, useGlobalProfile)?.trim().replace(/\n+/g, "");
const hasPendingPronouns = UserSettingsAccountStore.getPendingPronouns() != null;
const [pronouns] = useAwaiter(() => fetchPronouns(id));
if (settings.store.pronounSource === PronounSource.PreferDiscord && discordPronouns) {
return { pronouns: discordPronouns, source: "Discord", hasPendingPronouns };
}
if (pronouns != null && pronouns !== PronounMapping.unspecified) {
return { pronouns, source: "PronounDB", hasPendingPronouns };
}
return { pronouns: discordPronouns, source: "Discord", hasPendingPronouns };
}
export function useProfilePronouns(id: string, useGlobalProfile: boolean = false): Pronouns {
try {
const pronouns = useFormattedPronouns(id, useGlobalProfile);
if (!settings.store.showInProfile) return EmptyPronouns;
if (!settings.store.showSelf && id === UserStore.getCurrentUser()?.id) return EmptyPronouns;
return pronouns;
} catch (e) {
console.error(e);
return EmptyPronouns;
}
}

View file

@ -22,7 +22,7 @@ import { findByPropsLazy } from "@webpack";
import { UserStore } from "@webpack/common"; import { UserStore } from "@webpack/common";
import { Message } from "discord-types/general"; import { Message } from "discord-types/general";
import { useFormattedPronouns } from "../pronoundbUtils"; import { useFormattedPronouns } from "../api";
import { settings } from "../settings"; import { settings } from "../settings";
const styles: Record<string, string> = findByPropsLazy("timestampInline"); const styles: Record<string, string> = findByPropsLazy("timestampInline");
@ -53,25 +53,21 @@ export const CompactPronounsChatComponentWrapper = ErrorBoundary.wrap(({ message
}, { noop: true }); }, { noop: true });
function PronounsChatComponent({ message }: { message: Message; }) { function PronounsChatComponent({ message }: { message: Message; }) {
const [result] = useFormattedPronouns(message.author.id); const { pronouns } = useFormattedPronouns(message.author.id);
return result return pronouns && (
? ( <span
<span className={classes(styles.timestampInline, styles.timestamp)}
className={classes(styles.timestampInline, styles.timestamp)} > {pronouns}</span>
> {result}</span> );
)
: null;
} }
export const CompactPronounsChatComponent = ErrorBoundary.wrap(({ message }: { message: Message; }) => { export const CompactPronounsChatComponent = ErrorBoundary.wrap(({ message }: { message: Message; }) => {
const [result] = useFormattedPronouns(message.author.id); const { pronouns } = useFormattedPronouns(message.author.id);
return result return pronouns && (
? ( <span
<span className={classes(styles.timestampInline, styles.timestamp, "vc-pronoundb-compact")}
className={classes(styles.timestampInline, styles.timestamp, "vc-pronoundb-compact")} > {pronouns}</span>
> {result}</span> );
)
: null;
}, { noop: true }); }, { noop: true });

View file

@ -21,9 +21,9 @@ import "./styles.css";
import { Devs } from "@utils/constants"; import { Devs } from "@utils/constants";
import definePlugin from "@utils/types"; import definePlugin from "@utils/types";
import { useProfilePronouns } from "./api";
import PronounsAboutComponent from "./components/PronounsAboutComponent"; import PronounsAboutComponent from "./components/PronounsAboutComponent";
import { CompactPronounsChatComponentWrapper, PronounsChatComponentWrapper } from "./components/PronounsChatComponent"; import { CompactPronounsChatComponentWrapper, PronounsChatComponentWrapper } from "./components/PronounsChatComponent";
import { useProfilePronouns } from "./pronoundbUtils";
import { settings } from "./settings"; import { settings } from "./settings";
export default definePlugin({ export default definePlugin({
@ -53,15 +53,15 @@ export default definePlugin({
replacement: [ replacement: [
{ {
match: /\.PANEL},/, match: /\.PANEL},/,
replace: "$&[vcPronoun,vcPronounSource,vcHasPendingPronouns]=$self.useProfilePronouns(arguments[0].user?.id)," replace: "$&{pronouns:vcPronoun,source:vcPronounSource,hasPendingPronouns:vcHasPendingPronouns}=$self.useProfilePronouns(arguments[0].user?.id),"
}, },
{ {
match: /text:\i\.\i.Messages.USER_PROFILE_PRONOUNS/, match: /text:\i\.\i.Messages.USER_PROFILE_PRONOUNS/,
replace: '$&+vcHasPendingPronouns?"":` (${vcPronounSource})`' replace: '$&+(vcPronoun==null||vcHasPendingPronouns?"":` (${vcPronounSource})`)'
}, },
{ {
match: /(\.pronounsText.+?children:)(\i)/, match: /(\.pronounsText.+?children:)(\i)/,
replace: "$1vcHasPendingPronouns?$2:vcPronoun" replace: "$1(vcPronoun==null||vcHasPendingPronouns)?$2:vcPronoun"
} }
] ]
} }

View file

@ -1,169 +0,0 @@
/*
* 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 { Settings } from "@api/Settings";
import { debounce } from "@shared/debounce";
import { VENCORD_USER_AGENT } from "@shared/vencordUserAgent";
import { getCurrentChannel } from "@utils/discord";
import { useAwaiter } from "@utils/react";
import { findStoreLazy } from "@webpack";
import { UserProfileStore, UserStore } from "@webpack/common";
import { settings } from "./settings";
import { CachePronouns, PronounCode, PronounMapping, PronounsResponse } from "./types";
const UserSettingsAccountStore = findStoreLazy("UserSettingsAccountStore");
type PronounsWithSource = [pronouns: string | null, source: string, hasPendingPronouns: boolean];
const EmptyPronouns: PronounsWithSource = [null, "", false];
export const enum PronounsFormat {
Lowercase = "LOWERCASE",
Capitalized = "CAPITALIZED"
}
export const enum PronounSource {
PreferPDB,
PreferDiscord
}
// A map of cached pronouns so the same request isn't sent twice
const cache: Record<string, CachePronouns> = {};
// A map of ids and callbacks that should be triggered on fetch
const requestQueue: Record<string, ((pronouns: string) => void)[]> = {};
// Executes all queued requests and calls their callbacks
const bulkFetch = debounce(async () => {
const ids = Object.keys(requestQueue);
const pronouns = await bulkFetchPronouns(ids);
for (const id of ids) {
// Call all callbacks for the id
requestQueue[id]?.forEach(c => c(pronouns[id] ? extractPronouns(pronouns[id].sets) : ""));
delete requestQueue[id];
}
});
function getDiscordPronouns(id: string, useGlobalProfile: boolean = false) {
const globalPronouns = UserProfileStore.getUserProfile(id)?.pronouns;
if (useGlobalProfile) return globalPronouns;
return (
UserProfileStore.getGuildMemberProfile(id, getCurrentChannel()?.guild_id)?.pronouns
|| globalPronouns
);
}
export function useFormattedPronouns(id: string, useGlobalProfile: boolean = false): PronounsWithSource {
// Discord is so stupid you can put tons of newlines in pronouns
const discordPronouns = getDiscordPronouns(id, useGlobalProfile)?.trim().replace(NewLineRe, " ");
const [result] = useAwaiter(() => fetchPronouns(id), {
fallbackValue: getCachedPronouns(id),
onError: e => console.error("Fetching pronouns failed: ", e)
});
const hasPendingPronouns = UserSettingsAccountStore.getPendingPronouns() != null;
if (settings.store.pronounSource === PronounSource.PreferDiscord && discordPronouns)
return [discordPronouns, "Discord", hasPendingPronouns];
if (result && result !== PronounMapping.unspecified)
return [result, "PronounDB", hasPendingPronouns];
return [discordPronouns, "Discord", hasPendingPronouns];
}
export function useProfilePronouns(id: string, useGlobalProfile: boolean = false): PronounsWithSource {
const pronouns = useFormattedPronouns(id, useGlobalProfile);
if (!settings.store.showInProfile) return EmptyPronouns;
if (!settings.store.showSelf && id === UserStore.getCurrentUser().id) return EmptyPronouns;
return pronouns;
}
const NewLineRe = /\n+/g;
// Gets the cached pronouns, if you're too impatient for a promise!
export function getCachedPronouns(id: string): string | null {
const cached = cache[id] ? extractPronouns(cache[id].sets) : undefined;
if (cached && cached !== PronounMapping.unspecified) return cached;
return cached || null;
}
// Fetches the pronouns for one id, returning a promise that resolves if it was cached, or once the request is completed
export function fetchPronouns(id: string): Promise<string> {
return new Promise(res => {
const cached = getCachedPronouns(id);
if (cached) return res(cached);
// If there is already a request added, then just add this callback to it
if (id in requestQueue) return requestQueue[id].push(res);
// If not already added, then add it and call the debounced function to make sure the request gets executed
requestQueue[id] = [res];
bulkFetch();
});
}
async function bulkFetchPronouns(ids: string[]): Promise<PronounsResponse> {
const params = new URLSearchParams();
params.append("platform", "discord");
params.append("ids", ids.join(","));
try {
const req = await fetch("https://pronoundb.org/api/v2/lookup?" + params.toString(), {
method: "GET",
headers: {
"Accept": "application/json",
"X-PronounDB-Source": VENCORD_USER_AGENT
}
});
return await req.json()
.then((res: PronounsResponse) => {
Object.assign(cache, res);
return res;
});
} catch (e) {
// If the request errors, treat it as if no pronouns were found for all ids, and log it
console.error("PronounDB fetching failed: ", e);
const dummyPronouns = Object.fromEntries(ids.map(id => [id, { sets: {} }] as const));
Object.assign(cache, dummyPronouns);
return dummyPronouns;
}
}
export function extractPronouns(pronounSet?: { [locale: string]: PronounCode[]; }): string {
if (!pronounSet || !pronounSet.en) return PronounMapping.unspecified;
// PronounDB returns an empty set instead of {sets: {en: ["unspecified"]}}.
const pronouns = pronounSet.en;
const { pronounsFormat } = Settings.plugins.PronounDB as { pronounsFormat: PronounsFormat, enabled: boolean; };
if (pronouns.length === 1) {
// For capitalized pronouns or special codes (any, ask, avoid), we always return the normal (capitalized) string
if (pronounsFormat === PronounsFormat.Capitalized || ["any", "ask", "avoid", "other", "unspecified"].includes(pronouns[0]))
return PronounMapping[pronouns[0]];
else return PronounMapping[pronouns[0]].toLowerCase();
}
const pronounString = pronouns.map(p => p[0].toUpperCase() + p.slice(1)).join("/");
return pronounsFormat === PronounsFormat.Capitalized ? pronounString : pronounString.toLowerCase();
}

View file

@ -19,7 +19,7 @@
import { definePluginSettings } from "@api/Settings"; import { definePluginSettings } from "@api/Settings";
import { OptionType } from "@utils/types"; import { OptionType } from "@utils/types";
import { PronounsFormat, PronounSource } from "./pronoundbUtils"; import { PronounsFormat, PronounSource } from "./types";
export const settings = definePluginSettings({ export const settings = definePluginSettings({
pronounsFormat: { pronounsFormat: {

View file

@ -25,22 +25,13 @@ export interface UserProfilePronounsProps {
hidePersonalInformation: boolean; hidePersonalInformation: boolean;
} }
export interface PronounsResponse { export type PronounSets = Record<string, PronounCode[]>;
[id: string]: { export type PronounsResponse = Record<string, { sets?: PronounSets; }>;
sets?: {
[locale: string]: PronounCode[];
}
}
}
export interface CachePronouns { export interface PronounsCache {
sets?: { sets?: PronounSets;
[locale: string]: PronounCode[];
}
} }
export type PronounCode = keyof typeof PronounMapping;
export const PronounMapping = { export const PronounMapping = {
he: "He/Him", he: "He/Him",
it: "It/Its", it: "It/Its",
@ -51,4 +42,22 @@ export const PronounMapping = {
ask: "Ask me my pronouns", ask: "Ask me my pronouns",
avoid: "Avoid pronouns, use my name", avoid: "Avoid pronouns, use my name",
unspecified: "No pronouns specified.", unspecified: "No pronouns specified.",
} as const; } as const satisfies Record<string, string>;
export type PronounCode = keyof typeof PronounMapping;
export interface Pronouns {
pronouns?: string;
source: string;
hasPendingPronouns: boolean;
}
export const enum PronounsFormat {
Lowercase = "LOWERCASE",
Capitalized = "CAPITALIZED"
}
export const enum PronounSource {
PreferPDB,
PreferDiscord
}

View file

@ -12,7 +12,7 @@ import { Flex, Menu } from "@webpack/common";
const DefaultEngines = { const DefaultEngines = {
Google: "https://www.google.com/search?q=", Google: "https://www.google.com/search?q=",
DuckDuckGo: "https://duckduckgo.com/", DuckDuckGo: "https://duckduckgo.com/?q=",
Brave: "https://search.brave.com/search?q=", Brave: "https://search.brave.com/search?q=",
Bing: "https://www.bing.com/search?q=", Bing: "https://www.bing.com/search?q=",
Yahoo: "https://search.yahoo.com/search?p=", Yahoo: "https://search.yahoo.com/search?p=",

View file

@ -22,12 +22,13 @@ import { useForceUpdater } from "@utils/react";
import { Paginator, Text, useRef, useState } from "@webpack/common"; import { Paginator, Text, useRef, useState } from "@webpack/common";
import { Auth } from "../auth"; import { Auth } from "../auth";
import { ReviewType } from "../entities";
import { Response, REVIEWS_PER_PAGE } from "../reviewDbApi"; import { Response, REVIEWS_PER_PAGE } from "../reviewDbApi";
import { cl } from "../utils"; import { cl } from "../utils";
import ReviewComponent from "./ReviewComponent"; import ReviewComponent from "./ReviewComponent";
import ReviewsView, { ReviewsInputComponent } from "./ReviewsView"; import ReviewsView, { ReviewsInputComponent } from "./ReviewsView";
function Modal({ modalProps, modalKey, discordId, name }: { modalProps: any; modalKey: string, discordId: string; name: string; }) { function Modal({ modalProps, modalKey, discordId, name, type }: { modalProps: any; modalKey: string, discordId: string; name: string; type: ReviewType; }) {
const [data, setData] = useState<Response>(); const [data, setData] = useState<Response>();
const [signal, refetch] = useForceUpdater(true); const [signal, refetch] = useForceUpdater(true);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
@ -58,6 +59,7 @@ function Modal({ modalProps, modalKey, discordId, name }: { modalProps: any; mod
onFetchReviews={setData} onFetchReviews={setData}
scrollToTop={() => ref.current?.scrollTo({ top: 0, behavior: "smooth" })} scrollToTop={() => ref.current?.scrollTo({ top: 0, behavior: "smooth" })}
hideOwnReview hideOwnReview
type={type}
/> />
</div> </div>
</ModalContent> </ModalContent>
@ -95,7 +97,7 @@ function Modal({ modalProps, modalKey, discordId, name }: { modalProps: any; mod
); );
} }
export function openReviewsModal(discordId: string, name: string) { export function openReviewsModal(discordId: string, name: string, type: ReviewType) {
const modalKey = "vc-rdb-modal-" + Date.now(); const modalKey = "vc-rdb-modal-" + Date.now();
openModal(props => ( openModal(props => (
@ -104,6 +106,7 @@ export function openReviewsModal(discordId: string, name: string) {
modalProps={props} modalProps={props}
discordId={discordId} discordId={discordId}
name={name} name={name}
type={type}
/> />
), { modalKey }); ), { modalKey });
} }

View file

@ -21,7 +21,7 @@ import { findByCodeLazy, findByPropsLazy, findComponentByCodeLazy } from "@webpa
import { Forms, React, RelationshipStore, useRef, UserStore } from "@webpack/common"; import { Forms, React, RelationshipStore, useRef, UserStore } from "@webpack/common";
import { Auth, authorize } from "../auth"; import { Auth, authorize } from "../auth";
import { Review } from "../entities"; import { Review, ReviewType } from "../entities";
import { addReview, getReviews, Response, REVIEWS_PER_PAGE } from "../reviewDbApi"; import { addReview, getReviews, Response, REVIEWS_PER_PAGE } from "../reviewDbApi";
import { settings } from "../settings"; import { settings } from "../settings";
import { cl, showToast } from "../utils"; import { cl, showToast } from "../utils";
@ -45,6 +45,7 @@ interface Props extends UserProps {
page?: number; page?: number;
scrollToTop?(): void; scrollToTop?(): void;
hideOwnReview?: boolean; hideOwnReview?: boolean;
type: ReviewType;
} }
export default function ReviewsView({ export default function ReviewsView({
@ -56,6 +57,7 @@ export default function ReviewsView({
page = 1, page = 1,
showInput = false, showInput = false,
hideOwnReview = false, hideOwnReview = false,
type,
}: Props) { }: Props) {
const [signal, refetch] = useForceUpdater(true); const [signal, refetch] = useForceUpdater(true);
@ -80,6 +82,7 @@ export default function ReviewsView({
reviews={reviewData!.reviews} reviews={reviewData!.reviews}
hideOwnReview={hideOwnReview} hideOwnReview={hideOwnReview}
profileId={discordId} profileId={discordId}
type={type}
/> />
{showInput && ( {showInput && (
@ -94,7 +97,7 @@ export default function ReviewsView({
); );
} }
function ReviewList({ refetch, reviews, hideOwnReview, profileId }: { refetch(): void; reviews: Review[]; hideOwnReview: boolean; profileId: string; }) { function ReviewList({ refetch, reviews, hideOwnReview, profileId, type }: { refetch(): void; reviews: Review[]; hideOwnReview: boolean; profileId: string; type: ReviewType; }) {
const myId = UserStore.getCurrentUser().id; const myId = UserStore.getCurrentUser().id;
return ( return (
@ -111,7 +114,7 @@ function ReviewList({ refetch, reviews, hideOwnReview, profileId }: { refetch():
{reviews?.length === 0 && ( {reviews?.length === 0 && (
<Forms.FormText className={cl("placeholder")}> <Forms.FormText className={cl("placeholder")}>
Looks like nobody reviewed this user yet. You could be the first! Looks like nobody reviewed this {type === ReviewType.User ? "user" : "server"} yet. You could be the first!
</Forms.FormText> </Forms.FormText>
)} )}
</div> </div>

View file

@ -30,7 +30,7 @@ import { Guild, User } from "discord-types/general";
import { Auth, initAuth, updateAuth } from "./auth"; import { Auth, initAuth, updateAuth } from "./auth";
import { openReviewsModal } from "./components/ReviewModal"; import { openReviewsModal } from "./components/ReviewModal";
import { NotificationType } from "./entities"; import { NotificationType, ReviewType } from "./entities";
import { getCurrentUserInfo, readNotification } from "./reviewDbApi"; import { getCurrentUserInfo, readNotification } from "./reviewDbApi";
import { settings } from "./settings"; import { settings } from "./settings";
import { showToast } from "./utils"; import { showToast } from "./utils";
@ -44,7 +44,7 @@ const guildPopoutPatch: NavContextMenuPatchCallback = (children, { guild }: { gu
label="View Reviews" label="View Reviews"
id="vc-rdb-server-reviews" id="vc-rdb-server-reviews"
icon={OpenExternalIcon} icon={OpenExternalIcon}
action={() => openReviewsModal(guild.id, guild.name)} action={() => openReviewsModal(guild.id, guild.name, ReviewType.Server)}
/> />
); );
}; };
@ -56,7 +56,7 @@ const userContextPatch: NavContextMenuPatchCallback = (children, { user }: { use
label="View Reviews" label="View Reviews"
id="vc-rdb-user-reviews" id="vc-rdb-user-reviews"
icon={OpenExternalIcon} icon={OpenExternalIcon}
action={() => openReviewsModal(user.id, user.username)} action={() => openReviewsModal(user.id, user.username, ReviewType.User)}
/> />
); );
}; };
@ -91,7 +91,7 @@ export default definePlugin({
} }
}, },
{ {
find: ".PANEL,isInteractionSource:", find: 'location:"UserProfilePanel"',
replacement: { replacement: {
match: /{profileType:\i\.\i\.PANEL,children:\[/, match: /{profileType:\i\.\i\.PANEL,children:\[/,
replace: "$&$self.BiteSizeReviewsButton({user:arguments[0].user})," replace: "$&$self.BiteSizeReviewsButton({user:arguments[0].user}),"
@ -157,7 +157,7 @@ export default definePlugin({
return ( return (
<TooltipContainer text="View Reviews"> <TooltipContainer text="View Reviews">
<Button <Button
onClick={() => openReviewsModal(user.id, user.username)} onClick={() => openReviewsModal(user.id, user.username, ReviewType.User)}
look={Button.Looks.FILLED} look={Button.Looks.FILLED}
size={Button.Sizes.NONE} size={Button.Sizes.NONE}
color={RoleButtonClasses.bannerColor} color={RoleButtonClasses.bannerColor}

View file

@ -18,10 +18,14 @@
import { definePluginSettings } from "@api/Settings"; import { definePluginSettings } from "@api/Settings";
import ErrorBoundary from "@components/ErrorBoundary"; import ErrorBoundary from "@components/ErrorBoundary";
import { makeRange } from "@components/PluginSettings/components";
import { Devs } from "@utils/constants"; import { Devs } from "@utils/constants";
import definePlugin, { OptionType } from "@utils/types"; import definePlugin, { OptionType } from "@utils/types";
import { findByCodeLazy } from "@webpack";
import { ChannelStore, GuildMemberStore, GuildStore } from "@webpack/common"; import { ChannelStore, GuildMemberStore, GuildStore } from "@webpack/common";
const useMessageAuthor = findByCodeLazy('"Result cannot be null because the message is not null"');
const settings = definePluginSettings({ const settings = definePluginSettings({
chatMentions: { chatMentions: {
type: OptionType.BOOLEAN, type: OptionType.BOOLEAN,
@ -46,18 +50,30 @@ const settings = definePluginSettings({
default: true, default: true,
description: "Show role colors in the reactors list", description: "Show role colors in the reactors list",
restartNeeded: true restartNeeded: true
} },
colorChatMessages: {
type: OptionType.BOOLEAN,
default: false,
description: "Color chat messages based on the author's role color",
restartNeeded: true,
},
messageSaturation: {
type: OptionType.SLIDER,
description: "Intensity of message coloring.",
markers: makeRange(0, 100, 10),
default: 30
},
}); });
export default definePlugin({ export default definePlugin({
name: "RoleColorEverywhere", name: "RoleColorEverywhere",
authors: [Devs.KingFish, Devs.lewisakura, Devs.AutumnVN], authors: [Devs.KingFish, Devs.lewisakura, Devs.AutumnVN, Devs.Kyuuhachi],
description: "Adds the top role color anywhere possible", description: "Adds the top role color anywhere possible",
patches: [ patches: [
// Chat Mentions // Chat Mentions
{ {
find: 'location:"UserMention', find: ".USER_MENTION)",
replacement: [ replacement: [
{ {
match: /onContextMenu:\i,color:\i,\.\.\.\i(?=,children:)(?<=user:(\i),channel:(\i).{0,500}?)/, match: /onContextMenu:\i,color:\i,\.\.\.\i(?=,children:)(?<=user:(\i),channel:(\i).{0,500}?)/,
@ -114,7 +130,15 @@ export default definePlugin({
replace: "$&,style:{color:$self.getColor($2?.id,$1)}" replace: "$&,style:{color:$self.getColor($2?.id,$1)}"
}, },
predicate: () => settings.store.reactorsList, predicate: () => settings.store.reactorsList,
} },
{
find: '.Messages.MESSAGE_EDITED,")"',
replacement: {
match: /(?<=isUnsupported\]:(\i)\.isUnsupported\}\),)(?=children:\[)/,
replace: "style:{color:$self.useMessageColor($1)},"
},
predicate: () => settings.store.colorChatMessages,
},
], ],
settings, settings,
@ -148,5 +172,17 @@ export default definePlugin({
color: this.getColor(userId, { guildId }) color: this.getColor(userId, { guildId })
} }
}; };
} },
useMessageColor(message: any) {
try {
const { messageSaturation } = settings.use(["messageSaturation"]);
const author = useMessageAuthor(message);
if (author.colorString !== undefined && messageSaturation !== 0)
return `color-mix(in oklab, ${author.colorString} ${messageSaturation}%, var(--text-normal))`;
} catch (e) {
console.error("[RCE] failed to get message color", e);
}
return undefined;
},
}); });

View file

@ -1,6 +0,0 @@
# SearchReply
Adds a reply button to search results.
![the plugin in action](https://github.com/Vendicated/Vencord/assets/45497981/07e741d3-0f97-4e5c-82b0-80712ecf2cbb)

View file

@ -1,75 +0,0 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { findGroupChildrenByChildId, NavContextMenuPatchCallback } from "@api/ContextMenu";
import { ReplyIcon } from "@components/Icons";
import { Devs } from "@utils/constants";
import definePlugin from "@utils/types";
import { findByCodeLazy } from "@webpack";
import { ChannelStore, i18n, Menu, PermissionsBits, PermissionStore, SelectedChannelStore } from "@webpack/common";
import { Message } from "discord-types/general";
const replyToMessage = findByCodeLazy(".TEXTAREA_FOCUS)", "showMentionToggle:");
const messageContextMenuPatch: NavContextMenuPatchCallback = (children, { message }: { message: Message; }) => {
// make sure the message is in the selected channel
if (SelectedChannelStore.getChannelId() !== message.channel_id) return;
const channel = ChannelStore.getChannel(message?.channel_id);
if (!channel) return;
if (channel.guild_id && !PermissionStore.can(PermissionsBits.SEND_MESSAGES, channel)) return;
// dms and group chats
const dmGroup = findGroupChildrenByChildId("pin", children);
if (dmGroup && !dmGroup.some(child => child?.props?.id === "reply")) {
const pinIndex = dmGroup.findIndex(c => c?.props.id === "pin");
dmGroup.splice(pinIndex + 1, 0, (
<Menu.MenuItem
id="reply"
label={i18n.Messages.MESSAGE_ACTION_REPLY}
icon={ReplyIcon}
action={(e: React.MouseEvent) => replyToMessage(channel, message, e)}
/>
));
return;
}
// servers
const serverGroup = findGroupChildrenByChildId("mark-unread", children);
if (serverGroup && !serverGroup.some(child => child?.props?.id === "reply")) {
serverGroup.unshift((
<Menu.MenuItem
id="reply"
label={i18n.Messages.MESSAGE_ACTION_REPLY}
icon={ReplyIcon}
action={(e: React.MouseEvent) => replyToMessage(channel, message, e)}
/>
));
return;
}
};
export default definePlugin({
name: "SearchReply",
description: "Adds a reply button to search results",
authors: [Devs.Aria],
contextMenus: {
"message": messageContextMenuPatch
}
});

View file

@ -4,21 +4,38 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
*/ */
import { definePluginSettings } from "@api/Settings";
import { Devs } from "@utils/constants"; import { Devs } from "@utils/constants";
import definePlugin from "@utils/types"; import definePlugin, { OptionType } from "@utils/types";
const settings = definePluginSettings({
onlySnow: {
type: OptionType.BOOLEAN,
description: "Only play the Snow Halation Theme",
default: false,
restartNeeded: true
}
});
// NOTE - Ultimately should probably be turned into a ringtone picker plugin
export default definePlugin({ export default definePlugin({
name: "SecretRingToneEnabler", name: "SecretRingToneEnabler",
description: "Always play the secret version of the discord ringtone (except during special ringtone events)", description: "Always play the secret version of the discord ringtone (except during special ringtone events)",
authors: [Devs.AndrewDLO, Devs.FieryFlames], authors: [Devs.AndrewDLO, Devs.FieryFlames, Devs.RamziAH],
settings,
patches: [ patches: [
{ {
find: '"call_ringing_beat"', find: '"call_ringing_beat"',
replacement: { replacement: [
match: /500!==\i\(\)\.random\(1,1e3\)/, {
replace: "false", match: /500!==\i\(\)\.random\(1,1e3\)/,
} replace: "false"
}, },
], {
predicate: () => settings.store.onlySnow,
match: /"call_ringing_beat",/,
replace: ""
}
]
}
]
}); });

View file

@ -112,12 +112,12 @@ export default definePlugin({
}, },
// patch request that queries if term is allowed // patch request that queries if term is allowed
{ {
find: ".GUILD_DISCOVERY_VALID_TERM", find: ".GUILD_DISCOVERY_VALID_TERM,query:",
predicate: () => settings.store.disableDisallowedDiscoveryFilters, predicate: () => settings.store.disableDisallowedDiscoveryFilters,
all: true, all: true,
replacement: { replacement: {
match: /\i\.\i\.get\(\{url:\i\.\i\.GUILD_DISCOVERY_VALID_TERM,query:\{term:\i\},oldFormErrors:!0\}\);/g, match: /\i\.\i\.get\(\{url:\i\.\i\.GUILD_DISCOVERY_VALID_TERM,query:\{term:\i\},oldFormErrors:!0\}\)/g,
replace: "Promise.resolve({ body: { valid: true } });" replace: "Promise.resolve({ body: { valid: true } })"
} }
} }
], ],

View file

@ -88,7 +88,7 @@ export default definePlugin({
name: "SilentTyping", name: "SilentTyping",
authors: [Devs.Ven, Devs.Rini, Devs.ImBanana], authors: [Devs.Ven, Devs.Rini, Devs.ImBanana],
description: "Hide that you are typing", description: "Hide that you are typing",
dependencies: ["CommandsAPI", "ChatInputButtonAPI"], dependencies: ["ChatInputButtonAPI"],
settings, settings,
contextMenus: { contextMenus: {
"textarea-context": ChatBarContextCheckbox "textarea-context": ChatBarContextCheckbox

View file

@ -48,7 +48,7 @@ export default definePlugin({
find: ".Messages.FRIEND_REQUEST_CANCEL", find: ".Messages.FRIEND_REQUEST_CANCEL",
replacement: { replacement: {
predicate: () => settings.store.showDates, predicate: () => settings.store.showDates,
match: /subText:(\i)(?=,className:\i\.userInfo}\))(?<=user:(\i).+?)/, match: /subText:(\i)(?<=user:(\i).+?)/,
replace: (_, subtext, user) => `subText:$self.makeSubtext(${subtext},${user})` replace: (_, subtext, user) => `subText:$self.makeSubtext(${subtext},${user})`
} }
}], }],
@ -66,7 +66,7 @@ export default definePlugin({
makeSubtext(text: string, user: User) { makeSubtext(text: string, user: User) {
const since = this.getSince(user); const since = this.getSince(user);
return ( return (
<Flex flexDirection="row" style={{ gap: 0, flexWrap: "wrap", lineHeight: "0.9rem" }}> <Flex flexDirection="column" style={{ gap: 0, flexWrap: "wrap", lineHeight: "0.9rem" }}>
<span>{text}</span> <span>{text}</span>
{!isNaN(since.getTime()) && <span>Received &mdash; {since.toDateString()}</span>} {!isNaN(since.getTime()) && <span>Received &mdash; {since.toDateString()}</span>}
</Flex> </Flex>

View file

@ -76,7 +76,6 @@ export default definePlugin({
name: "SpotifyShareCommands", name: "SpotifyShareCommands",
description: "Share your current Spotify track, album or artist via slash command (/track, /album, /artist)", description: "Share your current Spotify track, album or artist via slash command (/track, /album, /artist)",
authors: [Devs.katlyn], authors: [Devs.katlyn],
dependencies: ["CommandsAPI"],
commands: [ commands: [
{ {
name: "track", name: "track",

View file

@ -1,5 +0,0 @@
# TimeBarAllActivities
Adds the Spotify time bar to all activities if they have start and end timestamps.
![](https://github.com/user-attachments/assets/9fbbe33c-8218-43c9-8b8d-f907a4e809fe)

View file

@ -1,76 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { definePluginSettings } from "@api/Settings";
import { Devs } from "@utils/constants";
import definePlugin, { OptionType } from "@utils/types";
import { findComponentByCodeLazy } from "@webpack";
interface Activity {
timestamps?: ActivityTimestamps;
}
interface ActivityTimestamps {
start?: string;
end?: string;
}
const ActivityTimeBar = findComponentByCodeLazy<ActivityTimestamps>(".Millis.HALF_SECOND", ".bar", ".progress");
export const settings = definePluginSettings({
hideActivityDetailText: {
type: OptionType.BOOLEAN,
description: "Hide the large title text next to the activity",
default: true,
},
hideActivityTimerBadges: {
type: OptionType.BOOLEAN,
description: "Hide the timer badges next to the activity",
default: true,
}
});
export default definePlugin({
name: "TimeBarAllActivities",
description: "Adds the Spotify time bar to all activities if they have start and end timestamps",
authors: [Devs.fawn, Devs.niko],
settings,
patches: [
{
find: ".Messages.USER_ACTIVITY_PLAYING",
replacement: [
// Insert Spotify time bar component
{
match: /\(0,.{0,30}activity:(\i),className:\i\.badges\}\)/g,
replace: "$&,$self.getTimeBar($1)"
},
// Hide the large title on listening activities, to make them look more like Spotify (also visible from hovering over the large icon)
{
match: /(\i).type===(\i\.\i)\.WATCHING/,
replace: "($self.settings.store.hideActivityDetailText&&$self.isActivityTimestamped($1)&&$1.type===$2.LISTENING)||$&"
}
]
},
// Hide the "badge" timers that count the time since the activity starts
{
find: ".TvIcon).otherwise",
replacement: {
match: /null!==\(\i=null===\(\i=(\i)\.timestamps\).{0,50}created_at/,
replace: "($self.settings.store.hideActivityTimerBadges&&$self.isActivityTimestamped($1))?null:$&"
}
}
],
isActivityTimestamped(activity: Activity) {
return activity.timestamps != null && activity.timestamps.start != null && activity.timestamps.end != null;
},
getTimeBar(activity: Activity) {
if (this.isActivityTimestamped(activity)) {
return <ActivityTimeBar start={activity.timestamps!.start} end={activity.timestamps!.end} />;
}
}
});

View file

@ -0,0 +1,7 @@
# User Voice Show
Shows an indicator when a user is in a Voice Channel
![a preview of the indicator in the user profile](https://github.com/user-attachments/assets/48f825e4-fad5-40d7-bb4f-41d5e595aae0)
![a preview of the indicator in the member list](https://github.com/user-attachments/assets/51be081d-7bbb-45c5-8533-d565228e50c1)

View file

@ -0,0 +1,196 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { classNameFactory } from "@api/Styles";
import ErrorBoundary from "@components/ErrorBoundary";
import { classes } from "@utils/misc";
import { filters, findByCodeLazy, findByPropsLazy, findComponentByCodeLazy, findStoreLazy, mapMangledModuleLazy } from "@webpack";
import { ChannelRouter, ChannelStore, GuildStore, IconUtils, match, P, PermissionsBits, PermissionStore, React, showToast, Text, Toasts, Tooltip, useMemo, UserStore, useStateFromStores } from "@webpack/common";
import { Channel } from "discord-types/general";
const cl = classNameFactory("vc-uvs-");
const { selectVoiceChannel } = findByPropsLazy("selectVoiceChannel", "selectChannel");
const { useChannelName } = mapMangledModuleLazy(".Messages.GROUP_DM_ALONE", {
useChannelName: filters.byCode("()=>null==")
});
const getDMChannelIcon = findByCodeLazy(".getChannelIconURL({");
const VoiceStateStore = findStoreLazy("VoiceStateStore");
const UserSummaryItem = findComponentByCodeLazy("defaultRenderUser", "showDefaultAvatarsForNullUsers");
const Avatar = findComponentByCodeLazy(".AVATAR_STATUS_TYPING_16;");
const GroupDMAvatars = findComponentByCodeLazy(".AvatarSizeSpecs[", "getAvatarURL");
const ActionButtonClasses = findByPropsLazy("actionButton", "highlight");
interface IconProps extends React.ComponentPropsWithoutRef<"div"> {
size?: number;
}
function SpeakerIcon(props: IconProps) {
props.size ??= 16;
return (
<div
{...props}
role={props.onClick != null ? "button" : undefined}
className={classes(cl("speaker"), props.onClick != null ? cl("clickable") : undefined, props.className)}
>
<svg
width={props.size}
height={props.size}
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M12 3a1 1 0 0 0-1-1h-.06a1 1 0 0 0-.74.32L5.92 7H3a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h2.92l4.28 4.68a1 1 0 0 0 .74.32H11a1 1 0 0 0 1-1V3ZM15.1 20.75c-.58.14-1.1-.33-1.1-.92v-.03c0-.5.37-.92.85-1.05a7 7 0 0 0 0-13.5A1.11 1.11 0 0 1 14 4.2v-.03c0-.6.52-1.06 1.1-.92a9 9 0 0 1 0 17.5Z" />
<path d="M15.16 16.51c-.57.28-1.16-.2-1.16-.83v-.14c0-.43.28-.8.63-1.02a3 3 0 0 0 0-5.04c-.35-.23-.63-.6-.63-1.02v-.14c0-.63.59-1.1 1.16-.83a5 5 0 0 1 0 9.02Z" />
</svg>
</div>
);
}
function LockedSpeakerIcon(props: IconProps) {
props.size ??= 16;
return (
<div
{...props}
role={props.onClick != null ? "button" : undefined}
className={classes(cl("speaker"), props.onClick != null ? cl("clickable") : undefined, props.className)}
>
<svg
width={props.size}
height={props.size}
viewBox="0 0 24 24"
fill="currentColor"
>
<path fillRule="evenodd" clipRule="evenodd" d="M16 4h.5v-.5a2.5 2.5 0 0 1 5 0V4h.5a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1h-6a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1Zm4-.5V4h-2v-.5a1 1 0 1 1 2 0Z" />
<path d="M11 2a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1h-.06a1 1 0 0 1-.74-.32L5.92 17H3a1 1 0 0 1-1-1V8a1 1 0 0 1 1-1h2.92l4.28-4.68a1 1 0 0 1 .74-.32H11ZM20.5 12c-.28 0-.5.22-.52.5a7 7 0 0 1-5.13 6.25c-.48.13-.85.55-.85 1.05v.03c0 .6.52 1.06 1.1.92a9 9 0 0 0 6.89-8.25.48.48 0 0 0-.49-.5h-1ZM16.5 12c-.28 0-.5.23-.54.5a3 3 0 0 1-1.33 2.02c-.35.23-.63.6-.63 1.02v.14c0 .63.59 1.1 1.16.83a5 5 0 0 0 2.82-4.01c.02-.28-.2-.5-.48-.5h-1Z" />
</svg>
</div>
);
}
interface VoiceChannelTooltipProps {
channel: Channel;
isLocked: boolean;
}
function VoiceChannelTooltip({ channel, isLocked }: VoiceChannelTooltipProps) {
const voiceStates = useStateFromStores([VoiceStateStore], () => VoiceStateStore.getVoiceStatesForChannel(channel.id));
const users = useMemo(
() => Object.values<any>(voiceStates).map(voiceState => UserStore.getUser(voiceState.userId)).filter(user => user != null),
[voiceStates]
);
const guild = channel.getGuildId() == null ? undefined : GuildStore.getGuild(channel.getGuildId());
const guildIcon = guild?.icon == null ? undefined : IconUtils.getGuildIconURL({
id: guild.id,
icon: guild.icon,
size: 30
});
const channelIcon = match(channel.type)
.with(P.union(1, 3), () => {
return channel.recipients.length >= 2 && channel.icon == null
? <GroupDMAvatars recipients={channel.recipients} size="SIZE_32" />
: <Avatar src={getDMChannelIcon(channel)} size="SIZE_32" />;
})
.otherwise(() => null);
const channelName = useChannelName(channel);
return (
<>
{guild != null && (
<div className={cl("name")}>
{guildIcon != null && <img className={cl("guild-icon")} src={guildIcon} alt="" />}
<Text variant="text-sm/bold">{guild.name}</Text>
</div>
)}
<div className={cl("name")}>
{channelIcon}
<Text variant="text-sm/semibold">{channelName}</Text>
</div>
<div className={cl("vc-members")}>
{isLocked ? <LockedSpeakerIcon size={18} /> : <SpeakerIcon size={18} />}
<UserSummaryItem
users={users}
renderIcon={false}
max={13}
size={18}
/>
</div>
</>
);
}
interface VoiceChannelIndicatorProps {
userId: string;
isMessageIndicator?: boolean;
isProfile?: boolean;
isActionButton?: boolean;
shouldHighlight?: boolean;
}
const clickTimers = {} as Record<string, any>;
export const VoiceChannelIndicator = ErrorBoundary.wrap(({ userId, isMessageIndicator, isProfile, isActionButton, shouldHighlight }: VoiceChannelIndicatorProps) => {
const channelId = useStateFromStores([VoiceStateStore], () => VoiceStateStore.getVoiceStateForUser(userId)?.channelId as string | undefined);
const channel = channelId == null ? undefined : ChannelStore.getChannel(channelId);
if (channel == null) return null;
const isDM = channel.isDM() || channel.isMultiUserDM();
if (!isDM && !PermissionStore.can(PermissionsBits.VIEW_CHANNEL, channel) && !Vencord.Plugins.isPluginEnabled("ShowHiddenChannels")) return null;
const isLocked = !isDM && (!PermissionStore.can(PermissionsBits.VIEW_CHANNEL, channel) || !PermissionStore.can(PermissionsBits.CONNECT, channel));
function onClick(e: React.MouseEvent) {
e.preventDefault();
e.stopPropagation();
if (channel == null || channelId == null) return;
clearTimeout(clickTimers[channelId]);
delete clickTimers[channelId];
if (e.detail > 1) {
if (!isDM && !PermissionStore.can(PermissionsBits.CONNECT, channel)) {
showToast("You cannot join the user's Voice Channel", Toasts.Type.FAILURE);
return;
}
selectVoiceChannel(channelId);
} else {
clickTimers[channelId] = setTimeout(() => {
ChannelRouter.transitionToChannel(channelId);
delete clickTimers[channelId];
}, 250);
}
}
return (
<Tooltip
text={<VoiceChannelTooltip channel={channel} isLocked={isLocked} />}
tooltipClassName={cl("tooltip-container")}
tooltipContentClassName={cl("tooltip-content")}
>
{props => {
const iconProps: IconProps = {
...props,
className: classes(isMessageIndicator && cl("message-indicator"), (!isProfile && !isActionButton) && cl("speaker-margin"), isActionButton && ActionButtonClasses.actionButton, shouldHighlight && ActionButtonClasses.highlight),
size: isActionButton ? 20 : undefined,
onClick
};
return isLocked ?
<LockedSpeakerIcon {...iconProps} />
: <SpeakerIcon {...iconProps} />;
}}
</Tooltip>
);
}, { noop: true });

View file

@ -1,27 +0,0 @@
.vc-uvs-button>div {
white-space: normal !important;
}
.vc-uvs-button {
width: 100%;
margin: auto;
height: unset;
}
.vc-uvs-header {
color: var(--header-primary);
margin-bottom: 6px;
}
.vc-uvs-modal-margin {
margin: 0 12px;
}
.vc-uvs-modal-margin div {
margin-bottom: 0 !important;
}
.vc-uvs-popout-margin-self>[class^="section"] {
padding-top: 0;
padding-bottom: 12px;
}

View file

@ -1,61 +0,0 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import "./VoiceChannelSection.css";
import { findByPropsLazy } from "@webpack";
import { Button, Forms, PermissionStore, Toasts } from "@webpack/common";
import { Channel } from "discord-types/general";
const ChannelActions = findByPropsLazy("selectChannel", "selectVoiceChannel");
const CONNECT = 1n << 20n;
interface VoiceChannelFieldProps {
channel: Channel;
label: string;
showHeader: boolean;
}
export const VoiceChannelSection = ({ channel, label, showHeader }: VoiceChannelFieldProps) => (
// @TODO The div is supposed to be a UserPopoutSection
<div>
{showHeader && <Forms.FormTitle className="vc-uvs-header">In a voice channel</Forms.FormTitle>}
<Button
className="vc-uvs-button"
color={Button.Colors.TRANSPARENT}
size={Button.Sizes.SMALL}
onClick={() => {
if (PermissionStore.can(CONNECT, channel))
ChannelActions.selectVoiceChannel(channel.id);
else
Toasts.show({
message: "Insufficient permissions to enter the channel.",
id: "user-voice-show-insufficient-permissions",
type: Toasts.Type.FAILURE,
options: {
position: Toasts.Position.BOTTOM,
}
});
}}
>
{label}
</Button>
</div>
);

View file

@ -16,85 +16,97 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import "./style.css";
import { addDecorator, removeDecorator } from "@api/MemberListDecorators";
import { addDecoration, removeDecoration } from "@api/MessageDecorations";
import { definePluginSettings } from "@api/Settings"; import { definePluginSettings } from "@api/Settings";
import ErrorBoundary from "@components/ErrorBoundary";
import { Devs } from "@utils/constants"; import { Devs } from "@utils/constants";
import definePlugin, { OptionType } from "@utils/types"; import definePlugin, { OptionType } from "@utils/types";
import { findStoreLazy } from "@webpack";
import { ChannelStore, GuildStore, UserStore } from "@webpack/common";
import { User } from "discord-types/general";
import { VoiceChannelSection } from "./components/VoiceChannelSection"; import { VoiceChannelIndicator } from "./components";
const VoiceStateStore = findStoreLazy("VoiceStateStore");
const settings = definePluginSettings({ const settings = definePluginSettings({
showInUserProfileModal: { showInUserProfileModal: {
type: OptionType.BOOLEAN, type: OptionType.BOOLEAN,
description: "Show a user's voice channel in their profile modal", description: "Show a user's Voice Channel indicator in their profile next to the name",
default: true, default: true,
restartNeeded: true
}, },
showVoiceChannelSectionHeader: { showInMemberList: {
type: OptionType.BOOLEAN, type: OptionType.BOOLEAN,
description: 'Whether to show "IN A VOICE CHANNEL" above the join button', description: "Show a user's Voice Channel indicator in the member and DMs list",
default: true, default: true,
restartNeeded: true
},
showInMessages: {
type: OptionType.BOOLEAN,
description: "Show a user's Voice Channel indicator in messages",
default: true,
restartNeeded: true
} }
}); });
interface UserProps {
user: User;
}
const VoiceChannelField = ErrorBoundary.wrap(({ user }: UserProps) => {
const { channelId } = VoiceStateStore.getVoiceStateForUser(user.id) ?? {};
if (!channelId) return null;
const channel = ChannelStore.getChannel(channelId);
if (!channel) return null;
const guild = GuildStore.getGuild(channel.guild_id);
if (!guild) return null; // When in DM call
const result = `${guild.name} | ${channel.name}`;
return (
<VoiceChannelSection
channel={channel}
label={result}
showHeader={settings.store.showVoiceChannelSectionHeader}
/>
);
});
export default definePlugin({ export default definePlugin({
name: "UserVoiceShow", name: "UserVoiceShow",
description: "Shows whether a User is currently in a voice channel somewhere in their profile", description: "Shows an indicator when a user is in a Voice Channel",
authors: [Devs.LordElias], authors: [Devs.Nuckyz, Devs.LordElias],
dependencies: ["MemberListDecoratorsAPI", "MessageDecorationsAPI"],
settings, settings,
patchModal({ user }: UserProps) {
if (!settings.store.showInUserProfileModal)
return null;
return (
<div className="vc-uvs-modal-margin">
<VoiceChannelField user={user} />
</div>
);
},
patchProfilePopout: ({ user }: UserProps) => {
const isSelfUser = user.id === UserStore.getCurrentUser().id;
return (
<div className={isSelfUser ? "vc-uvs-popout-margin-self" : ""}>
<VoiceChannelField user={user} />
</div>
);
},
patches: [ patches: [
// @TODO Maybe patch UserVoiceShow in simplified profile popout // User Popout, Full Size Profile, Direct Messages Side Profile
// @TODO Patch new profile modal {
find: ".Messages.USER_PROFILE_LOAD_ERROR",
replacement: {
match: /(\.fetchError.+?\?)null/,
replace: (_, rest) => `${rest}$self.VoiceChannelIndicator({userId:arguments[0]?.userId,isProfile:true})`
},
predicate: () => settings.store.showInUserProfileModal
},
// To use without the MemberList decorator API
/* // Guild Members List
{
find: ".lostPermission)",
replacement: {
match: /\.lostPermission\).+?(?=avatar:)/,
replace: "$&children:[$self.VoiceChannelIndicator({userId:arguments[0]?.user?.id})],"
},
predicate: () => settings.store.showVoiceChannelIndicator
},
// Direct Messages List
{
find: "PrivateChannel.renderAvatar",
replacement: {
match: /\.Messages\.CLOSE_DM.+?}\)(?=])/,
replace: "$&,$self.VoiceChannelIndicator({userId:arguments[0]?.user?.id})"
},
predicate: () => settings.store.showVoiceChannelIndicator
}, */
// Friends List
{
find: "null!=this.peopleListItemRef.current",
replacement: {
match: /\.actions,children:\[(?<=isFocused:(\i).+?)/,
replace: "$&$self.VoiceChannelIndicator({userId:this?.props?.user?.id,isActionButton:true,shouldHighlight:$1}),"
},
predicate: () => settings.store.showInMemberList
}
], ],
start() {
if (settings.store.showInMemberList) {
addDecorator("UserVoiceShow", ({ user }) => user == null ? null : <VoiceChannelIndicator userId={user.id} />);
}
if (settings.store.showInMessages) {
addDecoration("UserVoiceShow", ({ message }) => message?.author == null ? null : <VoiceChannelIndicator userId={message.author.id} isMessageIndicator />);
}
},
stop() {
removeDecorator("UserVoiceShow");
removeDecoration("UserVoiceShow");
},
VoiceChannelIndicator
}); });

View file

@ -0,0 +1,50 @@
.vc-uvs-speaker {
color: var(--interactive-normal);
display: flex;
align-items: center;
justify-content: center;
}
.vc-uvs-clickable {
cursor: pointer;
}
.vc-uvs-clickable:hover {
color: var(--interactive-hover);
}
.vc-uvs-speaker-margin {
margin-left: 4px;
}
.vc-uvs-message-indicator {
display: inline-flex;
top: 2.5px;
position: relative;
}
.vc-uvs-tooltip-container {
max-width: 300px;
}
.vc-uvs-tooltip-content {
display: flex;
flex-direction: column;
gap: 6px;
}
.vc-uvs-name {
display: flex;
align-items: center;
gap: 8px;
}
.vc-uvs-guild-icon {
border-radius: 100%;
align-self: center;
}
.vc-uvs-vc-members {
display: flex;
gap: 6px;
}

View file

@ -39,7 +39,7 @@ interface StreamData {
gainNode?: GainNode, gainNode?: GainNode,
id: string, id: string,
levelNode: AudioWorkletNode, levelNode: AudioWorkletNode,
sinkId: string, sinkId: string | "default",
stream: MediaStream, stream: MediaStream,
streamSourceNode?: MediaStreamAudioSourceNode, streamSourceNode?: MediaStreamAudioSourceNode,
videoStreamId: string, videoStreamId: string,
@ -69,7 +69,7 @@ export default definePlugin({
// Patches needed for web/vesktop // Patches needed for web/vesktop
{ {
find: "streamSourceNode", find: "streamSourceNode",
predicate: () => IS_WEB, predicate: () => !IS_DISCORD_DESKTOP,
group: true, group: true,
replacement: [ replacement: [
// Remove rounding algorithm // Remove rounding algorithm
@ -128,6 +128,12 @@ export default definePlugin({
gain.connect(data.audioContext.destination); gain.connect(data.audioContext.destination);
} }
// @ts-expect-error
if (data.sinkId != null && data.sinkId !== data.audioContext.sinkId && "setSinkId" in AudioContext.prototype) {
// @ts-expect-error https://developer.mozilla.org/en-US/docs/Web/API/AudioContext/setSinkId
data.audioContext.setSinkId(data.sinkId);
}
data.gainNode.gain.value = data._mute data.gainNode.gain.value = data._mute
? 0 ? 0
: data._volume / 100; : data._volume / 100;

View file

@ -86,7 +86,7 @@ interface NotificationObject {
title: string; title: string;
content: string; content: string;
useBase64Icon: boolean; useBase64Icon: boolean;
icon: ArrayBuffer | string; icon: string;
sourceApp: string; sourceApp: string;
} }
@ -320,23 +320,29 @@ function shouldIgnoreForChannelType(channel: Channel) {
} }
function sendMsgNotif(titleString: string, content: string, message: Message) { function sendMsgNotif(titleString: string, content: string, message: Message) {
fetch(`https://cdn.discordapp.com/avatars/${message.author.id}/${message.author.avatar}.png?size=128`).then(response => response.arrayBuffer()).then(result => { fetch(`https://cdn.discordapp.com/avatars/${message.author.id}/${message.author.avatar}.png?size=128`)
const msgData: NotificationObject = { .then(response => response.blob())
type: 1, .then(blob => new Promise<string>(resolve => {
timeout: settings.store.lengthBasedTimeout ? calculateTimeout(content) : settings.store.timeout, const r = new FileReader();
height: calculateHeight(content), r.onload = () => resolve((r.result as string).split(",")[1]);
opacity: settings.store.opacity, r.readAsDataURL(blob);
volume: settings.store.volume, })).then(result => {
audioPath: settings.store.soundPath, const msgData: NotificationObject = {
title: titleString, type: 1,
content: content, timeout: settings.store.lengthBasedTimeout ? calculateTimeout(content) : settings.store.timeout,
useBase64Icon: true, height: calculateHeight(content),
icon: new TextDecoder().decode(result), opacity: settings.store.opacity,
sourceApp: "Vencord" volume: settings.store.volume,
}; audioPath: settings.store.soundPath,
title: titleString,
content: content,
useBase64Icon: true,
icon: result,
sourceApp: "Vencord"
};
sendToOverlay(msgData); sendToOverlay(msgData);
}); });
} }
function sendOtherNotif(content: string, titleString: string) { function sendOtherNotif(content: string, titleString: string) {

View file

@ -39,7 +39,8 @@ export const Devs = /* #__PURE__*/ Object.freeze({
}, },
Arjix: { Arjix: {
name: "ArjixWasTaken", name: "ArjixWasTaken",
id: 674710789138939916n id: 674710789138939916n,
badge: false
}, },
Cyn: { Cyn: {
name: "Cynosphere", name: "Cynosphere",
@ -267,7 +268,7 @@ export const Devs = /* #__PURE__*/ Object.freeze({
id: 841509053422632990n id: 841509053422632990n
}, },
F53: { F53: {
name: "F53", name: "Cassie (Code)",
id: 280411966126948353n id: 280411966126948353n
}, },
AutumnVN: { AutumnVN: {
@ -570,6 +571,14 @@ export const Devs = /* #__PURE__*/ Object.freeze({
name: "niko", name: "niko",
id: 341377368075796483n, id: 341377368075796483n,
}, },
relitrix: {
name: "Relitrix",
id: 423165393901715456n,
},
RamziAH: {
name: "RamziAH",
id: 1279957227612147747n,
},
} satisfies Record<string, Dev>); } satisfies Record<string, Dev>);
// iife so #__PURE__ works correctly // iife so #__PURE__ works correctly

View file

@ -18,7 +18,7 @@
import { MessageObject } from "@api/MessageEvents"; import { MessageObject } from "@api/MessageEvents";
import { ChannelStore, ComponentDispatch, Constants, FluxDispatcher, GuildStore, InviteActions, MaskedLink, MessageActions, ModalImageClasses, PrivateChannelsStore, RestAPI, SelectedChannelStore, SelectedGuildStore, UserProfileActions, UserProfileStore, UserSettingsActionCreators, UserUtils } from "@webpack/common"; import { ChannelStore, ComponentDispatch, Constants, FluxDispatcher, GuildStore, InviteActions, MaskedLink, MessageActions, ModalImageClasses, PrivateChannelsStore, RestAPI, SelectedChannelStore, SelectedGuildStore, UserProfileActions, UserProfileStore, UserSettingsActionCreators, UserUtils } from "@webpack/common";
import { Guild, Message, User } from "discord-types/general"; import { Channel, Guild, Message, User } from "discord-types/general";
import { ImageModal, ModalRoot, ModalSize, openModal } from "./modal"; import { ImageModal, ModalRoot, ModalSize, openModal } from "./modal";
@ -54,12 +54,12 @@ export async function openInviteModal(code: string) {
}); });
} }
export function getCurrentChannel() { export function getCurrentChannel(): Channel | undefined {
return ChannelStore.getChannel(SelectedChannelStore.getChannelId()); return ChannelStore.getChannel(SelectedChannelStore.getChannelId());
} }
export function getCurrentGuild(): Guild | undefined { export function getCurrentGuild(): Guild | undefined {
return GuildStore.getGuild(getCurrentChannel()?.guild_id); return GuildStore.getGuild(getCurrentChannel()?.guild_id!);
} }
export function openPrivateChannel(userId: string) { export function openPrivateChannel(userId: string) {

View file

@ -17,6 +17,7 @@
*/ */
import { Settings, SettingsStore } from "@api/Settings"; import { Settings, SettingsStore } from "@api/Settings";
import { ThemeStore } from "@webpack/common";
let style: HTMLStyleElement; let style: HTMLStyleElement;
@ -59,7 +60,18 @@ async function initThemes() {
const { themeLinks, enabledThemes } = Settings; const { themeLinks, enabledThemes } = Settings;
const links: string[] = [...themeLinks]; // "darker" and "midnight" both count as dark
const activeTheme = ThemeStore.theme === "light" ? "light" : "dark";
const links = themeLinks
.map(rawLink => {
const match = /^@(light|dark) (.*)/.exec(rawLink);
if (!match) return rawLink;
const [, mode, link] = match;
return mode === activeTheme ? link : null;
})
.filter(link => link !== null);
if (IS_WEB) { if (IS_WEB) {
for (const theme of enabledThemes) { for (const theme of enabledThemes) {
@ -85,6 +97,7 @@ document.addEventListener("DOMContentLoaded", () => {
SettingsStore.addChangeListener("themeLinks", initThemes); SettingsStore.addChangeListener("themeLinks", initThemes);
SettingsStore.addChangeListener("enabledThemes", initThemes); SettingsStore.addChangeListener("enabledThemes", initThemes);
ThemeStore.addChangeListener(initThemes);
if (!IS_WEB) if (!IS_WEB)
VencordNative.quickCss.addThemeChangeListener(initThemes); VencordNative.quickCss.addThemeChangeListener(initThemes);

View file

@ -72,13 +72,13 @@ export interface PluginDef {
stop?(): void; stop?(): void;
patches?: Omit<Patch, "plugin">[]; patches?: Omit<Patch, "plugin">[];
/** /**
* List of commands. If you specify these, you must add CommandsAPI to dependencies * List of commands that your plugin wants to register
*/ */
commands?: Command[]; commands?: Command[];
/** /**
* A list of other plugins that your plugin depends on. * A list of other plugins that your plugin depends on.
* These will automatically be enabled and loaded before your plugin * These will automatically be enabled and loaded before your plugin
* Common examples are CommandsAPI, MessageEventsAPI... * Generally these will be API plugins
*/ */
dependencies?: string[], dependencies?: string[],
/** /**

View file

@ -28,6 +28,8 @@ export let Forms = {} as {
FormText: t.FormText, FormText: t.FormText,
}; };
export let Icons = {} as t.Icons;
export let Card: t.Card; export let Card: t.Card;
export let Button: t.Button; export let Button: t.Button;
export let Switch: t.Switch; export let Switch: t.Switch;
@ -85,4 +87,5 @@ waitFor(["FormItem", "Button"], m => {
Heading Heading
} = m); } = m);
Forms = m; Forms = m;
Icons = m;
}); });

View file

@ -53,6 +53,7 @@ export let RelationshipStore: Stores.RelationshipStore & t.FluxStore & {
}; };
export let EmojiStore: t.EmojiStore; export let EmojiStore: t.EmojiStore;
export let ThemeStore: t.ThemeStore;
export let WindowStore: t.WindowStore; export let WindowStore: t.WindowStore;
export let DraftStore: t.DraftStore; export let DraftStore: t.DraftStore;
@ -84,3 +85,4 @@ waitForStore("GuildChannelStore", m => GuildChannelStore = m);
waitForStore("MessageStore", m => MessageStore = m); waitForStore("MessageStore", m => MessageStore = m);
waitForStore("WindowStore", m => WindowStore = m); waitForStore("WindowStore", m => WindowStore = m);
waitForStore("EmojiStore", m => EmojiStore = m); waitForStore("EmojiStore", m => EmojiStore = m);
waitForStore("ThemeStore", m => ThemeStore = m);

View file

@ -18,6 +18,8 @@
import type { ComponentType, CSSProperties, FunctionComponent, HtmlHTMLAttributes, HTMLProps, KeyboardEvent, MouseEvent, PropsWithChildren, PropsWithRef, ReactNode, Ref } from "react"; import type { ComponentType, CSSProperties, FunctionComponent, HtmlHTMLAttributes, HTMLProps, KeyboardEvent, MouseEvent, PropsWithChildren, PropsWithRef, ReactNode, Ref } from "react";
import { IconNames } from "./iconNames";
export type TextVariant = "heading-sm/normal" | "heading-sm/medium" | "heading-sm/semibold" | "heading-sm/bold" | "heading-md/normal" | "heading-md/medium" | "heading-md/semibold" | "heading-md/bold" | "heading-lg/normal" | "heading-lg/medium" | "heading-lg/semibold" | "heading-lg/bold" | "heading-xl/normal" | "heading-xl/medium" | "heading-xl/bold" | "heading-xxl/normal" | "heading-xxl/medium" | "heading-xxl/bold" | "eyebrow" | "heading-deprecated-14/normal" | "heading-deprecated-14/medium" | "heading-deprecated-14/bold" | "text-xxs/normal" | "text-xxs/medium" | "text-xxs/semibold" | "text-xxs/bold" | "text-xs/normal" | "text-xs/medium" | "text-xs/semibold" | "text-xs/bold" | "text-sm/normal" | "text-sm/medium" | "text-sm/semibold" | "text-sm/bold" | "text-md/normal" | "text-md/medium" | "text-md/semibold" | "text-md/bold" | "text-lg/normal" | "text-lg/medium" | "text-lg/semibold" | "text-lg/bold" | "display-sm" | "display-md" | "display-lg" | "code"; export type TextVariant = "heading-sm/normal" | "heading-sm/medium" | "heading-sm/semibold" | "heading-sm/bold" | "heading-md/normal" | "heading-md/medium" | "heading-md/semibold" | "heading-md/bold" | "heading-lg/normal" | "heading-lg/medium" | "heading-lg/semibold" | "heading-lg/bold" | "heading-xl/normal" | "heading-xl/medium" | "heading-xl/bold" | "heading-xxl/normal" | "heading-xxl/medium" | "heading-xxl/bold" | "eyebrow" | "heading-deprecated-14/normal" | "heading-deprecated-14/medium" | "heading-deprecated-14/bold" | "text-xxs/normal" | "text-xxs/medium" | "text-xxs/semibold" | "text-xxs/bold" | "text-xs/normal" | "text-xs/medium" | "text-xs/semibold" | "text-xs/bold" | "text-sm/normal" | "text-sm/medium" | "text-sm/semibold" | "text-sm/bold" | "text-md/normal" | "text-md/medium" | "text-md/semibold" | "text-md/bold" | "text-lg/normal" | "text-lg/medium" | "text-lg/semibold" | "text-lg/bold" | "display-sm" | "display-md" | "display-lg" | "code";
export type FormTextTypes = Record<"DEFAULT" | "INPUT_PLACEHOLDER" | "DESCRIPTION" | "LABEL_BOLD" | "LABEL_SELECTED" | "LABEL_DESCRIPTOR" | "ERROR" | "SUCCESS", string>; export type FormTextTypes = Record<"DEFAULT" | "INPUT_PLACEHOLDER" | "DESCRIPTION" | "LABEL_BOLD" | "LABEL_SELECTED" | "LABEL_DESCRIPTOR" | "ERROR" | "SUCCESS", string>;
export type HeadingTag = `h${1 | 2 | 3 | 4 | 5 | 6}`; export type HeadingTag = `h${1 | 2 | 3 | 4 | 5 | 6}`;
@ -69,7 +71,7 @@ export type FormText = ComponentType<PropsWithChildren<{
}> & TextProps> & { Types: FormTextTypes; }; }> & TextProps> & { Types: FormTextTypes; };
export type Tooltip = ComponentType<{ export type Tooltip = ComponentType<{
text: ReactNode; text: ReactNode | ComponentType;
children: FunctionComponent<{ children: FunctionComponent<{
onClick(): void; onClick(): void;
onMouseEnter(): void; onMouseEnter(): void;
@ -91,7 +93,7 @@ export type Tooltip = ComponentType<{
/** Tooltip.Colors.BLACK */ /** Tooltip.Colors.BLACK */
color?: string; color?: string;
/** TooltipPositions.TOP */ /** TooltipPositions.TOP */
position?: string; position?: PopoutPosition;
tooltipClassName?: string; tooltipClassName?: string;
tooltipContentClassName?: string; tooltipContentClassName?: string;
@ -110,7 +112,7 @@ export type TooltipContainer = ComponentType<PropsWithChildren<{
/** Tooltip.Colors.BLACK */ /** Tooltip.Colors.BLACK */
color?: string; color?: string;
/** TooltipPositions.TOP */ /** TooltipPositions.TOP */
position?: string; position?: PopoutPosition;
spacing?: number; spacing?: number;
className?: string; className?: string;
@ -252,7 +254,7 @@ export type Select = ComponentType<PropsWithChildren<{
look?: 0 | 1; look?: 0 | 1;
className?: string; className?: string;
popoutClassName?: string; popoutClassName?: string;
popoutPosition?: "top" | "left" | "right" | "bottom" | "center" | "window_center"; popoutPosition?: PopoutPosition;
optionClassName?: string; optionClassName?: string;
autoFocus?: boolean; autoFocus?: boolean;
@ -293,7 +295,7 @@ export type SearchableSelect = ComponentType<PropsWithChildren<{
className?: string; className?: string;
popoutClassName?: string; popoutClassName?: string;
wrapperClassName?: string; wrapperClassName?: string;
popoutPosition?: "top" | "left" | "right" | "bottom" | "center" | "window_center"; popoutPosition?: PopoutPosition;
optionClassName?: string; optionClassName?: string;
autoFocus?: boolean; autoFocus?: boolean;
@ -376,6 +378,8 @@ declare enum PopoutAnimation {
FADE = "4" FADE = "4"
} }
type PopoutPosition = "top" | "bottom" | "left" | "right" | "center" | "window_center";
export type Popout = ComponentType<{ export type Popout = ComponentType<{
children( children(
thing: { thing: {
@ -387,7 +391,7 @@ export type Popout = ComponentType<{
}, },
data: { data: {
isShown: boolean; isShown: boolean;
position: string; position: PopoutPosition;
} }
): ReactNode; ): ReactNode;
shouldShow?: boolean; shouldShow?: boolean;
@ -395,7 +399,7 @@ export type Popout = ComponentType<{
closePopout(): void; closePopout(): void;
isPositioned: boolean; isPositioned: boolean;
nudge: number; nudge: number;
position: string; position: PopoutPosition;
setPopoutRef(ref: any): void; setPopoutRef(ref: any): void;
updatePosition(): void; updatePosition(): void;
}): ReactNode; }): ReactNode;
@ -404,13 +408,13 @@ export type Popout = ComponentType<{
onRequestClose?(): void; onRequestClose?(): void;
/** "center" and others */ /** "center" and others */
align?: string; align?: "left" | "right" | "center";
/** Popout.Animation */ /** Popout.Animation */
animation?: PopoutAnimation; animation?: PopoutAnimation;
autoInvert?: boolean; autoInvert?: boolean;
nudgeAlignIntoViewport?: boolean; nudgeAlignIntoViewport?: boolean;
/** "bottom" and others */ /** "bottom" and others */
position?: string; position?: PopoutPosition;
positionKey?: string; positionKey?: string;
spacing?: number; spacing?: number;
}> & { }> & {
@ -459,7 +463,7 @@ export type ScrollerThin = ComponentType<PropsWithChildren<{
style?: CSSProperties; style?: CSSProperties;
dir?: "ltr"; dir?: "ltr";
orientation?: "horizontal" | "vertical"; orientation?: "horizontal" | "vertical" | "auto";
paddingFix?: boolean; paddingFix?: boolean;
fade?: boolean; fade?: boolean;
@ -500,3 +504,10 @@ export type Avatar = ComponentType<PropsWithChildren<{
type FocusLock = ComponentType<PropsWithChildren<{ type FocusLock = ComponentType<PropsWithChildren<{
containerRef: RefObject<HTMLElement>; containerRef: RefObject<HTMLElement>;
}>>; }>>;
export type Icon = ComponentType<JSX.IntrinsicElements["svg"] & {
size?: string;
colorClass?: string;
} & Record<string, any>>;
export type Icons = Record<IconNames, Icon>;

14
src/webpack/common/types/iconNames.d.ts vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -72,6 +72,11 @@ export interface Menu {
onChange(value: number): void, onChange(value: number): void,
renderValue?(value: number): string, renderValue?(value: number): string,
}>; }>;
MenuSearchControl: RC<{
query: string
onChange(query: string): void;
placeholder?: string;
}>;
} }
export interface ContextMenuApi { export interface ContextMenuApi {

View file

@ -220,6 +220,14 @@ export class GuildStore extends FluxStore {
getAllGuildRoles(): Record<string, Record<string, Role>>; getAllGuildRoles(): Record<string, Record<string, Role>>;
} }
export class ThemeStore extends FluxStore {
theme: "light" | "dark" | "darker" | "midnight";
darkSidebar: boolean;
isSystemThemeAvailable: boolean;
systemPrefersColorScheme: "light" | "dark";
systemTheme: null;
}
export type useStateFromStores = <T>( export type useStateFromStores = <T>(
stores: t.FluxStore[], stores: t.FluxStore[],
mapper: () => T, mapper: () => T,

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