Merge remote-tracking branch 'upstream/dev' into dev

This commit is contained in:
thororen1234 2026-07-27 19:25:42 -04:00
commit 334c39decc
No known key found for this signature in database
47 changed files with 646 additions and 258 deletions

View file

@ -1,7 +1,7 @@
{ {
"name": "equicord", "name": "equicord",
"private": "true", "private": "true",
"version": "1.14.16.0", "version": "1.15.0.0",
"description": "The other cutest Discord client mod", "description": "The other cutest Discord client mod",
"homepage": "https://github.com/Equicord/Equicord#readme", "homepage": "https://github.com/Equicord/Equicord#readme",
"bugs": { "bugs": {
@ -97,7 +97,7 @@
"typescript-transform-paths": "^3.5.6", "typescript-transform-paths": "^3.5.6",
"zip-local": "^0.3.5" "zip-local": "^0.3.5"
}, },
"packageManager": "pnpm@11.13.0", "packageManager": "pnpm@11.17.0",
"engines": { "engines": {
"node": ">=22" "node": ">=22"
} }

View file

@ -24,7 +24,7 @@ import { appendFile, mkdir, readdir, readFile, rm, writeFile } from "fs/promises
import path, { join } from "path"; import path, { join } from "path";
import Zip from "zip-local"; import Zip from "zip-local";
import { BUILD_TIMESTAMP, commonOpts, globPlugins, IS_DEV, IS_REPORTER, IS_COMPANION_TEST, IS_STANDALONE, VERSION, commonRendererPlugins, buildOrWatchAll, stringifyValues, IS_ANTI_CRASH_TEST } from "./common.mjs"; import { BUILD_TIMESTAMP, commonOpts, globPlugins, IS_DEV, IS_ANTI_CRASH_TEST, IS_REPORTER, IS_COMPANION_TEST, IS_STANDALONE, VERSION, commonRendererPlugins, buildOrWatchAll, stringifyValues } from "./common.mjs";
/** /**
* @type {import("esbuild").BuildOptions} * @type {import("esbuild").BuildOptions}

View file

@ -43,7 +43,7 @@ export interface ProfileBadge {
/** Action to perform when you click the badge */ /** Action to perform when you click the badge */
onClick?(event: React.MouseEvent, props: ProfileBadge & BadgeUserArgs): void; onClick?(event: React.MouseEvent, props: ProfileBadge & BadgeUserArgs): void;
/** Action to perform when you right click the badge */ /** Action to perform when you right click the badge */
onContextMenu?(event: React.MouseEvent, props: BadgeUserArgs & BadgeUserArgs): void; onContextMenu?(event: React.MouseEvent, props: ProfileBadge & BadgeUserArgs): void;
/** Should the user display this badge? */ /** Should the user display this badge? */
shouldShow?(userInfo: BadgeUserArgs): boolean; shouldShow?(userInfo: BadgeUserArgs): boolean;
/** Optional props (e.g. style) for the badge, ignored for component badges */ /** Optional props (e.g. style) for the badge, ignored for component badges */

View file

@ -46,16 +46,14 @@ export let OptionalMessageOption: CommandOption = OptPlaceholder;
*/ */
export let RequiredMessageOption: CommandOption = ReqPlaceholder; export let RequiredMessageOption: CommandOption = ReqPlaceholder;
// Discord's command list has random gaps for some reason, which can cause issues while rendering the commands let idCounter = 99;
// Add this offset to every added command to keep them unique
let commandIdOffset: number;
export const _init = function (cmds: VencordCommand[]) { export const _init = function (cmds: VencordCommand[]) {
try { try {
BUILT_IN = cmds; BUILT_IN = cmds;
OptionalMessageOption = cmds.find(c => (c.untranslatedName || c.displayName) === "shrug")!.options![0]; OptionalMessageOption = cmds.find(c => (c.untranslatedName || c.displayName) === "shrug")!.options![0];
RequiredMessageOption = cmds.find(c => (c.untranslatedName || c.displayName) === "me")!.options![0]; RequiredMessageOption = cmds.find(c => (c.untranslatedName || c.displayName) === "me")!.options![0];
commandIdOffset = Math.abs(BUILT_IN.map(x => Number(x.id)).sort((x, y) => x - y)[0]) - BUILT_IN.length; idCounter = Math.abs(BUILT_IN.map(x => Number(x.id)).sort((x, y) => x - y)[0]) + 1;
} catch (e) { } catch (e) {
new Logger("CommandsAPI").error("Failed to load CommandsApi", e, " - cmds is", cmds); new Logger("CommandsAPI").error("Failed to load CommandsApi", e, " - cmds is", cmds);
} }
@ -106,21 +104,23 @@ export function prepareOption<O extends CommandOption | VencordCommand>(opt: O):
return opt; return opt;
} }
const isSubCommandParent = (cmd: VencordCommand) => cmd.options?.[0]?.type === ApplicationCommandOptionType.SUB_COMMAND;
const getSubCommandName = (cmd: VencordCommand, option: CommandOption) => `${cmd.name} ${option.name}`;
// Yes, Discord registers individual commands for each subcommand // Yes, Discord registers individual commands for each subcommand
// TODO: This probably doesn't support nested subcommands. If that is ever needed,
// investigate
function registerSubCommands(cmd: VencordCommand, plugin: string) { function registerSubCommands(cmd: VencordCommand, plugin: string) {
cmd.options?.forEach(o => { cmd.options?.forEach(o => {
if (o.type !== ApplicationCommandOptionType.SUB_COMMAND) if (o.type !== ApplicationCommandOptionType.SUB_COMMAND)
throw new Error("When specifying sub-command options, all options must be sub-commands."); throw new Error("When specifying sub-command options, all options must be sub-commands.");
const subCmd = { const subCmd = {
...cmd, ...cmd,
...o, ...o,
options: o.options !== undefined ? o.options : undefined, options: o.options !== undefined ? o.options : undefined,
type: ApplicationCommandType.CHAT_INPUT, type: ApplicationCommandType.CHAT_INPUT,
name: `${cmd.name} ${o.name}`,
id: `${o.name}-${cmd.id}`, id: `${o.name}-${cmd.id}`,
displayName: `${cmd.name} ${o.name}`, name: getSubCommandName(cmd, o),
displayName: getSubCommandName(cmd, o),
subCommandPath: [{ subCommandPath: [{
name: o.name, name: o.name,
type: o.type, type: o.type,
@ -128,10 +128,18 @@ function registerSubCommands(cmd: VencordCommand, plugin: string) {
}], }],
rootCommand: cmd rootCommand: cmd
}; };
registerCommand(subCmd as any, plugin); registerCommand(subCmd, plugin);
}); });
} }
function unregisterSubCommands(cmd: VencordCommand): boolean {
const results = BUILT_IN
.filter(c => c.rootCommand === cmd)
.map(c => unregisterCommand(c.name));
return results.length > 0 && results.every(x => x);
}
export function registerCommand<C extends VencordCommand>(command: C, plugin: string) { export function registerCommand<C extends VencordCommand>(command: C, plugin: string) {
if (!BUILT_IN) { if (!BUILT_IN) {
console.warn( console.warn(
@ -148,24 +156,30 @@ export function registerCommand<C extends VencordCommand>(command: C, plugin: st
command.isVencordCommand = true; command.isVencordCommand = true;
command.untranslatedName ??= command.name; command.untranslatedName ??= command.name;
command.untranslatedDescription ??= command.description; command.untranslatedDescription ??= command.description;
command.id ??= `-${BUILT_IN.length + commandIdOffset + 1}`; command.id ??= `-${idCounter++}`;
command.applicationId ??= "-1"; // BUILT_IN; command.applicationId ??= "-1"; // BUILT_IN;
command.type ??= ApplicationCommandType.CHAT_INPUT; command.type ??= ApplicationCommandType.CHAT_INPUT;
command.inputType ??= ApplicationCommandInputType.BUILT_IN_TEXT; command.inputType ??= ApplicationCommandInputType.BUILT_IN_TEXT;
command.plugin ||= plugin; command.plugin ||= plugin;
prepareOption(command); prepareOption(command);
commands[command.name] = command;
if (command.options?.[0]?.type === ApplicationCommandOptionType.SUB_COMMAND) { if (isSubCommandParent(command)) {
registerSubCommands(command, plugin); registerSubCommands(command, plugin);
return; return;
} }
commands[command.name] = command;
BUILT_IN.push(command); BUILT_IN.push(command);
} }
export function unregisterCommand(name: string) { export function unregisterCommand(name: string, isSubCommands = false) {
const cmd = commands[name];
if (cmd && isSubCommandParent(cmd)) {
delete commands[name];
return unregisterSubCommands(cmd);
}
const idx = BUILT_IN.findIndex(c => c.name === name); const idx = BUILT_IN.findIndex(c => c.name === name);
if (idx === -1) if (idx === -1)
return false; return false;

View file

@ -9,4 +9,5 @@ export { ApplicationCommandInputType, ApplicationCommandOptionType, ApplicationC
export interface VencordCommand extends Command { export interface VencordCommand extends Command {
isVencordCommand?: boolean; isVencordCommand?: boolean;
rootCommand?: VencordCommand;
} }

View file

@ -75,14 +75,21 @@ export async function persistNotification(notification: NotificationData) {
signals.forEach(x => x()); signals.forEach(x => x());
} }
export async function deleteNotification(timestamp: number) { export async function deleteNotification(id: string) {
const log = await getLog(); let found = false;
const index = log.findIndex(x => x.timestamp === timestamp);
if (index === -1) return;
log.splice(index, 1); await DataStore.update(KEY, (old: PersistentNotificationData[] | undefined) => {
await DataStore.set(KEY, log); const log = old ?? [];
signals.forEach(x => x()); const index = log.findIndex(x => x.id === id);
if (index === -1) return log;
log.splice(index, 1);
found = true;
return log;
});
if (found)
signals.forEach(x => x());
} }
export function useLogs() { export function useLogs() {
@ -114,7 +121,7 @@ function NotificationEntry({ data }: { data: PersistentNotificationData; }) {
if (removing) return; if (removing) return;
setRemoving(true); setRemoving(true);
setTimeout(() => deleteNotification(data.timestamp), 200); setTimeout(() => deleteNotification(data.id), 200);
}} }}
richBody={ richBody={
<div className={cl("body-wrapper")}> <div className={cl("body-wrapper")}>

View file

@ -76,6 +76,7 @@ export interface Settings {
plugins: { plugins: {
[plugin: string]: { [plugin: string]: {
enabled: boolean; enabled: boolean;
isFavorite?: boolean;
[setting: string]: any; [setting: string]: any;
}; };
}; };

View file

@ -18,6 +18,7 @@
import { Settings, SettingsStore, type ThemeActivationMode } from "@api/Settings"; import { Settings, SettingsStore, type ThemeActivationMode } from "@api/Settings";
import { createAndAppendStyle } from "@utils/css"; import { createAndAppendStyle } from "@utils/css";
import { isNonNullish } from "@utils/guards";
import { ThemeStore } from "@vencord/discord-types"; import { ThemeStore } from "@vencord/discord-types";
import { PopoutWindowStore } from "@webpack/common"; import { PopoutWindowStore } from "@webpack/common";
@ -52,6 +53,9 @@ async function toggle(isEnabled: boolean) {
style.disabled = !isEnabled; style.disabled = !isEnabled;
} }
// for cleanup
let previousThemeBlobObjectURLs = [] as string[];
async function initThemes() { async function initThemes() {
themesStyle ??= createAndAppendStyle("vencord-themes", userStyleRootNode); themesStyle ??= createAndAppendStyle("vencord-themes", userStyleRootNode);
@ -78,15 +82,22 @@ async function initThemes() {
} }
if (IS_WEB) { if (IS_WEB) {
for (const theme of enabledThemes) { previousThemeBlobObjectURLs.forEach(url => URL.revokeObjectURL(url));
const mode = getThemeActivationMode(theme);
if (!shouldApplyTheme(mode, activeTheme)) continue;
const themesToApply = enabledThemes.filter(theme =>
shouldApplyTheme(getThemeActivationMode(theme), activeTheme)
);
const objectUrls = await Promise.all(themesToApply.map(async theme => {
const themeData = await VencordNative.themes.getThemeData(theme); const themeData = await VencordNative.themes.getThemeData(theme);
if (!themeData) continue; if (!themeData) return null;
const blob = new Blob([themeData], { type: "text/css" }); const blob = new Blob([themeData], { type: "text/css" });
links.add(URL.createObjectURL(blob)); return URL.createObjectURL(blob);
} }));
previousThemeBlobObjectURLs = objectUrls.filter(isNonNullish);
previousThemeBlobObjectURLs.forEach(url => links.add(url));
} else { } else {
const version = Date.now(); const version = Date.now();
for (const theme of enabledThemes) { for (const theme of enabledThemes) {

View file

@ -1224,3 +1224,31 @@ export function DoubleCheckmarkIcon(props: IconProps) {
</Icon> </Icon>
); );
} }
export function StarFilled(props: IconProps) {
return (
<Icon
{...props}
viewBox="0 0 24 24"
>
<path
fill={props.fill || "currentColor"}
d="M10.81 2.86c.38-1.15 2-1.15 2.38 0l1.89 5.83h6.12c1.2 0 1.71 1.54.73 2.25l-4.95 3.6 1.9 5.82a1.25 1.25 0 0 1-1.93 1.4L12 18.16l-4.95 3.6c-.98.7-2.3-.25-1.92-1.4l1.89-5.82-4.95-3.6a1.25 1.25 0 0 1 .73-2.25h6.12l1.9-5.83Z"
/>
</Icon>
);
}
export function StarOutlined(props: IconProps) {
return (
<Icon
{...props}
viewBox="0 0 24 24"
>
<path
fill={props.fill || "currentColor"}
fillRule="evenodd"
d="M2.07 10.94a1.25 1.25 0 0 1 .73-2.25h6.12l1.9-5.83c.37-1.15 2-1.15 2.37 0l1.89 5.83h6.12c1.2 0 1.71 1.54.73 2.25l-4.95 3.6 1.9 5.82a1.25 1.25 0 0 1-1.93 1.4L12 18.16l-4.95 3.6c-.98.7-2.3-.25-1.92-1.4l1.89-5.82-4.95-3.6Zm11.55-.25h5.26l-4.25 3.09 1.62 5-4.25-3.1-4.25 3.1 1.62-5-4.25-3.1h5.26l1.62-5 1.62 5Z" />
</Icon>
);
}

View file

@ -24,7 +24,6 @@ export function Link({
children, children,
...restProps ...restProps
}: PropsWithChildren<LinkProps>) { }: PropsWithChildren<LinkProps>) {
const isInternal = href && /^(?:discord:\/)?\/[a-zA-Z0-9_-]/.test(href); const isInternal = href && /^(?:discord:\/)?\/[a-zA-Z0-9_-]/.test(href);
const needsSafeAttrs = href && !isInternal; const needsSafeAttrs = href && !isInternal;

View file

@ -17,13 +17,14 @@ export interface QuickActionProps {
text: ReactNode; text: ReactNode;
action?: () => void; action?: () => void;
disabled?: boolean; disabled?: boolean;
style?: React.CSSProperties;
} }
export function QuickAction(props: QuickActionProps) { export function QuickAction(props: QuickActionProps) {
const { Icon, action, text, disabled } = props; const { Icon, action, text, disabled, style } = props;
return ( return (
<button className={cl("pill")} onClick={action} disabled={disabled}> <button className={cl("pill")} onClick={action} disabled={disabled} style={style}>
<Icon className={cl("img")} /> <Icon className={cl("img")} />
{text} {text}
</button> </button>

View file

@ -19,8 +19,8 @@ import { Modal, openModal, showToast, useEffect, useMemo, UserProfileStore, useS
import Plugins, { PluginMeta } from "~plugins"; import Plugins, { PluginMeta } from "~plugins";
import { GithubButton, WebsiteButton } from "./LinkIconButton";
import { PluginCard } from "./PluginCard"; import { PluginCard } from "./PluginCard";
import { GithubButton, WebsiteButton } from "./PluginModalButtons";
const cl = classNameFactory("vc-author-modal-"); const cl = classNameFactory("vc-author-modal-");

View file

@ -1,31 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { Button } from "@components/Button";
import { GithubIcon, WebsiteIcon } from "@components/Icons";
interface Props {
text: string;
href: string;
}
export function WebsiteButton({ text, href }: Props) {
return (
<Button variant="secondary" size="small" style={{ gap: 4 }} onClick={() => VencordNative.native.openExternal(href)}>
<WebsiteIcon width={16} height={16} />
{text}
</Button>
);
}
export function GithubButton({ text, href }: Props) {
return (
<Button variant="secondary" size="small" style={{ gap: 4 }} onClick={() => VencordNative.native.openExternal(href)}>
<GithubIcon width={16} height={16} />
{text}
</Button>
);
}

View file

@ -42,7 +42,7 @@ import { PluginMeta } from "~plugins";
import { OptionComponentMap } from "./components"; import { OptionComponentMap } from "./components";
import { openContributorModal } from "./ContributorModal"; import { openContributorModal } from "./ContributorModal";
import { GithubButton, WebsiteButton } from "./LinkIconButton"; import { FavoriteButton, GithubButton, WebsiteButton } from "./PluginModalButtons";
const cl = classNameFactory("vc-plugin-modal-"); const cl = classNameFactory("vc-plugin-modal-");
@ -258,6 +258,10 @@ export default function PluginModal({ plugin, onRestartNeeded, onClose, transiti
) : <div />} ) : <div />}
{!pluginMeta.userPlugin && ( {!pluginMeta.userPlugin && (
<div className={cl("links")}> <div className={cl("links")}>
<FavoriteButton
isFavorite={pluginSettings.isFavorite ?? false}
onClick={() => pluginSettings.isFavorite = !pluginSettings.isFavorite}
/>
<WebsiteButton <WebsiteButton
text="Website" text="Website"
href={isEquicordPlugin ? `https://equicord.org/plugins/${plugin.name}` : `https://vencord.dev/plugins/${plugin.name}`} href={isEquicordPlugin ? `https://equicord.org/plugins/${plugin.name}` : `https://vencord.dev/plugins/${plugin.name}`}

View file

@ -0,0 +1,37 @@
.vc-settings-modal-light {
color: black;
}
.vc-settings-modal-dark {
color: white;
}
.vc-settings-modal-favorite-button {
cursor: pointer;
transition: color 0.2s ease-in-out;
}
.vc-settings-modal-favorite-button-active,
.vc-settings-modal-favorite-button:hover {
color: var(--yellow-300);
}
.vc-settings-modal-link-icon {
height: 32px;
width: 32px;
border-radius: 50%;
border: 4px solid var(--background-base-lowest);
box-sizing: border-box
}
.vc-settings-modal-favorite-icon {
background: var(--background-base-lowest);
padding: 1px;
}
.vc-settings-modal-links {
display: flex;
gap: 0.2em;
margin-left: auto;
}

View file

@ -0,0 +1,53 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import "./PluginModalButtons.css";
import { GithubIcon, StarFilled, StarOutlined, WebsiteIcon } from "@components/Icons";
import { classNameFactory } from "@utils/css";
import { getTheme, Theme } from "@utils/discord";
import { Clickable, MaskedLink, Tooltip } from "@webpack/common";
const cl = classNameFactory("vc-settings-modal-");
const getThemeClass = () => getTheme() !== Theme.Light ? "dark" : "light";
interface Props {
text: string;
href: string;
}
function LinkIcon({ text, href, Icon }: Props & { Icon: React.ComponentType; }) {
return (
<Tooltip text={text}>
{props =>
<MaskedLink {...props} href={href} className={cl("link-button", getThemeClass())}>
<Icon />
</MaskedLink>
}
</Tooltip>
);
}
const GithubLinkIcon = () => <GithubIcon aria-hidden className={cl("link-icon")} />;
const WebsiteLinkIcon = () => <WebsiteIcon aria-hidden className={cl("link-icon")} />;
export const GithubButton = (props: Props) => <LinkIcon {...props} Icon={GithubLinkIcon} />;
export const WebsiteButton = (props: Props) => <LinkIcon {...props} Icon={WebsiteLinkIcon} />;
export function FavoriteButton({ isFavorite, onClick }: { isFavorite: boolean; onClick: () => void; }) {
const Icon = isFavorite ? StarFilled : StarOutlined;
return (
<Tooltip text={isFavorite ? "Unfavorite plugin" : "Favorite plugin - pins it to the top of the plugin list"}>
{props =>
<Clickable {...props} onClick={onClick} className={cl("favorite-button", isFavorite && "favorite-button-active", getThemeClass())}>
<Icon aria-hidden className={cl("link-icon", "favorite-icon")} />
</Clickable>
}
</Tooltip>
);
}

View file

@ -99,6 +99,7 @@ function ReloadRequiredCard({ required, enabledPlugins, openWarningModal, resetC
const enum SearchStatus { const enum SearchStatus {
ALL, ALL,
FAVORITES,
ENABLED, ENABLED,
DISABLED, DISABLED,
EQUICORD, EQUICORD,
@ -170,10 +171,10 @@ export default function PluginSettings() {
<p>The following plugins require a restart:</p> <p>The following plugins require a restart:</p>
<div> <div>
{displayed.map((s, i) => ( {displayed.map((s, i) => (
<span key={i}> <React.Fragment key={i}>
{i > 0 && ", "} {i > 0 && ", "}
{Parser.parse("`" + s + "`")} {Parser.parse("`" + s + "`")}
</span> </React.Fragment>
))} ))}
{remainingCount > 0 && <span> and {remainingCount} more</span>} {remainingCount > 0 && <span> and {remainingCount} more</span>}
</div> </div>
@ -197,8 +198,11 @@ export default function PluginSettings() {
return o; return o;
}, []); }, []);
const sortedPlugins = useMemo(() => Object.values(Plugins) const sortedPlugins = useMemo(() =>
.sort((a, b) => a.name.localeCompare(b.name)), []); Object.values(Plugins).sort((a, b) => a.name.localeCompare(b.name)),
[]
)
.toSorted((a, b) => Number(settings.plugins[b.name]?.isFavorite ?? false) - Number(settings.plugins[a.name]?.isFavorite ?? false));
const hasUserPlugins = useMemo(() => !IS_STANDALONE && Object.values(PluginMeta).some(m => m.userPlugin), []); const hasUserPlugins = useMemo(() => !IS_STANDALONE && Object.values(PluginMeta).some(m => m.userPlugin), []);
@ -211,6 +215,9 @@ export default function PluginSettings() {
const { status, tags } = searchValue; const { status, tags } = searchValue;
switch (status) { switch (status) {
case SearchStatus.FAVORITES:
if (!settings.plugins[plugin.name]?.isFavorite) return false;
break;
case SearchStatus.DISABLED: case SearchStatus.DISABLED:
if (isPluginEnabled(plugin.name)) return false; if (isPluginEnabled(plugin.name)) return false;
break; break;
@ -419,6 +426,7 @@ export default function PluginSettings() {
<Select <Select
options={[ options={[
{ label: "Show All", value: SearchStatus.ALL, default: true }, { label: "Show All", value: SearchStatus.ALL, default: true },
{ label: "Show Favorites", value: SearchStatus.FAVORITES },
{ label: "Show Enabled", value: SearchStatus.ENABLED }, { label: "Show Enabled", value: SearchStatus.ENABLED },
{ label: "Show Disabled", value: SearchStatus.DISABLED }, { label: "Show Disabled", value: SearchStatus.DISABLED },
{ label: "Show Equicord", value: SearchStatus.EQUICORD }, { label: "Show Equicord", value: SearchStatus.EQUICORD },

View file

@ -26,6 +26,7 @@ import { debounce } from "@shared/debounce";
import { classNameFactory } from "@utils/css"; import { classNameFactory } from "@utils/css";
import { copyWithToast, openImageModal } from "@utils/discord"; import { copyWithToast, openImageModal } from "@utils/discord";
import { classes } from "@utils/misc"; import { classes } from "@utils/misc";
import { formatDuration } from "@utils/text";
import { ContextMenuApi, FluxDispatcher, Menu, React, useEffect, useState, useStateFromStores } from "@webpack/common"; import { ContextMenuApi, FluxDispatcher, Menu, React, useEffect, useState, useStateFromStores } from "@webpack/common";
import { settings } from "../settings"; import { settings } from "../settings";
@ -34,13 +35,6 @@ import { SpotifyStore, Track } from "./SpotifyStore";
const cl = classNameFactory("vc-spotify-"); const cl = classNameFactory("vc-spotify-");
function msToHuman(ms: number) {
const minutes = ms / 1000 / 60;
const m = Math.floor(minutes);
const s = Math.floor((minutes - m) * 60);
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
}
function Svg(path: string, label: string) { function Svg(path: string, label: string) {
return () => ( return () => (
<svg <svg
@ -193,7 +187,7 @@ function SpotifySeekBar() {
className={cl("progress-time") + " " + cl("time-left")} className={cl("progress-time") + " " + cl("time-left")}
aria-label="Progress" aria-label="Progress"
> >
{msToHuman(position)} {formatDuration(position)}
</Span> </Span>
<SeekBar <SeekBar
initialValue={position} initialValue={position}
@ -201,7 +195,7 @@ function SpotifySeekBar() {
maxValue={duration} maxValue={duration}
onValueChange={onChange} onValueChange={onChange}
asValueChanges={onChange} asValueChanges={onChange}
onValueRender={msToHuman} onValueRender={formatDuration}
/> />
<Span <Span
size="xs" size="xs"
@ -209,7 +203,7 @@ function SpotifySeekBar() {
className={cl("progress-time") + " " + cl("time-right")} className={cl("progress-time") + " " + cl("time-right")}
aria-label="Total Duration" aria-label="Total Duration"
> >
{msToHuman(duration)} {formatDuration(duration)}
</Span> </Span>
</div> </div>
); );

View file

@ -16,14 +16,16 @@
* 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 "./ipcMain";
import { app, net, protocol } from "electron"; import { app, net, protocol } from "electron";
import { join } from "path"; import { join } from "path";
import { pathToFileURL } from "url"; import { pathToFileURL } from "url";
import { initCsp } from "./csp"; import { initCsp } from "./csp";
import { ensureSafePath } from "./ipcMain";
import { RendererSettings } from "./settings"; import { RendererSettings } from "./settings";
import { IS_VANILLA, THEMES_DIR } from "./utils/constants"; import { IS_VANILLA, THEMES_DIR } from "./utils/constants";
import { ensureSafePath } from "./utils/ensureSafePath";
import { installExt } from "./utils/extensions"; import { installExt } from "./utils/extensions";
if (!IS_VANILLA && !IS_EXTENSION) { if (!IS_VANILLA && !IS_EXTENSION) {

View file

@ -27,11 +27,12 @@ import monacoHtml from "file://monacoWin.html?minify&base64";
import { FSWatcher, mkdirSync, readFileSync, watch, writeFileSync } from "fs"; import { FSWatcher, mkdirSync, readFileSync, watch, writeFileSync } from "fs";
import { open, readdir, readFile, unlink } from "fs/promises"; import { open, readdir, readFile, unlink } from "fs/promises";
import { release } from "os"; import { release } from "os";
import { join, normalize } from "path"; import { join } from "path";
import { registerCspIpcHandlers } from "./csp/manager"; import { registerCspIpcHandlers } from "./csp/manager";
import { getThemeInfo, stripBOM, UserThemeHeader } from "./themes"; import { getThemeInfo, stripBOM, UserThemeHeader } from "./themes";
import { ALLOWED_PROTOCOLS, QUICK_CSS_PATH, SETTINGS_DIR, THEMES_DIR } from "./utils/constants"; import { ALLOWED_PROTOCOLS, QUICK_CSS_PATH, SETTINGS_DIR, THEMES_DIR } from "./utils/constants";
import { ensureSafePath } from "./utils/ensureSafePath";
import { makeLinksOpenExternally } from "./utils/externalLinks"; import { makeLinksOpenExternally } from "./utils/externalLinks";
const RENDERER_CSS_PATH = join(__dirname, "renderer.css"); const RENDERER_CSS_PATH = join(__dirname, "renderer.css");
@ -40,13 +41,6 @@ mkdirSync(THEMES_DIR, { recursive: true });
registerCspIpcHandlers(); registerCspIpcHandlers();
export function ensureSafePath(basePath: string, path: string) {
const normalizedBasePath = normalize(basePath + "/");
const newPath = join(basePath, path);
const normalizedPath = normalize(newPath);
return normalizedPath.startsWith(normalizedBasePath) ? normalizedPath : null;
}
function readCss() { function readCss() {
return readFile(QUICK_CSS_PATH, "utf-8").catch(() => ""); return readFile(QUICK_CSS_PATH, "utf-8").catch(() => "");
} }
@ -86,7 +80,8 @@ ipcMain.handle(IpcEvents.OPEN_EXTERNAL, (_, url) => {
if (!ALLOWED_PROTOCOLS.includes(protocol)) if (!ALLOWED_PROTOCOLS.includes(protocol))
throw "Disallowed protocol."; throw "Disallowed protocol.";
shell.openExternal(url); shell.openExternal(url)
.catch(err => console.error("[Vencord] Failed to open external link", url, err));
}); });
ipcMain.handle(IpcEvents.GET_QUICK_CSS, () => readCss()); ipcMain.handle(IpcEvents.GET_QUICK_CSS, () => readCss());
@ -116,7 +111,11 @@ ipcMain.handle(IpcEvents.GET_THEME_SYSTEM_VALUES, () => {
ipcMain.handle(IpcEvents.OPEN_THEMES_FOLDER, () => shell.openPath(THEMES_DIR)); ipcMain.handle(IpcEvents.OPEN_THEMES_FOLDER, () => shell.openPath(THEMES_DIR));
ipcMain.handle(IpcEvents.OPEN_SETTINGS_FOLDER, () => shell.openPath(SETTINGS_DIR)); ipcMain.handle(IpcEvents.OPEN_SETTINGS_FOLDER, () => shell.openPath(SETTINGS_DIR));
let fsWatchers = [] as FSWatcher[];
ipcMain.handle(IpcEvents.INIT_FILE_WATCHERS, ({ sender }) => { ipcMain.handle(IpcEvents.INIT_FILE_WATCHERS, ({ sender }) => {
fsWatchers.forEach(w => w.close());
let quickCssWatcher: FSWatcher | undefined; let quickCssWatcher: FSWatcher | undefined;
let rendererCssWatcher: FSWatcher | undefined; let rendererCssWatcher: FSWatcher | undefined;
@ -137,10 +136,13 @@ ipcMain.handle(IpcEvents.INIT_FILE_WATCHERS, ({ sender }) => {
}); });
} }
fsWatchers = [quickCssWatcher, themesWatcher, rendererCssWatcher].filter(Boolean) as FSWatcher[];
sender.once("destroyed", () => { sender.once("destroyed", () => {
quickCssWatcher?.close(); quickCssWatcher?.close();
themesWatcher.close(); themesWatcher.close();
rendererCssWatcher?.close(); rendererCssWatcher?.close();
fsWatchers = [];
}); });
}); });

View file

@ -0,0 +1,16 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { join, normalize } from "path";
export function ensureSafePath(basePath: string, path: string) {
const normalizedBasePath = normalize(basePath + "/");
const newPath = join(basePath, path);
const normalizedPath = normalize(newPath);
return normalizedPath === normalize(basePath) || normalizedPath.startsWith(normalizedBasePath)
? normalizedPath
: null;
}

View file

@ -24,6 +24,7 @@ import { join } from "path";
import { DATA_DIR } from "./constants"; import { DATA_DIR } from "./constants";
import { crxToZip } from "./crxToZip"; import { crxToZip } from "./crxToZip";
import { ensureSafePath } from "./ensureSafePath";
import { fetchBuffer } from "./http"; import { fetchBuffer } from "./http";
const extensionCacheDir = join(DATA_DIR, "ExtensionCache"); const extensionCacheDir = join(DATA_DIR, "ExtensionCache");
@ -33,24 +34,35 @@ async function extract(data: Buffer, outDir: string) {
return new Promise<void>((resolve, reject) => { return new Promise<void>((resolve, reject) => {
unzip(data, (err, files) => { unzip(data, (err, files) => {
if (err) return void reject(err); if (err) return void reject(err);
Promise.all(Object.keys(files).map(async f => { Promise.all(Object.keys(files).map(async f => {
// Signature stuff // Signature stuff
// 'Cannot load extension with file or directory name // 'Cannot load extension with file or directory name
// _metadata. Filenames starting with "_" are reserved for use by the system.'; // _metadata. Filenames starting with "_" are reserved for use by the system.';
if (f.startsWith("_metadata/")) return; if (f.startsWith("_metadata/")) return;
if (f.endsWith("/")) return void mkdir(join(outDir, f), { recursive: true }); if (f.includes("\0")) throw new Error(`Invalid filename: "${f}"`);
if (f.endsWith("/")) {
const dir = ensureSafePath(outDir, f);
if (!dir) throw new Error(`Path traversal detected: "${f}"`);
return void await mkdir(dir, { recursive: true });
}
const pathElements = f.split("/"); const pathElements = f.split("/");
const name = pathElements.pop()!; const directories = pathElements.slice(0, -1).join("/");
const directories = pathElements.join("/");
const dir = join(outDir, directories); const dir = ensureSafePath(outDir, directories);
if (!dir) throw new Error(`Path traversal detected: "${f}"`);
const filePath = ensureSafePath(outDir, f);
if (!filePath) throw new Error(`Path traversal detected: "${f}"`);
if (directories) { if (directories) {
await mkdir(dir, { recursive: true }); await mkdir(dir, { recursive: true });
} }
await writeFile(join(dir, name), files[f]); await writeFile(filePath, files[f]);
})) }))
.then(() => resolve()) .then(() => resolve())
.catch(err => { .catch(err => {
@ -62,7 +74,7 @@ async function extract(data: Buffer, outDir: string) {
} }
export async function installExt(id: string) { export async function installExt(id: string) {
const extDir = join(extensionCacheDir, `${id}`); const extDir = join(extensionCacheDir, id);
try { try {
await access(extDir, fsConstants.F_OK); await access(extDir, fsConstants.F_OK);
@ -79,6 +91,9 @@ export async function installExt(id: string) {
.catch(err => console.error(`Failed to extract extension ${id}`, err)); .catch(err => console.error(`Failed to extract extension ${id}`, err));
} }
// Electron 36 Deprecates session.defaultSession.loadExtension() if (session.defaultSession.extensions) {
session.defaultSession.extensions ? session.defaultSession.extensions.loadExtension(extDir) : session.defaultSession.loadExtension(extDir); session.defaultSession.extensions.loadExtension(extDir);
} else {
session.defaultSession.loadExtension(extDir);
}
} }

View file

@ -4,10 +4,11 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
*/ */
import { definePluginSettings, migratePluginSettings } from "@api/Settings"; import { definePluginSettings } from "@api/Settings";
import { getUserSettingLazy } from "@api/UserSettings"; import { getUserSettingLazy } from "@api/UserSettings";
import { Devs } from "@utils/constants"; import { Devs } from "@utils/constants";
import definePlugin, { OptionType } from "@utils/types"; import definePlugin, { OptionType } from "@utils/types";
import { UserSettingsProtoStore } from "@webpack/common";
let savedStatus: string | null; let savedStatus: string | null;
@ -36,36 +37,58 @@ const settings = definePluginSettings({
value: "invisible", value: "invisible",
} }
] ]
}, }
excludeInvisible: {
type: OptionType.BOOLEAN,
description: "Prevent automatic status changes while your status is set to invisible",
default: false
},
}); });
migratePluginSettings("AutoDNDWhilePlaying", "StatusWhilePlaying"); let lastStatus: string | null = null;
function handleUserSettingsChange() {
const status = StatusSettings.getSetting();
if (status !== lastStatus) {
lastStatus = status;
savedStatus = null;
}
}
async function setStatus(status: string) {
lastStatus = status;
await StatusSettings.updateSetting(status);
}
export default definePlugin({ export default definePlugin({
name: "AutoDNDWhilePlaying", name: "AutoDNDWhilePlaying",
description: "Automatically updates your online status (online, idle, dnd) when launching games", description: "Automatically updates your online status (online, idle, dnd) when launching games",
tags: ["Activity", "Utility"], tags: ["Activity", "Utility"],
authors: [Devs.thororen], authors: [Devs.thororen],
isModified: true,
settings, settings,
flux: { flux: {
RUNNING_GAMES_CHANGE({ games }) { async RUNNING_GAMES_CHANGE({ games }) {
const status = StatusSettings.getSetting(); const status = StatusSettings.getSetting();
if (settings.store.excludeInvisible && (savedStatus ?? status) === "invisible") return;
if (games.length > 0) { if (games.length > 0) {
if (status !== settings.store.statusToSet) { if (status !== settings.store.statusToSet && status !== "invisible") {
savedStatus = status; savedStatus = status;
StatusSettings.updateSetting(settings.store.statusToSet); await setStatus(settings.store.statusToSet);
}
} else if (savedStatus) {
const toRestore = savedStatus;
savedStatus = null;
if (status !== toRestore) {
await setStatus(toRestore);
} }
} else if (savedStatus && savedStatus !== settings.store.statusToSet) {
StatusSettings.updateSetting(savedStatus);
} }
} }
},
start() {
lastStatus = StatusSettings.getSetting();
UserSettingsProtoStore.addChangeListener(handleUserSettingsChange);
},
stop() {
UserSettingsProtoStore.removeChangeListener(handleUserSettingsChange);
} }
}); });

View file

@ -8,7 +8,7 @@ import { definePluginSettings } from "@api/Settings";
import ErrorBoundary from "@components/ErrorBoundary"; import ErrorBoundary from "@components/ErrorBoundary";
import { Devs, EquicordDevs } from "@utils/constants"; import { Devs, EquicordDevs } from "@utils/constants";
import { useFixedTimer } from "@utils/react"; import { useFixedTimer } from "@utils/react";
import { formatDurationMs } from "@utils/text"; import { formatDuration } from "@utils/text";
import definePlugin, { OptionType } from "@utils/types"; import definePlugin, { OptionType } from "@utils/types";
import { PassiveUpdateState, VoiceState } from "@vencord/discord-types"; import { PassiveUpdateState, VoiceState } from "@vencord/discord-types";
import { FluxDispatcher, GuildStore, React, UserStore } from "@webpack/common"; import { FluxDispatcher, GuildStore, React, UserStore } from "@webpack/common";
@ -30,7 +30,7 @@ export const settings = definePluginSettings({
label: "30d 23h 00m 42s", label: "30d 23h 00m 42s",
value: "human" value: "human"
} }
] ] as const
}, },
allCallTimers: { allCallTimers: {
type: OptionType.BOOLEAN, type: OptionType.BOOLEAN,
@ -273,7 +273,7 @@ export default definePlugin({
return ( return (
<p style={{ margin: 0, fontFamily: "var(--font-code)" }}> <p style={{ margin: 0, fontFamily: "var(--font-code)" }}>
{formatDurationMs(time, settings.store.format === "human")} {formatDuration(time, settings.store.format === "human")}
</p> </p>
); );
}, { noop: true }), }, { noop: true }),

View file

@ -16,7 +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 { findGroupChildrenByChildId, NavContextMenuPatchCallback } from "@api/ContextMenu"; import { NavContextMenuPatchCallback } from "@api/ContextMenu";
import { isPluginEnabled } from "@api/PluginManager"; import { isPluginEnabled } from "@api/PluginManager";
import ExpressionClonerPlugin from "@plugins/expressionCloner"; import ExpressionClonerPlugin from "@plugins/expressionCloner";
import { Devs } from "@utils/constants"; import { Devs } from "@utils/constants";
@ -68,9 +68,7 @@ const messageContextMenuPatch: NavContextMenuPatchCallback = (
const sticker = message.stickerItems.find(s => s.id === favoriteableId); const sticker = message.stickerItems.find(s => s.id === favoriteableId);
if (!sticker?.format_type) return; if (!sticker?.format_type) return;
const idx = children.findIndex(c => Array.isArray(c) && findGroupChildrenByChildId("vc-copy-sticker-url", c) != null); children.splice(-1, 0, buildMenuItem(sticker, false));
children.splice(idx, 0, buildMenuItem(sticker, idx !== -1));
}; };
const expressionPickerPatch: NavContextMenuPatchCallback = (children, props: { target: HTMLElement; }) => { const expressionPickerPatch: NavContextMenuPatchCallback = (children, props: { target: HTMLElement; }) => {

View file

@ -18,7 +18,7 @@
import "./styles.css"; import "./styles.css";
import { ApplicationCommandInputType, ApplicationCommandOptionType, findOption, registerCommand, sendBotMessage } from "@api/Commands"; import { ApplicationCommandInputType, ApplicationCommandOptionType, findOption, registerCommand, sendBotMessage, unregisterCommand } from "@api/Commands";
import { migratePluginSettings } from "@api/Settings"; import { migratePluginSettings } from "@api/Settings";
import { Devs } from "@utils/constants"; import { Devs } from "@utils/constants";
import { sendMessage } from "@utils/discord"; import { sendMessage } from "@utils/discord";
@ -96,11 +96,12 @@ export default definePlugin({
tags: ["Commands", "Customisation", "Utility"], tags: ["Commands", "Customisation", "Utility"],
settings, settings,
async start() { start() {
const tags = getTags(); getTags().forEach(registerTagCommand);
for (const tagName in tags) { },
registerTagCommand(tags[tagName]);
} stop() {
getTags().forEach(tag => unregisterCommand(tag.name));
}, },
commands: [ commands: [
@ -160,7 +161,7 @@ export default definePlugin({
} }
case "list": { case "list": {
const content = Object.values(getTags()) const content = getTags()
.map(tag => `\`${tag.name}\`: ${tag.message.slice(0, 72).replaceAll("\\n", " ")}${tag.message.length > 72 ? "..." : ""}`) .map(tag => `\`${tag.name}\`: ${tag.message.slice(0, 72).replaceAll("\\n", " ")}${tag.message.length > 72 ? "..." : ""}`)
.join("\n"); .join("\n");

View file

@ -29,7 +29,7 @@ export interface Tag {
} }
export function getTags() { export function getTags() {
return settings.store.tagsList; return Object.values(settings.store.tagsList);
} }
export function getTag(name: string) { export function getTag(name: string) {

View file

@ -139,7 +139,10 @@ function SelectSetting<T>({ settingsKey, label, options, disabled }: SelectOptio
options={options} options={options}
maxVisibleItems={5} maxVisibleItems={5}
closeOnSelect={true} closeOnSelect={true}
select={v => settings.store[settingsKey] = v} select={v => {
settings.store[settingsKey] = v;
updateRPC();
}}
isSelected={v => v === settings.store[settingsKey]} isSelected={v => v === settings.store[settingsKey]}
serialize={v => String(v)} serialize={v => String(v)}
isDisabled={disabled} isDisabled={disabled}

View file

@ -99,7 +99,6 @@ 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;

View file

@ -73,6 +73,10 @@ const fakeNitroEmojiRegex = /\/emojis\/(\d+?)\.(png|webp|gif)/;
const fakeNitroStickerRegex = /\/stickers\/(\d+?)\./; const fakeNitroStickerRegex = /\/stickers\/(\d+?)\./;
const fakeNitroGifStickerRegex = /\/attachments\/\d+?\/\d+?\/(\d+?)\.gif/; const fakeNitroGifStickerRegex = /\/attachments\/\d+?\/\d+?\/(\d+?)\.gif/;
const hyperLinkRegex = /\[.+?\]\((https?:\/\/.+?)\)/; const hyperLinkRegex = /\[.+?\]\((https?:\/\/.+?)\)/;
const mediaSizes = [16, 32, 48, 56, 64, 96, 128, 160, 256, 512, 1024];
const DEFAULT_EMOJI_SIZE = 48;
const DEFAULT_STICKER_SIZE = 160;
const settings = definePluginSettings({ const settings = definePluginSettings({
enableEmojiBypass: { enableEmojiBypass: {
@ -83,9 +87,12 @@ const settings = definePluginSettings({
}, },
emojiSize: { emojiSize: {
description: "Size of the emojis when sending", description: "Size of the emojis when sending",
type: OptionType.SLIDER, type: OptionType.SELECT,
default: 48, default: DEFAULT_EMOJI_SIZE,
markers: [32, 48, 56, 64, 96, 128, 160, 256, 512] options: mediaSizes.map(size => ({
label: `${size}px`,
value: size
}))
}, },
transformEmojis: { transformEmojis: {
description: "Whether to transform fake emojis into real ones", description: "Whether to transform fake emojis into real ones",
@ -101,9 +108,12 @@ const settings = definePluginSettings({
}, },
stickerSize: { stickerSize: {
description: "Size of the stickers when sending", description: "Size of the stickers when sending",
type: OptionType.SLIDER, type: OptionType.SELECT,
default: 160, default: DEFAULT_STICKER_SIZE,
markers: [32, 64, 128, 160, 256, 512] options: mediaSizes.map(size => ({
label: `${size}px`,
value: size
}))
}, },
transformStickers: { transformStickers: {
description: "Whether to transform fake stickers into real ones", description: "Whether to transform fake stickers into real ones",
@ -752,7 +762,7 @@ export default definePlugin({
.then(parseAPNG); .then(parseAPNG);
const gif = GIFEncoder(); const gif = GIFEncoder();
const resolution = settings.store.stickerSize; const resolution = settings.store.stickerSize ?? DEFAULT_STICKER_SIZE;
const canvas = document.createElement("canvas"); const canvas = document.createElement("canvas");
canvas.width = resolution; canvas.width = resolution;
@ -892,10 +902,11 @@ export default definePlugin({
hasBypass = true; hasBypass = true;
const emojiSize = s.emojiSize ?? DEFAULT_EMOJI_SIZE;
const emojiString = `<${emoji.animated ? "a" : ""}:${emoji.originalName || emoji.name}:${emoji.id}>`; const emojiString = `<${emoji.animated ? "a" : ""}:${emoji.originalName || emoji.name}:${emoji.id}>`;
const url = new URL(IconUtils.getEmojiURL({ id: emoji.id, animated: emoji.animated, size: s.emojiSize })); const url = new URL(IconUtils.getEmojiURL({ id: emoji.id, animated: emoji.animated, size: emojiSize }));
url.searchParams.set("size", s.emojiSize.toString()); url.searchParams.set("size", emojiSize.toString());
url.searchParams.set("name", emoji.name); url.searchParams.set("name", emoji.name);
url.searchParams.set("lossless", "true"); url.searchParams.set("lossless", "true");
@ -930,8 +941,10 @@ export default definePlugin({
hasBypass = true; hasBypass = true;
const url = new URL(IconUtils.getEmojiURL({ id: emoji.id, animated: emoji.animated, size: s.emojiSize })); const emojiSize = s.emojiSize ?? DEFAULT_EMOJI_SIZE;
url.searchParams.set("size", s.emojiSize.toString());
const url = new URL(IconUtils.getEmojiURL({ id: emoji.id, animated: emoji.animated, size: emojiSize }));
url.searchParams.set("size", emojiSize.toString());
url.searchParams.set("name", emoji.name); url.searchParams.set("name", emoji.name);
url.searchParams.set("lossless", "true"); url.searchParams.set("lossless", "true");

View file

@ -50,14 +50,14 @@ app.on("browser-window-created", (_, win) => {
} }
}); });
}); });
});
RendererSettings.addChangeListener("plugins.FixSpotifyEmbeds.volume", newVolume => {
try { RendererSettings.addChangeListener("plugins.FixSpotifyEmbeds.volume", newVolume => {
cleanUpAndGetSpotifyFrames().forEach(frame => try {
frame.executeJavaScript(`globalThis._vcVolume = ${newVolume / 100}`) cleanUpAndGetSpotifyFrames().forEach(frame =>
); frame.executeJavaScript(`globalThis._vcVolume = ${newVolume / 100}`)
} catch (e) { );
console.error("FixSpotifyEmbeds: Failed to update volume", e); } catch (e) {
} console.error("FixSpotifyEmbeds: Failed to update volume", e);
}); }
}); });

View file

@ -41,7 +41,7 @@ export default definePlugin({
sendBotMessage(ctx.channel.id, { sendBotMessage(ctx.channel.id, {
content: ` content: `
discord.gg/${invite.code} · discord.gg/${invite.code} ·
Expires: <t:${new Date(invite.expires_at).getTime() / 1000}:R> · Expires: <t:${Math.round(new Date(invite.expires_at).getTime() / 1000)}:R> ·
Max uses: \`${invite.max_uses}\` Max uses: \`${invite.max_uses}\`
`.trim().replace(/\s+/g, " ") `.trim().replace(/\s+/g, " ")
}); });
@ -56,7 +56,7 @@ export default definePlugin({
const friendInviteList = invites.map(i => const friendInviteList = invites.map(i =>
` `
_discord.gg/${i.code}_ · _discord.gg/${i.code}_ ·
Expires: <t:${new Date(i.expires_at).getTime() / 1000}:R> · Expires: <t:${Math.round(new Date(i.expires_at).getTime() / 1000)}:R> ·
Times used: \`${i.uses}/${i.max_uses}\` Times used: \`${i.uses}/${i.max_uses}\`
`.trim().replace(/\s+/g, " ") `.trim().replace(/\s+/g, " ")
); );

View file

@ -18,12 +18,28 @@
import { isPluginEnabled } from "@api/PluginManager"; import { isPluginEnabled } from "@api/PluginManager";
import { definePluginSettings } from "@api/Settings"; import { definePluginSettings } from "@api/Settings";
import { UserAreaButton, UserAreaRenderProps } from "@api/UserArea"; import { UserAreaRenderProps } from "@api/UserArea";
import { getUserSettingLazy } from "@api/UserSettings"; import { getUserSettingLazy } from "@api/UserSettings";
import equicordToolbox from "@equicordplugins/equicordToolbox"; import equicordToolbox from "@equicordplugins/equicordToolbox";
import { Devs } from "@utils/constants"; import { Devs } from "@utils/constants";
import definePlugin, { OptionType } from "@utils/types"; import definePlugin, { OptionType } from "@utils/types";
import { Menu } from "@webpack/common"; import { FluxStore } from "@vencord/discord-types";
import { findByPropsLazy, findComponentByCodeLazy, findStoreLazy } from "@webpack";
import { Menu, Popout, useRef, useState, useStateFromStores } from "@webpack/common";
interface ConnectedAccount {
id: string;
type: string;
revoked: boolean;
showActivity: boolean;
}
interface ConnectedAccountsStore extends FluxStore {
getAccounts(): ConnectedAccount[];
}
const Button = findComponentByCodeLazy(".GREEN,positionKeyStemOverride:");
const ConnectedAccountsStore = findStoreLazy("ConnectedAccountsStore") as ConnectedAccountsStore;
const ConnectedAccountActions = findByPropsLazy("setShowActivity");
const ShowCurrentGame = getUserSettingLazy<boolean>("status", "showCurrentGame")!; const ShowCurrentGame = getUserSettingLazy<boolean>("status", "showCurrentGame")!;
@ -76,28 +92,68 @@ function Icon({ className }: { className?: string; }) {
); );
} }
function GameActivityToggleButton({ iconForeground, hideTooltips, nameplate }: UserAreaRenderProps) { function GameActivityToggleButton(props: UserAreaRenderProps) {
const { location } = settings.use(["location"]); const { location } = settings.use(["location"]);
const showCurrentGame = ShowCurrentGame.useSetting(); const showCurrentGame = ShowCurrentGame.useSetting();
const connectedAccounts = useStateFromStores([ConnectedAccountsStore], () => ConnectedAccountsStore.getAccounts());
const spotifyAccounts = connectedAccounts.filter(account => account.type === "spotify" && !account.revoked);
// The update is an API request which takes a bit to update the store, so we have to use our own state to reflect the change immediately
const [shareSpotifyActivity, setShareSpotifyActivity] = useState(spotifyAccounts[0]?.showActivity ?? false);
const buttonRef = useRef<HTMLButtonElement | null>(null);
if (location !== "PANEL" && isPluginEnabled(equicordToolbox.name)) return null; if (location !== "PANEL" && isPluginEnabled(equicordToolbox.name)) return null;
const buttonProps = {
tooltipText: showCurrentGame ? "Disable Game Activity" : "Enable Game Activity",
icon: Icon,
role: "switch",
ariaChecked: !showCurrentGame,
redGlow: !showCurrentGame,
plated: props?.nameplate != null,
onClick: () => ShowCurrentGame.updateSetting(old => !old)
};
// Only show switch if there's exactly one Spotify account connected. Otherwise it may lead to confusion
if (spotifyAccounts.length !== 1)
return <Button {...buttonProps} />;
const spotifyAccount = spotifyAccounts[0];
return ( return (
<UserAreaButton <Popout
tooltipText={hideTooltips ? void 0 : showCurrentGame ? "Disable Game Activity" : "Enable Game Activity"} position="top"
icon={<Icon className={iconForeground} />} align="left"
role="switch" targetElementRef={buttonRef}
aria-checked={!showCurrentGame} renderPopout={({ closePopout }) => (
redGlow={!showCurrentGame} <Menu.Menu navId="vc-gameActivityToggle-menu" onClose={closePopout}>
plated={nameplate != null} <Menu.MenuCheckboxItem
onClick={() => ShowCurrentGame.updateSetting(old => !old)} id="vc-toggle-spotify"
/> label="Share Spotify Activity"
checked={shareSpotifyActivity}
action={async () => {
ConnectedAccountActions.setShowActivity(spotifyAccount.type, spotifyAccount.id, !shareSpotifyActivity);
setShareSpotifyActivity(!shareSpotifyActivity);
}}
/>
</Menu.Menu>
)}
>
{popoutProps => (
<Button
ref={buttonRef}
onContextMenu={popoutProps.onClick}
{...buttonProps}
/>
)}
</Popout>
); );
} }
export default definePlugin({ export default definePlugin({
name: "GameActivityToggle", name: "GameActivityToggle",
description: "Adds a button next to the mic and deafen button to toggle game activity.", description: "Adds a button next to the mic and deafen button to toggle game activity. Right click it to toggle Spotify activity.",
tags: ["Activity", "Shortcuts"], tags: ["Activity", "Shortcuts"],
authors: [Devs.Nuckyz, Devs.RuukuLada], authors: [Devs.Nuckyz, Devs.RuukuLada],
dependencies: ["UserSettingsAPI", "UserAreaAPI"], dependencies: ["UserSettingsAPI", "UserAreaAPI"],

View file

@ -17,13 +17,17 @@ export function MemberCount({ isTooltip, tooltipGuildId }: { isTooltip?: true; t
const { voiceActivity } = settings.use(["voiceActivity"]); const { voiceActivity } = settings.use(["voiceActivity"]);
const includeVoice = voiceActivity && !isTooltip; const includeVoice = voiceActivity && !isTooltip;
const currentChannel = useStateFromStores([SelectedChannelStore], () => getCurrentChannel()); const currentChannel = useStateFromStores(
const guildId = isTooltip ? tooltipGuildId! : currentChannel?.guild_id; [SelectedChannelStore], () => isTooltip ? undefined : getCurrentChannel(),
[], (a, b) => a?.id === b?.id
);
const guildId = tooltipGuildId ?? currentChannel?.guild_id;
const voiceActivityCount = useStateFromStores( const voiceActivityCount = useStateFromStores(
[VoiceStateStore], [VoiceStateStore],
() => { () => {
if (!includeVoice) return 0; if (!includeVoice || !guildId) return 0;
const voiceStates = VoiceStateStore.getVoiceStates(guildId); const voiceStates = VoiceStateStore.getVoiceStates(guildId);
if (!voiceStates) return 0; if (!voiceStates) return 0;
@ -31,6 +35,7 @@ export function MemberCount({ isTooltip, tooltipGuildId }: { isTooltip?: true; t
return Object.values(voiceStates) return Object.values(voiceStates)
.filter(({ channelId }) => { .filter(({ channelId }) => {
if (!channelId) return false; if (!channelId) return false;
const channel = ChannelStore.getChannel(channelId); const channel = ChannelStore.getChannel(channelId);
return channel && PermissionStore.can(PermissionsBits.VIEW_CHANNEL, channel); return channel && PermissionStore.can(PermissionsBits.VIEW_CHANNEL, channel);
}) })
@ -40,34 +45,57 @@ export function MemberCount({ isTooltip, tooltipGuildId }: { isTooltip?: true; t
const totalCount = useStateFromStores( const totalCount = useStateFromStores(
[GuildMemberCountStore], [GuildMemberCountStore],
() => GuildMemberCountStore.getMemberCount(guildId!) () => guildId ? GuildMemberCountStore.getMemberCount(guildId) : null
); );
let onlineCount = useStateFromStores( let onlineCount = useStateFromStores(
[OnlineMemberCountStore], [OnlineMemberCountStore],
() => OnlineMemberCountStore.getCount(guildId) () => guildId ? OnlineMemberCountStore.getCount(guildId) : null
); );
const { groups } = useStateFromStores( const memberListOnlineCount = useStateFromStores(
[ChannelMemberStore], [ChannelMemberStore],
() => ChannelMemberStore.getProps(guildId, currentChannel?.id) () => {
if (isTooltip || !guildId) return null;
const { groups } = ChannelMemberStore.getProps(guildId, currentChannel?.id);
if (groups.length >= 1 || groups[0].id !== "unknown") {
return groups.reduce(
(total, curr) => total + (curr.id === "offline" ? 0 : curr.count),
0
);
}
return null;
}
); );
const threadGroups = useStateFromStores( const threadListOnlineCount = useStateFromStores(
[ThreadMemberListStore], [ThreadMemberListStore],
() => ThreadMemberListStore.getMemberListSections(currentChannel?.id) () => {
if (isTooltip) return null;
const threadGroups = ThreadMemberListStore.getMemberListSections(currentChannel?.id);
if (threadGroups && !isObjectEmpty(threadGroups)) {
return Object.values(threadGroups).reduce(
(total, curr) => total + (curr.sectionId === "offline" ? 0 : curr.userIds.length),
0
);
}
return null;
}
); );
if (!isTooltip && (groups.length >= 1 || groups[0].id !== "unknown")) { if (memberListOnlineCount != null) onlineCount = memberListOnlineCount;
onlineCount = groups.reduce((total, curr) => total + (curr.id === "offline" ? 0 : curr.count), 0); if (threadListOnlineCount != null) onlineCount = threadListOnlineCount;
}
if (!isTooltip && threadGroups && !isObjectEmpty(threadGroups)) {
onlineCount = Object.values(threadGroups).reduce((total, curr) => total + (curr.sectionId === "offline" ? 0 : curr.userIds.length), 0);
}
useEffect(() => { useEffect(() => {
OnlineMemberCountStore.ensureCount(guildId); if (guildId) {
OnlineMemberCountStore.ensureCount(guildId);
}
}, [guildId]); }, [guildId]);
if (totalCount == null) if (totalCount == null)
@ -86,6 +114,7 @@ export function MemberCount({ isTooltip, tooltipGuildId }: { isTooltip?: true; t
</div> </div>
)} )}
</Tooltip> </Tooltip>
<Tooltip text={`${numberFormat(totalCount)} total server members`} position="bottom"> <Tooltip text={`${numberFormat(totalCount)} total server members`} position="bottom">
{props => ( {props => (
<div {...props} className={cl("container")}> <div {...props} className={cl("container")}>
@ -94,6 +123,7 @@ export function MemberCount({ isTooltip, tooltipGuildId }: { isTooltip?: true; t
</div> </div>
)} )}
</Tooltip> </Tooltip>
{includeVoice && voiceActivityCount > 0 && {includeVoice && voiceActivityCount > 0 &&
<Tooltip text={`${formattedVoiceCount} members in voice`} position="bottom"> <Tooltip text={`${formattedVoiceCount} members in voice`} position="bottom">
{props => ( {props => (

View file

@ -65,16 +65,44 @@ export default definePlugin({
videoClone.loop = settings.store.loop; videoClone.loop = settings.store.loop;
videoClone.style.display = "none"; videoClone.style.display = "none";
videoClone.onleavepictureinpicture = () => videoClone.remove();
function launchPiP() { let cleaned = false;
videoClone.currentTime = video.currentTime; function cleanup() {
videoClone.requestPictureInPicture(); if (cleaned) return;
video.pause(); cleaned = true;
videoClone.play();
const { currentTime } = videoClone;
videoClone.onloadedmetadata = null;
videoClone.onleavepictureinpicture = null;
videoClone.pause();
videoClone.removeAttribute("src");
videoClone.load();
videoClone.remove();
// resume original if still in the document
if (video.isConnected) {
video.currentTime = currentTime;
video.play().catch(() => 0);
}
} }
if (videoClone.readyState === 4 /* HAVE_ENOUGH_DATA */) videoClone.onleavepictureinpicture = cleanup;
async function launchPiP() {
try {
videoClone.currentTime = video.currentTime;
video.pause();
await videoClone.play();
await videoClone.requestPictureInPicture();
} catch (err) {
console.error("Failed to enter Picture-in-Picture", err);
cleanup();
}
}
if (videoClone.readyState >= 1)
launchPiP(); launchPiP();
else else
videoClone.onloadedmetadata = launchPiP; videoClone.onloadedmetadata = launchPiP;

View file

@ -23,6 +23,8 @@ import definePlugin, { IconComponent, StartAt } from "@utils/types";
import { CloudUpload, MessageAttachment } from "@vencord/discord-types"; import { CloudUpload, MessageAttachment } from "@vencord/discord-types";
import { DraftStore, DraftType, UploadAttachmentStore, UserStore, useStateFromStores } from "@webpack/common"; import { DraftStore, DraftType, UploadAttachmentStore, UserStore, useStateFromStores } from "@webpack/common";
const objectURLMap = new Map<string, string[]>();
const getDraft = (channelId: string) => DraftStore.getDraft(channelId, DraftType.ChannelMessage); const getDraft = (channelId: string) => DraftStore.getDraft(channelId, DraftType.ChannelMessage);
const getImageBox = (url: string): Promise<{ width: number, height: number; } | null> => const getImageBox = (url: string): Promise<{ width: number, height: number; } | null> =>
@ -42,6 +44,8 @@ const getAttachments = async (channelId: string) =>
UploadAttachmentStore.getUploads(channelId, DraftType.ChannelMessage) UploadAttachmentStore.getUploads(channelId, DraftType.ChannelMessage)
.map(async (upload: CloudUpload) => { .map(async (upload: CloudUpload) => {
const { isImage, filename, spoiler, item: { file } } = upload; const { isImage, filename, spoiler, item: { file } } = upload;
// FIXME: revoke object url to fix memory leak
const url = URL.createObjectURL(file); const url = URL.createObjectURL(file);
const attachment: MessageAttachment = { const attachment: MessageAttachment = {
id: generateId(), id: generateId(),
@ -57,13 +61,13 @@ const getAttachments = async (channelId: string) =>
if (isImage) { if (isImage) {
const box = await getImageBox(url); const box = await getImageBox(url);
if (!box) return attachment; if (box) {
attachment.width = box.width;
attachment.width = box.width; attachment.height = box.height;
attachment.height = box.height; }
} }
return attachment; return { attachment, objectURL: url };
}) })
); );
@ -96,15 +100,20 @@ const PreviewButton: ChatBarButtonFactory = ({ isAnyChat, isEmpty, type: { attac
return ( return (
<ChatBarButton <ChatBarButton
tooltip="Preview Message" tooltip="Preview Message"
onClick={async () => onClick={async () => {
sendBotMessage( const attachments = hasAttachments ? await getAttachments(channelId) : undefined;
const message = sendBotMessage(
channelId, channelId,
{ {
content: getDraft(channelId), content: getDraft(channelId),
author: UserStore.getCurrentUser(), author: UserStore.getCurrentUser(),
attachments: hasAttachments ? await getAttachments(channelId) : undefined, attachments: attachments?.map(a => a.attachment),
} }
)} );
if (attachments)
objectURLMap.set(message.id, attachments.map(a => a.objectURL));
}}
buttonProps={{ buttonProps={{
style: { style: {
translate: "0 2px" translate: "0 2px"
@ -130,5 +139,15 @@ export default definePlugin({
chatBarButton: { chatBarButton: {
icon: PreviewIcon, icon: PreviewIcon,
render: PreviewButton render: PreviewButton
},
flux: {
MESSAGE_DELETE({ id: messageId }) {
const objectURLs = objectURLMap.get(messageId);
if (objectURLs) {
objectURLs.forEach(url => URL.revokeObjectURL(url));
objectURLMap.delete(messageId);
}
}
} }
}); });

View file

@ -232,7 +232,7 @@ export default function ReviewComponent({ review, refetch, profileId }: { review
<> <>
{Parser.parseGuildEventDescription(review.comment.substring(0, 200))}... {Parser.parseGuildEventDescription(review.comment.substring(0, 200))}...
<br /> <br />
<a onClick={() => setShowAll(true)}>Read more</a>] <a onClick={() => setShowAll(true)}>Read more</a>
</> </>
) )
: Parser.parseGuildEventDescription(review.comment)} : Parser.parseGuildEventDescription(review.comment)}

View file

@ -23,7 +23,7 @@ import PermissionsViewerPlugin from "@plugins/permissionsViewer";
import openRolesAndUsersPermissionsModal from "@plugins/permissionsViewer/components/RolesAndUsersPermissions"; import openRolesAndUsersPermissionsModal from "@plugins/permissionsViewer/components/RolesAndUsersPermissions";
import { sortPermissionOverwrites } from "@plugins/permissionsViewer/utils"; import { sortPermissionOverwrites } from "@plugins/permissionsViewer/utils";
import { classes } from "@utils/misc"; import { classes } from "@utils/misc";
import { formatDuration } from "@utils/text"; import { formatDurationVerbose } from "@utils/text";
import type { Channel, RoleOrUserPermission } from "@vencord/discord-types"; import type { Channel, RoleOrUserPermission } from "@vencord/discord-types";
import { findByPropsLazy, findComponentByCodeLazy, findCssClassesLazy } from "@webpack"; import { findByPropsLazy, findComponentByCodeLazy, findCssClassesLazy } from "@webpack";
import { EmojiStore, FluxDispatcher, GuildMemberStore, GuildStore, Parser, PermissionsBits, PermissionStore, SnowflakeUtils, Timestamp, Tooltip, useEffect, useState } from "@webpack/common"; import { EmojiStore, FluxDispatcher, GuildMemberStore, GuildStore, Parser, PermissionsBits, PermissionStore, SnowflakeUtils, Timestamp, Tooltip, useEffect, useState } from "@webpack/common";
@ -207,13 +207,11 @@ function HiddenChannelLockScreen({ channel }: { channel: Channel; }) {
</BaseText> </BaseText>
} }
{(rateLimitPerUser ?? 0) > 0 && {(rateLimitPerUser ?? 0) > 0 &&
<BaseText size="md"> <BaseText size="md">Slowmode: {formatDurationVerbose(rateLimitPerUser!, "seconds")}</BaseText>
Slowmode: {formatDuration(rateLimitPerUser!, "seconds")}
</BaseText>
} }
{(defaultThreadRateLimitPerUser ?? 0) > 0 && {(defaultThreadRateLimitPerUser ?? 0) > 0 &&
<BaseText size="md"> <BaseText size="md">
Default thread slowmode: {formatDuration(defaultThreadRateLimitPerUser!, "seconds")} Default thread slowmode: {formatDurationVerbose(defaultThreadRateLimitPerUser!, "seconds")}
</BaseText> </BaseText>
} }
{((channel.isGuildVoice() || channel.isGuildStageVoice()) && bitrate != null) && {((channel.isGuildVoice() || channel.isGuildStageVoice()) && bitrate != null) &&
@ -232,7 +230,7 @@ function HiddenChannelLockScreen({ channel }: { channel: Channel; }) {
{(defaultAutoArchiveDuration ?? 0) > 0 && {(defaultAutoArchiveDuration ?? 0) > 0 &&
<BaseText size="md"> <BaseText size="md">
Default inactivity duration before archiving {channel.isForumChannel() ? "posts" : "threads"}: Default inactivity duration before archiving {channel.isForumChannel() ? "posts" : "threads"}:
{" " + formatDuration(defaultAutoArchiveDuration!, "minutes")} {" " + formatDurationVerbose(defaultAutoArchiveDuration!, "minutes")}
</BaseText> </BaseText>
} }
{defaultForumLayout != null && {defaultForumLayout != null &&

View file

@ -39,7 +39,7 @@ const messageContextMenuPatch: NavContextMenuPatchCallback = (
const menuGroup = findGroupChildrenByChildId("delete", children); const menuGroup = findGroupChildrenByChildId("delete", children);
const deleteIndex = menuGroup?.findIndex(i => i?.props?.id === "delete"); const deleteIndex = menuGroup?.findIndex(i => i?.props?.id === "delete");
if (!deleteIndex || !menuGroup) return; if (deleteIndex == null || !menuGroup) return;
menuGroup.splice(deleteIndex - 1, 0, ( menuGroup.splice(deleteIndex - 1, 0, (
<Menu.MenuItem <Menu.MenuItem

View file

@ -24,6 +24,7 @@ import { openImageModal } from "@utils/discord";
import definePlugin, { OptionType } from "@utils/types"; import definePlugin, { OptionType } from "@utils/types";
import type { Channel, Guild, User } from "@vencord/discord-types"; import type { Channel, Guild, User } from "@vencord/discord-types";
import { GuildMemberStore, IconUtils, Menu } from "@webpack/common"; import { GuildMemberStore, IconUtils, Menu } from "@webpack/common";
import type { MouseEvent } from "react";
interface UserContextProps { interface UserContextProps {
channel: Channel; channel: Channel;
@ -67,10 +68,18 @@ const settings = definePluginSettings({
} }
}); });
const openAvatar = (url: string) => openImage(url, 512, 512); const openAvatar = (url: string, event?: MouseEvent) => openImage({ url, width: 512, height: 512, event });
const openBanner = (url: string) => openImage(url, 1024); const openBanner = (url: string, event?: MouseEvent) => openImage({ url, width: 1024, event });
function openImage(url: string, width: number, height?: number) { interface OpenImageProps {
url: string;
width: number;
height?: number;
event?: MouseEvent;
}
function openImage({ url, width, height, event }: OpenImageProps) {
event?.stopPropagation();
const u = new URL(url, window.location.href); const u = new URL(url, window.location.href);
const format = url.startsWith("/") const format = url.startsWith("/")
@ -214,8 +223,8 @@ export default definePlugin({
{ {
find: "return{avatarProps:{", find: "return{avatarProps:{",
replacement: { replacement: {
match: /(?<=avatarProps:(\i),eventHandlers:\i.{0,150}?)return null==(?<=onOpenAvatar:(\i).+?)/, match: /(?<=onClick:.{0,30}?)null!=(\i)(?=.{0,200}children:.{0,50}...(\i),imageClassName:)/,
replace: "$2&&=$self.openAvatar.bind(undefined,$1.src);$&", replace: "null!=($1&&=$self.openAvatar.bind(undefined,$2.src))",
} }
}, },
// Banners // Banners
@ -223,7 +232,7 @@ export default definePlugin({
find: 'backgroundColor:"COMPLETE"', find: 'backgroundColor:"COMPLETE"',
replacement: { replacement: {
match: /(overflow:"visible",.{0,125}?!1\),)style:{(?=.+?backgroundImage:null!=(\i)\?`url\(\$\{\2\}\))/, match: /(overflow:"visible",.{0,125}?!1\),)style:{(?=.+?backgroundImage:null!=(\i)\?`url\(\$\{\2\}\))/,
replace: (_, rest, bannerSrc) => `${rest}onClick:()=>${bannerSrc}!=null&&$self.openBanner(${bannerSrc}),style:{cursor:${bannerSrc}!=null?"pointer":void 0,` replace: (_, rest, bannerSrc) => `${rest}onClick:vcEvent=>${bannerSrc}!=null&&$self.openBanner(${bannerSrc}, vcEvent),style:{cursor:${bannerSrc}!=null?"pointer":void 0,`
} }
}, },
// Group DMs top small & large icon // Group DMs top small & large icon
@ -232,7 +241,7 @@ export default definePlugin({
replacement: { replacement: {
match: /null==\i\.icon\?.+?src:(\(0,\i\.\i\).+?\))(?=[,}])/, match: /null==\i\.icon\?.+?src:(\(0,\i\.\i\).+?\))(?=[,}])/,
// We have to check that icon is not an unread GDM in the server bar // We have to check that icon is not an unread GDM in the server bar
replace: (m, iconUrl) => `${m},onClick:()=>arguments[0]?.size!=="SIZE_48"&&$self.openAvatar(${iconUrl})` replace: (m, iconUrl) => `${m},onClick:vcEvent=>arguments[0]?.size!=="SIZE_48"&&$self.openAvatar(${iconUrl},vcEvent)`
} }
}, },
// User DMs top small icon // User DMs top small icon
@ -240,7 +249,7 @@ export default definePlugin({
find: ".channel.getRecipientId(),", find: ".channel.getRecipientId(),",
replacement: { replacement: {
match: /(?=,src:(\i.getAvatarURL\(.+?[)]))/, match: /(?=,src:(\i.getAvatarURL\(.+?[)]))/,
replace: (_, avatarUrl) => `,onClick:()=>$self.openAvatar(${avatarUrl})` replace: (_, avatarUrl) => `,onClick:vcEvent=>$self.openAvatar(${avatarUrl},vcEvent)`
} }
}, },
// User Dms top large icon // User Dms top large icon
@ -248,7 +257,7 @@ export default definePlugin({
find: ".EMPTY_GROUP_DM)", find: ".EMPTY_GROUP_DM)",
replacement: { replacement: {
match: /(?<=SIZE_80,)(?=src:(.+?\))[,}])/, match: /(?<=SIZE_80,)(?=src:(.+?\))[,}])/,
replace: (_, avatarUrl) => `onClick:()=>$self.openAvatar(${avatarUrl}),` replace: (_, avatarUrl) => `onClick:vcEvent=>$self.openAvatar(${avatarUrl},vcEvent),`
} }
} }
] ]

View file

@ -68,6 +68,7 @@ export const VoiceRecorderWeb: VoiceRecorder = ({ setAudioBlob, onRecordingChang
}); });
} else { } else {
recorder?.stop(); recorder?.stop();
recorder?.stream.getTracks().forEach(track => track.stop());
} }
} }

View file

@ -303,6 +303,7 @@ export default definePlugin({
stop() { stop() {
socket.close(); socket.close();
Native.closeSocket();
}, },
settingsAboutComponent: () => ( settingsAboutComponent: () => (

View file

@ -6,7 +6,7 @@
import { createSocket, Socket } from "dgram"; import { createSocket, Socket } from "dgram";
let xsoSocket: Socket; let xsoSocket: Socket | null = null;
export function sendToOverlay(_, data: any) { export function sendToOverlay(_, data: any) {
data.messageType = data.type; data.messageType = data.type;
@ -14,3 +14,8 @@ export function sendToOverlay(_, data: any) {
xsoSocket ??= createSocket("udp4"); xsoSocket ??= createSocket("udp4");
xsoSocket.send(json, 42069, "127.0.0.1"); xsoSocket.send(json, 42069, "127.0.0.1");
} }
export function closeSocket() {
xsoSocket?.close();
xsoSocket = null;
}

View file

@ -670,6 +670,10 @@ export const Devs = /* #__PURE__*/ Object.freeze({
name: "Lunascape", name: "Lunascape",
id: 383365021415243776n id: 383365021415243776n
}, },
jax: {
name: "jax",
id: 1493703027801194598n
},
yuna0x0: { yuna0x0: {
name: "yuna0x0", name: "yuna0x0",
id: 213656926414831616n id: 213656926414831616n

View file

@ -113,7 +113,7 @@ export function useAwaiter<T>(factory: () => Promise<T>, providedOpts?: AwaiterO
}) })
.catch(error => { .catch(error => {
if (!isAlive) return; if (!isAlive) return;
setState({ value: null, error, pending: false }); setState({ value: opts.fallbackValue, error, pending: false });
opts.onError?.(error); opts.onError?.(error);
}); });

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 { isTruthy } from "./guards";
// Utils for readable text transformations eg: `toTitle(fromKebab())` // Utils for readable text transformations eg: `toTitle(fromKebab())`
// Case style to words // Case style to words
@ -51,7 +53,7 @@ function getUnitStr(unit: Units, isOne: boolean, short: boolean) {
* @param unit The unit the time is on * @param unit The unit the time is on
* @param short Whether to use short units like "d" instead of "days" * @param short Whether to use short units like "d" instead of "days"
*/ */
export function formatDuration(time: number, unit: Units, short: boolean = false) { export function formatDurationVerbose(time: number, unit: Units, short: boolean = false) {
const { moment } = require("@webpack/common") as typeof import("@webpack/common"); const { moment } = require("@webpack/common") as typeof import("@webpack/common");
const dur = moment.duration(time, unit); const dur = moment.duration(time, unit);
@ -93,6 +95,33 @@ export function formatDuration(time: number, unit: Units, short: boolean = false
return res.length ? res : `0 ${getUnitStr(unit, false, short)}`; return res.length ? res : `0 ${getUnitStr(unit, false, short)}`;
} }
export function formatDuration(ms: number, human = false) {
const DAY = 86_400_000;
const HOUR = 3_600_000;
const MINUTE = 60_000;
const days = Math.floor(ms / DAY);
const hours = Math.floor((ms % DAY) / HOUR);
const minutes = Math.floor((ms % HOUR) / MINUTE);
const seconds = Math.floor((ms % MINUTE) / 1000);
const pad = (n: number) => human ? n : n.toString().padStart(2, "0");
const unit = (s: string) => human ? s : "";
const showDays = days !== 0;
const showHours = showDays || hours !== 0;
const showMinutes = showHours || minutes !== 0 || !human;
const duration = [
showHours && `${pad(hours)}${unit("h")}`,
showMinutes && `${pad(minutes)}${unit("m")}`,
`${pad(seconds)}${unit("s")}`
].filter(isTruthy).join(human ? " " : ":");
const prefix = showDays ? `${days}d ` : "";
return prefix + duration;
}
/** /**
* The `formatDurationMs` function formats a duration in milliseconds into a human-readable string, * The `formatDurationMs` function formats a duration in milliseconds into a human-readable string,
* with the option to include units such as days, hours, minutes, and seconds. * with the option to include units such as days, hours, minutes, and seconds.

View file

@ -507,8 +507,8 @@ function patchFactory(moduleId: PropertyKey, originalFactory: AnyModuleFactory):
const isArrowFunction = originalFactoryCode.startsWith("("); const isArrowFunction = originalFactoryCode.startsWith("(");
// 0, prefix to turn it into an expression: 0,function(){} would be invalid syntax without the 0, // 0, prefix to turn it into an expression: 0,function(){} would be invalid syntax without the 0,
let code = "0," + (!isArrowFunction ? "function" : "") + originalFactoryCode.slice(originalFactoryCode.indexOf("(")); let patchedCode = "0," + (!isArrowFunction ? "function" : "") + originalFactoryCode.slice(originalFactoryCode.indexOf("("));
let patchedSource = code; let patchedSource = patchedCode;
let patchedFactory = originalFactory; let patchedFactory = originalFactory;
const patchedBy = new Set<string>(); const patchedBy = new Set<string>();
@ -521,58 +521,59 @@ function patchFactory(moduleId: PropertyKey, originalFactory: AnyModuleFactory):
if ( if (
shouldCheckBuildNumber && shouldCheckBuildNumber &&
(patch.fromBuild != null && buildNumber < patch.fromBuild) || ((patch.fromBuild != null && buildNumber < patch.fromBuild) || (patch.toBuild != null && buildNumber > patch.toBuild))
(patch.toBuild != null && buildNumber > patch.toBuild)
) { ) {
patches.splice(i--, 1); patches.splice(i--, 1);
continue; continue;
} }
const moduleMatches = typeof patch.find === "string" const moduleMatches = typeof patch.find === "string"
? code.includes(patch.find) ? originalFactoryCode.includes(patch.find)
: (patch.find.global && (patch.find.lastIndex = 0), patch.find.test(code)); : (patch.find.global && (patch.find.lastIndex = 0), patch.find.test(originalFactoryCode));
if (!moduleMatches) { if (!moduleMatches) {
continue; continue;
} }
// Save the result from the previous patch so we can restore it in case a patch group fails.
const previousPatchedCode = patchedCode;
const previousPatchedSource = patchedSource;
const previousPatchedFactory = patchedFactory;
let shouldRestorePrevious = false;
let markedAsPatched = false;
const executePatch = traceFunctionWithResults(`patch by ${patch.plugin}`, (match: string | RegExp, replace: string) => { const executePatch = traceFunctionWithResults(`patch by ${patch.plugin}`, (match: string | RegExp, replace: string) => {
if (typeof match !== "string" && match.global) { if (typeof match !== "string" && match.global) {
match.lastIndex = 0; match.lastIndex = 0;
} }
return code.replace(match, replace); return patchedCode.replace(match, replace);
}); });
const previousCode = code;
const previousFactory = originalFactory;
let markedAsPatched = false;
// We change all patch.replacement to array in PluginManager // We change all patch.replacement to array in PluginManager
for (const replacement of patch.replacement as PatchReplacement[]) { for (const replacement of patch.replacement as PatchReplacement[]) {
if ( if (
shouldCheckBuildNumber && shouldCheckBuildNumber &&
(replacement.fromBuild != null && buildNumber < replacement.fromBuild) || ((replacement.fromBuild != null && buildNumber < replacement.fromBuild) || (replacement.toBuild != null && buildNumber > replacement.toBuild))
(replacement.toBuild != null && buildNumber > replacement.toBuild)
) { ) {
continue; continue;
} }
const lastCode = code; let newPatchedCode: string = "";
const lastFactory = originalFactory;
try { try {
const [newCode, totalTime] = executePatch(replacement.match, replacement.replace as string); const [patchResult, totalTime] = executePatch(replacement.match, replacement.replace as string);
newPatchedCode = patchResult;
if (IS_REPORTER) { if (IS_REPORTER) {
patchTimings.push([patch.plugin, moduleId, replacement.match, totalTime]); patchTimings.push([patch.plugin, moduleId, replacement.match, totalTime]);
} }
if (newCode === code) { if (newPatchedCode === patchedCode) {
if (!(patch.noWarn || replacement.noWarn)) { if (!(patch.noWarn || replacement.noWarn)) {
logger.warn(`Patch by ${patch.plugin} had no effect (Module id is ${String(moduleId)}): ${replacement.match}`); logger.warn(`Patch by ${patch.plugin} had no effect (Module id is ${String(moduleId)}): ${replacement.match}`);
if (IS_DEV) { if (IS_DEV) {
logger.debug("Function Source:\n", code); logger.debug("Function Source:\n", patchedCode);
} }
if (IS_COMPANION_TEST) if (IS_COMPANION_TEST)
reporterData.failedPatches.hadNoEffect.push({ reporterData.failedPatches.hadNoEffect.push({
@ -583,8 +584,6 @@ function patchFactory(moduleId: PropertyKey, originalFactory: AnyModuleFactory):
if (patch.group) { if (patch.group) {
logger.warn(`Undoing patch group ${patch.find} by ${patch.plugin} because replacement ${replacement.match} had no effect`); logger.warn(`Undoing patch group ${patch.find} by ${patch.plugin} because replacement ${replacement.match} had no effect`);
code = previousCode;
patchedFactory = previousFactory;
if (markedAsPatched) { if (markedAsPatched) {
patchedBy.delete(patch.plugin); patchedBy.delete(patch.plugin);
@ -596,6 +595,8 @@ function patchFactory(moduleId: PropertyKey, originalFactory: AnyModuleFactory):
id: moduleId id: moduleId
}); });
shouldRestorePrevious = true;
break; break;
} }
@ -607,14 +608,17 @@ function patchFactory(moduleId: PropertyKey, originalFactory: AnyModuleFactory):
pluginsList.push(patch.plugin); pluginsList.push(patch.plugin);
} }
code = newCode; const newPatchedSource = `// Webpack Module ${String(moduleId)} - Patched by ${pluginsList.join(", ")}\n${newPatchedCode}\n//# sourceURL=file:///WebpackModule${String(moduleId)}`;
patchedSource = `// Webpack Module ${String(moduleId)} - Patched by ${pluginsList.join(", ")}\n${code}\n//# sourceURL=file:///WebpackModule${String(moduleId)}`; const newPatchedFactory = (0, eval)(newPatchedSource);
patchedFactory = (0, eval)(patchedSource);
if (!patchedBy.has(patch.plugin)) { if (!patchedBy.has(patch.plugin)) {
patchedBy.add(patch.plugin); patchedBy.add(patch.plugin);
markedAsPatched = true; markedAsPatched = true;
} }
patchedCode = newPatchedCode;
patchedSource = newPatchedSource;
patchedFactory = newPatchedFactory;
} catch (err) { } catch (err) {
// FIXME: Maybe fix this properly // FIXME: Maybe fix this properly
const shouldSuppressError = patch.plugin === "ContextMenuAPI" && err instanceof SyntaxError && err.message.includes("arguments"); const shouldSuppressError = patch.plugin === "ContextMenuAPI" && err instanceof SyntaxError && err.message.includes("arguments");
@ -624,20 +628,16 @@ function patchFactory(moduleId: PropertyKey, originalFactory: AnyModuleFactory):
if (IS_COMPANION_TEST) if (IS_COMPANION_TEST)
reporterData.failedPatches.erroredPatch.push({ reporterData.failedPatches.erroredPatch.push({
...patch, ...patch,
oldModule: lastCode, oldModule: patchedCode,
newModule: code, newModule: newPatchedCode,
id: moduleId id: moduleId
}); });
if (IS_DEV) { if (IS_DEV) {
diffErroredPatch(code, lastCode, lastCode.match(replacement.match)!); diffErroredPatch(newPatchedCode, patchedCode, patchedCode.match(replacement.match)!);
} }
} }
if (markedAsPatched) {
patchedBy.delete(patch.plugin);
}
if (patch.group) { if (patch.group) {
logger.warn(`Undoing patch group ${patch.find} by ${patch.plugin} because replacement ${replacement.match} errored`); logger.warn(`Undoing patch group ${patch.find} by ${patch.plugin} because replacement ${replacement.match} errored`);
if (IS_COMPANION_TEST) if (IS_COMPANION_TEST)
@ -645,13 +645,19 @@ function patchFactory(moduleId: PropertyKey, originalFactory: AnyModuleFactory):
...patch, ...patch,
id: moduleId id: moduleId
}); });
code = previousCode; shouldRestorePrevious = true;
patchedFactory = previousFactory;
break; break;
} }
}
}
code = lastCode; if (shouldRestorePrevious) {
patchedFactory = lastFactory; patchedCode = previousPatchedCode;
patchedSource = previousPatchedSource;
patchedFactory = previousPatchedFactory;
if (markedAsPatched) {
patchedBy.delete(patch.plugin);
} }
} }
@ -673,9 +679,12 @@ function patchFactory(moduleId: PropertyKey, originalFactory: AnyModuleFactory):
function diffErroredPatch(code: string, lastCode: string, match: RegExpMatchArray) { function diffErroredPatch(code: string, lastCode: string, match: RegExpMatchArray) {
const changeSize = code.length - lastCode.length; const changeSize = code.length - lastCode.length;
const matchIndex = match?.index ?? 0;
const matchLength = match?.[0]?.length ?? 0;
// Use 200 surrounding characters of context // Use 200 surrounding characters of context
const start = Math.max(0, match.index! - 200); const start = Math.max(0, matchIndex - 200);
const end = Math.min(lastCode.length, match.index! + match[0].length + 200); const end = Math.min(lastCode.length, matchIndex + matchLength + 200);
// (changeSize may be negative) // (changeSize may be negative)
const endPatched = end + changeSize; const endPatched = end + changeSize;