Plugin
BetterFolders
Shows server folders on dedicated sidebar and adds folder related improvements
1
/*2
* FiveCord — a Discord client mod3
* Copyright (c) 2025 FiveCord4
* SPDX-License-Identifier: GPL-3.0-or-later5
*/6
7
import "./style.css";8
9
import { definePluginSettings } from "@api/Settings";10
import { Devs } from "@utils/constants";11
import { getIntlMessage } from "@utils/discord";12
import { Logger } from "@utils/Logger";13
import definePlugin, { OptionType } from "@utils/types";14
import { findByPropsLazy, findStoreLazy } from "@webpack";15
import { FluxDispatcher } from "@webpack/common";16
import { ReactNode } from "react";17
18
import FolderSideBar from "./FolderSideBar";19
20
enum FolderIconDisplay {21
Never,22
Always,23
MoreThanOneFolderExpanded24
}25
26
export const ExpandedGuildFolderStore = findStoreLazy("ExpandedGuildFolderStore");27
export const SortedGuildStore = findStoreLazy("SortedGuildStore");28
const FolderUtils = findByPropsLazy("move", "toggleGuildFolderExpand");29
30
let lastGuildId = null as string | null;31
let dispatchingFoldersClose = false;32
33
function getGuildFolder(id: string) {34
return SortedGuildStore.getGuildFolders().find(folder => folder.guildIds.includes(id));35
}36
37
function 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 not43
function 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
70
export const settings = definePluginSettings({71
sidebar: {72
type: OptionType.BOOLEAN,73
description: "Display servers from folder on dedicated sidebar",74
restartNeeded: true,75
default: true76
},77
sidebarAnim: {78
type: OptionType.BOOLEAN,79
description: "Animate opening the folder sidebar",80
default: true81
},82
closeAllFolders: {83
type: OptionType.BOOLEAN,84
description: "Close all folders when selecting a server not in a folder",85
default: false86
},87
closeAllHomeButton: {88
type: OptionType.BOOLEAN,89
description: "Close all folders when clicking on the home button",90
restartNeeded: true,91
default: false92
},93
closeOthers: {94
type: OptionType.BOOLEAN,95
description: "Close other folders when opening a folder",96
default: false97
},98
forceOpen: {99
type: OptionType.BOOLEAN,100
description: "Force a folder to open when switching to a server of that folder",101
default: false102
},103
keepIcons: {104
type: OptionType.BOOLEAN,105
description: "Keep showing guild icons in the primary guild bar folder when it039;s open in the BetterFolders sidebar",106
restartNeeded: true,107
default: false108
},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: true118
}119
});120
121
const IS_BETTER_FOLDERS_VAR = "typeof isBetterFolders!==039;undefined039;?isBetterFolders:arguments[0]?.isBetterFolders";122
const BETTER_FOLDERS_EXPANDED_IDS_VAR = "typeof betterFoldersExpandedIds!==039;undefined039;?betterFoldersExpandedIds:arguments[0]?.betterFoldersExpandedIds";123
const GRID_STYLE_NAME = "vc-betterFolders-sidebar-grid";124
125
export 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 component138
// 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 component144
{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 folder149
{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 component154
{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 betterFoldersExpandedIds159
{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 children164
{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 children169
{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 component177
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 folders182
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 succeeds187
// Also export the list of expanded folders to the child folder component if the patch above succeeds, else export undefined188
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 transitions202
{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 sidebar208
{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 expanded214
{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 sidebar220
{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 sidebar226
{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 sidebar240
// 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 elements249
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 button259
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 found334
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 guilds381
if (props?.folderNode?.id === 1) return true;382
383
return !!props?.isBetterFolders;384
},385
386
shouldRenderContents(props: any, isExpanded: boolean) {387
// Pending guilds388
if (props?.folderNode?.id === 1) return false;389
390
return !props?.isBetterFolders && isExpanded;391
}392
});393