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