Plugin

BetterFolders

Shows server folders on dedicated sidebar and adds folder related improvements

Organisation Servers Appearance
index.tsx
Download

Source

src/plugins/betterFolders/index.tsx
1/*
2 * FiveCord — a Discord client mod
3 * Copyright (c) 2025 FiveCord
4 * SPDX-License-Identifier: GPL-3.0-or-later
5 */
6
7import "./style.css";
8
9import { definePluginSettings } from "@api/Settings";
10import { Devs } from "@utils/constants";
11import { getIntlMessage } from "@utils/discord";
12import { Logger } from "@utils/Logger";
13import definePlugin, { OptionType } from "@utils/types";
14import { findByPropsLazy, findStoreLazy } from "@webpack";
15import { FluxDispatcher } from "@webpack/common";
16import { ReactNode } from "react";
17
18import FolderSideBar from "./FolderSideBar";
19
20enum FolderIconDisplay {
21 Never,
22 Always,
23 MoreThanOneFolderExpanded
24}
25
26export const ExpandedGuildFolderStore = findStoreLazy("ExpandedGuildFolderStore");
27export const SortedGuildStore = findStoreLazy("SortedGuildStore");
28const FolderUtils = findByPropsLazy("move", "toggleGuildFolderExpand");
29
30let lastGuildId = null as string | null;
31let dispatchingFoldersClose = false;
32
33function getGuildFolder(id: string) {
34 return SortedGuildStore.getGuildFolders().find(folder => folder.guildIds.includes(id));
35}
36
37function closeFolders() {
38 for (const id of ExpandedGuildFolderStore.getExpandedFolders())
39 FolderUtils.toggleGuildFolderExpand(id);
40}
41
42// Nuckyz: Unsure if this should be a general utility or not
43function filterTreeWithTargetNode(children: any, predicate: (node: any) => boolean) {
44 if (children == null) {
45 return false;
46 }
47
48 if (!Array.isArray(children)) {
49 if (predicate(children)) {
50 return true;
51 }
52
53 return filterTreeWithTargetNode(children.props?.children, predicate);
54 }
55
56 let childIsTargetChild = false;
57 for (let i = 0; i < children.length; i++) {
58 const shouldKeep = filterTreeWithTargetNode(children[i], predicate);
59 if (shouldKeep) {
60 childIsTargetChild = true;
61 continue;
62 }
63
64 children.splice(i--, 1);
65 }
66
67 return childIsTargetChild;
68}
69
70export const settings = definePluginSettings({
71 sidebar: {
72 type: OptionType.BOOLEAN,
73 description: "Display servers from folder on dedicated sidebar",
74 restartNeeded: true,
75 default: true
76 },
77 sidebarAnim: {
78 type: OptionType.BOOLEAN,
79 description: "Animate opening the folder sidebar",
80 default: true
81 },
82 closeAllFolders: {
83 type: OptionType.BOOLEAN,
84 description: "Close all folders when selecting a server not in a folder",
85 default: false
86 },
87 closeAllHomeButton: {
88 type: OptionType.BOOLEAN,
89 description: "Close all folders when clicking on the home button",
90 restartNeeded: true,
91 default: false
92 },
93 closeOthers: {
94 type: OptionType.BOOLEAN,
95 description: "Close other folders when opening a folder",
96 default: false
97 },
98 forceOpen: {
99 type: OptionType.BOOLEAN,
100 description: "Force a folder to open when switching to a server of that folder",
101 default: false
102 },
103 keepIcons: {
104 type: OptionType.BOOLEAN,
105 description: "Keep showing guild icons in the primary guild bar folder when it&#039;s open in the BetterFolders sidebar",
106 restartNeeded: true,
107 default: false
108 },
109 showFolderIcon: {
110 type: OptionType.SELECT,
111 description: "Show the folder icon above the folder guilds in the BetterFolders sidebar",
112 options: [
113 { label: "Never", value: FolderIconDisplay.Never },
114 { label: "Always", value: FolderIconDisplay.Always, default: true },
115 { label: "When more than one folder is expanded", value: FolderIconDisplay.MoreThanOneFolderExpanded }
116 ],
117 restartNeeded: true
118 }
119});
120
121const IS_BETTER_FOLDERS_VAR = "typeof isBetterFolders!==&#039;undefined&#039;?isBetterFolders:arguments[0]?.isBetterFolders";
122const BETTER_FOLDERS_EXPANDED_IDS_VAR = "typeof betterFoldersExpandedIds!==&#039;undefined&#039;?betterFoldersExpandedIds:arguments[0]?.betterFoldersExpandedIds";
123const GRID_STYLE_NAME = "vc-betterFolders-sidebar-grid";
124
125export default definePlugin({
126 name: "BetterFolders",
127 description: "Shows server folders on dedicated sidebar and adds folder related improvements",
128 authors: [Devs.juby, Devs.AutumnVN, Devs.Nuckyz],
129 tags: ["Organisation", "Servers", "Appearance"],
130 settings,
131
132 patches: [
133 {
134 find: &#039;("guildsnav")&#039;,
135 predicate: () => settings.store.sidebar,
136 replacement: [
137 // Create the isBetterFolders and betterFoldersExpandedIds variables in the GuildsBar component
138 // Needed because we access this from a non-arrow closure so we can't use arguments[0]
139 {
140 match: /let{disableAppDownload:\i=\i\.isPlatformEmbedded,isOverlay:.+?(?=}=\i)/,
141 replace: "$&,isBetterFolders,betterFoldersExpandedIds"
142 },
143 // Export the isBetterFolders and betterFoldersExpandedIds variable to the Guild List component
144 {
145 match: /,{guildDiscoveryButton:\i,/g,
146 replace: "$&isBetterFolders:arguments[0]?.isBetterFolders,betterFoldersExpandedIds:arguments[0]?.betterFoldersExpandedIds,"
147 },
148 // Wrap the guild node (guild or folder) component in a div with display: none if it's not an expanded folder or a guild in an expanded folder
149 {
150 match: /switch\((\i)\.type\){.+?default:return null}/,
151 replace: `return $self.wrapGuildNodeComponent($1,()=>{$&},${IS_BETTER_FOLDERS_VAR},${BETTER_FOLDERS_EXPANDED_IDS_VAR});`
152 },
153 // Export the isBetterFolders variable to the folder component
154 {
155 match: /switch\(\i\.type\){case \i\.\i\.FOLDER:.+?folderNode:\i,/,
156 replace: `$&isBetterFolders:${IS_BETTER_FOLDERS_VAR},`
157 },
158 // Make the callback for returning the guild node component depend on isBetterFolders and betterFoldersExpandedIds
159 {
160 match: /switch\(\i\.type\).+?,\i,\i\.setNodeRef/,
161 replace: "$&,arguments[0]?.isBetterFolders,arguments[0]?.betterFoldersExpandedIds"
162 },
163 // If we are rendering the Better Folders sidebar, we filter out everything but the guilds and folders from the Guild List children
164 {
165 match: /lastTargetNode:\i\[\i\.length-1\].+?}\)(?::null)?\](?=}\))/,
166 replace: "$&.filter($self.makeGuildsBarGuildListFilter(!!arguments[0]?.isBetterFolders))"
167 },
168 // If we are rendering the Better Folders sidebar, we filter out everything but the Guild List from the Sidebar children
169 {
170 match: /reverse:!0,.{0,150}?barClassName:.+?\}\)\]/,
171 replace: "$&.filter($self.makeGuildsBarSidebarFilter(!!arguments[0]?.isBetterFolders))"
172 }
173 ]
174 },
175 {
176 // This is the parent folder component
177 find: ".toggleGuildFolderExpand(",
178 predicate: () => settings.store.sidebar && settings.store.showFolderIcon !== FolderIconDisplay.Always,
179 replacement: [
180 {
181 // Modify the expanded state to instead return the list of expanded folders
182 match: /(\],\(\)=>)(\i\.\i)\.isFolderExpanded\(\i\)\)/,
183 replace: (_, rest, ExpandedGuildFolderStore) => `${rest}${ExpandedGuildFolderStore}.getExpandedFolders())`,
184 },
185 {
186 // Modify the expanded prop to use the boolean if the above patch fails, or check if the folder is expanded from the list if it succeeds
187 // Also export the list of expanded folders to the child folder component if the patch above succeeds, else export undefined
188 match: /(?<=\.\.\.\i,folderNode:(\i),expanded:)\i(?=,)/,
189 replace: (isExpandedOrExpandedIds, folderNote) => ""
190 + `typeof ${isExpandedOrExpandedIds}==="boolean"?${isExpandedOrExpandedIds}:${isExpandedOrExpandedIds}.has(${folderNote}.id),`
191 + `betterFoldersExpandedIds:${isExpandedOrExpandedIds} instanceof Set?${isExpandedOrExpandedIds}:void 0`
192 }
193 ]
194 },
195 {
196 find: ".FOLDER_ITEM_ANIMATION_DURATION),",
197 predicate: () => settings.store.sidebar,
198 replacement: [
199 // We use arguments[0] to access the isBetterFolders variable in this nested folder component (the parent exports all the props so we don't have to patch it)
200
201 // If we are rendering the normal GuildsBar sidebar, we make Discord think the folder is always collapsed to show better icons (the mini guild icons) and avoid transitions
202 {
203 predicate: () => settings.store.keepIcons,
204 match: /(?<=let ?(?:\i,)*?{folderNode:\i,setNodeRef:\i,.+?expanded:(\i),.+?;)(?=let)/,
205 replace: (_, isExpanded) => `${isExpanded}=!!arguments[0]?.isBetterFolders&&${isExpanded};`
206 },
207 // Disable expanding and collapsing folders transition in the normal GuildsBar sidebar
208 {
209 predicate: () => !settings.store.keepIcons,
210 match: /(?=,\{from:\{height)/,
211 replace: "&&$self.shouldShowTransition(arguments[0])"
212 },
213 // If we are rendering the normal GuildsBar sidebar, we avoid rendering guilds from folders that are expanded
214 {
215 predicate: () => !settings.store.keepIcons,
216 match: /"--custom-folder-color".+?(?=\i\(\(\i,\i,\i\)=>{let{key:.{0,70}"ul")(?<=selected:\i,expanded:(\i),.+?)/,
217 replace: (m, isExpanded) => `${m}$self.shouldRenderContents(arguments[0],${isExpanded})?null:`
218 },
219 // Decide if we should render the expanded folder background if we are rendering the Better Folders sidebar
220 {
221 predicate: () => settings.store.showFolderIcon !== FolderIconDisplay.Always,
222 match: /"--custom-folder-color".{0,110}?children:\[/,
223 replace: "$&$self.shouldShowFolderIconAndBackground(!!arguments[0]?.isBetterFolders,arguments[0]?.betterFoldersExpandedIds)&&"
224 },
225 // Decide if we should render the expanded folder icon if we are rendering the Better Folders sidebar
226 {
227 predicate: () => settings.store.showFolderIcon !== FolderIconDisplay.Always,
228 match: /"--custom-folder-color".+?className:\i\.\i}\),(?=\i,)/,
229 replace: "$&!$self.shouldShowFolderIconAndBackground(!!arguments[0]?.isBetterFolders,arguments[0]?.betterFoldersExpandedIds)?null:"
230 }
231 ]
232 },
233 {
234 find: "APPLICATION_LIBRARY,render:",
235 predicate: () => settings.store.sidebar,
236 group: true,
237 replacement: [
238 {
239 // Render the Better Folders sidebar
240 // Discord has two different places where they render the sidebar.
241 // One is for visual refresh, one is not,
242 // and each has a bunch of conditions &&ed in front of it.
243 // Add the betterFolders sidebar to both, keeping the conditions Discord uses.
244 match: /(?<=[[,])((?:!?\i&&)+)\(.{0,50}({className:\i\.\i,themeOverride:\i})\)/g,
245 replace: (m, conditions, props) => `${m},${conditions}$self.FolderSideBar(${props})`
246 },
247 {
248 // Add grid styles to fix aligment with other visual refresh elements
249 match: /(?<=className:)\i\.\i(?=,"data-fullscreen")/,
250 replace: `"${GRID_STYLE_NAME} "+$&`
251 }
252 ]
253 },
254 {
255 find: "#{intl::DISCODO_DISABLED}",
256 predicate: () => settings.store.closeAllHomeButton,
257 replacement: {
258 // Close all folders when clicking the home button
259 match: /(?<=onClick:\(\)=>{)(?=.{0,300}"discodo")/,
260 replace: "$self.closeFolders();"
261 }
262 }
263 ],
264
265 flux: {
266 CHANNEL_SELECT(data) {
267 if (!settings.store.closeAllFolders && !settings.store.forceOpen)
268 return;
269
270 if (lastGuildId !== data.guildId) {
271 lastGuildId = data.guildId;
272 const guildFolder = getGuildFolder(data.guildId);
273
274 if (guildFolder?.folderId) {
275 if (settings.store.forceOpen && !ExpandedGuildFolderStore.isFolderExpanded(guildFolder.folderId)) {
276 FolderUtils.toggleGuildFolderExpand(guildFolder.folderId);
277 }
278 } else if (settings.store.closeAllFolders) {
279 closeFolders();
280 }
281 }
282 },
283
284 TOGGLE_GUILD_FOLDER_EXPAND(data) {
285 if (settings.store.closeOthers && !dispatchingFoldersClose) {
286 dispatchingFoldersClose = true;
287
288 FluxDispatcher.wait(() => {
289 const expandedFolders = ExpandedGuildFolderStore.getExpandedFolders();
290
291 if (expandedFolders.size > 1) {
292 for (const id of expandedFolders) if (id !== data.folderId)
293 FolderUtils.toggleGuildFolderExpand(id);
294 }
295
296 dispatchingFoldersClose = false;
297 });
298 }
299 },
300
301 LOGOUT() {
302 closeFolders();
303 }
304 },
305
306 FolderSideBar,
307 closeFolders,
308
309
310 wrapGuildNodeComponent(node: any, originalComponent: () => ReactNode, isBetterFolders: boolean, expandedFolderIds?: Set<any>) {
311 if (
312 !isBetterFolders ||
313 node.type === "folder" && expandedFolderIds?.has(node.id) ||
314 node.type === "guild" && expandedFolderIds?.has(node.parentId)
315 ) {
316 return originalComponent();
317 }
318
319 return (
320 <div style={{ display: "none" }}>
321 {originalComponent()}
322 </div>
323 );
324 },
325
326 makeGuildsBarGuildListFilter(isBetterFolders: boolean) {
327 return (child: any) => {
328 if (!isBetterFolders) {
329 return true;
330 }
331
332 try {
333 // can cause hang if intl message is not found
334 const serversIntlMsg = getIntlMessage("SERVERS");
335 if (!serversIntlMsg) {
336 new Logger("BetterFolders").error("Failed to get SERVERS intl message");
337 return true;
338 }
339 return child?.props?.["aria-label"] === serversIntlMsg;
340 } catch (e) {
341 console.error(e);
342 return true;
343 }
344 };
345 },
346
347 makeGuildsBarSidebarFilter(isBetterFolders: boolean) {
348 return (child: any) => {
349 if (!isBetterFolders) {
350 return true;
351 }
352
353 try {
354 return filterTreeWithTargetNode(child, child => child?.props?.renderTreeNode != null);
355 } catch (e) {
356 console.error(e);
357 return true;
358 }
359 };
360 },
361
362 shouldShowFolderIconAndBackground(isBetterFolders: boolean, expandedFolderIds?: Set<any>) {
363 if (!isBetterFolders) {
364 return true;
365 }
366
367 switch (settings.store.showFolderIcon) {
368 case FolderIconDisplay.Never:
369 return false;
370 case FolderIconDisplay.Always:
371 return true;
372 case FolderIconDisplay.MoreThanOneFolderExpanded:
373 return (expandedFolderIds?.size ?? 0) > 1;
374 default:
375 return true;
376 }
377 },
378
379 shouldShowTransition(props: any) {
380 // Pending guilds
381 if (props?.folderNode?.id === 1) return true;
382
383 return !!props?.isBetterFolders;
384 },
385
386 shouldRenderContents(props: any, isExpanded: boolean) {
387 // Pending guilds
388 if (props?.folderNode?.id === 1) return false;
389
390 return !props?.isBetterFolders && isExpanded;
391 }
392});
393