Merge branch 'dev' into translation
This commit is contained in:
commit
16bf7d03fc
51 changed files with 1716 additions and 1118 deletions
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "equicord",
|
||||
"private": "true",
|
||||
"version": "1.14.10.1",
|
||||
"version": "1.14.11.0",
|
||||
"description": "The other cutest Discord client mod",
|
||||
"homepage": "https://github.com/Equicord/Equicord#readme",
|
||||
"bugs": {
|
||||
|
|
|
|||
|
|
@ -81,7 +81,9 @@ const IGNORED_DISCORD_ERRORS = [
|
|||
"Downloading the full bad domains file",
|
||||
/\[GatewaySocket\].{0,110}Cannot access '/,
|
||||
"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>;
|
||||
|
||||
function toCodeBlock(s: string, indentation = 0, isDiscord = false) {
|
||||
|
|
|
|||
|
|
@ -21,17 +21,25 @@ import { ManifestEntry, SyncRequest, SyncResponse } from "./types";
|
|||
const logger = new Logger("SettingsSync:Cloud", "#39b7e0");
|
||||
|
||||
const MANIFEST_STORE_KEY = "Vencord_cloudManifest";
|
||||
const API_VERSION_STORE_KEY = "Vencord_cloudApiVersions";
|
||||
|
||||
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> {
|
||||
return apiVersion;
|
||||
const map = await loadApiVersionMap();
|
||||
return map[getCloudUrl().origin] ?? "v2";
|
||||
}
|
||||
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -204,14 +204,13 @@ export default function PluginSettings() {
|
|||
|
||||
const pluginFilter = useCallback((plugin: typeof Plugins[keyof typeof Plugins], newPluginsSet: Set<string> | null) => {
|
||||
const { status, tags } = searchValue;
|
||||
const enabled = isPluginEnabled(plugin.name);
|
||||
|
||||
switch (status) {
|
||||
case SearchStatus.DISABLED:
|
||||
if (enabled) return false;
|
||||
if (isPluginEnabled(plugin.name)) return false;
|
||||
break;
|
||||
case SearchStatus.ENABLED:
|
||||
if (!enabled) return false;
|
||||
if (!isPluginEnabled(plugin.name)) return false;
|
||||
break;
|
||||
case SearchStatus.EQUICORD:
|
||||
if (!PluginMeta[plugin.name].folderName.startsWith("src/equicordplugins/")) return false;
|
||||
|
|
@ -236,6 +235,7 @@ export default function PluginSettings() {
|
|||
|
||||
return (
|
||||
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.searchTerms?.some(t => t.toLowerCase().includes(search))
|
||||
);
|
||||
|
|
|
|||
|
|
@ -65,75 +65,63 @@ function EquicordSettings() {
|
|||
title: string;
|
||||
description?: string;
|
||||
restartRequired?: boolean;
|
||||
warning: { enabled: boolean; message?: string; };
|
||||
warning?: string;
|
||||
}
|
||||
> = [
|
||||
{
|
||||
key: "useQuickCss",
|
||||
title: t("vencord.settings.useQuickCss.title"),
|
||||
description: t("vencord.settings.useQuickCss.description"),
|
||||
restartRequired: true,
|
||||
warning: { enabled: false },
|
||||
},
|
||||
!IS_WEB && {
|
||||
key: "enableReactDevtools",
|
||||
title: t("vencord.settings.enableReactDevtools.title"),
|
||||
description: t("vencord.settings.enableReactDevtools.description"),
|
||||
restartRequired: true,
|
||||
warning: { enabled: false },
|
||||
},
|
||||
(!IS_WEB && !IS_DISCORD_DESKTOP || !IS_WINDOWS) && {
|
||||
key: "mainWindowFrameless",
|
||||
title: t("equicord.mainWindowFrameless.title"),
|
||||
description: t("equicord.mainWindowFrameless.description"),
|
||||
restartRequired: true,
|
||||
warning: { enabled: false },
|
||||
},
|
||||
!IS_WEB &&
|
||||
(!IS_DISCORD_DESKTOP || !IS_WINDOWS
|
||||
{
|
||||
key: "useQuickCss",
|
||||
title: t("vencord.settings.useQuickCss.title"),
|
||||
description: t("vencord.settings.useQuickCss.description"),
|
||||
},
|
||||
!IS_WEB && {
|
||||
key: "enableReactDevtools",
|
||||
title: t("vencord.settings.enableReactDevtools.title"),
|
||||
description: t("vencord.settings.enableReactDevtools.description"),
|
||||
restartRequired: true,
|
||||
},
|
||||
(!IS_WEB && !IS_DISCORD_DESKTOP || !IS_WINDOWS) && {
|
||||
key: "mainWindowFrameless",
|
||||
title: t("equicord.mainWindowFrameless.title"),
|
||||
description: t("equicord.mainWindowFrameless.description"),
|
||||
restartRequired: true,
|
||||
},
|
||||
!IS_WEB && (!IS_DISCORD_DESKTOP || !IS_WINDOWS
|
||||
? {
|
||||
key: "frameless",
|
||||
title: t("vencord.settings.frameless.title"),
|
||||
description: t("vencord.settings.frameless.description"),
|
||||
restartRequired: true,
|
||||
warning: { enabled: false },
|
||||
}
|
||||
: {
|
||||
key: "winNativeTitleBar",
|
||||
title: t("vencord.settings.winNativeTitleBar.title"),
|
||||
description: t("vencord.settings.winNativeTitleBar.description"),
|
||||
restartRequired: true,
|
||||
warning: { enabled: false },
|
||||
}
|
||||
),
|
||||
!IS_WEB && {
|
||||
key: "transparent",
|
||||
title: t("vencord.settings.transparent.title"),
|
||||
description: t("vencord.settings.transparent.description"),
|
||||
restartRequired: true,
|
||||
warning: {
|
||||
enabled: true,
|
||||
message: IS_WINDOWS
|
||||
!IS_WEB && {
|
||||
key: "transparent",
|
||||
title: t("vencord.settings.transparent.title"),
|
||||
description: t("vencord.settings.transparent.description"),
|
||||
restartRequired: true,
|
||||
warning: IS_WINDOWS
|
||||
? t("vencord.settings.transparent.noteWindows")
|
||||
: t("vencord.settings.transparent.note"),
|
||||
},
|
||||
},
|
||||
IS_DISCORD_DESKTOP && {
|
||||
key: "disableMinSize",
|
||||
title: t("vencord.settings.disableMinSize.title"),
|
||||
description: t("vencord.settings.disableMinSize.description"),
|
||||
restartRequired: true,
|
||||
warning: { enabled: false },
|
||||
},
|
||||
!IS_WEB &&
|
||||
IS_DISCORD_DESKTOP && {
|
||||
key: "disableMinSize",
|
||||
title: t("vencord.settings.disableMinSize.title"),
|
||||
description: t("vencord.settings.disableMinSize.description"),
|
||||
restartRequired: true,
|
||||
},
|
||||
!IS_WEB &&
|
||||
IS_WINDOWS && {
|
||||
key: "winCtrlQ",
|
||||
title: t("vencord.settings.winCtrlQ.title"),
|
||||
description: t("vencord.settings.winCtrlQ.description"),
|
||||
restartRequired: true,
|
||||
warning: { enabled: false },
|
||||
},
|
||||
];
|
||||
key: "winCtrlQ",
|
||||
title: t("vencord.settings.winCtrlQ.title"),
|
||||
description: t("vencord.settings.winCtrlQ.description"),
|
||||
restartRequired: true,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<SettingsTab>
|
||||
|
|
@ -265,11 +253,11 @@ function EquicordSettings() {
|
|||
}}
|
||||
title={s.title}
|
||||
description={
|
||||
s.warning.enabled ? (
|
||||
s.warning ? (
|
||||
<>
|
||||
{s.description}
|
||||
<Notice.Warning className={Margins.top8} style={{ width: "100%" }}>
|
||||
{s.warning.message}
|
||||
{s.warning}
|
||||
</Notice.Warning>
|
||||
</>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ export default definePlugin({
|
|||
authors: [Devs.thororen],
|
||||
patches: [
|
||||
{
|
||||
find: "#{intl::XcTHmQ::raw}",
|
||||
find: "#{intl::USER_PROFILE_FRIEND_REQUEST_TOAST}",
|
||||
replacement: {
|
||||
match: /user:\i,widgets:.{0,100}?\}\),/,
|
||||
replace: "$&Vencord.Api.ProfileCollections.renderProfileCollections(arguments[0]),",
|
||||
|
|
|
|||
32
src/equicordplugins/_core/concatenatedModules.tsx
Normal file
32
src/equicordplugins/_core/concatenatedModules.tsx
Normal 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;
|
||||
},
|
||||
});
|
||||
|
|
@ -9,49 +9,83 @@ import "./styles.css";
|
|||
import { definePluginSettings } from "@api/Settings";
|
||||
import { Heading } from "@components/Heading";
|
||||
import { DeleteIcon, PlusIcon } from "@components/Icons";
|
||||
import { Devs } from "@utils/constants";
|
||||
import { Devs, EquicordDevs } from "@utils/constants";
|
||||
import { classNameFactory } from "@utils/css";
|
||||
import { t } from "@utils/translation";
|
||||
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-");
|
||||
|
||||
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() {
|
||||
const { reasons } = settings.store;
|
||||
const [reasons, setReasons] = useState<BanReason[]>(getStoredReasons);
|
||||
|
||||
const update = (next: BanReason[]) => {
|
||||
setReasons(next);
|
||||
settings.store.reasons = next;
|
||||
};
|
||||
|
||||
return (
|
||||
<section>
|
||||
<Heading>{t("equicord.betterBanReasons.ui.reasons")}</Heading>
|
||||
{reasons.map((r, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cl("reason-wrapper")}
|
||||
>
|
||||
<div key={i} className={cl("reason-wrapper")}>
|
||||
<TextInput
|
||||
value={r}
|
||||
onChange={v => {
|
||||
reasons[i] = v;
|
||||
settings.store.reasons = reasons;
|
||||
}}
|
||||
value={r.text}
|
||||
onChange={v => update(reasons.map((x, j) => j === i ? { ...x, text: v } : x))}
|
||||
placeholder={t("equicord.betterBanReasons.ui.reasonPlaceholder")}
|
||||
/>
|
||||
<Button
|
||||
className={cl("remove-button")}
|
||||
color={Button.Colors.TRANSPARENT}
|
||||
onClick={() => {
|
||||
reasons.splice(i, 1);
|
||||
settings.store.reasons = reasons;
|
||||
}}
|
||||
onClick={() => update(reasons.filter((_, j) => j !== i))}
|
||||
look={Button.Looks.FILLED}
|
||||
size={Button.Sizes.MIN}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</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 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")}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -63,7 +97,7 @@ const settings = definePluginSettings({
|
|||
reasons: {
|
||||
description: t("equicord.betterBanReasons.settings.reasons"),
|
||||
type: OptionType.COMPONENT,
|
||||
default: [] as string[],
|
||||
default: [] as BanReason[],
|
||||
component: ReasonsComponent,
|
||||
},
|
||||
isTextInputDefault: {
|
||||
|
|
@ -76,30 +110,56 @@ export default definePlugin({
|
|||
name: "BetterBanReasons",
|
||||
description: t("equicord.betterBanReasons.description"),
|
||||
tags: ["Appearance", "Customisation"],
|
||||
authors: [Devs.Inbestigator],
|
||||
authors: [Devs.Inbestigator, EquicordDevs.yonn2222],
|
||||
|
||||
durationSetter: null as ((v: number) => void) | null,
|
||||
|
||||
patches: [
|
||||
{
|
||||
find: "#{intl::BAN_REASON_OPTION_SPAM_ACCOUNT}",
|
||||
replacement: [{
|
||||
match: /(\[\{name:\i\.\i\.\i\(\i\.\i\.\i\),.+?"other"\}\])/,
|
||||
replace: "$self.getReasons($1)"
|
||||
},
|
||||
{
|
||||
match: /useState\(null\)(?=.{0,300}targetUserId:)/,
|
||||
replace: "useState($self.getDefaultState())"
|
||||
}]
|
||||
replacement: [
|
||||
{
|
||||
match: /(\[\{name:\i\.\i\.\i\(\i\.\i\.\i\),.+?"other"\}\])/,
|
||||
replace: "$self.getReasons($1)"
|
||||
},
|
||||
{
|
||||
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());
|
||||
const reasons: string[] = storedReasons.length
|
||||
? storedReasons
|
||||
: [];
|
||||
|
||||
getReasons(defaults: { name: string; value: string; }[]) {
|
||||
const stored = getStoredReasons().filter(r => r.text.trim());
|
||||
return [
|
||||
...reasons.map(s => ({ name: s, value: s })),
|
||||
...defaults
|
||||
...stored.map(r => ({ name: r.text, value: r.text })),
|
||||
...defaults,
|
||||
];
|
||||
},
|
||||
|
||||
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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,6 +8,13 @@
|
|||
|
||||
.vc-bbr-remove-button {
|
||||
color: var(--text-muted);
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
.vc-bbr-duration-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.vc-bbr-remove-button:hover {
|
||||
|
|
|
|||
|
|
@ -23,17 +23,18 @@ export default definePlugin({
|
|||
patches: [
|
||||
{
|
||||
find: '"],{numberOfBlockedUsers:',
|
||||
group: true,
|
||||
replacement: [
|
||||
{
|
||||
match: /(?<=listType:\i,numberOfUsers:\i\}=(\i).{0,800}numberOfIgnoredUsers:\i\}\)\}\)\]\}\))/,
|
||||
replace: ",$1.listType==='blocked'?$self.renderSearchInput():null"
|
||||
match: /(?<=\(0,\i\.jsx\)\(\i,\{listType:(\i),numberOfUsers:\i\.length\}\),)/,
|
||||
replace: "$1==='blocked'?$self.renderSearchInput():null,"
|
||||
},
|
||||
{
|
||||
match: /(?<=\{userId:(\i).*?\.globalName.{0,25}\}\)\]\}\),)(\(.*?loading:\i\}\))/,
|
||||
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);"
|
||||
},
|
||||
{
|
||||
|
|
@ -51,17 +52,19 @@ export default definePlugin({
|
|||
updateFunc(searchResults);
|
||||
}, []);
|
||||
|
||||
return <TextInput
|
||||
placeholder={t("equicord.betterBlockedUsers.ui.searchPlaceholder")}
|
||||
style={{ width: "200px" }}
|
||||
onInput={e => {
|
||||
const search = (e.target as HTMLInputElement).value.toLowerCase().trim();
|
||||
setValue(search);
|
||||
lastSearch = search;
|
||||
const searchResults = this.getFilteredUsers(search);
|
||||
updateFunc(searchResults);
|
||||
}} value={value}
|
||||
></TextInput>;
|
||||
return <div className="vc-bbu-search">
|
||||
<TextInput
|
||||
placeholder={t("equicord.betterBlockedUsers.ui.searchPlaceholder")}
|
||||
style={{ width: "200px" }}
|
||||
onInput={e => {
|
||||
const search = (e.target as HTMLInputElement).value.toLowerCase().trim();
|
||||
setValue(search);
|
||||
lastSearch = search;
|
||||
const searchResults = this.getFilteredUsers(search);
|
||||
updateFunc(searchResults);
|
||||
}} value={value}
|
||||
/>
|
||||
</div>;
|
||||
},
|
||||
renderUser(userId: string, rest: any) {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,3 +1,8 @@
|
|||
[class*="usersList"] [class*="text"] {
|
||||
user-select: text !important;
|
||||
}
|
||||
|
||||
.vc-bbu-search,
|
||||
.vc-bbu-search * {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
|
@ -288,6 +288,16 @@ export default function ChannelsTabsContainer(props: BasicChannelTabsProps) {
|
|||
|
||||
if (isFullscreen) return null;
|
||||
|
||||
const shouldFollowNewTabButton = newTabButtonBehavior && !tabsOverflow;
|
||||
const newTabButton = (
|
||||
<button
|
||||
onClick={() => createTab(props, true)}
|
||||
className={cl("button", "new-button", "hoverable")}
|
||||
>
|
||||
<PlusSmallIcon />
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classes(
|
||||
|
|
@ -322,20 +332,16 @@ export default function ChannelsTabsContainer(props: BasicChannelTabsProps) {
|
|||
<div className={cl("tab-container")}>
|
||||
<HorizontalScroller
|
||||
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) =>
|
||||
<ChannelTab {...tab} index={i} key={tab.id} />
|
||||
)}
|
||||
{GhostTabs}
|
||||
{shouldFollowNewTabButton && newTabButton}
|
||||
</HorizontalScroller>
|
||||
|
||||
<button
|
||||
onClick={() => createTab(props, true)}
|
||||
className={cl("button", "new-button", "hoverable")}
|
||||
>
|
||||
<PlusSmallIcon />
|
||||
</button>
|
||||
{!shouldFollowNewTabButton && newTabButton}
|
||||
</div >
|
||||
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,10 +4,8 @@
|
|||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { iconsModule } from "@equicordplugins/_core/concatenatedModules";
|
||||
import { Icon } from "@vencord/discord-types";
|
||||
import { findByPropsLazy } from "@webpack";
|
||||
|
||||
const iconsModule = findByPropsLazy("AngleBracketsIcon", "StaffBadgeIcon") as Record<string, Icon>;
|
||||
|
||||
let iconNames: string[] | undefined;
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,27 @@ import { Devs } from "@utils/constants";
|
|||
import { t } from "@utils/translation";
|
||||
import definePlugin from "@utils/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({
|
||||
name: "CleanChannelName",
|
||||
|
|
@ -20,27 +41,41 @@ export default definePlugin({
|
|||
replacement: {
|
||||
match: /(?<=getChannel\(\i\)\{if\(null!=\i\)return )\i\(\i\)/,
|
||||
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) {
|
||||
if (channel) {
|
||||
channel.name = channel.name
|
||||
.normalize("NFKC")
|
||||
.replace(/[ᴀʙᴄᴅᴇꜰɢʜɪᴊᴋʟᴍɴᴏᴘǫʀꜱᴛᴜᴠᴡxʏᴢ]/g, match => {
|
||||
const smallCapsToNormal = {
|
||||
"ᴀ": "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"
|
||||
};
|
||||
return smallCapsToNormal[match];
|
||||
})
|
||||
.replace(/[^\u0020-\u007E]?\p{Extended_Pictographic}[^\u0020-\u007E]?/ug, "")
|
||||
.replace(/-?[^\p{Letter}\u0020-\u007E]-?/ug, [2, 4].includes(channel.type) ? " " : "-")
|
||||
.replace(/(^-|-$)/g, "")
|
||||
.replace(/-+/g, "-");
|
||||
}
|
||||
if (channel == null) return channel;
|
||||
const c = channel as any;
|
||||
|
||||
if (c[ORIGINAL_NAME] !== undefined) return channel;
|
||||
|
||||
c[ORIGINAL_NAME] = channel.name;
|
||||
|
||||
Object.defineProperty(channel, "name", {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get() {
|
||||
if (editingChannelId === channel.id) return c[ORIGINAL_NAME];
|
||||
return computeClean(c[ORIGINAL_NAME], channel.type);
|
||||
},
|
||||
set(value: string) {
|
||||
c[ORIGINAL_NAME] = value;
|
||||
},
|
||||
});
|
||||
|
||||
return channel;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -225,8 +225,8 @@ export default definePlugin({
|
|||
{
|
||||
find: "ACTIVE_NOW_COLUMN)",
|
||||
replacement: {
|
||||
match: /(?<=__invalid_consentCard.{0,80})(\i),\{(?=\}\)\])/,
|
||||
replace: '"div",{children:$self.activeNowView($1())'
|
||||
match: /(__invalid_consentCard.{0,40}\()(\i),\{/,
|
||||
replace: '$1"div",{children:$self.activeNowView($2())'
|
||||
}
|
||||
},
|
||||
// mutual friends list in user profile
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
|
||||
import "./style.css";
|
||||
|
||||
import { isPluginEnabled } from "@api/PluginManager";
|
||||
import { definePluginSettings, migrateOldSettingToNewPlugin, migratePluginSetting, migratePluginSettings } from "@api/Settings";
|
||||
import { Divider } from "@components/Divider";
|
||||
import { HeadingSecondary } from "@components/Heading";
|
||||
|
|
@ -14,6 +15,7 @@ import { classNameFactory } from "@utils/css";
|
|||
import { Devs, EquicordDevs } from "@utils/index";
|
||||
import { t, Translate } from "@utils/translation";
|
||||
import definePlugin, { OptionType } from "@utils/types";
|
||||
import { Alerts } from "@webpack/common";
|
||||
|
||||
migratePluginSettings("Declutter", "BetterUserArea", "Anammox");
|
||||
migrateOldSettingToNewPlugin("Declutter", "removeClanTag", "GuildTagSettings", "hideTags");
|
||||
|
|
@ -38,6 +40,13 @@ export const settings = definePluginSettings({
|
|||
type: OptionType.COMPONENT,
|
||||
component: () => SectionSeparator(t("equicord.declutter.headers.userProfile")),
|
||||
},
|
||||
removeAvatarDecoration: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Remove avatar decorations.",
|
||||
default: false,
|
||||
disabled: () => isPluginEnabled("Decor"),
|
||||
restartNeeded: true,
|
||||
},
|
||||
removeNameplate: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: t("equicord.declutter.settings.removeNameplate"),
|
||||
|
|
@ -146,8 +155,29 @@ export default definePlugin({
|
|||
description: t("equicord.declutter.description"),
|
||||
tags: ["Appearance", "Customisation"],
|
||||
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,
|
||||
patches: [
|
||||
{
|
||||
// Avatar decoration
|
||||
find: "getAvatarDecorationURL:",
|
||||
replacement: {
|
||||
match: /(?<=function \i\(\i\){)(?=.{0,150}let{avatarDecoration)/,
|
||||
replace: "$&return null;"
|
||||
},
|
||||
predicate: () => settings.store.removeAvatarDecoration && !isPluginEnabled("Decor"),
|
||||
},
|
||||
{
|
||||
// Nameplate
|
||||
find: ".MINI_PREVIEW,[",
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import { cancelCurrentUpload, getUploadState, isConfigured, subscribeUploadState
|
|||
const cl = classNameFactory("vc-file-upload-");
|
||||
const { getUserMaxFileSize } = findByPropsLazy("getUserMaxFileSize");
|
||||
let uploadAddFilesInterceptor: ((event: unknown) => void) | null = null;
|
||||
let pasteEventListener: ((event: ClipboardEvent) => void) | null = null;
|
||||
|
||||
type UploadAddFilesEvent = {
|
||||
type: string;
|
||||
|
|
@ -38,7 +39,7 @@ type UploadAddFilesEvent = {
|
|||
};
|
||||
|
||||
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 fallbackLimit = getUserMaxFileSize(UserStore.getCurrentUser());
|
||||
|
|
@ -92,6 +93,18 @@ function interceptUploadAddFiles(event: unknown): void {
|
|||
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 [state, setState] = useState(getUploadState);
|
||||
|
||||
|
|
@ -249,6 +262,9 @@ export default definePlugin({
|
|||
|
||||
uploadAddFilesInterceptor = event => interceptUploadAddFiles(event);
|
||||
FluxDispatcher.addInterceptor(uploadAddFilesInterceptor);
|
||||
|
||||
pasteEventListener = event => handlePaste(event);
|
||||
document.addEventListener("paste", pasteEventListener, true);
|
||||
},
|
||||
stop() {
|
||||
if (!uploadAddFilesInterceptor) {
|
||||
|
|
@ -261,6 +277,11 @@ export default definePlugin({
|
|||
}
|
||||
|
||||
uploadAddFilesInterceptor = null;
|
||||
|
||||
if (pasteEventListener) {
|
||||
document.removeEventListener("paste", pasteEventListener, true);
|
||||
pasteEventListener = null;
|
||||
}
|
||||
},
|
||||
shouldBypassDiscordUploadSizeCheck(): boolean {
|
||||
return Boolean((settings.store as { interceptDiscordUpload?: boolean; }).interceptDiscordUpload) && isConfigured();
|
||||
|
|
|
|||
|
|
@ -172,15 +172,15 @@ export const settings = definePluginSettings({
|
|||
default: false,
|
||||
hidden: true
|
||||
},
|
||||
interceptDiscordUpload: {
|
||||
bypassDiscordUpload: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Intercept Discord uploads and use FileUpload instead.",
|
||||
default: false,
|
||||
description: "Bypass Discord uploads and use FileUpload instead.",
|
||||
default: true,
|
||||
hidden: true
|
||||
},
|
||||
interceptDiscordUploadOnlyOverLimit: {
|
||||
bypassDiscordUploadOnlyOverLimit: {
|
||||
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,
|
||||
hidden: true
|
||||
},
|
||||
|
|
@ -239,6 +239,11 @@ export const settings = definePluginSettings({
|
|||
default: true,
|
||||
hidden: true
|
||||
},
|
||||
autoUploadPastedFiles: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Automatically upload files from clipboard to image host when pasting in chat input.",
|
||||
default: false
|
||||
},
|
||||
settingsComponent: {
|
||||
type: OptionType.COMPONENT,
|
||||
description: "Settings",
|
||||
|
|
@ -592,17 +597,24 @@ export function SettingsComponent() {
|
|||
/>
|
||||
</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
|
||||
checked={Boolean((store as { interceptDiscordUpload?: boolean; }).interceptDiscordUpload)}
|
||||
onChange={v => (store as { interceptDiscordUpload?: boolean; }).interceptDiscordUpload = v}
|
||||
checked={Boolean((store as { bypassDiscordUpload?: boolean; }).bypassDiscordUpload)}
|
||||
onChange={v => (store as { bypassDiscordUpload?: boolean; }).bypassDiscordUpload = v}
|
||||
/>
|
||||
</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
|
||||
checked={store.interceptDiscordUploadOnlyOverLimit}
|
||||
onChange={v => store.interceptDiscordUploadOnlyOverLimit = v}
|
||||
checked={store.autoUploadPastedFiles}
|
||||
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>
|
||||
|
||||
|
|
|
|||
|
|
@ -7,18 +7,15 @@
|
|||
import "./styles.css";
|
||||
|
||||
import ErrorBoundary from "@components/ErrorBoundary";
|
||||
import { Paginator, requirePaginator } from "@plugins/reviewDB/components/ReviewModal";
|
||||
import { ModalCloseButton } from "@utils/modal";
|
||||
import * as t 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 { ComponentProps, MutableRefObject } from "react";
|
||||
import { MutableRefObject } from "react";
|
||||
|
||||
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");
|
||||
|
||||
export default function ReplyNavigator({ replies }: { replies: Message[]; }) {
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import { t } from "@utils/translation";
|
|||
import definePlugin, { OptionType } from "@utils/types";
|
||||
import { User } from "@vencord/discord-types";
|
||||
import { findByCodeLazy } from "@webpack";
|
||||
import { React } from "@webpack/common";
|
||||
import { React, UserProfileStore } from "@webpack/common";
|
||||
|
||||
import { ProfilePopoutComponent } from "./components/ProfilePopoutComponent";
|
||||
import { ProfileTabComponent } from "./components/ProfileTabComponent";
|
||||
|
|
@ -49,8 +49,8 @@ export default definePlugin({
|
|||
{
|
||||
find: "#{intl::USER_PROFILE_ACTIVITY}",
|
||||
replacement: {
|
||||
match: /\.MUTUAL_GUILDS\}\)\)(?=,(\i))/,
|
||||
replace: '$&,$1.push({text:"GitHub",section:"GITHUB"})',
|
||||
match: /(\i)\.id!==\i\?\.id&&\i&&\(.{0,300}\.MUTUAL_GUILDS\}\)\)(?=,(\i))/,
|
||||
replace: '$&,$self.shouldShowGitHub($1.id)&&$2.push({text:"GitHub",section:"GITHUB"})',
|
||||
}
|
||||
},
|
||||
// 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; }) => {
|
||||
return (
|
||||
<ProfilePopoutComponent
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { Button } from "@components/Button";
|
|||
import { Heading } from "@components/Heading";
|
||||
import { SettingsTab, wrapTab } from "@components/settings";
|
||||
import { TooltipContainer } from "@components/TooltipContainer";
|
||||
import { iconsModule } from "@equicordplugins/_core/concatenatedModules";
|
||||
import { debounce } from "@shared/debounce";
|
||||
import { Margins } from "@utils/margins";
|
||||
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 { IconsDef } from "../types";
|
||||
import { IconsModule } from "../utils";
|
||||
import { openIconModal } from "./Modals";
|
||||
|
||||
let cachedIcons: IconsDef | null = null;
|
||||
|
|
@ -25,7 +25,7 @@ function getIcons(): IconsDef {
|
|||
if (cachedIcons) return cachedIcons;
|
||||
|
||||
cachedIcons = Object.fromEntries(
|
||||
Object.entries(IconsModule).filter(([name, fn]) =>
|
||||
Object.entries(iconsModule).filter(([name, fn]) =>
|
||||
typeof fn === "function" && name.endsWith("Icon")
|
||||
)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -24,15 +24,6 @@ export default definePlugin({
|
|||
authors: [EquicordDevs.iamme],
|
||||
dependencies: ["Settings"],
|
||||
startAt: StartAt.WebpackReady,
|
||||
patches: [
|
||||
{
|
||||
find: "AngleBracketsIcon",
|
||||
replacement: {
|
||||
match: /SectionDivider:\(\)=>\i,(?=.*?(\i),\{AIcon)/,
|
||||
replace: "$&icons:()=>$1,"
|
||||
}
|
||||
}
|
||||
],
|
||||
toolboxActions: {
|
||||
[t("equicord.iconViewer.ui.openIconsTab")]() {
|
||||
SettingsRouter.openUserSettings("equicord_icon_viewer_panel");
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { Icon } from "@vencord/discord-types";
|
||||
import { findByPropsLazy, waitFor } from "@webpack";
|
||||
|
||||
import { CssColorData, IconSize } from "./types";
|
||||
|
|
@ -12,8 +11,6 @@ import { CssColorData, IconSize } from "./types";
|
|||
let colorKeys: string[] = [];
|
||||
|
||||
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 iconSizes: IconSize[] = ["xxs", "xs", "sm", "md", "lg"];
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
[class*="outerContainer"] [class*="container"] [class*="actionBar"] {
|
||||
gap: 0;
|
||||
}
|
||||
|
|
@ -23,47 +23,31 @@ import { getUserSettingLazy } from "@api/UserSettings";
|
|||
import ErrorBoundary from "@components/ErrorBoundary";
|
||||
import { EquicordDevs } from "@utils/constants";
|
||||
import { proxyLazy } from "@utils/lazy";
|
||||
import { classes } from "@utils/misc";
|
||||
import { openModalLazy } from "@utils/modal";
|
||||
import { useForceUpdater } from "@utils/react";
|
||||
import { t } from "@utils/translation";
|
||||
import definePlugin, { OptionType, StartAt } from "@utils/types";
|
||||
import { extractAndLoadChunksLazy, findComponentByCodeLazy, findModuleId, wreq } from "@webpack";
|
||||
import { Clickable, Menu, OverridePremiumTypeStore, Toasts, useState } from "@webpack/common";
|
||||
|
||||
import managedStyle from "./fixActionBar.css?managed";
|
||||
|
||||
const settings = definePluginSettings({
|
||||
StatusPresets: {
|
||||
type: OptionType.COMPONENT,
|
||||
description: "Status Presets",
|
||||
component: () => <></>,
|
||||
default: {}
|
||||
}
|
||||
});
|
||||
import { Menu, OverridePremiumTypeStore, Toasts } from "@webpack/common";
|
||||
|
||||
interface Emoji {
|
||||
animated: boolean;
|
||||
id: bigint | null;
|
||||
id: string | null;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const PlusSmallIcon = findComponentByCodeLazy("0-2h-5V6Z");
|
||||
|
||||
interface DiscordStatus {
|
||||
emojiInfo: Emoji | null;
|
||||
text: string;
|
||||
clearAfter: "TODAY" | number | null;
|
||||
status: "online" | "dnd" | "idle" | "invisible";
|
||||
}
|
||||
|
||||
// TODO: find clearCustomStatusHint original css/svg or replace
|
||||
let PMenu;
|
||||
const PMenu = findComponentByCodeLazy("#{intl::MORE_OPTIONS}", ",renderSubmenu:");
|
||||
const EmojiComponent = findComponentByCodeLazy(/\.translateSurrogatesToInlineEmoji\(\i\.name\);/);
|
||||
|
||||
const CustomStatusSettings = getUserSettingLazy("status", "customStatus")!;
|
||||
const StatusModule = proxyLazy(() => {
|
||||
const id = findModuleId(".customStatusInputTitle,");
|
||||
const id = findModuleId("#{intl::SAVE}", '"custom-status-input"', '"Invalid custom status clear timeout"),');
|
||||
return wreq(Number(id));
|
||||
});
|
||||
|
||||
|
|
@ -78,7 +62,6 @@ const openCustomStatusModalLazy = () => openModalLazy(async () => {
|
|||
|
||||
function getExpirationMs(expiration: "TODAY" | number) {
|
||||
if (expiration !== "TODAY") return Date.now() + expiration;
|
||||
|
||||
const now = new Date();
|
||||
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 premiumType = OverridePremiumTypeStore.getState().premiumTypeActual ?? 0;
|
||||
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
|
||||
key={"status-presets-" + index}
|
||||
id={"status-presets-" + index}
|
||||
label={status.status}
|
||||
action={() => (status.emojiInfo?.id != null && premiumType > 0 || status.emojiInfo?.id == null) && setStatus(status)}
|
||||
render={() => <RenderStatusMenuItem
|
||||
status={status}
|
||||
update={update}
|
||||
disabled={status.emojiInfo?.id != null && premiumType === 0}
|
||||
/>}
|
||||
/> : null)}
|
||||
</Menu.Menu>;
|
||||
|
||||
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
|
||||
key={"status-presets-" + index}
|
||||
id={"status-presets-" + index}
|
||||
label={status.text}
|
||||
action={() => (status.emojiInfo?.id == null || premiumType > 0) && setStatus(status)}
|
||||
icon={status.emojiInfo != null
|
||||
? () => <EmojiComponent emoji={status.emojiInfo} animate={false} hideTooltip={false} />
|
||||
: 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({
|
||||
name: "StatusPresets",
|
||||
description: t("equicord.statusPresets.description"),
|
||||
tags: ["Activity", "Utility"],
|
||||
authors: [EquicordDevs.iamme],
|
||||
settings: settings,
|
||||
settings,
|
||||
dependencies: ["UserSettingsAPI"],
|
||||
managedStyle,
|
||||
patches: [
|
||||
{
|
||||
find: '="custom-status-input";',
|
||||
replacement: {
|
||||
match: /(?<=(\i)\?\.state\?\?.*?)\{text:\i\.\i\.\i\(\i\.\i#{intl::SAVE}\)/,
|
||||
replace: "$self.renderRememberButton($1),$&"
|
||||
match: /(?<=\[(\i).{0,6}\.useState\(\i\?\.state\?\?""\),\[(\i).{0,25}\?\?null\),\[(\i).*?)\{text:\i\.\i\.\i\(\i\.\i#{intl::SAVE}\)/,
|
||||
replace: "$self.renderRememberButton({text:$1,emojiInfo:$2,clearAfter:$3}),$&"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -168,14 +146,12 @@ export default definePlugin({
|
|||
{
|
||||
find: "#{intl::MORE_OPTIONS}),...",
|
||||
replacement: {
|
||||
match: /(?<=targetElementRef:\i,spacing:.{0,150}className:\i\.\i,children:(\i)\(\)\}\)\})/,
|
||||
replace: "$self.PMenu=$1;"
|
||||
match: /\i:\(\)=>\i,(?=.*?function (\i).{0,100}renderSubmenu:\i,ref:)/,
|
||||
replace: "$&PMenu:()=>$1,"
|
||||
}
|
||||
},
|
||||
],
|
||||
set PMenu(value: any) {
|
||||
PMenu = value;
|
||||
},
|
||||
|
||||
render() {
|
||||
const status = CustomStatusSettings.getSetting();
|
||||
return (
|
||||
|
|
@ -186,9 +162,7 @@ export default definePlugin({
|
|||
id="sp-custom/presets-status"
|
||||
action="PRESS_SET_STATUS"
|
||||
onClick={openCustomStatusModalLazy}
|
||||
icon={
|
||||
() => <div />
|
||||
}
|
||||
icon={() => <div />}
|
||||
label="Set Custom Status"
|
||||
renderSubmenu={StatusSubMenuComponent}
|
||||
/>
|
||||
|
|
@ -197,19 +171,13 @@ export default definePlugin({
|
|||
id="sp-edit/presets-status"
|
||||
action="PRESS_EDIT_CUSTOM_STATUS"
|
||||
onClick={openCustomStatusModalLazy}
|
||||
hint={<ClearStatusButton />}
|
||||
icon={
|
||||
() => status.emoji != null ? (
|
||||
<EmojiComponent
|
||||
emoji={status.emoji}
|
||||
animate={false}
|
||||
hideTooltip={false}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
icon={() => status.emoji != null ? (
|
||||
<EmojiComponent emoji={status.emoji} animate={false} hideTooltip={false} />
|
||||
) : null}
|
||||
label="Edit Custom Status"
|
||||
renderSubmenu={StatusSubMenuComponent}
|
||||
/>}
|
||||
/>
|
||||
}
|
||||
</ErrorBoundary>
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -2,12 +2,23 @@
|
|||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.vc-sp-item:hover {
|
||||
background-color: var(--menu-item-default-hover-bg) !important;
|
||||
color: var(--white) !important;
|
||||
}
|
||||
|
||||
.vc-sp-icon {
|
||||
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;
|
||||
}
|
||||
385
src/equicordplugins/zipPreview/components.tsx
Normal file
385
src/equicordplugins/zipPreview/components.tsx
Normal 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 });
|
||||
44
src/equicordplugins/zipPreview/index.tsx
Normal file
44
src/equicordplugins/zipPreview/index.tsx
Normal 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"));
|
||||
}
|
||||
});
|
||||
114
src/equicordplugins/zipPreview/native.ts
Normal file
114
src/equicordplugins/zipPreview/native.ts
Normal 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);
|
||||
}
|
||||
217
src/equicordplugins/zipPreview/style.css
Normal file
217
src/equicordplugins/zipPreview/style.css
Normal 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;
|
||||
}
|
||||
}
|
||||
273
src/equicordplugins/zipPreview/utils.ts
Normal file
273
src/equicordplugins/zipPreview/utils.ts
Normal 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;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
@ -21,19 +21,35 @@ import { findComponentByCodeLazy } from "@webpack";
|
|||
import { Animations, ChannelRTCStore, useStateFromStores } from "@webpack/common";
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
import { ExpandedGuildFolderStore, settings, SortedGuildStore } from ".";
|
||||
import { ExpandedGuildFolderStore, getExpandedFolderIdSet, getParentFolderId, settings, SortedGuildStore } from ".";
|
||||
|
||||
const GuildsBar = findComponentByCodeLazy('("guildsnav")');
|
||||
|
||||
type GuildTreeNode = {
|
||||
id?: string | number;
|
||||
type?: string;
|
||||
children?: GuildTreeNode[];
|
||||
};
|
||||
|
||||
function getExpandedFolderIds() {
|
||||
const expandedFolders = ExpandedGuildFolderStore.getExpandedFolders();
|
||||
const folders = SortedGuildStore.getGuildFolders();
|
||||
const expandedFolders = getExpandedFolderIdSet();
|
||||
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 (expandedFolders.has(folder.folderId) && folder.guildIds?.length) {
|
||||
expandedFolderIds.add(folder.folderId);
|
||||
if (node.type === "folder") {
|
||||
const folderId = node.id;
|
||||
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(""));
|
||||
|
||||
// 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.
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
import "./style.css";
|
||||
|
||||
import { definePluginSettings } from "@api/Settings";
|
||||
import { Devs } from "@utils/constants";
|
||||
import { Devs, EquicordDevs } from "@utils/constants";
|
||||
import { getIntlMessage } from "@utils/discord";
|
||||
import { t } from "@utils/translation";
|
||||
import definePlugin, { OptionType } from "@utils/types";
|
||||
|
|
@ -69,28 +69,82 @@ const FolderItem = findComponentByCodeLazy("FolderItem", "onExpandCollapse", "fo
|
|||
|
||||
const MAX_TREE_FILTER_DEPTH = 1000;
|
||||
let dispatchingFoldersClose = false;
|
||||
let pendingNavigationGuildId: string | null | undefined;
|
||||
let pendingNavigationCloseServerFolder = false;
|
||||
let navigationExpandTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
function getGuildFolder(id: string) {
|
||||
return SortedGuildStore.getGuildFolders().find((folder: GuildFolder) => folder.guildIds.includes(id));
|
||||
}
|
||||
|
||||
function expandGuildNavigationPath(guildId: string | null | undefined) {
|
||||
if (!guildId || guildId === "@me") return;
|
||||
function getGuildNavigationPathTargets(guildId: string | null | undefined): string[] {
|
||||
if (!guildId || guildId === "@me") return [];
|
||||
|
||||
for (const folderId of getGuildNavigationExpandTargets(
|
||||
return getGuildNavigationExpandTargets(
|
||||
getNestedFolderMap(),
|
||||
SortedGuildStore.getGuildFolders(),
|
||||
guildId,
|
||||
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() {
|
||||
for (const id of ExpandedGuildFolderStore.getExpandedFolders())
|
||||
for (const id of getExpandedFolderIdSet())
|
||||
FolderUtils.toggleGuildFolderExpand(id);
|
||||
}
|
||||
|
||||
|
|
@ -132,6 +186,8 @@ function filterTreeWithTargetNode(children: any, predicate: (node: any) => boole
|
|||
}
|
||||
|
||||
function getNestedFolderMap(): Record<string, string> {
|
||||
if (!settings.store.enableNestedFolders) return {};
|
||||
|
||||
const nestedFolders = settings.store.nestedFolders ?? {};
|
||||
const validFolderIds = new Set<string>(
|
||||
SortedGuildStore.getGuildFolders()
|
||||
|
|
@ -228,6 +284,11 @@ export const settings = definePluginSettings({
|
|||
description: t("vencord.betterFolders.settings.forceOpen"),
|
||||
default: false
|
||||
},
|
||||
enableNestedFolders: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Allow nesting folders inside other folders by dragging.",
|
||||
default: true
|
||||
},
|
||||
keepIcons: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: t("vencord.betterFolders.settings.keepIcons"),
|
||||
|
|
@ -256,12 +317,20 @@ export default definePlugin({
|
|||
name: "BetterFolders",
|
||||
description: t("vencord.betterFolders.description"),
|
||||
tags: ["Organisation", "Servers", "Appearance"],
|
||||
authors: [Devs.juby, Devs.AutumnVN, Devs.Nuckyz],
|
||||
authors: [Devs.juby, Devs.AutumnVN, Devs.Nuckyz, EquicordDevs.justjxke],
|
||||
isModified: true,
|
||||
settings,
|
||||
start() {
|
||||
settings.store.nestedFolders = getNestedFolderMap();
|
||||
},
|
||||
stop() {
|
||||
if (navigationExpandTimeout != null) {
|
||||
clearTimeout(navigationExpandTimeout);
|
||||
navigationExpandTimeout = undefined;
|
||||
}
|
||||
pendingNavigationGuildId = undefined;
|
||||
pendingNavigationCloseServerFolder = false;
|
||||
},
|
||||
|
||||
patches: [
|
||||
{
|
||||
|
|
@ -447,26 +516,42 @@ export default definePlugin({
|
|||
match: /return \i\.type!==\i\.\i\.GUILD/,
|
||||
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: {
|
||||
GUILD_SELECT({ guildId }: { guildId: string | null; }) {
|
||||
if (guildId == null) return;
|
||||
expandGuildNavigationPath(guildId);
|
||||
scheduleGuildNavigationPath(guildId);
|
||||
},
|
||||
|
||||
CHANNEL_SELECT(data) {
|
||||
const guildFolder = getGuildFolder(data.guildId);
|
||||
|
||||
if (guildFolder?.folderId) {
|
||||
expandGuildNavigationPath(data.guildId);
|
||||
|
||||
if (settings.store.forceOpen && !ExpandedGuildFolderStore.isFolderExpanded(guildFolder.folderId)) {
|
||||
FolderUtils.toggleGuildFolderExpand(guildFolder.folderId);
|
||||
}
|
||||
if (settings.store.closeServerFolder && ExpandedGuildFolderStore.isFolderExpanded(guildFolder.folderId)) {
|
||||
FolderUtils.toggleGuildFolderExpand(guildFolder.folderId);
|
||||
if (settings.store.forceOpen || settings.store.closeServerFolder) {
|
||||
scheduleGuildNavigationPath(data.guildId, settings.store.closeServerFolder);
|
||||
}
|
||||
} else if (settings.store.closeAllFolders) {
|
||||
closeFolders();
|
||||
|
|
@ -501,6 +586,19 @@ export default definePlugin({
|
|||
|
||||
FolderSideBar,
|
||||
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 {
|
||||
const mentionChannelIds = ReadStateStore.getMentionChannelIds() ?? [];
|
||||
let count = 0;
|
||||
|
|
@ -620,8 +718,6 @@ export default definePlugin({
|
|||
props: FolderRenderProps | undefined,
|
||||
baseClassName: string
|
||||
): string {
|
||||
if (props?.isBetterFolders) return baseClassName;
|
||||
|
||||
const folderNode = props?.folderNode;
|
||||
if (folderNode == null) return baseClassName;
|
||||
|
||||
|
|
@ -652,6 +748,7 @@ export default definePlugin({
|
|||
|
||||
handleFolderDrop(dragItem: FolderDragItem, targetNode: GuildTreeNode, _moveToBelow: boolean, isCombine: boolean): boolean {
|
||||
if (dragItem.type !== "folder") return false;
|
||||
if (!settings.store.enableNestedFolders) return false;
|
||||
|
||||
if (targetNode.type === "folder") {
|
||||
const childId = dragItem.nodeId?.toString();
|
||||
|
|
@ -679,13 +776,14 @@ export default definePlugin({
|
|||
|
||||
shouldShowCombineTarget(noCombine: boolean, targetNode: GuildTreeNode): boolean {
|
||||
if (noCombine) return false;
|
||||
if (!settings.store.enableNestedFolders) return targetNode.parentId == null;
|
||||
return targetNode.parentId == null || targetNode.type === "folder";
|
||||
},
|
||||
|
||||
getFolderAcceptTypes(targetNode: GuildTreeNode): string[] {
|
||||
const GUILD = "guild";
|
||||
const FOLDER = "folder";
|
||||
if (targetNode?.type === FOLDER) return [GUILD, FOLDER];
|
||||
if (settings.store.enableNestedFolders && targetNode?.type === FOLDER) return [GUILD, FOLDER];
|
||||
return [GUILD];
|
||||
},
|
||||
|
||||
|
|
@ -693,6 +791,7 @@ export default definePlugin({
|
|||
if (dragItem.nodeId === targetNode.id) return false;
|
||||
|
||||
if (dragItem.type === "folder" && targetNode.type === "folder") {
|
||||
if (!settings.store.enableNestedFolders) return false;
|
||||
return !hasParentInChain(targetNode.id?.toString(), dragItem.nodeId?.toString());
|
||||
}
|
||||
|
||||
|
|
@ -716,7 +815,7 @@ export default definePlugin({
|
|||
|
||||
if (
|
||||
!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)
|
||||
) {
|
||||
return originalComponent();
|
||||
|
|
|
|||
|
|
@ -139,8 +139,8 @@ export default definePlugin({
|
|||
{
|
||||
find: "renderConnectionStatus(){",
|
||||
replacement: {
|
||||
match: /(lineClamp:1,children:)(\i)(?=,|}\))/,
|
||||
replace: "$1[$2,$self.renderConnectionTimer(this.props.channel.id)]"
|
||||
match: /(renderConnectionStatus\(\).{0,1000}?lineClamp:1,children:)(\i)(?=,|}\))/,
|
||||
replace: "$1[$2,$self.renderConnectionTimer(this.props?.channel?.id)]"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ export default definePlugin({
|
|||
const listener = () => {
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -139,8 +139,14 @@ async function createActivity(): Promise<Activity | undefined> {
|
|||
case TimestampMode.CUSTOM:
|
||||
if (startTime || endTime) {
|
||||
activity.timestamps = {};
|
||||
if (startTime) activity.timestamps.start = startTime;
|
||||
if (endTime) activity.timestamps.end = endTime;
|
||||
if (startTime && endTime && endTime > startTime) {
|
||||
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;
|
||||
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({
|
||||
name: "CustomRPC",
|
||||
description: t("vencord.customRPC.description"),
|
||||
|
|
@ -223,8 +262,14 @@ export default definePlugin({
|
|||
requiresRestart: false,
|
||||
settings,
|
||||
|
||||
start: setRpc,
|
||||
stop: () => setRpc(true),
|
||||
start() {
|
||||
startTimestampLoop();
|
||||
setRpc();
|
||||
},
|
||||
stop() {
|
||||
setRpc(true);
|
||||
stopTimestampLoop();
|
||||
},
|
||||
|
||||
// Discord hides buttons on your own Rich Presence for some reason. This patch disables that behaviour
|
||||
patches: [
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ export default definePlugin({
|
|||
{
|
||||
find: ".REQUEST_GUILD_MEMBERS,",
|
||||
replacement: {
|
||||
match: /\.send\(\i\.REQUEST_GUILD_MEMBERS,{/,
|
||||
match: /\.send\(\i\.\i\.REQUEST_GUILD_MEMBERS,{/,
|
||||
replace: "$&nonce:arguments[1].nonce,"
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -27,13 +27,6 @@ export default definePlugin({
|
|||
settings,
|
||||
|
||||
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(",
|
||||
predicate: () => settings.store.inlineVideo,
|
||||
|
|
|
|||
|
|
@ -32,9 +32,9 @@ import { ComponentProps } from "react";
|
|||
import ReviewComponent from "./ReviewComponent";
|
||||
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
|
||||
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; }) {
|
||||
const [data, setData] = useState<UserReviewsData>();
|
||||
|
|
|
|||
|
|
@ -108,8 +108,8 @@ export default definePlugin({
|
|||
find: 'tutorialId:"whos-online',
|
||||
replacement: [
|
||||
{
|
||||
match: /,"aria-hidden":!0,children:\[.{0,200}— ",\i\]\}\)\]/,
|
||||
replace: ',"aria-hidden":!0,children:[$self.RoleGroupColor(arguments[0])]'
|
||||
match: /(#{intl::CHANNEL_MEMBERS_A11Y_LABEL}.+}\):null,).{0,100}?— ",\i\]\}\)\]/,
|
||||
replace: (_, rest) => `${rest}$self.RoleGroupColor(arguments[0])]`
|
||||
},
|
||||
],
|
||||
predicate: () => settings.store.memberList
|
||||
|
|
|
|||
|
|
@ -22,9 +22,9 @@ import { shiki } from "@plugins/shikiCodeblocks.desktop/api/shiki";
|
|||
import { useShikiSettings } from "@plugins/shikiCodeblocks.desktop/hooks/useShikiSettings";
|
||||
import { useTheme } from "@plugins/shikiCodeblocks.desktop/hooks/useTheme";
|
||||
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 { React } from "@webpack/common";
|
||||
import { React, useEffect } from "@webpack/common";
|
||||
|
||||
import { ButtonRow } from "./ButtonRow";
|
||||
import { Code } from "./Code";
|
||||
|
|
@ -43,13 +43,25 @@ export interface HighlighterProps {
|
|||
isPreview: boolean;
|
||||
}
|
||||
|
||||
export const createHighlighter = (props: HighlighterProps) => (
|
||||
<pre className={cl("container")}>
|
||||
<ErrorBoundary>
|
||||
<Highlighter {...props} />
|
||||
</ErrorBoundary>
|
||||
</pre>
|
||||
);
|
||||
let didLoadHljs = false;
|
||||
export const HighlighterContainer = (props: HighlighterProps) => {
|
||||
const [_, _err, isPending] = useAwaiter(requireHljs);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPending) didLoadHljs = true;
|
||||
}, [isPending]);
|
||||
|
||||
if (!didLoadHljs && isPending) return null;
|
||||
|
||||
return (
|
||||
<pre className={cl("container")}>
|
||||
<ErrorBoundary>
|
||||
<Highlighter {...props} />
|
||||
</ErrorBoundary>
|
||||
</pre>
|
||||
);
|
||||
};
|
||||
|
||||
export const Highlighter = ({
|
||||
lang,
|
||||
content,
|
||||
|
|
|
|||
|
|
@ -25,12 +25,11 @@ import definePlugin, { ReporterTestable } from "@utils/types";
|
|||
import previewExampleText from "file://previewExample.tsx";
|
||||
|
||||
import { shiki } from "./api/shiki";
|
||||
import { createHighlighter } from "./components/Highlighter";
|
||||
import { HighlighterContainer } from "./components/Highlighter";
|
||||
import deviconStyle from "./devicon.css?managed";
|
||||
import { settings } from "./settings";
|
||||
import { DeviconSetting } from "./types";
|
||||
import { clearStyles } from "./utils/createStyle";
|
||||
import { requireHljs } from "./utils/misc";
|
||||
|
||||
export default definePlugin({
|
||||
name: "ShikiCodeblocks",
|
||||
|
|
@ -56,6 +55,7 @@ export default definePlugin({
|
|||
}
|
||||
}
|
||||
],
|
||||
|
||||
start: async () => {
|
||||
if (settings.store.useDevIcon !== DeviconSetting.Disabled)
|
||||
enableStyle(deviconStyle);
|
||||
|
|
@ -66,21 +66,12 @@ export default definePlugin({
|
|||
shiki.destroy();
|
||||
clearStyles();
|
||||
},
|
||||
settingsAboutComponent: () => createHighlighter({
|
||||
lang: "tsx",
|
||||
content: previewExampleText,
|
||||
isPreview: true
|
||||
}),
|
||||
|
||||
settingsAboutComponent: () => <HighlighterContainer lang="tsx" content={previewExampleText} isPreview />,
|
||||
|
||||
// exports
|
||||
shiki,
|
||||
createHighlighter,
|
||||
renderHighlighter: ({ lang, content }: { lang: string; content: string; }) => {
|
||||
requireHljs();
|
||||
return createHighlighter({
|
||||
lang,
|
||||
content,
|
||||
isPreview: false,
|
||||
});
|
||||
return <HighlighterContainer lang={lang?.toLowerCase()} content={content} isPreview={false} />;
|
||||
},
|
||||
});
|
||||
|
|
@ -169,7 +169,7 @@ export default definePlugin({
|
|||
patches: [
|
||||
{
|
||||
// Same find as ReviewDB
|
||||
find: "#{intl::XcTHmQ::raw}",
|
||||
find: "#{intl::USER_PROFILE_FRIEND_REQUEST_TOAST}",
|
||||
replacement: {
|
||||
match: /userId:\i\.id,guild:\i\}\)(?=])/,
|
||||
replace: "$&,$self.profilePopoutComponent(arguments[0])"
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ import { formatDuration } from "@utils/text";
|
|||
import { t } from "@utils/translation";
|
||||
import type { Channel, RoleOrUserPermission } from "@vencord/discord-types";
|
||||
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 { cl, settings } from "..";
|
||||
|
|
|
|||
|
|
@ -170,8 +170,8 @@ export default definePlugin({
|
|||
predicate: () => settings.store.showMode === ShowMode.LockIcon,
|
||||
replacement: {
|
||||
// Lock Icon
|
||||
match: /(?=switch\((\i)\.type\).{0,30}\.GUILD_ANNOUNCEMENT.{0,70}\(0,\i\.\i\))/,
|
||||
replace: (_, channel) => `if($self.isHiddenChannel(${channel}))return $self.LockIcon;`
|
||||
match: /(?<=(\i)\.isNSFW\(\);)switch\(\i\.type\)/,
|
||||
replace: (m, channel) => `if($self.isHiddenChannel(${channel}))return $self.LockIcon;${m}`
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -142,7 +142,6 @@ function MakeContextCallback(name: "Guild" | "Role" | "User" | "Channel" | "Mess
|
|||
const isMessage = name === "Message";
|
||||
if (isMessage && !settings.store.messageContextMenu) return;
|
||||
|
||||
|
||||
// typescript parser goes crazy if this is inline
|
||||
const id = `vc-view-${name.toLowerCase()}-raw`;
|
||||
const action = isMessage
|
||||
|
|
|
|||
|
|
@ -287,7 +287,7 @@ export default definePlugin({
|
|||
settings,
|
||||
patches: [
|
||||
{
|
||||
find: "#{intl::3XohGn::raw}",
|
||||
find: "#{intl::PAUSE_VOICE_MESSAGE_A11Y_LABEL}",
|
||||
replacement: {
|
||||
match: /(?<=\i=)(?=\i\.memo\(.{0,50}?=1,onVolumeChange:[^}]+?waveform:[^}]+?playbackCacheKey:)/,
|
||||
replace: "$self.VoiceMessage=",
|
||||
|
|
|
|||
|
|
@ -1296,6 +1296,10 @@ export const EquicordDevs = Object.freeze({
|
|||
name: "DKA",
|
||||
id: 119386840624005121n
|
||||
},
|
||||
yonn2222: {
|
||||
name: "yonn2222",
|
||||
id: 821835831844012103n
|
||||
},
|
||||
} satisfies Record<string, Dev>);
|
||||
|
||||
// iife so #__PURE__ works correctly
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue