/* * Vencord, a modification for Discord's desktop app * Copyright (c) 2023 Vendicated and contributors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ import "./style.css"; import { definePluginSettings } from "@api/Settings"; import { Devs } from "@utils/constants"; import { getIntlMessage } from "@utils/discord"; import definePlugin, { OptionType } from "@utils/types"; import type { GuildFolder } from "@vencord/discord-types"; import { findByPropsLazy, findComponentByCodeLazy, findStoreLazy } from "@webpack"; import { ChannelStore, FluxDispatcher, ReadStateStore } from "@webpack/common"; import { ReactNode } from "react"; import FolderSideBar from "./FolderSideBar"; enum FolderIconDisplay { Never, Always, MoreThanOneFolderExpanded } type GuildTreeNode = { id: string | number; type?: string; parentId?: string | number | null; name?: string; children?: GuildTreeNode[]; isBetterFoldersNested?: boolean; [key: string]: unknown; }; type FolderRenderProps = { isBetterFolders?: boolean; folderNode?: GuildTreeNode; }; type FolderDragItem = { type?: string; nodeId: string | number; }; type FolderMentionProps = { mentionCount?: number; folderNode?: GuildTreeNode; }; export const ExpandedGuildFolderStore = findStoreLazy("ExpandedGuildFolderStore"); export const SortedGuildStore = findStoreLazy("SortedGuildStore"); const FolderUtils = findByPropsLazy("move", "toggleGuildFolderExpand"); const FolderItem = findComponentByCodeLazy("FolderItem", "onExpandCollapse", "folderButtonSize"); const MAX_TREE_FILTER_DEPTH = 1000; let lastGuildId = null as string | null; let dispatchingFoldersClose = false; function getGuildFolder(id: string) { return SortedGuildStore.getGuildFolders().find((folder: GuildFolder) => folder.guildIds.includes(id)); } function closeFolders() { for (const id of ExpandedGuildFolderStore.getExpandedFolders()) FolderUtils.toggleGuildFolderExpand(id); } // Nuckyz: Unsure if this should be a general utility or not function filterTreeWithTargetNode(children: any, predicate: (node: any) => boolean, visited = new WeakSet(), depth = 0) { if (children == null) { return false; } if (depth > MAX_TREE_FILTER_DEPTH) { return false; } if (typeof children === "object") { if (visited.has(children)) return false; visited.add(children); } if (!Array.isArray(children)) { if (predicate(children)) { return true; } return filterTreeWithTargetNode(children.props?.children, predicate, visited, depth + 1); } let childIsTargetChild = false; for (let i = 0; i < children.length; i++) { const shouldKeep = filterTreeWithTargetNode(children[i], predicate, visited, depth + 1); if (shouldKeep) { childIsTargetChild = true; continue; } children.splice(i--, 1); } return childIsTargetChild; } function getNestedFolderMap(): Record { return settings.store.nestedFolders ?? {}; } function saveNestedFolderMap(map: Record) { settings.store.nestedFolders = map; } export function getParentFolderId(childId: string | number): string | undefined { return getNestedFolderMap()[childId.toString()]; } export function getChildFolderIds(parentId: string | number): string[] { const map = getNestedFolderMap(); return Object.entries(map) .filter(([, pid]) => pid === String(parentId)) .map(([cid]) => cid); } function getDescendantFolderIds(parentId: string | number): string[] { const descendants: string[] = []; const queue = [...getChildFolderIds(parentId)]; while (queue.length) { const current = queue.shift(); if (!current) continue; descendants.push(current); queue.push(...getChildFolderIds(current)); } return descendants; } function nestFolder(childId: string, parentId: string) { if (childId === parentId) return; if (hasParentInChain(parentId, childId)) return; const map = { ...getNestedFolderMap() }; delete map[childId]; map[childId] = parentId; saveNestedFolderMap(map); } function unnestFolder(childId: string) { const map = { ...getNestedFolderMap() }; if (map[childId] == null) return; delete map[childId]; saveNestedFolderMap(map); } function hasParentInChain(childId: string, parentId: string): boolean { const seen = new Set(); let current = getParentFolderId(childId); while (current != null && !seen.has(current)) { if (current === parentId) return true; seen.add(current); current = getParentFolderId(current); } return false; } function areNestedRelated(firstId: string, secondId: string): boolean { return hasParentInChain(firstId, secondId) || hasParentInChain(secondId, firstId); } export const settings = definePluginSettings({ sidebar: { type: OptionType.BOOLEAN, description: "Display servers from folder on dedicated sidebar", restartNeeded: true, default: true }, sidebarAnim: { type: OptionType.BOOLEAN, description: "Animate opening the folder sidebar", default: true }, closeAllFolders: { type: OptionType.BOOLEAN, description: "Close all folders when selecting a server not in a folder", default: false }, closeAllHomeButton: { type: OptionType.BOOLEAN, description: "Close all folders when clicking on the home button", restartNeeded: true, default: false }, closeOthers: { type: OptionType.BOOLEAN, description: "Close other folders when opening a folder", default: false }, closeServerFolder: { type: OptionType.BOOLEAN, description: "Close folder when selecting a server in that folder", default: false, }, forceOpen: { type: OptionType.BOOLEAN, description: "Force a folder to open when switching to a server of that folder", default: false }, keepIcons: { type: OptionType.BOOLEAN, description: "Keep showing guild icons in the primary guild bar folder when it's open in the BetterFolders sidebar", restartNeeded: true, default: false }, showFolderIcon: { type: OptionType.SELECT, description: "Show the folder icon above the folder guilds in the BetterFolders sidebar", options: [ { label: "Never", value: FolderIconDisplay.Never }, { label: "Always", value: FolderIconDisplay.Always, default: true }, { label: "When more than one folder is expanded", value: FolderIconDisplay.MoreThanOneFolderExpanded } ], restartNeeded: true }, }).withPrivateSettings<{ nestedFolders: Record; }>(); const IS_BETTER_FOLDERS_VAR = "typeof isBetterFolders!=='undefined'?isBetterFolders:arguments[0]?.isBetterFolders"; const BETTER_FOLDERS_EXPANDED_IDS_VAR = "typeof betterFoldersExpandedIds!=='undefined'?betterFoldersExpandedIds:arguments[0]?.betterFoldersExpandedIds"; const GRID_STYLE_NAME = "vc-betterFolders-sidebar-grid"; export default definePlugin({ name: "BetterFolders", description: "Shows server folders on dedicated sidebar and adds folder related improvements", authors: [Devs.juby, Devs.AutumnVN, Devs.Nuckyz], isModified: true, settings, start() { settings.store.nestedFolders ??= {}; }, patches: [ { find: '("guildsnav")', predicate: () => settings.store.sidebar, replacement: [ // Create the isBetterFolders and betterFoldersExpandedIds variables in the GuildsBar component // Needed because we access this from a non-arrow closure so we can't use arguments[0] { match: /let{disableAppDownload:\i=\i\.isPlatformEmbedded,isOverlay:.+?(?=}=\i,)/, replace: "$&,isBetterFolders,betterFoldersExpandedIds" }, // Export the isBetterFolders and betterFoldersExpandedIds variable to the Guild List component { match: /,{guildDiscoveryButton:\i,/g, replace: "$&isBetterFolders:arguments[0]?.isBetterFolders,betterFoldersExpandedIds:arguments[0]?.betterFoldersExpandedIds," }, // 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 { match: /switch\((\i)\.type\){.+?default:return null}/, replace: `return $self.wrapGuildNodeComponent($1,()=>{$&},${IS_BETTER_FOLDERS_VAR},${BETTER_FOLDERS_EXPANDED_IDS_VAR});` }, // Export the isBetterFolders variable to the folder component { match: /switch\(\i\.type\){case \i\.\i\.FOLDER:.+?folderNode:\i,/, replace: `$&isBetterFolders:${IS_BETTER_FOLDERS_VAR},` }, // Make the callback for returning the guild node component depend on isBetterFolders and betterFoldersExpandedIds { match: /switch\(\i\.type\).+?,\i,\i\.setNodeRef/, replace: "$&,arguments[0]?.isBetterFolders,arguments[0]?.betterFoldersExpandedIds" }, // If we are rendering the Better Folders sidebar, we filter out everything but the guilds and folders from the Guild List children { match: /lastTargetNode:\i\[\i\.length-1\].+?}\)(?::null)?\](?=}\))/, replace: "$&.filter($self.makeGuildsBarGuildListFilter(!!arguments[0]?.isBetterFolders))" }, // If we are rendering the Better Folders sidebar, we filter out everything but the Guild List from the Sidebar children { match: /reverse:!0,.{0,150}?barClassName:.+?\}\)\]/, replace: "$&.filter($self.makeGuildsBarSidebarFilter(!!arguments[0]?.isBetterFolders))" } ] }, { find: '("guildsnav")', predicate: () => !settings.store.sidebar, replacement: { match: /switch\((\i)\.type\){case \i\.\i\.FOLDER:.{0,800}?case \i\.\i\.GUILD:.{0,800}?default:return null}/, replace: "return $self.wrapGuildNodeComponent($1,()=>{$&},false,void 0);" } }, { // This is the parent folder component find: ".toggleGuildFolderExpand(", predicate: () => settings.store.sidebar && settings.store.showFolderIcon !== FolderIconDisplay.Always, replacement: [ { // Modify the expanded state to instead return the list of expanded folders match: /(\],\(\)=>)(\i\.\i)\.isFolderExpanded\(\i\)\)/, replace: (_, rest, ExpandedGuildFolderStore) => `${rest}${ExpandedGuildFolderStore}.getExpandedFolders())`, }, { // 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 // Also export the list of expanded folders to the child folder component if the patch above succeeds, else export undefined match: /(?<=folderNode:(\i),expanded:)\i(?=,)/, replace: (isExpandedOrExpandedIds, folderNote) => "" + `typeof ${isExpandedOrExpandedIds}==="boolean"?${isExpandedOrExpandedIds}:${isExpandedOrExpandedIds}.has(${folderNote}.id),` + `betterFoldersExpandedIds:${isExpandedOrExpandedIds} instanceof Set?${isExpandedOrExpandedIds}:void 0` } ] }, { find: ".FOLDER_ITEM_ANIMATION_DURATION),", predicate: () => settings.store.sidebar, replacement: [ // 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) // 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 { predicate: () => settings.store.keepIcons, match: /(?<=let ?(?:\i,)*?{folderNode:\i,setNodeRef:\i,.+?expanded:(\i),.+?;)(?=let)/, replace: (_, isExpanded) => `${isExpanded}=!!arguments[0]?.isBetterFolders&&${isExpanded};` }, // Disable expanding and collapsing folders transition in the normal GuildsBar sidebar { predicate: () => !settings.store.keepIcons, match: /(?=,\{from:\{height)/, replace: "&&$self.shouldShowTransition(arguments[0])" }, // If we are rendering the normal GuildsBar sidebar, we avoid rendering guilds from folders that are expanded { predicate: () => !settings.store.keepIcons, match: /"--custom-folder-color".+?(?=\i\(\(\i,\i,\i\)=>{let{key:.{0,70}"ul")(?<=selected:\i,expanded:(\i),.+?)/, replace: (m, isExpanded) => `${m}$self.shouldRenderContents(arguments[0],${isExpanded})?null:` }, // Decide if we should render the expanded folder background if we are rendering the Better Folders sidebar { predicate: () => settings.store.showFolderIcon !== FolderIconDisplay.Always, match: /"--custom-folder-color".{0,110}?children:\[/, replace: "$&$self.shouldShowFolderIconAndBackground(!!arguments[0]?.isBetterFolders,arguments[0]?.betterFoldersExpandedIds)&&" }, // Decide if we should render the expanded folder icon if we are rendering the Better Folders sidebar { predicate: () => settings.store.showFolderIcon !== FolderIconDisplay.Always, match: /"--custom-folder-color".+?className:\i\.\i}\),(?=\i,)/, replace: "$&!$self.shouldShowFolderIconAndBackground(!!arguments[0]?.isBetterFolders,arguments[0]?.betterFoldersExpandedIds)?null:" } ] }, { find: ".FOLDER_ITEM_ANIMATION_DURATION),", replacement: [ { match: /mentionCount:(\i),isMentionLowImportance:(\i)/, replace: "mentionCount:$self.getFolderMentionCountWithNested(arguments[0]),isMentionLowImportance:$self.getFolderIsMentionLowImportanceWithNested(arguments[0],$2)" }, { match: /(\{id:\i,name:\i,children:\i\})=(\i),/, replace: "$1=$self.getFolderNodeForRender($2)," }, { match: /(?<=gap:"xs",className:)(\i\.\i)/, replace: "$self.getFolderGuildsListClassName(arguments[0],$1)" } ] }, { find: "APPLICATION_LIBRARY,render:", predicate: () => settings.store.sidebar, group: true, replacement: [ { // Render the Better Folders sidebar // Discord has two different places where they render the sidebar. // One is for visual refresh, one is not, // and each has a bunch of conditions &&ed in front of it. // Add the betterFolders sidebar to both, keeping the conditions Discord uses. match: /(?<=[[,])((?:!?\i&&)+)\(.{0,50}({className:\i\.\i,themeOverride:\i})\)/g, replace: (m, conditions, props) => `${m},${conditions}$self.FolderSideBar(${props})` }, { // Add grid styles to fix alignment with other visual refresh elements match: /(?<=className:)\i\.\i(?=,"data-fullscreen")/, replace: `"${GRID_STYLE_NAME} "+$&` } ] }, { find: "#{intl::DISCODO_DISABLED}", predicate: () => settings.store.closeAllHomeButton, replacement: { // Close all folders when clicking the home button match: /(?<=onClick:\(\)=>{)(?=.{0,300}"discodo")/, replace: "$self.closeFolders();" } }, { find: "[GuildDropTarget]", all: true, replacement: [ { match: /(\i)=!(\i)&&null==(\i)\.parentId/, replace: "$1=$self.shouldShowCombineTarget($2,$3)" }, { match: /canDrop:\i=>\i\.nodeId.{0,100}==\i\.parentId\)/, replace: "canDrop:e=>$self.canDropOnFolder(e,arguments[1],arguments[3])" }, { match: /drop\(\i\)\{(?=.{0,25}!==\i\.\i\.FOLDER)/, replace: "$&if($self.handleFolderDrop(arguments[0],arguments[1],arguments[2],arguments[3]))return;" }, { match: /\[\i\.\i\.GUILD\](?=.{0,250}#{intl::DND_DROP_COMBINE})/, replace: "[...$self.getFolderAcceptTypes(arguments[0]?.targetNode)]" } ] }, { find: ".hasFetchedRequestToJoinGuilds)", replacement: { match: /return \i\.type!==\i\.\i\.GUILD/, replace: "return $self.renderFolderChild(arguments[0],arguments[1],arguments[2])" } } ], flux: { CHANNEL_SELECT(data) { if (!settings.store.closeAllFolders && !settings.store.forceOpen && !settings.store.closeServerFolder) return; if (lastGuildId !== data.guildId) { lastGuildId = data.guildId; const guildFolder = getGuildFolder(data.guildId); if (guildFolder?.folderId) { if (settings.store.forceOpen && !ExpandedGuildFolderStore.isFolderExpanded(guildFolder.folderId)) { FolderUtils.toggleGuildFolderExpand(guildFolder.folderId); } if (settings.store.closeServerFolder && ExpandedGuildFolderStore.isFolderExpanded(guildFolder.folderId)) { FolderUtils.toggleGuildFolderExpand(guildFolder.folderId); } } else if (settings.store.closeAllFolders) { closeFolders(); } } }, TOGGLE_GUILD_FOLDER_EXPAND(data) { if (settings.store.closeOthers && !dispatchingFoldersClose) { dispatchingFoldersClose = true; FluxDispatcher.wait(() => { const expandedFolders = ExpandedGuildFolderStore.getExpandedFolders(); const expandedId = data.folderId?.toString(); if (expandedFolders.size > 1) { for (const id of expandedFolders) { const folderId = id?.toString(); if (folderId === expandedId || areNestedRelated(folderId, expandedId)) continue; FolderUtils.toggleGuildFolderExpand(id); } } dispatchingFoldersClose = false; }); } }, LOGOUT() { closeFolders(); } }, FolderSideBar, closeFolders, getGuildMentionCount(guildId: string): number { const mentionChannelIds = ReadStateStore.getMentionChannelIds() ?? []; let count = 0; for (const channelId of mentionChannelIds) { const channel = ChannelStore.getChannel(channelId); if (channel?.guild_id !== guildId) continue; count += ReadStateStore.getMentionCount(channelId); } return count; }, getFolderMentionMetaWithNested(props: FolderMentionProps | undefined): { mentionCount: number, hasNestedMention: boolean; } { const folderNode = props?.folderNode; const baseMentionCount = props?.mentionCount ?? 0; if (folderNode?.id == null) { return { mentionCount: baseMentionCount, hasNestedMention: false }; } const descendantIds = getDescendantFolderIds(folderNode.id); if (descendantIds.length === 0) { return { mentionCount: baseMentionCount, hasNestedMention: false }; } const tree = SortedGuildStore.getGuildsTree(); if (typeof tree?.getNode !== "function") { return { mentionCount: baseMentionCount, hasNestedMention: false }; } const nestedGuildIds = new Set(); for (const childFolderId of descendantIds) { const stack = [...(tree.getNode(childFolderId)?.children ?? [])]; while (stack.length) { const node = stack.pop(); if (!node) continue; if (node.type === "guild") { nestedGuildIds.add(node.id?.toString()); continue; } if (node.type === "folder" && Array.isArray(node.children)) { stack.push(...node.children); } } } if (nestedGuildIds.size === 0) { return { mentionCount: baseMentionCount, hasNestedMention: false }; } let nestedMentionCount = 0; for (const guildId of nestedGuildIds) nestedMentionCount += this.getGuildMentionCount(guildId); return { mentionCount: baseMentionCount + nestedMentionCount, hasNestedMention: nestedMentionCount > 0 }; }, getFolderMentionCountWithNested(props: FolderMentionProps | undefined): number { return this.getFolderMentionMetaWithNested(props).mentionCount; }, getFolderIsMentionLowImportanceWithNested(props: FolderMentionProps | undefined, originalFlag: boolean): boolean { return this.getFolderMentionMetaWithNested(props).hasNestedMention ? false : originalFlag; }, augmentFolderChildren(folderNode: GuildTreeNode, originalChildren: GuildTreeNode[]): GuildTreeNode[] { const childIds = getChildFolderIds(folderNode.id); if (childIds.length === 0) return originalChildren; try { const tree = SortedGuildStore.getGuildsTree(); if (typeof tree?.getNode !== "function") { return originalChildren; } const existingIds = new Set(originalChildren.map(child => child?.id?.toString())); const childFolderNodes: GuildTreeNode[] = []; for (const id of childIds) { if (existingIds.has(id?.toString())) continue; const node = tree.getNode(id); if (node) { childFolderNodes.push({ ...node, parentId: folderNode.id, isBetterFoldersNested: true }); } } if (childFolderNodes.length === 0) return originalChildren; return [...childFolderNodes, ...originalChildren]; } catch { return originalChildren; } }, getFolderNodeForRender(folderNode: GuildTreeNode): GuildTreeNode { const children = this.augmentFolderChildren(folderNode, folderNode.children ?? []); if (children === folderNode.children) return folderNode; return { ...folderNode, children }; }, getFolderGuildsListClassName( props: FolderRenderProps | undefined, baseClassName: string ): string { if (props?.isBetterFolders) return baseClassName; const folderNode = props?.folderNode; if (folderNode == null) return baseClassName; const children = this.getFolderNodeForRender(folderNode)?.children; if (!Array.isArray(children)) return baseClassName; return children.some(child => child?.type === "folder") ? `${baseClassName} vc-betterFolders-nested-parent-list` : baseClassName; }, renderFolderChild(node: GuildTreeNode, posInSet: number, setSize: number): ReactNode | null { if (node?.type !== "folder") return null; try { if (!FolderItem) return null; return ( ); } catch { return null; } }, handleFolderDrop(dragItem: FolderDragItem, targetNode: GuildTreeNode, _moveToBelow: boolean, isCombine: boolean): boolean { if (dragItem.type !== "folder") return false; if (!isCombine) { unnestFolder(dragItem.nodeId?.toString()); return false; } if (targetNode.type !== "folder") return false; const childId = dragItem.nodeId?.toString(); const parentId = targetNode.id?.toString(); try { nestFolder(childId, parentId); FluxDispatcher.dispatch({ type: "BETTER_FOLDERS_NESTED_UPDATE" }); return true; } catch { return false; } }, shouldShowCombineTarget(noCombine: boolean, targetNode: GuildTreeNode): boolean { if (noCombine) return false; return targetNode.parentId == null || targetNode.type === "folder"; }, getFolderAcceptTypes(targetNode: GuildTreeNode): string[] { const GUILD = "guild"; const FOLDER = "folder"; if (targetNode?.type === FOLDER) return [GUILD, FOLDER]; return [GUILD]; }, canDropOnFolder(dragItem: FolderDragItem, targetNode: GuildTreeNode, isCombine: boolean): boolean { if (dragItem.nodeId === targetNode.id) return false; if (dragItem.type === "folder" && targetNode.type === "folder") { if (isCombine) return !hasParentInChain(targetNode.id?.toString(), dragItem.nodeId?.toString()); return targetNode.parentId == null; } if (isCombine && dragItem.type === "folder") return false; if (dragItem.type === "folder" && targetNode.parentId != null) return false; return true; }, wrapGuildNodeComponent(node: GuildTreeNode, originalComponent: () => ReactNode, isBetterFolders: boolean, expandedFolderIds?: Set) { if (node.type === "folder") { const mappedParentId = getParentFolderId(node.id); if (mappedParentId != null && (node.parentId?.toString() !== mappedParentId || node.isBetterFoldersNested !== true)) { return (
{originalComponent()}
); } } if ( !isBetterFolders || node.type === "folder" && expandedFolderIds?.has(node.id) || node.type === "guild" && node.parentId != null && expandedFolderIds?.has(node.parentId) ) { return originalComponent(); } return (
{originalComponent()}
); }, makeGuildsBarGuildListFilter(isBetterFolders: boolean) { return (child: any) => { if (!isBetterFolders) { return true; } try { // can cause hang if intl message is not found const serversIntlMsg = getIntlMessage("SERVERS"); if (!serversIntlMsg) { return true; } return child?.props?.["aria-label"] === serversIntlMsg; } catch { return true; } }; }, makeGuildsBarSidebarFilter(isBetterFolders: boolean) { return (child: any) => { if (!isBetterFolders) { return true; } try { return filterTreeWithTargetNode(child, child => child?.props?.renderTreeNode != null); } catch { return true; } }; }, shouldShowFolderIconAndBackground(isBetterFolders: boolean, expandedFolderIds?: Set) { if (!isBetterFolders) { return true; } switch (settings.store.showFolderIcon) { case FolderIconDisplay.Never: return false; case FolderIconDisplay.Always: return true; case FolderIconDisplay.MoreThanOneFolderExpanded: return (expandedFolderIds?.size ?? 0) > 1; default: return true; } }, shouldShowTransition(props: any) { // Pending guilds if (props?.folderNode?.id === 1) return true; return !!props?.isBetterFolders; }, shouldRenderContents(props: any, isExpanded: boolean) { // Pending guilds if (props?.folderNode?.id === 1) return false; return !props?.isBetterFolders && isExpanded; } });