Merge branch 'dev' into translation

This commit is contained in:
thororen1234 2026-04-30 10:42:32 -04:00
commit 16bf7d03fc
No known key found for this signature in database
51 changed files with 1716 additions and 1118 deletions

View file

@ -1,7 +1,7 @@
{ {
"name": "equicord", "name": "equicord",
"private": "true", "private": "true",
"version": "1.14.10.1", "version": "1.14.11.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": {

View file

@ -81,7 +81,9 @@ const IGNORED_DISCORD_ERRORS = [
"Downloading the full bad domains file", "Downloading the full bad domains file",
/\[GatewaySocket\].{0,110}Cannot access '/, /\[GatewaySocket\].{0,110}Cannot access '/,
"search for 'name' in undefined", "search for 'name' in undefined",
"Attempting to set fast connect zstd when unsupported" "Attempting to set fast connect zstd when unsupported",
/Cannot read properties of undefined \(reading 'call'\)/,
"Could not complete Remote Auth login, trying to restart with a new Remote Auth session."
] as Array<string | RegExp>; ] as Array<string | RegExp>;
function toCodeBlock(s: string, indentation = 0, isDiscord = false) { function toCodeBlock(s: string, indentation = 0, isDiscord = false) {

View file

@ -21,17 +21,25 @@ import { ManifestEntry, SyncRequest, SyncResponse } from "./types";
const logger = new Logger("SettingsSync:Cloud", "#39b7e0"); const logger = new Logger("SettingsSync:Cloud", "#39b7e0");
const MANIFEST_STORE_KEY = "Vencord_cloudManifest"; const MANIFEST_STORE_KEY = "Vencord_cloudManifest";
const API_VERSION_STORE_KEY = "Vencord_cloudApiVersions";
type ApiVersion = "v2" | "v1"; type ApiVersion = "v2" | "v1";
let apiVersion: ApiVersion = "v2"; async function loadApiVersionMap(): Promise<Record<string, ApiVersion>> {
return await DataStore.get<Record<string, ApiVersion>>(API_VERSION_STORE_KEY) ?? {};
}
async function getApiVersion(): Promise<ApiVersion> { async function getApiVersion(): Promise<ApiVersion> {
return apiVersion; const map = await loadApiVersionMap();
return map[getCloudUrl().origin] ?? "v2";
} }
async function setApiVersion(version: ApiVersion) { async function setApiVersion(version: ApiVersion) {
apiVersion = version; await DataStore.update<Record<string, ApiVersion>>(API_VERSION_STORE_KEY, map => {
map ??= {};
map[getCloudUrl().origin] = version;
return map;
});
} }
function toBase64(data: Uint8Array): string { function toBase64(data: Uint8Array): string {

View file

@ -204,14 +204,13 @@ export default function PluginSettings() {
const pluginFilter = useCallback((plugin: typeof Plugins[keyof typeof Plugins], newPluginsSet: Set<string> | null) => { const pluginFilter = useCallback((plugin: typeof Plugins[keyof typeof Plugins], newPluginsSet: Set<string> | null) => {
const { status, tags } = searchValue; const { status, tags } = searchValue;
const enabled = isPluginEnabled(plugin.name);
switch (status) { switch (status) {
case SearchStatus.DISABLED: case SearchStatus.DISABLED:
if (enabled) return false; if (isPluginEnabled(plugin.name)) return false;
break; break;
case SearchStatus.ENABLED: case SearchStatus.ENABLED:
if (!enabled) return false; if (!isPluginEnabled(plugin.name)) return false;
break; break;
case SearchStatus.EQUICORD: case SearchStatus.EQUICORD:
if (!PluginMeta[plugin.name].folderName.startsWith("src/equicordplugins/")) return false; if (!PluginMeta[plugin.name].folderName.startsWith("src/equicordplugins/")) return false;
@ -236,6 +235,7 @@ export default function PluginSettings() {
return ( return (
plugin.name.toLowerCase().includes(search.replace(/\s+/g, "")) || plugin.name.toLowerCase().includes(search.replace(/\s+/g, "")) ||
plugin.name.match(/[A-Z]/g)?.join("").toLowerCase().includes(search) || // acronyms like BF for BetterFolders
plugin.description.toLowerCase().includes(search) || plugin.description.toLowerCase().includes(search) ||
plugin.searchTerms?.some(t => t.toLowerCase().includes(search)) plugin.searchTerms?.some(t => t.toLowerCase().includes(search))
); );

View file

@ -65,75 +65,63 @@ function EquicordSettings() {
title: string; title: string;
description?: string; description?: string;
restartRequired?: boolean; restartRequired?: boolean;
warning: { enabled: boolean; message?: string; }; warning?: string;
} }
> = [ > = [
{ {
key: "useQuickCss", key: "useQuickCss",
title: t("vencord.settings.useQuickCss.title"), title: t("vencord.settings.useQuickCss.title"),
description: t("vencord.settings.useQuickCss.description"), description: t("vencord.settings.useQuickCss.description"),
restartRequired: true, },
warning: { enabled: false }, !IS_WEB && {
}, key: "enableReactDevtools",
!IS_WEB && { title: t("vencord.settings.enableReactDevtools.title"),
key: "enableReactDevtools", description: t("vencord.settings.enableReactDevtools.description"),
title: t("vencord.settings.enableReactDevtools.title"), restartRequired: true,
description: t("vencord.settings.enableReactDevtools.description"), },
restartRequired: true, (!IS_WEB && !IS_DISCORD_DESKTOP || !IS_WINDOWS) && {
warning: { enabled: false }, key: "mainWindowFrameless",
}, title: t("equicord.mainWindowFrameless.title"),
(!IS_WEB && !IS_DISCORD_DESKTOP || !IS_WINDOWS) && { description: t("equicord.mainWindowFrameless.description"),
key: "mainWindowFrameless", restartRequired: true,
title: t("equicord.mainWindowFrameless.title"), },
description: t("equicord.mainWindowFrameless.description"), !IS_WEB && (!IS_DISCORD_DESKTOP || !IS_WINDOWS
restartRequired: true,
warning: { enabled: false },
},
!IS_WEB &&
(!IS_DISCORD_DESKTOP || !IS_WINDOWS
? { ? {
key: "frameless", key: "frameless",
title: t("vencord.settings.frameless.title"), title: t("vencord.settings.frameless.title"),
description: t("vencord.settings.frameless.description"), description: t("vencord.settings.frameless.description"),
restartRequired: true, restartRequired: true,
warning: { enabled: false },
} }
: { : {
key: "winNativeTitleBar", key: "winNativeTitleBar",
title: t("vencord.settings.winNativeTitleBar.title"), title: t("vencord.settings.winNativeTitleBar.title"),
description: t("vencord.settings.winNativeTitleBar.description"), description: t("vencord.settings.winNativeTitleBar.description"),
restartRequired: true, restartRequired: true,
warning: { enabled: false },
} }
), ),
!IS_WEB && { !IS_WEB && {
key: "transparent", key: "transparent",
title: t("vencord.settings.transparent.title"), title: t("vencord.settings.transparent.title"),
description: t("vencord.settings.transparent.description"), description: t("vencord.settings.transparent.description"),
restartRequired: true, restartRequired: true,
warning: { warning: IS_WINDOWS
enabled: true,
message: IS_WINDOWS
? t("vencord.settings.transparent.noteWindows") ? t("vencord.settings.transparent.noteWindows")
: t("vencord.settings.transparent.note"), : t("vencord.settings.transparent.note"),
}, },
}, IS_DISCORD_DESKTOP && {
IS_DISCORD_DESKTOP && { key: "disableMinSize",
key: "disableMinSize", title: t("vencord.settings.disableMinSize.title"),
title: t("vencord.settings.disableMinSize.title"), description: t("vencord.settings.disableMinSize.description"),
description: t("vencord.settings.disableMinSize.description"), restartRequired: true,
restartRequired: true, },
warning: { enabled: false }, !IS_WEB &&
},
!IS_WEB &&
IS_WINDOWS && { IS_WINDOWS && {
key: "winCtrlQ", key: "winCtrlQ",
title: t("vencord.settings.winCtrlQ.title"), title: t("vencord.settings.winCtrlQ.title"),
description: t("vencord.settings.winCtrlQ.description"), description: t("vencord.settings.winCtrlQ.description"),
restartRequired: true, restartRequired: true,
warning: { enabled: false }, },
}, ];
];
return ( return (
<SettingsTab> <SettingsTab>
@ -265,11 +253,11 @@ function EquicordSettings() {
}} }}
title={s.title} title={s.title}
description={ description={
s.warning.enabled ? ( s.warning ? (
<> <>
{s.description} {s.description}
<Notice.Warning className={Margins.top8} style={{ width: "100%" }}> <Notice.Warning className={Margins.top8} style={{ width: "100%" }}>
{s.warning.message} {s.warning}
</Notice.Warning> </Notice.Warning>
</> </>
) : ( ) : (

View file

@ -13,7 +13,7 @@ export default definePlugin({
authors: [Devs.thororen], authors: [Devs.thororen],
patches: [ patches: [
{ {
find: "#{intl::XcTHmQ::raw}", find: "#{intl::USER_PROFILE_FRIEND_REQUEST_TOAST}",
replacement: { replacement: {
match: /user:\i,widgets:.{0,100}?\}\),/, match: /user:\i,widgets:.{0,100}?\}\),/,
replace: "$&Vencord.Api.ProfileCollections.renderProfileCollections(arguments[0]),", replace: "$&Vencord.Api.ProfileCollections.renderProfileCollections(arguments[0]),",

View file

@ -0,0 +1,32 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { IconsDef } from "@equicordplugins/iconViewer/types";
import { Devs } from "@utils/constants";
import definePlugin from "@utils/types";
export let iconsModule: IconsDef;
export default definePlugin({
name: "ConcatenatedModules",
description: "Extract modules that have been concatenated by the bundler",
authors: [Devs.thororen],
required: true,
patches: [
{
find: "AngleBracketsIcon",
replacement: {
match: /\i\.\i\((\i)\),\i\.\i\(\i,\{AIcon/,
replace: "$self.iconsModule($1),$&"
}
}
],
set iconsModule(value: any) {
iconsModule = value;
},
});

View file

@ -9,49 +9,83 @@ import "./styles.css";
import { definePluginSettings } from "@api/Settings"; import { definePluginSettings } from "@api/Settings";
import { Heading } from "@components/Heading"; import { Heading } from "@components/Heading";
import { DeleteIcon, PlusIcon } from "@components/Icons"; import { DeleteIcon, PlusIcon } from "@components/Icons";
import { Devs } from "@utils/constants"; import { Devs, EquicordDevs } from "@utils/constants";
import { classNameFactory } from "@utils/css"; import { classNameFactory } from "@utils/css";
import { t } from "@utils/translation"; import { t } from "@utils/translation";
import definePlugin, { OptionType } from "@utils/types"; import definePlugin, { OptionType } from "@utils/types";
import { Button, TextInput } from "@webpack/common"; import { Button, Select, TextInput, useState } from "@webpack/common";
const cl = classNameFactory("vc-bbr-"); const cl = classNameFactory("vc-bbr-");
interface BanReason {
text: string;
deleteSeconds?: number;
}
const DELETE_DURATION_OPTIONS = [
{ label: "Discord default", value: null },
{ label: "Don't delete any", value: 0 },
{ label: "Previous hour", value: 3600 },
{ label: "Previous 6 hours", value: 21600 },
{ label: "Previous 12 hours", value: 43200 },
{ label: "Previous 24 hours", value: 86400 },
{ label: "Previous 3 days", value: 259200 },
{ label: "Previous 7 days", value: 604800 },
];
function normalizeReason(r: BanReason | string): BanReason {
return typeof r === "string" ? { text: r } : r;
}
function getStoredReasons(): BanReason[] {
return (settings.store.reasons as (BanReason | string)[]).map(normalizeReason);
}
function ReasonsComponent() { function ReasonsComponent() {
const { reasons } = settings.store; const [reasons, setReasons] = useState<BanReason[]>(getStoredReasons);
const update = (next: BanReason[]) => {
setReasons(next);
settings.store.reasons = next;
};
return ( return (
<section> <section>
<Heading>{t("equicord.betterBanReasons.ui.reasons")}</Heading> <Heading>{t("equicord.betterBanReasons.ui.reasons")}</Heading>
{reasons.map((r, i) => ( {reasons.map((r, i) => (
<div <div key={i} className={cl("reason-wrapper")}>
key={i}
className={cl("reason-wrapper")}
>
<TextInput <TextInput
value={r} value={r.text}
onChange={v => { onChange={v => update(reasons.map((x, j) => j === i ? { ...x, text: v } : x))}
reasons[i] = v;
settings.store.reasons = reasons;
}}
placeholder={t("equicord.betterBanReasons.ui.reasonPlaceholder")} placeholder={t("equicord.betterBanReasons.ui.reasonPlaceholder")}
/> />
<Button <Button
className={cl("remove-button")} className={cl("remove-button")}
color={Button.Colors.TRANSPARENT} color={Button.Colors.TRANSPARENT}
onClick={() => { onClick={() => update(reasons.filter((_, j) => j !== i))}
reasons.splice(i, 1);
settings.store.reasons = reasons;
}}
look={Button.Looks.FILLED} look={Button.Looks.FILLED}
size={Button.Sizes.MIN} size={Button.Sizes.MIN}
> >
<DeleteIcon /> <DeleteIcon />
</Button> </Button>
<div className={cl("duration-row")}>
<span className="vc-text-base vc-text-sm vc-text-normal vc-text-defaultColor vc-plugins-setting-description">Message Auto-delete duration</span>
<Select
options={DELETE_DURATION_OPTIONS}
select={v => update(reasons.map((x, j) => j === i ? { ...x, deleteSeconds: v === null ? undefined : v } : x))}
isSelected={v => (v ?? null) === (r.deleteSeconds ?? null)}
serialize={v => v == null ? "default" : String(v)}
/>
</div>
</div> </div>
))} ))}
<div className={cl("reason-wrapper")}> <div className={cl("reason-wrapper")}>
<Button onClick={() => settings.store.reasons.push("")} className={cl("add-button")} size={Button.Sizes.LARGE} color={Button.Colors.TRANSPARENT}> <Button
onClick={() => update([...reasons, { text: "" }])}
className={cl("add-button")}
size={Button.Sizes.LARGE}
color={Button.Colors.TRANSPARENT}
>
<PlusIcon /> {t("equicord.betterBanReasons.ui.addAnotherReason")} <PlusIcon /> {t("equicord.betterBanReasons.ui.addAnotherReason")}
</Button> </Button>
</div> </div>
@ -63,7 +97,7 @@ const settings = definePluginSettings({
reasons: { reasons: {
description: t("equicord.betterBanReasons.settings.reasons"), description: t("equicord.betterBanReasons.settings.reasons"),
type: OptionType.COMPONENT, type: OptionType.COMPONENT,
default: [] as string[], default: [] as BanReason[],
component: ReasonsComponent, component: ReasonsComponent,
}, },
isTextInputDefault: { isTextInputDefault: {
@ -76,30 +110,56 @@ export default definePlugin({
name: "BetterBanReasons", name: "BetterBanReasons",
description: t("equicord.betterBanReasons.description"), description: t("equicord.betterBanReasons.description"),
tags: ["Appearance", "Customisation"], tags: ["Appearance", "Customisation"],
authors: [Devs.Inbestigator], authors: [Devs.Inbestigator, EquicordDevs.yonn2222],
durationSetter: null as ((v: number) => void) | null,
patches: [ patches: [
{ {
find: "#{intl::BAN_REASON_OPTION_SPAM_ACCOUNT}", find: "#{intl::BAN_REASON_OPTION_SPAM_ACCOUNT}",
replacement: [{ replacement: [
match: /(\[\{name:\i\.\i\.\i\(\i\.\i\.\i\),.+?"other"\}\])/, {
replace: "$self.getReasons($1)" match: /(\[\{name:\i\.\i\.\i\(\i\.\i\.\i\),.+?"other"\}\])/,
}, replace: "$self.getReasons($1)"
{ },
match: /useState\(null\)(?=.{0,300}targetUserId:)/, {
replace: "useState($self.getDefaultState())" match: /useState\(null\)(?=.{0,300}targetUserId:)/,
}] replace: "useState($self.getDefaultState())"
},
{
match: /(\[\i,\i\])=(\i)\.useState\((null!=\i\?\i:\i)\)/,
replace: "$1=$self.captureDeleteState($2.useState,$3)"
},
{
match: /\i=\i\.useCallback\((\i)=>\{.{0,10},\i\(null\)(?=\},\[\]\))/,
replace: "$&,$self.onReasonSelect($1)"
}
]
} }
], ],
getReasons(defaults) {
const storedReasons = settings.store.reasons.filter((r: string) => r.trim()); getReasons(defaults: { name: string; value: string; }[]) {
const reasons: string[] = storedReasons.length const stored = getStoredReasons().filter(r => r.text.trim());
? storedReasons
: [];
return [ return [
...reasons.map(s => ({ name: s, value: s })), ...stored.map(r => ({ name: r.text, value: r.text })),
...defaults ...defaults,
]; ];
}, },
getDefaultState: () => settings.store.isTextInputDefault ? 1 : 0, getDefaultState: () => settings.store.isTextInputDefault ? 1 : 0,
captureDeleteState(hook: (initial: number) => [number, (v: number) => void], initial: number) {
const result = hook(initial);
this.durationSetter = result[1];
return result;
},
onReasonSelect(value: string) {
const reason = getStoredReasons().find(r => r.text === value);
if (reason?.deleteSeconds !== undefined) {
this.durationSetter?.(reason.deleteSeconds);
}
},
settings, settings,
}); });

View file

@ -8,6 +8,13 @@
.vc-bbr-remove-button { .vc-bbr-remove-button {
color: var(--text-muted); color: var(--text-muted);
align-self: start;
}
.vc-bbr-duration-row {
display: flex;
flex-direction: column;
gap: 4px;
} }
.vc-bbr-remove-button:hover { .vc-bbr-remove-button:hover {

View file

@ -23,17 +23,18 @@ export default definePlugin({
patches: [ patches: [
{ {
find: '"],{numberOfBlockedUsers:', find: '"],{numberOfBlockedUsers:',
group: true,
replacement: [ replacement: [
{ {
match: /(?<=listType:\i,numberOfUsers:\i\}=(\i).{0,800}numberOfIgnoredUsers:\i\}\)\}\)\]\}\))/, match: /(?<=\(0,\i\.jsx\)\(\i,\{listType:(\i),numberOfUsers:\i\.length\}\),)/,
replace: ",$1.listType==='blocked'?$self.renderSearchInput():null" replace: "$1==='blocked'?$self.renderSearchInput():null,"
}, },
{ {
match: /(?<=\{userId:(\i).*?\.globalName.{0,25}\}\)\]\}\),)(\(.*?loading:\i\}\))/, match: /(?<=\{userId:(\i).*?\.globalName.{0,25}\}\)\]\}\),)(\(.*?loading:\i\}\))/,
replace: "$self.renderUser($1,$2)", replace: "$self.renderUser($1,$2)",
}, },
{ {
match: /(?<=\}=(\i).{0,10}(\i).useState.{0,5};)/, match: /(?<=userIds:\i,listType:\i\}=(\i).{0,30}(\i)\.useState\(\d+\);)/,
replace: "let [searchResults,setSearchResults]=$2.useState([]);$self.setUpdateFunc($1,setSearchResults);" replace: "let [searchResults,setSearchResults]=$2.useState([]);$self.setUpdateFunc($1,setSearchResults);"
}, },
{ {
@ -51,17 +52,19 @@ export default definePlugin({
updateFunc(searchResults); updateFunc(searchResults);
}, []); }, []);
return <TextInput return <div className="vc-bbu-search">
placeholder={t("equicord.betterBlockedUsers.ui.searchPlaceholder")} <TextInput
style={{ width: "200px" }} placeholder={t("equicord.betterBlockedUsers.ui.searchPlaceholder")}
onInput={e => { style={{ width: "200px" }}
const search = (e.target as HTMLInputElement).value.toLowerCase().trim(); onInput={e => {
setValue(search); const search = (e.target as HTMLInputElement).value.toLowerCase().trim();
lastSearch = search; setValue(search);
const searchResults = this.getFilteredUsers(search); lastSearch = search;
updateFunc(searchResults); const searchResults = this.getFilteredUsers(search);
}} value={value} updateFunc(searchResults);
></TextInput>; }} value={value}
/>
</div>;
}, },
renderUser(userId: string, rest: any) { renderUser(userId: string, rest: any) {
return ( return (

View file

@ -1,3 +1,8 @@
[class*="usersList"] [class*="text"] { [class*="usersList"] [class*="text"] {
user-select: text !important; user-select: text !important;
}
.vc-bbu-search,
.vc-bbu-search * {
border-radius: 0 !important;
} }

View file

@ -288,6 +288,16 @@ export default function ChannelsTabsContainer(props: BasicChannelTabsProps) {
if (isFullscreen) return null; if (isFullscreen) return null;
const shouldFollowNewTabButton = newTabButtonBehavior && !tabsOverflow;
const newTabButton = (
<button
onClick={() => createTab(props, true)}
className={cl("button", "new-button", "hoverable")}
>
<PlusSmallIcon />
</button>
);
return ( return (
<div <div
className={classes( className={classes(
@ -322,20 +332,16 @@ export default function ChannelsTabsContainer(props: BasicChannelTabsProps) {
<div className={cl("tab-container")}> <div className={cl("tab-container")}>
<HorizontalScroller <HorizontalScroller
customRef={node => { scrollerRef.current = node; }} customRef={node => { scrollerRef.current = node; }}
className={cl("tab-scroller", newTabButtonBehavior && !tabsOverflow && "tab-scroller-following")} className={cl("tab-scroller", shouldFollowNewTabButton && "tab-scroller-following")}
> >
{openedTabs.filter(tab => tab != null).map((tab, i) => {openedTabs.filter(tab => tab != null).map((tab, i) =>
<ChannelTab {...tab} index={i} key={tab.id} /> <ChannelTab {...tab} index={i} key={tab.id} />
)} )}
{GhostTabs} {GhostTabs}
{shouldFollowNewTabButton && newTabButton}
</HorizontalScroller> </HorizontalScroller>
<button {!shouldFollowNewTabButton && newTabButton}
onClick={() => createTab(props, true)}
className={cl("button", "new-button", "hoverable")}
>
<PlusSmallIcon />
</button>
</div > </div >
</div> </div>

View file

@ -4,10 +4,8 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
*/ */
import { iconsModule } from "@equicordplugins/_core/concatenatedModules";
import { Icon } from "@vencord/discord-types"; import { Icon } from "@vencord/discord-types";
import { findByPropsLazy } from "@webpack";
const iconsModule = findByPropsLazy("AngleBracketsIcon", "StaffBadgeIcon") as Record<string, Icon>;
let iconNames: string[] | undefined; let iconNames: string[] | undefined;

View file

@ -8,6 +8,27 @@ import { Devs } from "@utils/constants";
import { t } from "@utils/translation"; import { t } from "@utils/translation";
import definePlugin from "@utils/types"; import definePlugin from "@utils/types";
import { Channel } from "@vencord/discord-types"; import { Channel } from "@vencord/discord-types";
import { ChannelStore } from "@webpack/common";
const SMALL_CAPS: Record<string, string> = {
"ᴀ": "a", "ʙ": "b", "ᴄ": "c", "ᴅ": "d", "ᴇ": "e", "ꜰ": "f", "ɢ": "g", "ʜ": "h", "ɪ": "i", "ᴊ": "j",
"ᴋ": "k", "ʟ": "l", "ᴍ": "m", "ɴ": "n", "ᴏ": "o", "ᴘ": "p", "ǫ": "q", "ʀ": "r", "ꜱ": "s", "ᴛ": "t",
"ᴜ": "u", "ᴠ": "v", "ᴡ": "w", "x": "x", "ʏ": "y", "ᴢ": "z",
};
const ORIGINAL_NAME = Symbol("cleanChannelName.original");
let editingChannelId: string | null = null;
function computeClean(name: string, type: number): string {
return name
.normalize("NFKC")
.replace(/[ᴀʙᴄᴅᴇꜰɢʜɪᴊᴋʟᴍɴᴏᴘǫʀꜱᴛᴜᴠᴡxʏᴢ]/g, m => SMALL_CAPS[m])
.replace(/[^ -~]?\p{Extended_Pictographic}[^ -~]?/ug, "")
.replace(/-?[^\p{Letter} -~]-?/ug, [2, 4].includes(type) ? " " : "-")
.replace(/(^-|-$)/g, "")
.replace(/-+/g, "-");
}
export default definePlugin({ export default definePlugin({
name: "CleanChannelName", name: "CleanChannelName",
@ -20,27 +41,41 @@ export default definePlugin({
replacement: { replacement: {
match: /(?<=getChannel\(\i\)\{if\(null!=\i\)return )\i\(\i\)/, match: /(?<=getChannel\(\i\)\{if\(null!=\i\)return )\i\(\i\)/,
replace: "$self.cleanChannelName($&)", replace: "$self.cleanChannelName($&)",
} },
} },
], ],
flux: {
CHANNEL_SETTINGS_INIT({ channelId }: { channelId: string; }) {
editingChannelId = channelId;
(ChannelStore as any).emitChange?.();
},
CHANNEL_SETTINGS_CLOSE() {
editingChannelId = null;
(ChannelStore as any).emitChange?.();
},
},
cleanChannelName(channel?: Channel) { cleanChannelName(channel?: Channel) {
if (channel) { if (channel == null) return channel;
channel.name = channel.name const c = channel as any;
.normalize("NFKC")
.replace(/[ᴀʙᴄᴅᴇꜰɢʜɪᴊᴋʟᴍɴᴏᴘǫʀꜱᴛᴜᴠᴡxʏᴢ]/g, match => { if (c[ORIGINAL_NAME] !== undefined) return channel;
const smallCapsToNormal = {
"ᴀ": "a", "ʙ": "b", "ᴄ": "c", "ᴅ": "d", "ᴇ": "e", "ꜰ": "f", "ɢ": "g", "ʜ": "h", "ɪ": "i", "ᴊ": "j", c[ORIGINAL_NAME] = channel.name;
"ᴋ": "k", "ʟ": "l", "ᴍ": "m", "ɴ": "n", "ᴏ": "o", "ᴘ": "p", "ǫ": "q", "ʀ": "r", "ꜱ": "s", "ᴛ": "t",
"ᴜ": "u", "ᴠ": "v", "ᴡ": "w", "x": "x", "ʏ": "y", "ᴢ": "z" Object.defineProperty(channel, "name", {
}; configurable: true,
return smallCapsToNormal[match]; enumerable: true,
}) get() {
.replace(/[^\u0020-\u007E]?\p{Extended_Pictographic}[^\u0020-\u007E]?/ug, "") if (editingChannelId === channel.id) return c[ORIGINAL_NAME];
.replace(/-?[^\p{Letter}\u0020-\u007E]-?/ug, [2, 4].includes(channel.type) ? " " : "-") return computeClean(c[ORIGINAL_NAME], channel.type);
.replace(/(^-|-$)/g, "") },
.replace(/-+/g, "-"); set(value: string) {
} c[ORIGINAL_NAME] = value;
},
});
return channel; return channel;
} },
}); });

View file

@ -225,8 +225,8 @@ export default definePlugin({
{ {
find: "ACTIVE_NOW_COLUMN)", find: "ACTIVE_NOW_COLUMN)",
replacement: { replacement: {
match: /(?<=__invalid_consentCard.{0,80})(\i),\{(?=\}\)\])/, match: /(__invalid_consentCard.{0,40}\()(\i),\{/,
replace: '"div",{children:$self.activeNowView($1())' replace: '$1"div",{children:$self.activeNowView($2())'
} }
}, },
// mutual friends list in user profile // mutual friends list in user profile

View file

@ -6,6 +6,7 @@
import "./style.css"; import "./style.css";
import { isPluginEnabled } from "@api/PluginManager";
import { definePluginSettings, migrateOldSettingToNewPlugin, migratePluginSetting, migratePluginSettings } from "@api/Settings"; import { definePluginSettings, migrateOldSettingToNewPlugin, migratePluginSetting, migratePluginSettings } from "@api/Settings";
import { Divider } from "@components/Divider"; import { Divider } from "@components/Divider";
import { HeadingSecondary } from "@components/Heading"; import { HeadingSecondary } from "@components/Heading";
@ -14,6 +15,7 @@ import { classNameFactory } from "@utils/css";
import { Devs, EquicordDevs } from "@utils/index"; import { Devs, EquicordDevs } from "@utils/index";
import { t, Translate } from "@utils/translation"; import { t, Translate } from "@utils/translation";
import definePlugin, { OptionType } from "@utils/types"; import definePlugin, { OptionType } from "@utils/types";
import { Alerts } from "@webpack/common";
migratePluginSettings("Declutter", "BetterUserArea", "Anammox"); migratePluginSettings("Declutter", "BetterUserArea", "Anammox");
migrateOldSettingToNewPlugin("Declutter", "removeClanTag", "GuildTagSettings", "hideTags"); migrateOldSettingToNewPlugin("Declutter", "removeClanTag", "GuildTagSettings", "hideTags");
@ -38,6 +40,13 @@ export const settings = definePluginSettings({
type: OptionType.COMPONENT, type: OptionType.COMPONENT,
component: () => SectionSeparator(t("equicord.declutter.headers.userProfile")), component: () => SectionSeparator(t("equicord.declutter.headers.userProfile")),
}, },
removeAvatarDecoration: {
type: OptionType.BOOLEAN,
description: "Remove avatar decorations.",
default: false,
disabled: () => isPluginEnabled("Decor"),
restartNeeded: true,
},
removeNameplate: { removeNameplate: {
type: OptionType.BOOLEAN, type: OptionType.BOOLEAN,
description: t("equicord.declutter.settings.removeNameplate"), description: t("equicord.declutter.settings.removeNameplate"),
@ -146,8 +155,29 @@ export default definePlugin({
description: t("equicord.declutter.description"), description: t("equicord.declutter.description"),
tags: ["Appearance", "Customisation"], tags: ["Appearance", "Customisation"],
authors: [EquicordDevs.Leon135, Devs.prism, Devs.Kyuuhachi], authors: [EquicordDevs.Leon135, Devs.prism, Devs.Kyuuhachi],
start() {
if (isPluginEnabled("Decor") && settings.store.removeAvatarDecoration) {
settings.store.removeAvatarDecoration = false;
Alerts.show({
title: "Declutter",
body: "Avatar decoration removal has been disabled to prevent conflicts with Decor plugin.",
confirmText: "OK",
// @ts-expect-error not typed
confirmVariant: "critical-primary"
});
}
},
settings, settings,
patches: [ patches: [
{
// Avatar decoration
find: "getAvatarDecorationURL:",
replacement: {
match: /(?<=function \i\(\i\){)(?=.{0,150}let{avatarDecoration)/,
replace: "$&return null;"
},
predicate: () => settings.store.removeAvatarDecoration && !isPluginEnabled("Decor"),
},
{ {
// Nameplate // Nameplate
find: ".MINI_PREVIEW,[", find: ".MINI_PREVIEW,[",

View file

@ -23,6 +23,7 @@ import { cancelCurrentUpload, getUploadState, isConfigured, subscribeUploadState
const cl = classNameFactory("vc-file-upload-"); const cl = classNameFactory("vc-file-upload-");
const { getUserMaxFileSize } = findByPropsLazy("getUserMaxFileSize"); const { getUserMaxFileSize } = findByPropsLazy("getUserMaxFileSize");
let uploadAddFilesInterceptor: ((event: unknown) => void) | null = null; let uploadAddFilesInterceptor: ((event: unknown) => void) | null = null;
let pasteEventListener: ((event: ClipboardEvent) => void) | null = null;
type UploadAddFilesEvent = { type UploadAddFilesEvent = {
type: string; type: string;
@ -38,7 +39,7 @@ type UploadAddFilesEvent = {
}; };
function shouldInterceptUploadFiles(files: readonly File[], payload: UploadAddFilesEvent): boolean { function shouldInterceptUploadFiles(files: readonly File[], payload: UploadAddFilesEvent): boolean {
if (!settings.store.interceptDiscordUploadOnlyOverLimit) return true; if (!settings.store.bypassDiscordUploadOnlyOverLimit) return true;
const directLimit = [payload.maxFileSize, payload.fileSizeLimit, payload.limits?.fileSize].find(limit => Number.isFinite(limit)) as number | undefined; const directLimit = [payload.maxFileSize, payload.fileSizeLimit, payload.limits?.fileSize].find(limit => Number.isFinite(limit)) as number | undefined;
const fallbackLimit = getUserMaxFileSize(UserStore.getCurrentUser()); const fallbackLimit = getUserMaxFileSize(UserStore.getCurrentUser());
@ -92,6 +93,18 @@ function interceptUploadAddFiles(event: unknown): void {
void uploadProvidedFiles(uniqueFiles); void uploadProvidedFiles(uniqueFiles);
} }
function handlePaste(event: ClipboardEvent) {
const files = Array.from(event.clipboardData?.files || []);
if (files.length === 0) return;
if (!settings.store.autoUploadPastedFiles || !isConfigured()) return;
event.preventDefault();
event.stopPropagation();
void uploadProvidedFiles(files);
}
const ProgressBarInner = () => { const ProgressBarInner = () => {
const [state, setState] = useState(getUploadState); const [state, setState] = useState(getUploadState);
@ -249,6 +262,9 @@ export default definePlugin({
uploadAddFilesInterceptor = event => interceptUploadAddFiles(event); uploadAddFilesInterceptor = event => interceptUploadAddFiles(event);
FluxDispatcher.addInterceptor(uploadAddFilesInterceptor); FluxDispatcher.addInterceptor(uploadAddFilesInterceptor);
pasteEventListener = event => handlePaste(event);
document.addEventListener("paste", pasteEventListener, true);
}, },
stop() { stop() {
if (!uploadAddFilesInterceptor) { if (!uploadAddFilesInterceptor) {
@ -261,6 +277,11 @@ export default definePlugin({
} }
uploadAddFilesInterceptor = null; uploadAddFilesInterceptor = null;
if (pasteEventListener) {
document.removeEventListener("paste", pasteEventListener, true);
pasteEventListener = null;
}
}, },
shouldBypassDiscordUploadSizeCheck(): boolean { shouldBypassDiscordUploadSizeCheck(): boolean {
return Boolean((settings.store as { interceptDiscordUpload?: boolean; }).interceptDiscordUpload) && isConfigured(); return Boolean((settings.store as { interceptDiscordUpload?: boolean; }).interceptDiscordUpload) && isConfigured();

View file

@ -172,15 +172,15 @@ export const settings = definePluginSettings({
default: false, default: false,
hidden: true hidden: true
}, },
interceptDiscordUpload: { bypassDiscordUpload: {
type: OptionType.BOOLEAN, type: OptionType.BOOLEAN,
description: "Intercept Discord uploads and use FileUpload instead.", description: "Bypass Discord uploads and use FileUpload instead.",
default: false, default: true,
hidden: true hidden: true
}, },
interceptDiscordUploadOnlyOverLimit: { bypassDiscordUploadOnlyOverLimit: {
type: OptionType.BOOLEAN, type: OptionType.BOOLEAN,
description: "Only intercept uploads that exceed Discord file size limit.", description: "Only use FileUpload if the file(s) exceed the file size limit.",
default: true, default: true,
hidden: true hidden: true
}, },
@ -239,6 +239,11 @@ export const settings = definePluginSettings({
default: true, default: true,
hidden: true hidden: true
}, },
autoUploadPastedFiles: {
type: OptionType.BOOLEAN,
description: "Automatically upload files from clipboard to image host when pasting in chat input.",
default: false
},
settingsComponent: { settingsComponent: {
type: OptionType.COMPONENT, type: OptionType.COMPONENT,
description: "Settings", description: "Settings",
@ -592,17 +597,24 @@ export function SettingsComponent() {
/> />
</SettingsSection> </SettingsSection>
<SettingsSection tag="label" name="Intercept Discord Upload Button" description="Use FileUpload when uploading through Discord's file picker" inlineSetting> <SettingsSection tag="label" name="Bypass Discord Upload Button" description="Use FileUpload when uploading through Discord's file picker" inlineSetting>
<Switch <Switch
checked={Boolean((store as { interceptDiscordUpload?: boolean; }).interceptDiscordUpload)} checked={Boolean((store as { bypassDiscordUpload?: boolean; }).bypassDiscordUpload)}
onChange={v => (store as { interceptDiscordUpload?: boolean; }).interceptDiscordUpload = v} onChange={v => (store as { bypassDiscordUpload?: boolean; }).bypassDiscordUpload = v}
/> />
</SettingsSection> </SettingsSection>
<SettingsSection tag="label" name="Only Intercept Over Discord File Size Limit" description="Use FileUpload only for files larger than your current Discord upload limit" inlineSetting> <SettingsSection tag="label" name="Auto Upload Pasted Files" description="Automatically upload files from clipboard to image host when pasting in chat input" inlineSetting>
<Switch <Switch
checked={store.interceptDiscordUploadOnlyOverLimit} checked={store.autoUploadPastedFiles}
onChange={v => store.interceptDiscordUploadOnlyOverLimit = v} onChange={v => store.autoUploadPastedFiles = v}
/>
</SettingsSection>
<SettingsSection tag="label" name="Respect Discord File Size Limit" description="Use FileUpload only for files larger than your current Discord upload limit" inlineSetting>
<Switch
checked={store.bypassDiscordUploadOnlyOverLimit}
onChange={v => store.bypassDiscordUploadOnlyOverLimit = v}
/> />
</SettingsSection> </SettingsSection>

View file

@ -7,18 +7,15 @@
import "./styles.css"; import "./styles.css";
import ErrorBoundary from "@components/ErrorBoundary"; import ErrorBoundary from "@components/ErrorBoundary";
import { Paginator, requirePaginator } from "@plugins/reviewDB/components/ReviewModal";
import { ModalCloseButton } from "@utils/modal"; import { ModalCloseButton } from "@utils/modal";
import * as t from "@vencord/discord-types";
import { Message } from "@vencord/discord-types"; import { Message } from "@vencord/discord-types";
import { DefaultExtractAndLoadChunksRegex, extractAndLoadChunksLazy, findComponentByCodeLazy, findCssClassesLazy } from "@webpack"; import { findCssClassesLazy } from "@webpack";
import { React, useRef, useState } from "@webpack/common"; import { React, useRef, useState } from "@webpack/common";
import { ComponentProps, MutableRefObject } from "react"; import { MutableRefObject } from "react";
import { jumper } from "./index"; import { jumper } from "./index";
const Paginator = findComponentByCodeLazy<ComponentProps<t.Paginator>>('rel:"prev",children:');
const requirePaginator = extractAndLoadChunksLazy(['name:"SearchResults"'], new RegExp(`${DefaultExtractAndLoadChunksRegex.source}.{0,30}?name:"SearchResults"`));
const containerStyles = findCssClassesLazy("containerBottom", "containerTop"); const containerStyles = findCssClassesLazy("containerBottom", "containerTop");
export default function ReplyNavigator({ replies }: { replies: Message[]; }) { export default function ReplyNavigator({ replies }: { replies: Message[]; }) {

View file

@ -14,7 +14,7 @@ import { t } from "@utils/translation";
import definePlugin, { OptionType } from "@utils/types"; import definePlugin, { OptionType } from "@utils/types";
import { User } from "@vencord/discord-types"; import { User } from "@vencord/discord-types";
import { findByCodeLazy } from "@webpack"; import { findByCodeLazy } from "@webpack";
import { React } from "@webpack/common"; import { React, UserProfileStore } from "@webpack/common";
import { ProfilePopoutComponent } from "./components/ProfilePopoutComponent"; import { ProfilePopoutComponent } from "./components/ProfilePopoutComponent";
import { ProfileTabComponent } from "./components/ProfileTabComponent"; import { ProfileTabComponent } from "./components/ProfileTabComponent";
@ -49,8 +49,8 @@ export default definePlugin({
{ {
find: "#{intl::USER_PROFILE_ACTIVITY}", find: "#{intl::USER_PROFILE_ACTIVITY}",
replacement: { replacement: {
match: /\.MUTUAL_GUILDS\}\)\)(?=,(\i))/, match: /(\i)\.id!==\i\?\.id&&\i&&\(.{0,300}\.MUTUAL_GUILDS\}\)\)(?=,(\i))/,
replace: '$&,$1.push({text:"GitHub",section:"GITHUB"})', replace: '$&,$self.shouldShowGitHub($1.id)&&$2.push({text:"GitHub",section:"GITHUB"})',
} }
}, },
// User Profile Modal v2 tab content // User Profile Modal v2 tab content
@ -62,6 +62,9 @@ export default definePlugin({
} }
} }
], ],
shouldShowGitHub(userId: string) {
return UserProfileStore.getUserProfile(userId)?.connectedAccounts?.some((c: any) => c.type === "github") ?? false;
},
renderProfileCollection: (props: { user: User; displayProfile?: any; }) => { renderProfileCollection: (props: { user: User; displayProfile?: any; }) => {
return ( return (
<ProfilePopoutComponent <ProfilePopoutComponent

View file

@ -8,6 +8,7 @@ import { Button } from "@components/Button";
import { Heading } from "@components/Heading"; import { Heading } from "@components/Heading";
import { SettingsTab, wrapTab } from "@components/settings"; import { SettingsTab, wrapTab } from "@components/settings";
import { TooltipContainer } from "@components/TooltipContainer"; import { TooltipContainer } from "@components/TooltipContainer";
import { iconsModule } from "@equicordplugins/_core/concatenatedModules";
import { debounce } from "@shared/debounce"; import { debounce } from "@shared/debounce";
import { Margins } from "@utils/margins"; import { Margins } from "@utils/margins";
import { classes } from "@utils/misc"; import { classes } from "@utils/misc";
@ -16,7 +17,6 @@ import { Icon } from "@vencord/discord-types";
import { Clickable, TextInput, useCallback, useEffect, useMemo, useState } from "@webpack/common"; import { Clickable, TextInput, useCallback, useEffect, useMemo, useState } from "@webpack/common";
import { IconsDef } from "../types"; import { IconsDef } from "../types";
import { IconsModule } from "../utils";
import { openIconModal } from "./Modals"; import { openIconModal } from "./Modals";
let cachedIcons: IconsDef | null = null; let cachedIcons: IconsDef | null = null;
@ -25,7 +25,7 @@ function getIcons(): IconsDef {
if (cachedIcons) return cachedIcons; if (cachedIcons) return cachedIcons;
cachedIcons = Object.fromEntries( cachedIcons = Object.fromEntries(
Object.entries(IconsModule).filter(([name, fn]) => Object.entries(iconsModule).filter(([name, fn]) =>
typeof fn === "function" && name.endsWith("Icon") typeof fn === "function" && name.endsWith("Icon")
) )
); );

View file

@ -24,15 +24,6 @@ export default definePlugin({
authors: [EquicordDevs.iamme], authors: [EquicordDevs.iamme],
dependencies: ["Settings"], dependencies: ["Settings"],
startAt: StartAt.WebpackReady, startAt: StartAt.WebpackReady,
patches: [
{
find: "AngleBracketsIcon",
replacement: {
match: /SectionDivider:\(\)=>\i,(?=.*?(\i),\{AIcon)/,
replace: "$&icons:()=>$1,"
}
}
],
toolboxActions: { toolboxActions: {
[t("equicord.iconViewer.ui.openIconsTab")]() { [t("equicord.iconViewer.ui.openIconsTab")]() {
SettingsRouter.openUserSettings("equicord_icon_viewer_panel"); SettingsRouter.openUserSettings("equicord_icon_viewer_panel");

View file

@ -4,7 +4,6 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
*/ */
import { Icon } from "@vencord/discord-types";
import { findByPropsLazy, waitFor } from "@webpack"; import { findByPropsLazy, waitFor } from "@webpack";
import { CssColorData, IconSize } from "./types"; import { CssColorData, IconSize } from "./types";
@ -12,8 +11,6 @@ import { CssColorData, IconSize } from "./types";
let colorKeys: string[] = []; let colorKeys: string[] = [];
const Colors = findByPropsLazy("colors", "layout"); const Colors = findByPropsLazy("colors", "layout");
export const IconsModule = findByPropsLazy("AngleBracketsIcon", "StaffBadgeIcon") as Record<string, Icon>;
export const iconSizesInPx: Record<string, number> = findByPropsLazy("md", "lg", "xxs"); export const iconSizesInPx: Record<string, number> = findByPropsLazy("md", "lg", "xxs");
export const iconSizes: IconSize[] = ["xxs", "xs", "sm", "md", "lg"]; export const iconSizes: IconSize[] = ["xxs", "xs", "sm", "md", "lg"];

View file

@ -1,595 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import "./styles.css";
import { NavContextMenuPatchCallback } from "@api/ContextMenu";
import { HeaderBarButton } from "@api/HeaderBar";
import ErrorBoundary from "@components/ErrorBoundary";
import { Devs, EquicordDevs } from "@utils/constants";
import { classNameFactory } from "@utils/css";
import { getCurrentChannel } from "@utils/discord";
import { t } from "@utils/translation";
import definePlugin from "@utils/types";
import { Channel, Guild, User } from "@vencord/discord-types";
import { ChannelType } from "@vencord/discord-types/enums";
import {
DefaultExtractAndLoadChunksRegex,
extractAndLoadChunksLazy,
findByPropsLazy,
findComponentByCodeLazy,
findCssClassesLazy,
findStoreLazy,
} from "@webpack";
import {
ChannelActionCreators,
ChannelRouter,
ChannelStore,
FluxDispatcher,
GuildStore,
Menu,
MessageActions,
MessageStore,
PermissionsBits,
PermissionStore,
PopoutActions,
PopoutWindowStore,
RelationshipStore,
SelectedChannelStore,
SelectedGuildStore,
useCallback,
useEffect,
useLayoutEffect,
UserStore,
useState,
useStateFromStores,
} from "@webpack/common";
import { getOpenPopoutWindowKeys, getPersistedPopoutChannelIds, getPopoutWindowKey, isPopoutWindowOpen, settings, SidebarStore, syncPersistedPopoutWindows } from "./store";
const cl = classNameFactory("vc-sidebar-chat-");
const HeaderBar = findComponentByCodeLazy("toolbarClassName:", "}),onDoubleClick:");
const ForumView = findComponentByCodeLazy("sidebarState");
const ArrowsLeftRightIcon = ({ color, ...rest }) => {
return (
<svg
aria-hidden="true"
role="img"
xmlns="http://www.w3.org/2000/svg"
fill={color}
viewBox="0 0 24 24"
{...rest}>
<path d="M2.3 7.7a1 1 0 0 1 0-1.4l4-4a1 1 0 0 1 1.4 1.4L5.42 6H21a1 1 0 1 1 0 2H5.41l2.3 2.3a1 1 0 1 1-1.42 1.4l-4-4ZM17.7 21.7l4-4a1 1 0 0 0 0-1.4l-4-4a1 1 0 0 0-1.4 1.4l2.29 2.3H3a1 1 0 1 0 0 2h15.59l-2.3 2.3a1 1 0 0 0 1.42 1.4Z" />
</svg>
);
};
const WindowLaunchIcon = findComponentByCodeLazy("1-1h6a1 1 0 1 0 0-2H5Z");
const XSmallIcon = findComponentByCodeLazy("1.4L12 13.42l5.3 5.3Z");
const Chat = findComponentByCodeLazy("filterAfterTimestamp:", "chatInputType");
const Resize = findComponentByCodeLazy("sidebarType:", "RESIZE_HANDLE_WIDTH)");
const ChannelHeader = findComponentByCodeLazy("`channel-${");
const PopoutWindow = findComponentByCodeLazy("Missing guestWindow reference");
const FullChannelView = findComponentByCodeLazy("showFollowButton:");
const WanderingCubesLoading = findComponentByCodeLazy('="wanderingCubes"');
const ChatInputTypes = findByPropsLazy("FORM", "NORMAL");
const Sidebars = findByPropsLazy("ThreadSidebar", "MessageRequestSidebar");
const ChatClasses = findCssClassesLazy("threadSidebarOpen", "loader");
const ChannelSectionStore = findStoreLazy("ChannelSectionStore");
const requireForumView = extractAndLoadChunksLazy(
["Missing channel in Channel.renderHeaderToolbar"],
new RegExp(DefaultExtractAndLoadChunksRegex.source + '.{1,150}name:"ForumChannel"')
);
function getChannelTitle(channel: Channel | null | undefined) {
if (!channel) return "Chat";
if (channel.isPrivate()) {
const recipientId = channel.getRecipientId?.();
if (!channel.name && recipientId) {
const user = UserStore.getUser(recipientId);
if (user) {
return RelationshipStore.getNickname(recipientId) || user.globalName || user.username || "DM";
}
}
return channel.name || "DM";
}
return channel.name || "Chat";
}
function canOpenPopout(channel: Channel) {
if (channel.isPrivate()) return true;
return !channel.isCategory() && !channel.isDirectory() && !channel.isVocal();
}
function getPopoutMenuLabel(channelId: string) {
return isPopoutWindowOpen(channelId) ? "Close popout chat" : "Popout chat";
}
let restorePersistedPopoutsInterval: number | null = null;
let restoringPersistedPopouts = false;
function clearPersistedPopoutRestoreLoop() {
restoringPersistedPopouts = false;
if (restorePersistedPopoutsInterval !== null) {
window.clearInterval(restorePersistedPopoutsInterval);
restorePersistedPopoutsInterval = null;
}
}
async function waitForChannel(channelId: string, timeoutMs = 2500) {
const startedAt = Date.now();
while (Date.now() - startedAt <= timeoutMs) {
const channel = ChannelStore.getChannel(channelId);
if (channel) return channel;
await new Promise(resolve => setTimeout(resolve, 80));
}
return null;
}
async function waitForDmChannel(userId: string, timeoutMs = 2500) {
const startedAt = Date.now();
while (Date.now() - startedAt <= timeoutMs) {
const channelId = ChannelStore.getDMFromUserId?.(userId);
if (channelId) return channelId;
await new Promise(resolve => setTimeout(resolve, 80));
}
return null;
}
async function openPopoutFromUserMenu(userId: string) {
try {
const channelId = await Promise.resolve(ChannelActionCreators.getOrEnsurePrivateChannel(userId));
if (!channelId) return;
const channel = await waitForChannel(channelId);
if (channel) openPopout(channel.id);
return;
} catch {
const fallbackChannelId = await waitForDmChannel(userId);
if (!fallbackChannelId) return;
const channel = await waitForChannel(fallbackChannelId);
if (channel) openPopout(channel.id);
}
}
function closePopout(channelId: string, syncPersistence = true) {
const windowKey = getPopoutWindowKey(channelId);
PopoutActions.close(windowKey);
if (syncPersistence && !restoringPersistedPopouts) {
syncPersistedPopoutWindows();
}
}
function openPopout(channelId: string, syncPersistence = true) {
const channel = ChannelStore.getChannel(channelId);
if (!channel || !canOpenPopout(channel)) return;
const windowKey = getPopoutWindowKey(channelId);
if (isPopoutWindowOpen(channelId)) {
closePopout(channelId, syncPersistence);
return;
}
const title = getChannelTitle(channel);
PopoutActions.open(
windowKey,
() => <RenderPopout channel={channel} name={title} windowKey={windowKey} />,
{
defaultWidth: 854,
defaultHeight: 480,
}
);
PopoutActions.setAlwaysOnTop(windowKey, settings.store.popoutAlwaysOnTop);
if (syncPersistence && !restoringPersistedPopouts) {
syncPersistedPopoutWindows();
}
}
function restorePersistedPopouts() {
if (!settings.store.persistPopoutWindows) return;
clearPersistedPopoutRestoreLoop();
const pendingRestoreIds = new Set(getPersistedPopoutChannelIds());
if (pendingRestoreIds.size === 0) return;
restoringPersistedPopouts = true;
const attemptRestore = () => {
for (const channelId of pendingRestoreIds) {
const channel = ChannelStore.getChannel(channelId);
if (!channel || !canOpenPopout(channel)) continue;
pendingRestoreIds.delete(channelId);
openPopout(channelId, false);
}
if (pendingRestoreIds.size === 0) {
clearPersistedPopoutRestoreLoop();
syncPersistedPopoutWindows();
}
};
attemptRestore();
if (pendingRestoreIds.size > 0) {
restorePersistedPopoutsInterval = window.setInterval(attemptRestore, 250);
}
}
const createSidebarChatContextMenuItem = (id: string, guildId: string | null) => {
return (
<Menu.MenuItem
id={`vc-sidebar-chat-${id}`}
label={"Open Sidebar Chat"}
action={() => {
FluxDispatcher.dispatch({
// @ts-ignore
type: "VC_SIDEBAR_CHAT_NEW",
guildId,
id,
});
}}
/>
);
};
const createPopoutChatContextMenuItem = (id: string, label: string, action: () => void | Promise<void>) => {
return (
<Menu.MenuItem
id={`vc-sidebar-chat-popout-${id}`}
label={label}
action={() => {
void action();
}}
/>
);
};
const UserContextPatch: NavContextMenuPatchCallback = (children, args: { user: User; }) => {
const checks = [
args.user,
args.user.id !== UserStore.getCurrentUser().id,
];
if (checks.some(check => !check)) return;
const channelId = ChannelStore.getDMFromUserId?.(args.user.id) ?? null;
const isOpen = channelId ? isPopoutWindowOpen(channelId) : false;
children.push(createSidebarChatContextMenuItem(args.user.id, null));
children.push(createPopoutChatContextMenuItem(
args.user.id,
isOpen ? "Close popout chat" : "Popout chat",
() => {
if (channelId && isOpen) {
closePopout(channelId);
return;
}
return openPopoutFromUserMenu(args.user.id);
}
));
};
const ChannelContextPatch: NavContextMenuPatchCallback = (children, args: { channel: Channel; }) => {
const checks = [
args.channel,
args.channel.type !== ChannelType.GUILD_CATEGORY,
PermissionStore.can(PermissionsBits.VIEW_CHANNEL, args.channel) || args.channel.type === ChannelType.GROUP_DM,
];
if (checks.some(check => !check)) return;
children.push(createSidebarChatContextMenuItem(args.channel.id, args.channel.guild_id));
children.push(createPopoutChatContextMenuItem(
args.channel.id,
getPopoutMenuLabel(args.channel.id),
() => openPopout(args.channel.id)
));
};
export default definePlugin({
name: "SidebarChat",
authors: [Devs.Joona, EquicordDevs.justjxke],
description: t("equicord.sidebarChat.description"),
tags: ["Appearance", "Chat", "Servers"],
dependencies: ["HeaderBarAPI"],
patches: [
{
find: 'case"pendingFriends":',
group: true,
replacement: [
{
match: /ChannelRenderer"\),/,
replace: "$&vc_SidebarChat=$self.renderSidebar(),"
},
{
match: /return(\(0,\i\.jsxs?\)\(\i,{}\))}/,
replace: "return [$1, vc_SidebarChat]}"
},
{
match: /(?<=guild_products.{0,1600})(case \i\.\i.{0,50}return)(.+?\}\));(?=.+?params\.messageId)/g,
replace: "$1[$2, vc_SidebarChat];",
predicate: () => settings.store.patchCommunity,
},
{
match: /(case \i\.\i\.GAME_SERVERS:.{0,50}\.CHANNEL.{0,25}return)(.*?);/,
replace: "$1[$2, vc_SidebarChat];",
predicate: () => settings.store.patchCommunity,
},
],
},
],
headerBarButton: {
icon: WindowLaunchIcon,
render: () => (
<>
<PopoutPersistenceSync />
<WrappedPopoutHeaderButton />
</>
)
},
stop() {
clearPersistedPopoutRestoreLoop();
syncPersistedPopoutWindows();
for (const windowKey of getOpenPopoutWindowKeys()) {
PopoutActions.close(windowKey);
}
},
async start() {
restorePersistedPopouts();
},
settings,
contextMenus: {
"user-context": UserContextPatch,
"channel-context": ChannelContextPatch,
"thread-context": ChannelContextPatch,
"gdm-context": ChannelContextPatch,
},
toolboxActions: {
"Open Previous Chat"() {
FluxDispatcher.dispatch({
// @ts-ignore
type: "VC_SIDEBAR_CHAT_PREVIOUS",
});
}
},
renderSidebar() {
const { guild, channel /* width*/ } = useStateFromStores(
[SidebarStore, GuildStore, ChannelStore], () => {
const { channelId, guildId } = SidebarStore.getState();
return {
guild: GuildStore.getGuild(guildId),
channel: ChannelStore.getChannel(channelId)
};
}, []
);
const [channelSidebar, guildSidebar] = useStateFromStores(
[ChannelSectionStore, SelectedChannelStore, ChannelStore], () => {
const currentChannelId = SelectedChannelStore.getChannelId();
const currentGuildId = SelectedGuildStore.getGuildId();
return [
ChannelSectionStore.getSidebarState(currentChannelId),
ChannelSectionStore.getGuildSidebarState(currentGuildId),
];
}, []
);
useEffect(() => {
if (!channel?.id || MessageStore.getLastMessage(channel.id)) return;
MessageActions.fetchMessages({
channelId: channel.id,
limit: 50,
});
}, [channel?.id]);
const [width, setWidth] = useState(window.innerWidth);
useLayoutEffect(() => {
const handleResize = () => setWidth(window.innerWidth);
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
const [View, setViewComponent] = useState<React.ReactNode>(null);
useEffect(() => {
if (!channel) return;
if (channel.isForumLikeChannel()) {
requireForumView().then(() => {
setViewComponent(
<ForumView
channel={channel}
guild={guild}
sidebarState={null}
/>
);
});
setViewComponent(
<div className={ChatClasses.loader}>
<WanderingCubesLoading />
</div>
);
} else {
setViewComponent(
<Chat
channel={channel}
guild={guild}
chatInputType={ChatInputTypes.SIDEBAR}
/>
);
}
}, [channel]);
if (!channel || channelSidebar || guildSidebar) return null;
return (
<ErrorBoundary noop>
<Resize
sidebarType={Sidebars.MessageRequestSidebar}
maxWidth={~~(width * 0.31)/* width - 690*/}
>
<Header channel={channel} guild={guild} />
{View}
</Resize>
</ErrorBoundary>
);
},
});
const Header = ({ guild, channel }: { guild: Guild; channel: Channel; }) => {
const recipientId = channel.isPrivate() ? channel.getRecipientId() as string : null;
const name = useStateFromStores([UserStore, RelationshipStore], () => getChannelTitle(channel), [channel.id, channel.name]);
const parentChannel = useStateFromStores(
[ChannelStore], () => ChannelStore.getChannel(channel?.parent_id),
[channel?.parent_id]
);
const closeSidebar = () => FluxDispatcher.dispatch({ type: "VC_SIDEBAR_CHAT_CLOSE", });
const isPopoutOpen = useStateFromStores(
[PopoutWindowStore], () => isPopoutWindowOpen(channel.id),
[channel.id]
);
const openPopoutClick = useCallback(() => openPopout(channel.id), [channel.id]);
const switchChannels = useCallback(() => {
const mainChannel = getCurrentChannel()!;
FluxDispatcher.dispatch({
// @ts-ignore
type: "VC_SIDEBAR_CHAT_NEW",
guildId: mainChannel.guild_id,
id: mainChannel.id,
});
ChannelRouter.transitionToChannel(channel.id);
}, [channel.id]);
return (
<HeaderBar
toolbar={
<>
<HeaderBarButton icon={ArrowsLeftRightIcon} tooltip="Switch channels" onClick={switchChannels} />
<HeaderBarButton
key={`${channel.id}-${isPopoutOpen ? "open" : "closed"}`}
icon={isPopoutOpen ? XSmallIcon : WindowLaunchIcon}
tooltip={isPopoutOpen ? "Close popout chat" : "Popout chat"}
selected={isPopoutOpen}
onClick={openPopoutClick}
/>
<HeaderBarButton icon={XSmallIcon} tooltip="Close Sidebar Chat" onClick={closeSidebar} />
</>
}
>
<ChannelHeader
channel={channel}
channelName={name}
guild={guild}
parentChannel={parentChannel}
/>
</HeaderBar>
);
};
const RenderPopout = ErrorBoundary.wrap(({ channel, name, windowKey }: { channel: Channel; name: string; windowKey: string; }) => {
// Copy from an unexported function of the one they use in the experiment
// right click a channel and search withTitleBar:!0,windowKey
useEffect(() => {
if (!channel?.id || MessageStore.getLastMessage(channel.id)) return;
MessageActions.fetchMessages({
channelId: channel.id,
limit: 50,
});
}, [channel?.id]);
return (
<PopoutWindow
withTitleBar
windowKey={windowKey}
title={name || "Equicord"}
channelId={channel.id}
>
<div className={cl("window")}>
<FullChannelView providedChannel={channel} />
</div>
</PopoutWindow>
);
});
function PopoutHeaderButton() {
const channelState = useStateFromStores(
[SelectedChannelStore, ChannelStore, PopoutWindowStore],
() => {
const channelId = SelectedChannelStore.getChannelId();
const channel = channelId ? ChannelStore.getChannel(channelId) : null;
return {
channel,
isOpen: channel ? isPopoutWindowOpen(channel.id) : false,
label: getChannelTitle(channel)
};
},
[]
);
if (!channelState.channel || !canOpenPopout(channelState.channel)) return null;
const { channel, isOpen, label } = channelState;
return (
<HeaderBarButton
key={`${channel.id}-${isOpen ? "open" : "closed"}`}
icon={isOpen ? XSmallIcon : WindowLaunchIcon}
tooltip={isOpen ? "Close popout chat" : `Popout chat for ${label}`}
aria-label="Popout chat"
selected={isOpen}
onClick={() => openPopout(channel.id)}
/>
);
}
const WrappedPopoutHeaderButton = ErrorBoundary.wrap(PopoutHeaderButton, { noop: true });
function PopoutPersistenceSync() {
const openWindowKeySignature = useStateFromStores(
[PopoutWindowStore],
() => getOpenPopoutWindowKeys().join("|"),
[]
);
useEffect(() => {
if (restoringPersistedPopouts) return;
syncPersistedPopoutWindows();
}, [openWindowKeySignature]);
return null;
}

View file

@ -1,155 +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 { proxyLazy } from "@utils/lazy";
import { OptionType } from "@utils/types";
import { Flux as TFlux } from "@vencord/discord-types";
import { ChannelActionCreators, Flux as FluxWP, FluxDispatcher, PopoutActions, PopoutWindowStore } from "@webpack/common";
interface IFlux extends TFlux {
PersistedStore: TFlux["Store"];
}
export const settings = definePluginSettings({
persistSidebar: {
type: OptionType.BOOLEAN,
description: "Keep the sidebar chat open across Discord restarts",
default: true,
},
persistPopoutWindows: {
type: OptionType.BOOLEAN,
description: "Restore open popout chats after Discord restarts.",
default: true,
onChange: value => {
if (!value) {
settings.store.persistedPopoutWindowIds = [];
return;
}
syncPersistedPopoutWindows();
}
},
persistedPopoutWindowIds: {
type: OptionType.CUSTOM,
description: "Persisted popout chat channel IDs.",
default: [] as string[],
hidden: true
},
popoutAlwaysOnTop: {
type: OptionType.BOOLEAN,
description: "Keep popout chat windows above all others.",
default: true,
onChange: value => {
setAlwaysOnTopForOpenPopouts(value);
}
},
patchCommunity: {
type: OptionType.BOOLEAN,
description: "Patch things like the Channel Browser or Members tab that community servers have.",
default: true,
restartNeeded: true,
}
});
export const SidebarStore = proxyLazy(() => {
const current = {
guildId: "",
channelId: "",
width: 0
};
let previous = { ...current };
class SidebarStore extends (FluxWP as IFlux).PersistedStore {
static persistKey = "SidebarStore";
// @ts-ignore
initialize(previousState: { guildId?: string; channelId?: string; width?: number; } | undefined) {
if (!settings.store.persistSidebar || !previousState) return;
const { guildId, channelId, width } = previousState;
current.guildId = guildId || "";
current.channelId = channelId || "";
current.width = width || 0;
}
getState() {
return current;
}
}
const store = new SidebarStore(FluxDispatcher, {
// @ts-ignore
async VC_SIDEBAR_CHAT_NEW({ guildId: newGId, id }: { guildId: string | null; id: string; }) {
previous = { ...current };
current.guildId = newGId || "";
if (current.guildId) {
current.channelId = id;
store.emitChange();
return;
}
current.channelId = await ChannelActionCreators.getOrEnsurePrivateChannel(id);
store.emitChange();
},
VC_SIDEBAR_CHAT_PREVIOUS() {
if (previous.channelId) {
current.guildId = previous.guildId;
current.channelId = previous.channelId;
}
store.emitChange();
},
VC_SIDEBAR_CHAT_CLOSE() {
previous = { ...current };
current.guildId = "";
current.channelId = "";
store.emitChange();
},
});
return store;
});
const WINDOW_PREFIX = "DISCORD_VC_SC-";
export function getPopoutWindowKey(channelId: string) {
return `${WINDOW_PREFIX}${channelId}`;
}
export function getOpenPopoutWindowKeys() {
return PopoutWindowStore.getWindowKeys().filter(key => key.startsWith(WINDOW_PREFIX));
}
export function isPopoutWindowOpen(channelId: string) {
return PopoutWindowStore.getWindowOpen(getPopoutWindowKey(channelId));
}
export function getPersistedPopoutChannelIds() {
return settings.store.persistedPopoutWindowIds ?? [];
}
export function getOpenPopoutChannelIds() {
return getOpenPopoutWindowKeys().map(key => key.slice(WINDOW_PREFIX.length));
}
export function syncPersistedPopoutWindows() {
if (!settings.store.persistPopoutWindows) {
settings.store.persistedPopoutWindowIds = [];
return;
}
settings.store.persistedPopoutWindowIds = getOpenPopoutChannelIds();
}
export function setAlwaysOnTopForOpenPopouts(value: boolean) {
for (const windowKey of getOpenPopoutWindowKeys()) {
PopoutActions.setAlwaysOnTop(windowKey, value);
}
}

View file

@ -1,19 +0,0 @@
[class*="content"] > [class*="page"] > * {
min-width: 0;
}
.vc-sidebar-chat-popout {
align-items: stretch;
background-color: var(--background-primary);
}
.vc-sidebar-chat-window {
display: flex;
flex: 1;
min-height: 0;
min-width: 0;
overflow: hidden;
width: 100%;
background-color: var(--background-primary);
color: var(--text-default);
}

View file

@ -1,3 +0,0 @@
[class*="outerContainer"] [class*="container"] [class*="actionBar"] {
gap: 0;
}

View file

@ -23,47 +23,31 @@ import { getUserSettingLazy } from "@api/UserSettings";
import ErrorBoundary from "@components/ErrorBoundary"; import ErrorBoundary from "@components/ErrorBoundary";
import { EquicordDevs } from "@utils/constants"; import { EquicordDevs } from "@utils/constants";
import { proxyLazy } from "@utils/lazy"; import { proxyLazy } from "@utils/lazy";
import { classes } from "@utils/misc";
import { openModalLazy } from "@utils/modal"; import { openModalLazy } from "@utils/modal";
import { useForceUpdater } from "@utils/react"; import { useForceUpdater } from "@utils/react";
import { t } from "@utils/translation"; import { t } from "@utils/translation";
import definePlugin, { OptionType, StartAt } from "@utils/types"; import definePlugin, { OptionType, StartAt } from "@utils/types";
import { extractAndLoadChunksLazy, findComponentByCodeLazy, findModuleId, wreq } from "@webpack"; import { extractAndLoadChunksLazy, findComponentByCodeLazy, findModuleId, wreq } from "@webpack";
import { Clickable, Menu, OverridePremiumTypeStore, Toasts, useState } from "@webpack/common"; import { Menu, OverridePremiumTypeStore, Toasts } from "@webpack/common";
import managedStyle from "./fixActionBar.css?managed";
const settings = definePluginSettings({
StatusPresets: {
type: OptionType.COMPONENT,
description: "Status Presets",
component: () => <></>,
default: {}
}
});
interface Emoji { interface Emoji {
animated: boolean; animated: boolean;
id: bigint | null; id: string | null;
name: string; name: string;
} }
const PlusSmallIcon = findComponentByCodeLazy("0-2h-5V6Z");
interface DiscordStatus { interface DiscordStatus {
emojiInfo: Emoji | null; emojiInfo: Emoji | null;
text: string; text: string;
clearAfter: "TODAY" | number | null; clearAfter: "TODAY" | number | null;
status: "online" | "dnd" | "idle" | "invisible";
} }
// TODO: find clearCustomStatusHint original css/svg or replace const PMenu = findComponentByCodeLazy("#{intl::MORE_OPTIONS}", ",renderSubmenu:");
let PMenu;
const EmojiComponent = findComponentByCodeLazy(/\.translateSurrogatesToInlineEmoji\(\i\.name\);/); const EmojiComponent = findComponentByCodeLazy(/\.translateSurrogatesToInlineEmoji\(\i\.name\);/);
const CustomStatusSettings = getUserSettingLazy("status", "customStatus")!; const CustomStatusSettings = getUserSettingLazy("status", "customStatus")!;
const StatusModule = proxyLazy(() => { const StatusModule = proxyLazy(() => {
const id = findModuleId(".customStatusInputTitle,"); const id = findModuleId("#{intl::SAVE}", '"custom-status-input"', '"Invalid custom status clear timeout"),');
return wreq(Number(id)); return wreq(Number(id));
}); });
@ -78,7 +62,6 @@ const openCustomStatusModalLazy = () => openModalLazy(async () => {
function getExpirationMs(expiration: "TODAY" | number) { function getExpirationMs(expiration: "TODAY" | number) {
if (expiration !== "TODAY") return Date.now() + expiration; if (expiration !== "TODAY") return Date.now() + expiration;
const now = new Date(); const now = new Date();
return new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1).getTime(); return new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1).getTime();
} }
@ -93,69 +76,64 @@ function setStatus(status: DiscordStatus) {
}); });
} }
const ClearStatusButton = () => <Clickable onClick={e => { e.stopPropagation(); CustomStatusSettings?.updateSetting(null); }}><PlusSmallIcon className={"vc-sp-icon"} /></Clickable>;
function StatusIcon({ isHovering, status }: { isHovering: boolean; status: DiscordStatus; }) {
return <div>{isHovering ?
<PlusSmallIcon className={"vc-sp-icon"} />
: (status.emojiInfo != null ? <EmojiComponent emoji={status.emojiInfo} animate={false} hideTooltip={false} /> : <div />)}</div>;
}
const RenderStatusMenuItem = ({ status, update, disabled }: { status: DiscordStatus; update: () => void; disabled: boolean; }) => {
const [isHovering, setIsHovering] = useState(false);
const handleMouseOver = () => {
setIsHovering(true);
};
const handleMouseOut = () => {
setIsHovering(false);
};
return <div className={classes("vc-sp-item", disabled ? "vc-sp-disabled" : null)}
onMouseOver={handleMouseOver}
onMouseOut={handleMouseOut}>
<Clickable
onClick={e => {
e.stopPropagation();
settings.store.StatusPresets[status.text] = undefined; // setting to undefined to remove it.
update();
}}><StatusIcon isHovering={isHovering} status={status} /></Clickable>
<div style={{ marginLeft: "2px" }}>{status.text}</div>
</div >;
};
const StatusSubMenuComponent = () => { const StatusSubMenuComponent = () => {
const premiumType = OverridePremiumTypeStore.getState().premiumTypeActual ?? 0; const premiumType = OverridePremiumTypeStore.getState().premiumTypeActual ?? 0;
const update = useForceUpdater(); const update = useForceUpdater();
return <Menu.Menu navId="sp-custom-status-submenu" onClose={() => { }}>
{Object.entries((settings.store.StatusPresets as { [k: string]: DiscordStatus | undefined; })).map(([index, status]) => status != null ? <Menu.MenuItem return (
key={"status-presets-" + index} <Menu.Menu navId="sp-custom-status-submenu" onClose={() => { }}>
id={"status-presets-" + index} {Object.entries((settings.store.StatusPresets as { [k: string]: DiscordStatus | undefined; })).map(([index, status]) =>
label={status.status} status != null ? (
action={() => (status.emojiInfo?.id != null && premiumType > 0 || status.emojiInfo?.id == null) && setStatus(status)} <Menu.MenuItem
render={() => <RenderStatusMenuItem key={"status-presets-" + index}
status={status} id={"status-presets-" + index}
update={update} label={status.text}
disabled={status.emojiInfo?.id != null && premiumType === 0} action={() => (status.emojiInfo?.id == null || premiumType > 0) && setStatus(status)}
/>} icon={status.emojiInfo != null
/> : null)} ? () => <EmojiComponent emoji={status.emojiInfo} animate={false} hideTooltip={false} />
</Menu.Menu>; : undefined
}
disabled={status.emojiInfo?.id != null && premiumType === 0}
>
<Menu.MenuItem
id={"status-presets-delete-" + index}
label="Delete Preset"
action={() => {
const newPresets = JSON.parse(JSON.stringify(settings.store.StatusPresets));
delete newPresets[status.text];
settings.store.StatusPresets = newPresets;
update();
}}
/>
</Menu.MenuItem>
) : null
)}
</Menu.Menu>
);
}; };
const settings = definePluginSettings({
StatusPresets: {
type: OptionType.COMPONENT,
description: "Status Presets",
component: () => <></>,
default: {}
}
});
export default definePlugin({ export default definePlugin({
name: "StatusPresets", name: "StatusPresets",
description: t("equicord.statusPresets.description"), description: t("equicord.statusPresets.description"),
tags: ["Activity", "Utility"], tags: ["Activity", "Utility"],
authors: [EquicordDevs.iamme], authors: [EquicordDevs.iamme],
settings: settings, settings,
dependencies: ["UserSettingsAPI"], dependencies: ["UserSettingsAPI"],
managedStyle,
patches: [ patches: [
{ {
find: '="custom-status-input";', find: '="custom-status-input";',
replacement: { replacement: {
match: /(?<=(\i)\?\.state\?\?.*?)\{text:\i\.\i\.\i\(\i\.\i#{intl::SAVE}\)/, match: /(?<=\[(\i).{0,6}\.useState\(\i\?\.state\?\?""\),\[(\i).{0,25}\?\?null\),\[(\i).*?)\{text:\i\.\i\.\i\(\i\.\i#{intl::SAVE}\)/,
replace: "$self.renderRememberButton($1),$&" replace: "$self.renderRememberButton({text:$1,emojiInfo:$2,clearAfter:$3}),$&"
} }
}, },
{ {
@ -168,14 +146,12 @@ export default definePlugin({
{ {
find: "#{intl::MORE_OPTIONS}),...", find: "#{intl::MORE_OPTIONS}),...",
replacement: { replacement: {
match: /(?<=targetElementRef:\i,spacing:.{0,150}className:\i\.\i,children:(\i)\(\)\}\)\})/, match: /\i:\(\)=>\i,(?=.*?function (\i).{0,100}renderSubmenu:\i,ref:)/,
replace: "$self.PMenu=$1;" replace: "$&PMenu:()=>$1,"
} }
}, },
], ],
set PMenu(value: any) {
PMenu = value;
},
render() { render() {
const status = CustomStatusSettings.getSetting(); const status = CustomStatusSettings.getSetting();
return ( return (
@ -186,9 +162,7 @@ export default definePlugin({
id="sp-custom/presets-status" id="sp-custom/presets-status"
action="PRESS_SET_STATUS" action="PRESS_SET_STATUS"
onClick={openCustomStatusModalLazy} onClick={openCustomStatusModalLazy}
icon={ icon={() => <div />}
() => <div />
}
label="Set Custom Status" label="Set Custom Status"
renderSubmenu={StatusSubMenuComponent} renderSubmenu={StatusSubMenuComponent}
/> />
@ -197,19 +171,13 @@ export default definePlugin({
id="sp-edit/presets-status" id="sp-edit/presets-status"
action="PRESS_EDIT_CUSTOM_STATUS" action="PRESS_EDIT_CUSTOM_STATUS"
onClick={openCustomStatusModalLazy} onClick={openCustomStatusModalLazy}
hint={<ClearStatusButton />} icon={() => status.emoji != null ? (
icon={ <EmojiComponent emoji={status.emoji} animate={false} hideTooltip={false} />
() => status.emoji != null ? ( ) : null}
<EmojiComponent
emoji={status.emoji}
animate={false}
hideTooltip={false}
/>
) : null
}
label="Edit Custom Status" label="Edit Custom Status"
renderSubmenu={StatusSubMenuComponent} renderSubmenu={StatusSubMenuComponent}
/>} />
}
</ErrorBoundary> </ErrorBoundary>
); );
}, },

View file

@ -2,12 +2,23 @@
opacity: 0.5; opacity: 0.5;
} }
.vc-sp-item:hover {
background-color: var(--menu-item-default-hover-bg) !important;
color: var(--white) !important;
}
.vc-sp-icon { .vc-sp-icon {
transform: rotate(45deg); transform: rotate(45deg);
margin-top: 4px; width: 16px;
height: 16px;
color: var(--interactive-normal);
opacity: 0;
} }
[data-menu-item="true"]:hover .vc-sp-icon {
opacity: 1;
}
[data-menu-item="true"]:hover .vc-sp-icon:hover {
color: var(--status-danger);
}
[data-menu-item="true"][id^="sp-custom-status-submenu"] img.emoji {
width: 16px !important;
height: 16px !important;
}

View file

@ -0,0 +1,385 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { Button } from "@components/Button";
import { CodeBlock } from "@components/CodeBlock";
import ErrorBoundary from "@components/ErrorBoundary";
import { Flex } from "@components/Flex";
import { Heading } from "@components/Heading";
import { iconsModule } from "@equicordplugins/_core/concatenatedModules";
import { classNameFactory } from "@utils/css";
import { copyWithToast } from "@utils/discord";
import { closeModal, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalRoot, ModalSize, openModal } from "@utils/modal";
import { useEffect, useMemo, useRef, useState } from "@webpack/common";
import {
createImageObjectUrl,
getAttachmentFileName,
getAttachmentUrl,
getCachedZip,
getCodeLanguage,
isZipFile,
makeDownload,
MAX_ENTRIES,
readTextEntry,
ZipEntry,
ZipPreviewAttachmentProps,
ZipPreviewCacheState
} from "./utils";
export const cl = classNameFactory("vc-zip-preview-");
interface VisibleEntries {
directories: string[];
files: ZipEntry[];
}
function getVisibleEntries(entries: ZipEntry[], currentPath: string): VisibleEntries {
const dirs = new Set<string>();
const files: ZipEntry[] = [];
const prefix = currentPath ? `${currentPath}/` : "";
for (const entry of entries) {
if (!entry.path.startsWith(prefix)) continue;
const remainder = entry.path.slice(prefix.length);
const slashIndex = remainder.indexOf("/");
if (slashIndex === -1) {
files.push(entry);
} else {
dirs.add(remainder.slice(0, slashIndex));
}
}
return {
directories: Array.from(dirs).sort(),
files
};
}
export function ZipPreviewInline(props: ZipPreviewAttachmentProps) {
const fileName = getAttachmentFileName(props);
const url = getAttachmentUrl(props);
const [cacheState, setCacheState] = useState<ZipPreviewCacheState | null>(null);
const [isExpanded, setIsExpanded] = useState(false);
const [isContentMounted, setIsContentMounted] = useState(false);
const [currentPath, setCurrentPath] = useState("");
const animationFrameRef = useRef<number | null>(null);
const animationTimeoutRef = useRef<number | null>(null);
const loadRequestIdRef = useRef(0);
useEffect(() => {
loadRequestIdRef.current++;
setCacheState(null);
setCurrentPath("");
setIsExpanded(false);
setIsContentMounted(false);
}, [url]);
useEffect(() => () => {
loadRequestIdRef.current++;
if (animationFrameRef.current != null) cancelAnimationFrame(animationFrameRef.current);
if (animationTimeoutRef.current != null) clearTimeout(animationTimeoutRef.current);
}, []);
if (!isZipFile(fileName) || !url) return null;
const loadPreview = () => {
const loadRequestId = ++loadRequestIdRef.current;
const state = getCachedZip(url);
setCacheState(state);
if (state.status === "pending") {
state.promise
.then(() => {
if (loadRequestIdRef.current === loadRequestId) setCacheState(getCachedZip(url));
})
.catch(error => {
if (loadRequestIdRef.current !== loadRequestId) return;
const message = error instanceof Error ? error.message : "Failed to preview ZIP.";
setCacheState({ status: "rejected", message });
});
}
};
const setExpanded = (nextIsExpanded: boolean) => {
if (animationFrameRef.current != null) cancelAnimationFrame(animationFrameRef.current);
if (animationTimeoutRef.current != null) clearTimeout(animationTimeoutRef.current);
if (nextIsExpanded) {
if (!cacheState || cacheState.status === "rejected") loadPreview();
setIsContentMounted(true);
animationFrameRef.current = requestAnimationFrame(() => {
animationFrameRef.current = null;
setIsExpanded(true);
});
return;
}
setIsExpanded(false);
animationTimeoutRef.current = window.setTimeout(() => {
animationTimeoutRef.current = null;
setIsContentMounted(false);
}, 180);
};
return (
<div className={cl("inline", isContentMounted ? "expanded" : "collapsed", isExpanded ? "open" : "closed")}>
{isContentMounted && (
<div className={cl("content")} aria-hidden={!isExpanded}>
<div className={cl("content-inner")}>
<ZipPreviewContent cacheState={cacheState} currentPath={currentPath} onNavigate={setCurrentPath} />
</div>
</div>
)}
<button
className={cl("toggle")}
type="button"
aria-expanded={isExpanded}
aria-label={isExpanded ? "Collapse ZIP preview" : "Expand ZIP preview"}
onClick={event => {
event.preventDefault();
event.stopPropagation();
setExpanded(!isExpanded);
}}
>
{isExpanded
? <iconsModule.ChevronSmallUpIcon className={cl("toggle-icon")} />
: <iconsModule.ChevronSmallDownIcon className={cl("toggle-icon")} />}
</button>
</div>
);
}
function ZipPreviewContent({
cacheState,
currentPath,
onNavigate
}: {
cacheState: ZipPreviewCacheState | null;
currentPath: string;
onNavigate: (path: string) => void;
}) {
if (!cacheState || cacheState.status === "pending") {
return <div className={cl("state")}>Loading ZIP preview...</div>;
}
if (cacheState.status === "rejected") {
return <div className={cl("state")}>{cacheState.message}</div>;
}
const { entries, truncated } = cacheState.result;
if (entries.length === 0) {
return <div className={cl("state")}>This ZIP is empty.</div>;
}
return (
<>
<ZipPreviewBreadcrumb path={currentPath} onNavigate={onNavigate} />
{truncated && <div className={cl("state")}>Only showing first {MAX_ENTRIES} entries.</div>}
<div className={cl("entries")}>
<ZipPreviewFileList entries={entries} currentPath={currentPath} onNavigate={onNavigate} />
</div>
</>
);
}
function ZipPreviewBreadcrumb({ path, onNavigate }: { path: string; onNavigate: (path: string) => void; }) {
if (!path) {
return (
<div className={cl("breadcrumb")}>
<iconsModule.FolderIcon className={cl("breadcrumb-icon")} />
<span className={cl("breadcrumb-current")}>/</span>
</div>
);
}
const parts = path.split("/");
return (
<div className={cl("breadcrumb")}>
<iconsModule.FolderIcon className={cl("breadcrumb-icon")} />
<button
className={cl("breadcrumb-segment")}
type="button"
onClick={event => {
event.preventDefault();
event.stopPropagation();
onNavigate("");
}}
>
/
</button>
{parts.map((part, index) => {
const segmentPath = parts.slice(0, index + 1).join("/");
const isLast = index === parts.length - 1;
return (
<span key={segmentPath}>
{isLast
? <span className={cl("breadcrumb-current")}>{part}/</span>
: (
<button
className={cl("breadcrumb-segment")}
type="button"
onClick={event => {
event.preventDefault();
event.stopPropagation();
onNavigate(segmentPath);
}}
>
{part}
</button>
)}
</span>
);
})}
</div>
);
}
function ZipPreviewFileList({ entries, currentPath, onNavigate }: { entries: ZipEntry[]; currentPath: string; onNavigate: (path: string) => void; }) {
const { directories, files } = useMemo(() => getVisibleEntries(entries, currentPath), [entries, currentPath]);
if (directories.length === 0 && files.length === 0) {
return <div className={cl("state")}>This folder is empty.</div>;
}
return (
<>
{directories.map(dir => (
<button
key={dir}
className={cl("row", "directory", "button")}
type="button"
onClick={event => {
event.preventDefault();
event.stopPropagation();
onNavigate(currentPath ? `${currentPath}/${dir}` : dir);
}}
>
{dir}/
</button>
))}
{files.map(entry => (
<ZipPreviewFileRow key={entry.path} entry={entry} />
))}
</>
);
}
function ZipPreviewFileRow({ entry }: { entry: ZipEntry; }) {
const previewable = entry.kind !== "unsupported";
if (!previewable) {
return (
<div className={cl("row", "file", "unsupported")}>
{entry.name} <span className={cl("size")}>({formatBytes(entry.size)})</span>
</div>
);
}
return (
<button
className={cl("row", "file", "button")}
type="button"
onClick={event => {
event.preventDefault();
event.stopPropagation();
openZipEntryModal(entry);
}}
>
{entry.name} <span className={cl("size")}>({formatBytes(entry.size)})</span>
</button>
);
}
function openZipEntryModal(entry: ZipEntry) {
if (entry.kind === "image") {
openImageEntryModal(entry);
return;
}
if (entry.kind === "text") {
openTextEntryModal(entry);
}
}
function openTextEntryModal(entry: ZipEntry) {
const content = readTextEntry(entry);
const key = openModal(modalProps => (
<ErrorBoundary>
<ModalRoot {...modalProps} size={ModalSize.LARGE}>
<ModalHeader>
<Heading tag="h2" className={cl("modal-title")}>{entry.name}</Heading>
<ModalCloseButton onClick={() => closeModal(key)} />
</ModalHeader>
<ModalContent className={cl("modal-content")}>
<div className={cl("code-wrap")}>
<CodeBlock content={content} lang={getCodeLanguage(entry)} />
</div>
</ModalContent>
<ModalFooter>
<Flex gap={8} justifyContent="flex-end">
<Button variant="secondary" onClick={() => copyWithToast(content, "File contents copied to clipboard!")}>
Copy
</Button>
<Button onClick={() => makeDownload(entry)}>
Download
</Button>
</Flex>
</ModalFooter>
</ModalRoot>
</ErrorBoundary>
));
}
function openImageEntryModal(entry: ZipEntry) {
const key = openModal(modalProps => (
<ErrorBoundary>
<ModalRoot {...modalProps} size={ModalSize.LARGE}>
<ModalHeader>
<Heading tag="h2" className={cl("modal-title")}>{entry.name}</Heading>
<ModalCloseButton onClick={() => closeModal(key)} />
</ModalHeader>
<ModalContent className={cl("modal-content")}>
<ZipImagePreview entry={entry} />
</ModalContent>
<ModalFooter>
<Flex gap={8} justifyContent="flex-end">
<Button onClick={() => makeDownload(entry)}>
Download
</Button>
</Flex>
</ModalFooter>
</ModalRoot>
</ErrorBoundary>
));
}
function ZipImagePreview({ entry }: { entry: ZipEntry; }) {
const [url] = useState(() => createImageObjectUrl(entry));
useEffect(() => {
return () => URL.revokeObjectURL(url);
}, [url]);
return (
<div className={cl("image-wrap")}>
<img className={cl("image")} src={url} alt={entry.name} />
</div>
);
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`;
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
}
export const SafeZipPreviewInline = ErrorBoundary.wrap(ZipPreviewInline, { noop: true });

View file

@ -0,0 +1,44 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { EquicordDevs } from "@utils/constants";
import { classes } from "@utils/misc";
import definePlugin from "@utils/types";
import { cl, SafeZipPreviewInline } from "./components";
import managedStyle from "./style.css?managed";
import { clearZipPreviewCache, getAttachmentFileName, isZipFile, ZipPreviewAttachmentProps } from "./utils";
export default definePlugin({
name: "ZipPreview",
description: "Previews ZIP contents inside file attachments.",
tags: ["Chat", "Utility"],
authors: [EquicordDevs.justjxke],
managedStyle,
patches: [
{
find: "#{intl::IMG_ALT_ATTACHMENT_FILE_TYPE}",
replacement: {
match: /(?<=renderAdjacentContent:\i}=(\i);.{0,120}className:)(\i\.\i)(,children:\[)/,
replace: "$self.fileClassName($2)$3$self.renderZipPreview($1),"
}
}
],
stop() {
clearZipPreviewCache();
},
renderZipPreview(props: ZipPreviewAttachmentProps) {
if (!isZipFile(getAttachmentFileName(props))) return null;
return <SafeZipPreviewInline {...props} />;
},
fileClassName(className: string) {
return classes(className, cl("file"));
}
});

View file

@ -0,0 +1,114 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { dialog, IpcMainInvokeEvent } from "electron";
const DISCORD_ATTACHMENT_BASE_URL = "https://cdn.discordapp.com/attachments/";
const MAX_ZIP_BYTES = 50 * 1024 * 1024;
export async function fetchDiscordAttachment(
_: IpcMainInvokeEvent,
pathAndQuery: string
): Promise<{ success: boolean; data?: ArrayBuffer; error?: string; }> {
try {
const attachmentUrl = getDiscordAttachmentUrl(pathAndQuery);
if (!attachmentUrl) return { success: false, error: "Invalid Discord attachment path" };
const previewDialog = await dialog.showMessageBox({
title: "Preview ZIP attachment",
message: "Discord is about to fetch this ZIP attachment for preview.",
type: "question",
detail: `Attachment: ${getAttachmentFileName(pathAndQuery)}\n\nIf you did not request this intentionally, choose Cancel.`,
buttons: ["Cancel", "Preview ZIP"]
});
if (previewDialog.response !== 1) return { success: false, error: "ZIP preview was cancelled." };
const response = await fetch(attachmentUrl);
if (!response.ok) return { success: false, error: `Fetch failed: ${response.status} ${response.statusText}` };
return {
success: true,
data: await readLimitedResponse(response)
};
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : "Unknown error" };
}
}
async function readLimitedResponse(response: Response): Promise<ArrayBuffer> {
const contentLength = Number(response.headers.get("content-length"));
if (Number.isFinite(contentLength) && contentLength > MAX_ZIP_BYTES) {
throw new Error("ZIP is too large to preview.");
}
if (!response.body) {
const buffer = await response.arrayBuffer();
if (buffer.byteLength > MAX_ZIP_BYTES) throw new Error("ZIP is too large to preview.");
return buffer;
}
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let totalBytes = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
if (!value) continue;
totalBytes += value.byteLength;
if (totalBytes > MAX_ZIP_BYTES) {
await reader.cancel();
throw new Error("ZIP is too large to preview.");
}
chunks.push(value);
}
const result = new Uint8Array(totalBytes);
let offset = 0;
for (const chunk of chunks) {
result.set(chunk, offset);
offset += chunk.byteLength;
}
return result.buffer;
}
function getAttachmentFileName(pathAndQuery: string): string {
const path = pathAndQuery.split("?", 1)[0];
const fileName = path.split("/").at(-1);
return fileName ? decodeURIComponent(fileName) : "Unknown attachment";
}
function getDiscordAttachmentUrl(pathAndQuery: string): URL | null {
if (!isValidDiscordAttachmentPathAndQuery(pathAndQuery)) return null;
const url = new URL(pathAndQuery, DISCORD_ATTACHMENT_BASE_URL);
return url.origin === "https://cdn.discordapp.com" && url.pathname.startsWith("/attachments/")
? url
: null;
}
function isValidDiscordAttachmentPathAndQuery(pathAndQuery: string): boolean {
if (
pathAndQuery.includes("\\")
|| pathAndQuery.includes("..")
|| pathAndQuery.includes("://")
|| pathAndQuery.startsWith("/")
|| pathAndQuery.startsWith("//")
) {
return false;
}
const path = pathAndQuery.split("?", 1)[0];
const parts = path.split("/");
return parts.length >= 3
&& /^\d+$/.test(parts[0])
&& /^\d+$/.test(parts[1])
&& parts.slice(2).every(part => part.length > 0);
}

View file

@ -0,0 +1,217 @@
.vc-zip-preview-file {
flex-wrap: wrap;
}
.vc-zip-preview-inline {
flex: 0 0 100%;
order: 3;
box-sizing: border-box;
min-width: 0;
padding-left: 0;
color: var(--text-normal);
font-size: 0.875rem;
line-height: 1.25rem;
}
.vc-zip-preview-collapsed {
display: flex;
justify-content: center;
height: 18px;
margin-top: 4px;
}
.vc-zip-preview-expanded {
margin-top: 14px;
}
.vc-zip-preview-content {
display: grid;
grid-template-rows: 0fr;
overflow: hidden;
opacity: 0;
pointer-events: none;
transform: translateY(-4px);
transition:
grid-template-rows 180ms ease,
opacity 140ms ease,
transform 180ms ease;
}
.vc-zip-preview-content-inner {
min-height: 0;
overflow: hidden;
}
.vc-zip-preview-open .vc-zip-preview-content {
grid-template-rows: 1fr;
opacity: 1;
pointer-events: auto;
transform: translateY(0);
}
.vc-zip-preview-state {
color: var(--text-muted);
}
.vc-zip-preview-entries {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.vc-zip-preview-row {
appearance: none;
display: block;
box-sizing: border-box;
width: 100%;
min-width: 0;
border: 0;
border-radius: 4px;
background: none;
color: var(--text-link);
font: inherit;
line-height: 1.25rem;
text-align: left;
outline: none;
}
.vc-zip-preview-row:hover,
.vc-zip-preview-row:focus,
.vc-zip-preview-row:active {
background: none !important;
background-color: transparent !important;
}
.vc-zip-preview-directory {
color: var(--text-link);
}
.vc-zip-preview-button {
cursor: pointer;
text-decoration: underline;
color: var(--text-link);
}
.vc-zip-preview-button:hover,
.vc-zip-preview-button:focus,
.vc-zip-preview-button:active {
color: var(--text-link);
background: none !important;
background-color: transparent !important;
}
.vc-zip-preview-unsupported {
color: var(--text-muted);
}
.vc-zip-preview-modal-title {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.vc-zip-preview-modal-content {
min-height: 240px;
}
.vc-zip-preview-code-wrap {
padding: 16px 0;
}
.vc-zip-preview-image-wrap {
display: flex;
align-items: center;
justify-content: center;
min-height: 360px;
padding: 16px 0;
}
.vc-zip-preview-image {
display: block;
max-width: 100%;
max-height: 70vh;
object-fit: contain;
}
.vc-zip-preview-toggle {
appearance: none;
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 18px;
margin: 0 auto;
padding: 0;
border: 0;
background: none;
color: var(--text-muted);
cursor: pointer;
transition: color 0.15s ease;
outline: none;
}
.vc-zip-preview-toggle:hover,
.vc-zip-preview-toggle:focus,
.vc-zip-preview-toggle:active {
background: none !important;
background-color: transparent !important;
color: var(--text-link);
}
.vc-zip-preview-toggle-icon {
width: 16px;
height: 16px;
}
.vc-zip-preview-breadcrumb {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 4px;
color: var(--text-normal);
}
.vc-zip-preview-breadcrumb-icon {
flex-shrink: 0;
width: 16px;
height: 16px;
color: var(--text-normal);
}
.vc-zip-preview-breadcrumb-segment {
display: inline;
padding: 0;
border: 0;
background: none;
color: var(--text-link);
font: inherit;
line-height: 1.25rem;
text-decoration: underline;
cursor: pointer;
outline: none;
}
.vc-zip-preview-breadcrumb-segment:hover,
.vc-zip-preview-breadcrumb-segment:focus,
.vc-zip-preview-breadcrumb-segment:active {
background: none !important;
background-color: transparent !important;
color: var(--text-link-low-saturation);
}
.vc-zip-preview-breadcrumb-current {
color: var(--text-normal);
}
.vc-zip-preview-size {
color: var(--text-muted);
}
@media (prefers-reduced-motion: reduce) {
.vc-zip-preview-content {
transition: none;
transform: none;
}
}

View file

@ -0,0 +1,273 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { PluginNative } from "@utils/types";
import { saveFile } from "@utils/web";
import { unzipSync } from "fflate";
const Native = VencordNative?.pluginHelpers?.ZipPreview as PluginNative<typeof import("./native")> | undefined;
export const MAX_ZIP_BYTES = 50 * 1024 * 1024;
export const MAX_ENTRIES = 1000;
export const MAX_PREVIEW_BYTES = 10 * 1024 * 1024;
const CANCELLED_PREVIEW_MESSAGE = "ZIP preview was cancelled.";
const NATIVE_UNAVAILABLE_MESSAGE = "Native helper is unavailable.";
const IMAGE_EXTENSIONS = new Set(["png", "jpg", "jpeg", "gif", "webp", "avif", "bmp"]);
const DISCORD_ATTACHMENT_HOSTS = new Set(["cdn.discordapp.com", "media.discordapp.net"]);
const TEXT_EXTENSIONS = new Set([
"c",
"cpp",
"cs",
"css",
"csv",
"go",
"h",
"html",
"java",
"js",
"json",
"jsx",
"log",
"lua",
"md",
"php",
"py",
"rs",
"scss",
"sh",
"svg",
"toml",
"ts",
"tsx",
"txt",
"xml",
"yaml",
"yml"
]);
export type ZipPreviewKind = "image" | "text" | "unsupported";
export interface ZipEntry {
path: string;
name: string;
size: number;
data: Uint8Array;
kind: ZipPreviewKind;
extension: string;
}
export interface ZipPreviewResult {
entries: ZipEntry[];
truncated: boolean;
}
export type ZipPreviewCacheState =
| { status: "pending"; promise: Promise<ZipPreviewResult>; }
| { status: "resolved"; result: ZipPreviewResult; }
| { status: "rejected"; message: string; };
interface NativeFetchResult {
success: boolean;
data?: ArrayBuffer;
error?: string;
}
const zipCache = new Map<string, ZipPreviewCacheState>();
export function isZipFile(fileName?: string): boolean {
return typeof fileName === "string" && /\.zip$/i.test(fileName);
}
export function getAttachmentFileName(props: ZipPreviewAttachmentProps): string | undefined {
return props.fileName ?? props.item?.originalItem?.filename ?? props.item?.originalItem?.title;
}
export function getAttachmentUrl(props: ZipPreviewAttachmentProps): string | undefined {
return props.url ?? props.item?.downloadUrl ?? props.item?.originalItem?.url ?? props.item?.originalItem?.proxy_url;
}
export function getCachedZip(url: string): ZipPreviewCacheState {
const cached = zipCache.get(url);
if (cached) return cached;
const promise = loadZip(url)
.then(result => {
zipCache.set(url, { status: "resolved", result });
return result;
})
.catch(error => {
const message = error instanceof Error ? error.message : "Failed to preview ZIP.";
if (message === CANCELLED_PREVIEW_MESSAGE || message === NATIVE_UNAVAILABLE_MESSAGE) zipCache.delete(url);
else zipCache.set(url, { status: "rejected", message });
throw error;
});
const pending = { status: "pending" as const, promise };
zipCache.set(url, pending);
return pending;
}
export function clearZipPreviewCache() {
zipCache.clear();
}
export function makeDownload(entry: ZipEntry) {
const type = entry.kind === "image" ? getImageMimeType(entry.extension) : "text/plain;charset=utf-8";
saveFile(new File([entry.data as BlobPart], entry.name, { type }));
}
export function createImageObjectUrl(entry: ZipEntry): string {
return URL.createObjectURL(new Blob([entry.data as BlobPart], { type: getImageMimeType(entry.extension) }));
}
export function readTextEntry(entry: ZipEntry): string {
return new TextDecoder("utf-8").decode(entry.data);
}
export function getCodeLanguage(entry: ZipEntry): string {
const languageMap: Record<string, string> = {
js: "javascript",
jsx: "jsx",
md: "markdown",
py: "python",
rs: "rust",
sh: "bash",
ts: "typescript",
tsx: "tsx",
yml: "yaml"
};
return languageMap[entry.extension] ?? entry.extension;
}
async function loadZip(url: string): Promise<ZipPreviewResult> {
const attachmentPath = getDiscordAttachmentPath(url);
if (attachmentPath) {
const nativeResult = await fetchNativeDiscordAttachment(attachmentPath);
if (nativeResult.success && nativeResult.data) {
if (nativeResult.data.byteLength > MAX_ZIP_BYTES) throw new Error("ZIP is too large to preview.");
return parseZipBuffer(nativeResult.data);
}
throw new Error(nativeResult.error || "Could not fetch ZIP through native Discord attachment fetch.");
}
const response = await fetch(url);
if (!response.ok) throw new Error("Could not fetch ZIP.");
const contentLength = Number(response.headers.get("content-length"));
if (Number.isFinite(contentLength) && contentLength > MAX_ZIP_BYTES) {
throw new Error("ZIP is too large to preview.");
}
const buffer = await response.arrayBuffer();
if (buffer.byteLength > MAX_ZIP_BYTES) throw new Error("ZIP is too large to preview.");
return parseZipBuffer(buffer);
}
async function fetchNativeDiscordAttachment(attachmentPath: string): Promise<NativeFetchResult> {
if (!Native) return { success: false, error: NATIVE_UNAVAILABLE_MESSAGE };
if (typeof Native.fetchDiscordAttachment === "function") return Native.fetchDiscordAttachment(attachmentPath);
return { success: false, error: "Native helper does not support attachment fetch." };
}
export function getDiscordAttachmentPath(url: string): string | null {
try {
const parsedUrl = new URL(url);
if (parsedUrl.protocol !== "https:") return null;
if (!DISCORD_ATTACHMENT_HOSTS.has(parsedUrl.hostname)) return null;
if (!parsedUrl.pathname.startsWith("/attachments/")) return null;
const attachmentPath = parsedUrl.pathname.slice("/attachments/".length);
if (!isValidDiscordAttachmentPath(attachmentPath)) return null;
return `${attachmentPath}${parsedUrl.search}`;
} catch {
return null;
}
}
function isValidDiscordAttachmentPath(path: string): boolean {
if (path.includes("\\") || path.includes("..") || path.startsWith("/") || path.startsWith("//")) return false;
const parts = path.split("/");
return parts.length >= 3
&& /^\d+$/.test(parts[0])
&& /^\d+$/.test(parts[1])
&& parts.slice(2).every(part => part.length > 0);
}
function parseZipBuffer(buffer: ArrayBuffer): ZipPreviewResult {
const unzipped = unzipSync(new Uint8Array(buffer));
const files = Object.entries(unzipped)
.filter(([path]) => !path.endsWith("/"))
.sort(([a], [b]) => a.localeCompare(b));
const truncated = files.length > MAX_ENTRIES;
const entries = files.slice(0, MAX_ENTRIES).map(([path, data]) => {
const normalizedPath = normalizePath(path);
const extension = getExtension(normalizedPath);
return {
path: normalizedPath,
name: getFileName(normalizedPath),
size: data.byteLength,
data,
extension,
kind: getPreviewKind(extension, data.byteLength)
};
});
return {
entries,
truncated
};
}
function getPreviewKind(extension: string, size: number): ZipPreviewKind {
if (size > MAX_PREVIEW_BYTES) return "unsupported";
if (IMAGE_EXTENSIONS.has(extension)) return "image";
if (TEXT_EXTENSIONS.has(extension)) return "text";
return "unsupported";
}
function getImageMimeType(extension: string): string {
if (extension === "jpg") return "image/jpeg";
return `image/${extension}`;
}
function normalizePath(path: string): string {
return path.replace(/^\/+/, "").replaceAll("\\", "/");
}
function getFileName(path: string): string {
return path.split("/").at(-1) || path;
}
function getExtension(path: string): string {
const fileName = getFileName(path);
const dotIndex = fileName.lastIndexOf(".");
return dotIndex === -1 ? "" : fileName.slice(dotIndex + 1).toLowerCase();
}
export interface ZipPreviewAttachmentProps {
fileName?: string;
fileSize?: number;
url?: string;
item?: {
downloadUrl?: string;
originalItem?: {
filename?: string;
proxy_url?: string;
size?: number;
title?: string;
url?: string;
};
};
}

View file

@ -21,19 +21,35 @@ import { findComponentByCodeLazy } from "@webpack";
import { Animations, ChannelRTCStore, useStateFromStores } from "@webpack/common"; import { Animations, ChannelRTCStore, useStateFromStores } from "@webpack/common";
import type { CSSProperties } from "react"; import type { CSSProperties } from "react";
import { ExpandedGuildFolderStore, settings, SortedGuildStore } from "."; import { ExpandedGuildFolderStore, getExpandedFolderIdSet, getParentFolderId, settings, SortedGuildStore } from ".";
const GuildsBar = findComponentByCodeLazy('("guildsnav")'); const GuildsBar = findComponentByCodeLazy('("guildsnav")');
type GuildTreeNode = {
id?: string | number;
type?: string;
children?: GuildTreeNode[];
};
function getExpandedFolderIds() { function getExpandedFolderIds() {
const expandedFolders = ExpandedGuildFolderStore.getExpandedFolders(); const expandedFolders = getExpandedFolderIdSet();
const folders = SortedGuildStore.getGuildFolders(); const tree = SortedGuildStore.getGuildsTree();
const expandedFolderIds = new Set<number>();
const stack = [...(tree?.root?.children ?? [])];
const expandedFolderIds = new Set<string>(); while (stack.length) {
const node = stack.pop();
if (!node) continue;
for (const folder of folders) { if (node.type === "folder") {
if (expandedFolders.has(folder.folderId) && folder.guildIds?.length) { const folderId = node.id;
expandedFolderIds.add(folder.folderId); if (folderId != null && expandedFolders.has(Number(folderId))) {
expandedFolderIds.add(Number(folderId));
}
}
if (node.children?.length) {
stack.push(...node.children);
} }
} }
@ -52,7 +68,11 @@ export default ErrorBoundary.wrap(guildsBarProps => {
/> />
); );
const visible = !!expandedFolderIds.size; const visible = SortedGuildStore.getGuildFolders().some(folder =>
folder.guildIds?.length &&
expandedFolderIds.has(Number(folder.folderId)) &&
getParentFolderId(folder.folderId) == null
);
const guilds = document.querySelector(guildsBarProps.className.split(" ").map(c => `.${c}`).join("")); const guilds = document.querySelector(guildsBarProps.className.split(" ").map(c => `.${c}`).join(""));
// We need to display none if we are in fullscreen. Yes this seems horrible doing with css, but it's literally how Discord does it. // We need to display none if we are in fullscreen. Yes this seems horrible doing with css, but it's literally how Discord does it.

View file

@ -19,7 +19,7 @@
import "./style.css"; import "./style.css";
import { definePluginSettings } from "@api/Settings"; import { definePluginSettings } from "@api/Settings";
import { Devs } from "@utils/constants"; import { Devs, EquicordDevs } from "@utils/constants";
import { getIntlMessage } from "@utils/discord"; import { getIntlMessage } from "@utils/discord";
import { t } from "@utils/translation"; import { t } from "@utils/translation";
import definePlugin, { OptionType } from "@utils/types"; import definePlugin, { OptionType } from "@utils/types";
@ -69,28 +69,82 @@ const FolderItem = findComponentByCodeLazy("FolderItem", "onExpandCollapse", "fo
const MAX_TREE_FILTER_DEPTH = 1000; const MAX_TREE_FILTER_DEPTH = 1000;
let dispatchingFoldersClose = false; let dispatchingFoldersClose = false;
let pendingNavigationGuildId: string | null | undefined;
let pendingNavigationCloseServerFolder = false;
let navigationExpandTimeout: ReturnType<typeof setTimeout> | undefined;
function getGuildFolder(id: string) { function getGuildFolder(id: string) {
return SortedGuildStore.getGuildFolders().find((folder: GuildFolder) => folder.guildIds.includes(id)); return SortedGuildStore.getGuildFolders().find((folder: GuildFolder) => folder.guildIds.includes(id));
} }
function expandGuildNavigationPath(guildId: string | null | undefined) { function getGuildNavigationPathTargets(guildId: string | null | undefined): string[] {
if (!guildId || guildId === "@me") return; if (!guildId || guildId === "@me") return [];
for (const folderId of getGuildNavigationExpandTargets( return getGuildNavigationExpandTargets(
getNestedFolderMap(), getNestedFolderMap(),
SortedGuildStore.getGuildFolders(), SortedGuildStore.getGuildFolders(),
guildId, guildId,
ExpandedGuildFolderStore.getExpandedFolders() ExpandedGuildFolderStore.getExpandedFolders()
)) { );
if (!ExpandedGuildFolderStore.isFolderExpanded(folderId)) { }
FolderUtils.toggleGuildFolderExpand(folderId);
export function getExpandedFolderIdSet() {
return new Set(Array.from(ExpandedGuildFolderStore.getExpandedFolders(), id => Number(id)));
}
function isFolderExpanded(folderId: string | number) {
return getExpandedFolderIdSet().has(Number(folderId));
}
function expandGuildNavigationPathNow(guildId: string | null | undefined) {
for (const folderId of getGuildNavigationPathTargets(guildId)) {
const numericFolderId = Number(folderId);
if (!isFolderExpanded(numericFolderId)) {
FolderUtils.toggleGuildFolderExpand(numericFolderId);
} }
} }
} }
function flushGuildNavigationPath() {
navigationExpandTimeout = undefined;
const guildId = pendingNavigationGuildId;
const shouldCloseServerFolder = pendingNavigationCloseServerFolder;
pendingNavigationGuildId = undefined;
pendingNavigationCloseServerFolder = false;
if (!guildId || guildId === "@me") return;
const folderIds = getGuildNavigationPathTargets(guildId);
const guildFolder = getGuildFolder(guildId);
const folderId = guildFolder?.folderId;
const shouldCloseTargetFolder = shouldCloseServerFolder
&& folderId != null
&& folderIds.length === 0
&& isFolderExpanded(folderId);
expandGuildNavigationPathNow(guildId);
if (shouldCloseTargetFolder) {
FolderUtils.toggleGuildFolderExpand(folderId);
}
}
function scheduleGuildNavigationPath(guildId: string | null | undefined, closeServerFolder = false) {
const isSamePendingGuild = pendingNavigationGuildId === guildId;
pendingNavigationGuildId = guildId;
pendingNavigationCloseServerFolder = isSamePendingGuild
? pendingNavigationCloseServerFolder || closeServerFolder
: closeServerFolder;
if (navigationExpandTimeout != null) return;
navigationExpandTimeout = setTimeout(flushGuildNavigationPath, 0);
}
function closeFolders() { function closeFolders() {
for (const id of ExpandedGuildFolderStore.getExpandedFolders()) for (const id of getExpandedFolderIdSet())
FolderUtils.toggleGuildFolderExpand(id); FolderUtils.toggleGuildFolderExpand(id);
} }
@ -132,6 +186,8 @@ function filterTreeWithTargetNode(children: any, predicate: (node: any) => boole
} }
function getNestedFolderMap(): Record<string, string> { function getNestedFolderMap(): Record<string, string> {
if (!settings.store.enableNestedFolders) return {};
const nestedFolders = settings.store.nestedFolders ?? {}; const nestedFolders = settings.store.nestedFolders ?? {};
const validFolderIds = new Set<string>( const validFolderIds = new Set<string>(
SortedGuildStore.getGuildFolders() SortedGuildStore.getGuildFolders()
@ -228,6 +284,11 @@ export const settings = definePluginSettings({
description: t("vencord.betterFolders.settings.forceOpen"), description: t("vencord.betterFolders.settings.forceOpen"),
default: false default: false
}, },
enableNestedFolders: {
type: OptionType.BOOLEAN,
description: "Allow nesting folders inside other folders by dragging.",
default: true
},
keepIcons: { keepIcons: {
type: OptionType.BOOLEAN, type: OptionType.BOOLEAN,
description: t("vencord.betterFolders.settings.keepIcons"), description: t("vencord.betterFolders.settings.keepIcons"),
@ -256,12 +317,20 @@ export default definePlugin({
name: "BetterFolders", name: "BetterFolders",
description: t("vencord.betterFolders.description"), description: t("vencord.betterFolders.description"),
tags: ["Organisation", "Servers", "Appearance"], tags: ["Organisation", "Servers", "Appearance"],
authors: [Devs.juby, Devs.AutumnVN, Devs.Nuckyz], authors: [Devs.juby, Devs.AutumnVN, Devs.Nuckyz, EquicordDevs.justjxke],
isModified: true, isModified: true,
settings, settings,
start() { start() {
settings.store.nestedFolders = getNestedFolderMap(); settings.store.nestedFolders = getNestedFolderMap();
}, },
stop() {
if (navigationExpandTimeout != null) {
clearTimeout(navigationExpandTimeout);
navigationExpandTimeout = undefined;
}
pendingNavigationGuildId = undefined;
pendingNavigationCloseServerFolder = false;
},
patches: [ patches: [
{ {
@ -447,26 +516,42 @@ export default definePlugin({
match: /return \i\.type!==\i\.\i\.GUILD/, match: /return \i\.type!==\i\.\i\.GUILD/,
replace: "return $self.renderFolderChild(arguments[0],arguments[1],arguments[2])" replace: "return $self.renderFolderChild(arguments[0],arguments[1],arguments[2])"
} }
},
{
find: "`transitionToGuild - Transitioning to",
replacement: {
match: /(`transitionToGuild - Transitioning to .{0,80}?guildId:\i,channelId:\i,messageId:\i.{0,40}?`\),)/,
replace: "$&$self.handleGuildNavigation(arguments[0]),"
}
},
{
find: "`transitionTo - Transitioning to",
replacement: {
match: /`transitionTo - Transitioning to \$\{\i\}`\);/,
replace: "$&$self.handleRouteNavigation(arguments[0]);"
}
},
{
find: ".getCompatibleGuildFolders())",
replacement: {
match: /\i:\(\)=>\i(?=.*?\.FOLDER_ITEM_ANIMATION_DURATION.{0,10}\.animated\)\(\i\.\i\),(\i))/,
replace: "$&,FolderItem:()=>$1"
}
} }
], ],
flux: { flux: {
GUILD_SELECT({ guildId }: { guildId: string | null; }) { GUILD_SELECT({ guildId }: { guildId: string | null; }) {
if (guildId == null) return; if (guildId == null) return;
expandGuildNavigationPath(guildId); scheduleGuildNavigationPath(guildId);
}, },
CHANNEL_SELECT(data) { CHANNEL_SELECT(data) {
const guildFolder = getGuildFolder(data.guildId); const guildFolder = getGuildFolder(data.guildId);
if (guildFolder?.folderId) { if (guildFolder?.folderId) {
expandGuildNavigationPath(data.guildId); if (settings.store.forceOpen || settings.store.closeServerFolder) {
scheduleGuildNavigationPath(data.guildId, settings.store.closeServerFolder);
if (settings.store.forceOpen && !ExpandedGuildFolderStore.isFolderExpanded(guildFolder.folderId)) {
FolderUtils.toggleGuildFolderExpand(guildFolder.folderId);
}
if (settings.store.closeServerFolder && ExpandedGuildFolderStore.isFolderExpanded(guildFolder.folderId)) {
FolderUtils.toggleGuildFolderExpand(guildFolder.folderId);
} }
} else if (settings.store.closeAllFolders) { } else if (settings.store.closeAllFolders) {
closeFolders(); closeFolders();
@ -501,6 +586,19 @@ export default definePlugin({
FolderSideBar, FolderSideBar,
closeFolders, closeFolders,
handleGuildNavigation(guildId: string | null | undefined) {
expandGuildNavigationPathNow(guildId);
},
handleRouteNavigation(route: unknown) {
if (typeof route !== "string") return;
const guildId = route.match(/^\/channels\/([^/]+)/)?.[1];
if (!guildId) return;
if (!settings.store.forceOpen && !settings.store.closeServerFolder) return;
expandGuildNavigationPathNow(guildId);
},
getGuildMentionCount(guildId: string): number { getGuildMentionCount(guildId: string): number {
const mentionChannelIds = ReadStateStore.getMentionChannelIds() ?? []; const mentionChannelIds = ReadStateStore.getMentionChannelIds() ?? [];
let count = 0; let count = 0;
@ -620,8 +718,6 @@ export default definePlugin({
props: FolderRenderProps | undefined, props: FolderRenderProps | undefined,
baseClassName: string baseClassName: string
): string { ): string {
if (props?.isBetterFolders) return baseClassName;
const folderNode = props?.folderNode; const folderNode = props?.folderNode;
if (folderNode == null) return baseClassName; if (folderNode == null) return baseClassName;
@ -652,6 +748,7 @@ export default definePlugin({
handleFolderDrop(dragItem: FolderDragItem, targetNode: GuildTreeNode, _moveToBelow: boolean, isCombine: boolean): boolean { handleFolderDrop(dragItem: FolderDragItem, targetNode: GuildTreeNode, _moveToBelow: boolean, isCombine: boolean): boolean {
if (dragItem.type !== "folder") return false; if (dragItem.type !== "folder") return false;
if (!settings.store.enableNestedFolders) return false;
if (targetNode.type === "folder") { if (targetNode.type === "folder") {
const childId = dragItem.nodeId?.toString(); const childId = dragItem.nodeId?.toString();
@ -679,13 +776,14 @@ export default definePlugin({
shouldShowCombineTarget(noCombine: boolean, targetNode: GuildTreeNode): boolean { shouldShowCombineTarget(noCombine: boolean, targetNode: GuildTreeNode): boolean {
if (noCombine) return false; if (noCombine) return false;
if (!settings.store.enableNestedFolders) return targetNode.parentId == null;
return targetNode.parentId == null || targetNode.type === "folder"; return targetNode.parentId == null || targetNode.type === "folder";
}, },
getFolderAcceptTypes(targetNode: GuildTreeNode): string[] { getFolderAcceptTypes(targetNode: GuildTreeNode): string[] {
const GUILD = "guild"; const GUILD = "guild";
const FOLDER = "folder"; const FOLDER = "folder";
if (targetNode?.type === FOLDER) return [GUILD, FOLDER]; if (settings.store.enableNestedFolders && targetNode?.type === FOLDER) return [GUILD, FOLDER];
return [GUILD]; return [GUILD];
}, },
@ -693,6 +791,7 @@ export default definePlugin({
if (dragItem.nodeId === targetNode.id) return false; if (dragItem.nodeId === targetNode.id) return false;
if (dragItem.type === "folder" && targetNode.type === "folder") { if (dragItem.type === "folder" && targetNode.type === "folder") {
if (!settings.store.enableNestedFolders) return false;
return !hasParentInChain(targetNode.id?.toString(), dragItem.nodeId?.toString()); return !hasParentInChain(targetNode.id?.toString(), dragItem.nodeId?.toString());
} }
@ -716,7 +815,7 @@ export default definePlugin({
if ( if (
!isBetterFolders || !isBetterFolders ||
node.type === "folder" && expandedFolderIds?.has(node.id) || node.type === "folder" && (node.isBetterFoldersNested === true || expandedFolderIds?.has(node.id)) ||
node.type === "guild" && node.parentId != null && expandedFolderIds?.has(node.parentId) node.type === "guild" && node.parentId != null && expandedFolderIds?.has(node.parentId)
) { ) {
return originalComponent(); return originalComponent();

View file

@ -139,8 +139,8 @@ export default definePlugin({
{ {
find: "renderConnectionStatus(){", find: "renderConnectionStatus(){",
replacement: { replacement: {
match: /(lineClamp:1,children:)(\i)(?=,|}\))/, match: /(renderConnectionStatus\(\).{0,1000}?lineClamp:1,children:)(\i)(?=,|}\))/,
replace: "$1[$2,$self.renderConnectionTimer(this.props.channel.id)]" replace: "$1[$2,$self.renderConnectionTimer(this.props?.channel?.id)]"
} }
} }
], ],

View file

@ -66,7 +66,7 @@ export default definePlugin({
const listener = () => { const listener = () => {
if (!editorRef?.current) return setSelectedCount(0); if (!editorRef?.current) return setSelectedCount(0);
setTimeout(() => setSelectedCount(SlateUtils.getSelectedText(editorRef.current.getSlateEditor())?.length ?? 0), 50); setTimeout(() => setSelectedCount(SlateUtils.getSelectedText(editorRef.current?.getSlateEditor())?.length ?? 0), 50);
}; };
document.addEventListener("selectionchange", listener); document.addEventListener("selectionchange", listener);

View file

@ -139,8 +139,14 @@ async function createActivity(): Promise<Activity | undefined> {
case TimestampMode.CUSTOM: case TimestampMode.CUSTOM:
if (startTime || endTime) { if (startTime || endTime) {
activity.timestamps = {}; activity.timestamps = {};
if (startTime) activity.timestamps.start = startTime; if (startTime && endTime && endTime > startTime) {
if (endTime) activity.timestamps.end = endTime; const anchor = getLoopAnchor();
activity.timestamps.start = anchor;
activity.timestamps.end = anchor + (endTime - startTime);
} else {
if (startTime) activity.timestamps.start = startTime;
if (endTime) activity.timestamps.end = endTime;
}
} }
break; break;
case TimestampMode.NONE: case TimestampMode.NONE:
@ -213,6 +219,39 @@ export async function setRpc(disable?: boolean) {
}); });
} }
let loopInterval: ReturnType<typeof setInterval> | undefined;
let loopAnchor = 0;
function getLoopAnchor() {
return loopAnchor;
}
function startTimestampLoop() {
const { timestampMode, startTime, endTime } = settings.store;
if (timestampMode !== TimestampMode.CUSTOM || !startTime || !endTime) return;
const duration = endTime - startTime;
if (duration <= 0) return;
stopTimestampLoop();
loopAnchor = Date.now();
loopInterval = setInterval(() => {
if (Date.now() >= loopAnchor + duration) {
loopAnchor = Date.now();
setRpc();
}
}, 1000);
}
function stopTimestampLoop() {
if (loopInterval !== undefined) {
clearInterval(loopInterval);
loopInterval = undefined;
}
loopAnchor = 0;
}
export default definePlugin({ export default definePlugin({
name: "CustomRPC", name: "CustomRPC",
description: t("vencord.customRPC.description"), description: t("vencord.customRPC.description"),
@ -223,8 +262,14 @@ export default definePlugin({
requiresRestart: false, requiresRestart: false,
settings, settings,
start: setRpc, start() {
stop: () => setRpc(true), startTimestampLoop();
setRpc();
},
stop() {
setRpc(true);
stopTimestampLoop();
},
// Discord hides buttons on your own Rich Presence for some reason. This patch disables that behaviour // Discord hides buttons on your own Rich Presence for some reason. This patch disables that behaviour
patches: [ patches: [

View file

@ -97,7 +97,7 @@ export default definePlugin({
{ {
find: ".REQUEST_GUILD_MEMBERS,", find: ".REQUEST_GUILD_MEMBERS,",
replacement: { replacement: {
match: /\.send\(\i\.REQUEST_GUILD_MEMBERS,{/, match: /\.send\(\i\.\i\.REQUEST_GUILD_MEMBERS,{/,
replace: "$&nonce:arguments[1].nonce," replace: "$&nonce:arguments[1].nonce,"
} }
}, },

View file

@ -27,13 +27,6 @@ export default definePlugin({
settings, settings,
patches: [ patches: [
{
find: '"PLAINTEXT_PREVIEW":"OTHER"',
replacement: {
match: /return"IMAGE"===\i\|\|"VIDEO"===\i(?:\|\|("VISUAL_PLACEHOLDER"===\i)\|\|\i&&"CLIP"===\i)?/,
replace: (_, visualPlaceholderPred) => visualPlaceholderPred != null ? `return ${visualPlaceholderPred}` : "return false"
}
},
{ {
find: "renderAttachments(", find: "renderAttachments(",
predicate: () => settings.store.inlineVideo, predicate: () => settings.store.inlineVideo,

View file

@ -32,9 +32,9 @@ import { ComponentProps } from "react";
import ReviewComponent from "./ReviewComponent"; import ReviewComponent from "./ReviewComponent";
import ReviewsView, { ReviewsInputComponent } from "./ReviewsView"; import ReviewsView, { ReviewsInputComponent } from "./ReviewsView";
const Paginator = findComponentByCodeLazy<ComponentProps<t.Paginator>>('rel:"prev",children:'); export const Paginator = findComponentByCodeLazy<ComponentProps<t.Paginator>>('rel:"prev",children:');
// This matches a massive module with ~230k chars so we need an anchor before to prevent REDOS // This matches a massive module with ~230k chars so we need an anchor before to prevent REDOS
const requirePaginator = extractAndLoadChunksLazy(['name:"SearchResults"'], new RegExp(`name:"StageChannelCall",renderLoader:.+?(?:${DefaultExtractAndLoadChunksRegex.source}).{0,30}?name:"SearchResults"`)); export const requirePaginator = extractAndLoadChunksLazy(['name:"SearchResults"'], new RegExp(`name:"StageChannelCall",renderLoader:.+?(?:${DefaultExtractAndLoadChunksRegex.source}).{0,30}?name:"SearchResults"`));
function Modal({ modalProps, modalKey, discordId, name, type }: { modalProps: any; modalKey: string, discordId: string; name: string; type: ReviewType; }) { function Modal({ modalProps, modalKey, discordId, name, type }: { modalProps: any; modalKey: string, discordId: string; name: string; type: ReviewType; }) {
const [data, setData] = useState<UserReviewsData>(); const [data, setData] = useState<UserReviewsData>();

View file

@ -108,8 +108,8 @@ export default definePlugin({
find: 'tutorialId:"whos-online', find: 'tutorialId:"whos-online',
replacement: [ replacement: [
{ {
match: /,"aria-hidden":!0,children:\[.{0,200}— ",\i\]\}\)\]/, match: /(#{intl::CHANNEL_MEMBERS_A11Y_LABEL}.+}\):null,).{0,100}?— ",\i\]\}\)\]/,
replace: ',"aria-hidden":!0,children:[$self.RoleGroupColor(arguments[0])]' replace: (_, rest) => `${rest}$self.RoleGroupColor(arguments[0])]`
}, },
], ],
predicate: () => settings.store.memberList predicate: () => settings.store.memberList

View file

@ -22,9 +22,9 @@ import { shiki } from "@plugins/shikiCodeblocks.desktop/api/shiki";
import { useShikiSettings } from "@plugins/shikiCodeblocks.desktop/hooks/useShikiSettings"; import { useShikiSettings } from "@plugins/shikiCodeblocks.desktop/hooks/useShikiSettings";
import { useTheme } from "@plugins/shikiCodeblocks.desktop/hooks/useTheme"; import { useTheme } from "@plugins/shikiCodeblocks.desktop/hooks/useTheme";
import { hex2Rgb } from "@plugins/shikiCodeblocks.desktop/utils/color"; import { hex2Rgb } from "@plugins/shikiCodeblocks.desktop/utils/color";
import { cl, hljs, shouldUseHljs } from "@plugins/shikiCodeblocks.desktop/utils/misc"; import { cl, hljs, requireHljs, shouldUseHljs } from "@plugins/shikiCodeblocks.desktop/utils/misc";
import { useAwaiter, useIntersection } from "@utils/react"; import { useAwaiter, useIntersection } from "@utils/react";
import { React } from "@webpack/common"; import { React, useEffect } from "@webpack/common";
import { ButtonRow } from "./ButtonRow"; import { ButtonRow } from "./ButtonRow";
import { Code } from "./Code"; import { Code } from "./Code";
@ -43,13 +43,25 @@ export interface HighlighterProps {
isPreview: boolean; isPreview: boolean;
} }
export const createHighlighter = (props: HighlighterProps) => ( let didLoadHljs = false;
<pre className={cl("container")}> export const HighlighterContainer = (props: HighlighterProps) => {
<ErrorBoundary> const [_, _err, isPending] = useAwaiter(requireHljs);
<Highlighter {...props} />
</ErrorBoundary> useEffect(() => {
</pre> if (!isPending) didLoadHljs = true;
); }, [isPending]);
if (!didLoadHljs && isPending) return null;
return (
<pre className={cl("container")}>
<ErrorBoundary>
<Highlighter {...props} />
</ErrorBoundary>
</pre>
);
};
export const Highlighter = ({ export const Highlighter = ({
lang, lang,
content, content,

View file

@ -25,12 +25,11 @@ import definePlugin, { ReporterTestable } from "@utils/types";
import previewExampleText from "file://previewExample.tsx"; import previewExampleText from "file://previewExample.tsx";
import { shiki } from "./api/shiki"; import { shiki } from "./api/shiki";
import { createHighlighter } from "./components/Highlighter"; import { HighlighterContainer } from "./components/Highlighter";
import deviconStyle from "./devicon.css?managed"; import deviconStyle from "./devicon.css?managed";
import { settings } from "./settings"; import { settings } from "./settings";
import { DeviconSetting } from "./types"; import { DeviconSetting } from "./types";
import { clearStyles } from "./utils/createStyle"; import { clearStyles } from "./utils/createStyle";
import { requireHljs } from "./utils/misc";
export default definePlugin({ export default definePlugin({
name: "ShikiCodeblocks", name: "ShikiCodeblocks",
@ -56,6 +55,7 @@ export default definePlugin({
} }
} }
], ],
start: async () => { start: async () => {
if (settings.store.useDevIcon !== DeviconSetting.Disabled) if (settings.store.useDevIcon !== DeviconSetting.Disabled)
enableStyle(deviconStyle); enableStyle(deviconStyle);
@ -66,21 +66,12 @@ export default definePlugin({
shiki.destroy(); shiki.destroy();
clearStyles(); clearStyles();
}, },
settingsAboutComponent: () => createHighlighter({
lang: "tsx", settingsAboutComponent: () => <HighlighterContainer lang="tsx" content={previewExampleText} isPreview />,
content: previewExampleText,
isPreview: true
}),
// exports // exports
shiki, shiki,
createHighlighter,
renderHighlighter: ({ lang, content }: { lang: string; content: string; }) => { renderHighlighter: ({ lang, content }: { lang: string; content: string; }) => {
requireHljs(); return <HighlighterContainer lang={lang?.toLowerCase()} content={content} isPreview={false} />;
return createHighlighter({
lang,
content,
isPreview: false,
});
}, },
}); });

View file

@ -169,7 +169,7 @@ export default definePlugin({
patches: [ patches: [
{ {
// Same find as ReviewDB // Same find as ReviewDB
find: "#{intl::XcTHmQ::raw}", find: "#{intl::USER_PROFILE_FRIEND_REQUEST_TOAST}",
replacement: { replacement: {
match: /userId:\i\.id,guild:\i\}\)(?=])/, match: /userId:\i\.id,guild:\i\}\)(?=])/,
replace: "$&,$self.profilePopoutComponent(arguments[0])" replace: "$&,$self.profilePopoutComponent(arguments[0])"

View file

@ -28,7 +28,7 @@ import { formatDuration } from "@utils/text";
import { t } from "@utils/translation"; import { t } from "@utils/translation";
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, Text, Timestamp, Tooltip, useEffect, useState } from "@webpack/common"; import { EmojiStore, FluxDispatcher, GuildMemberStore, GuildStore, Parser, PermissionsBits, PermissionStore, SnowflakeUtils, Timestamp, Tooltip, useEffect, useState } from "@webpack/common";
import { ComponentType } from "react"; import { ComponentType } from "react";
import { cl, settings } from ".."; import { cl, settings } from "..";

View file

@ -170,8 +170,8 @@ export default definePlugin({
predicate: () => settings.store.showMode === ShowMode.LockIcon, predicate: () => settings.store.showMode === ShowMode.LockIcon,
replacement: { replacement: {
// Lock Icon // Lock Icon
match: /(?=switch\((\i)\.type\).{0,30}\.GUILD_ANNOUNCEMENT.{0,70}\(0,\i\.\i\))/, match: /(?<=(\i)\.isNSFW\(\);)switch\(\i\.type\)/,
replace: (_, channel) => `if($self.isHiddenChannel(${channel}))return $self.LockIcon;` replace: (m, channel) => `if($self.isHiddenChannel(${channel}))return $self.LockIcon;${m}`
} }
}, },
{ {

View file

@ -142,7 +142,6 @@ function MakeContextCallback(name: "Guild" | "Role" | "User" | "Channel" | "Mess
const isMessage = name === "Message"; const isMessage = name === "Message";
if (isMessage && !settings.store.messageContextMenu) return; if (isMessage && !settings.store.messageContextMenu) return;
// typescript parser goes crazy if this is inline // typescript parser goes crazy if this is inline
const id = `vc-view-${name.toLowerCase()}-raw`; const id = `vc-view-${name.toLowerCase()}-raw`;
const action = isMessage const action = isMessage

View file

@ -287,7 +287,7 @@ export default definePlugin({
settings, settings,
patches: [ patches: [
{ {
find: "#{intl::3XohGn::raw}", find: "#{intl::PAUSE_VOICE_MESSAGE_A11Y_LABEL}",
replacement: { replacement: {
match: /(?<=\i=)(?=\i\.memo\(.{0,50}?=1,onVolumeChange:[^}]+?waveform:[^}]+?playbackCacheKey:)/, match: /(?<=\i=)(?=\i\.memo\(.{0,50}?=1,onVolumeChange:[^}]+?waveform:[^}]+?playbackCacheKey:)/,
replace: "$self.VoiceMessage=", replace: "$self.VoiceMessage=",

View file

@ -1296,6 +1296,10 @@ export const EquicordDevs = Object.freeze({
name: "DKA", name: "DKA",
id: 119386840624005121n id: 119386840624005121n
}, },
yonn2222: {
name: "yonn2222",
id: 821835831844012103n
},
} satisfies Record<string, Dev>); } satisfies Record<string, Dev>);
// iife so #__PURE__ works correctly // iife so #__PURE__ works correctly