Feat: CollapsibleUI plugin (#970)

* Add CollapsibleUI plugin

* Fix Conflicts

* fix lint

* lint

* use node 22

---------

Co-authored-by: thororen1234 <78185467+thororen1234@users.noreply.github.com>
This commit is contained in:
MasterPepe 2026-04-14 14:33:23 +01:00 • committed by GitHub
parent fd27b98a6c
commit 45b9cc479b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 452 additions and 20 deletions

View file

@ -23,10 +23,10 @@ jobs:
- uses: pnpm/action-setup@v3
- name: Use Node.js 20
- name: Use Node.js 22
uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22
cache: "pnpm"
- name: Install dependencies

View file

@ -38,11 +38,11 @@ jobs:
- uses: pnpm/action-setup@v3
if: github.ref_name != 'bun'
- name: Use Node.js 20
- name: Use Node.js 22
if: github.ref_name != 'bun'
uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22
cache: "pnpm"
- name: Install dependencies

View file

@ -20,10 +20,10 @@ jobs:
- uses: pnpm/action-setup@v3
- name: Use Node.js 20
- name: Use Node.js 22
uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22
cache: "pnpm"
- name: Install dependencies

View file

@ -62,11 +62,11 @@ jobs:
if: steps.check.outputs.release == 'true'
uses: pnpm/action-setup@v3
- name: Use Node.js 20
- name: Use Node.js 22
if: steps.check.outputs.release == 'true'
uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22
cache: "pnpm"
- name: Install dependencies

View file

@ -33,10 +33,10 @@ jobs:
- uses: pnpm/action-setup@v3 # Install pnpm using packageManager key in package.json
- name: Use Node.js 20
- name: Use Node.js 22
uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22
cache: "pnpm"
- name: Install dependencies

View file

@ -18,10 +18,10 @@ jobs:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3 # Install pnpm using packageManager key in package.json
- name: Use Node.js 20
- name: Use Node.js 22
uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22
cache: "pnpm"
- name: Install dependencies

View file

@ -22,8 +22,8 @@ export default definePlugin({
find: ".DISPLAY_NAME_STYLES_COACHMARK)",
replacement: [
{
match: /(?<=className:(\i)\.\i,style:\i,)children:\[/,
replace: "children:[...$self.renderButtons(arguments[0],$1),"
match: /children:\[(?=.{0,50}accountContainerRef:\i)/,
replace: "children:[...$self.renderButtons(arguments[0]),"
},
// fix discord weird shrink with extra buttons
{

View file

@ -0,0 +1,148 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { ChannelToolbarButton } from "@api/HeaderBar";
import ErrorBoundary from "@components/ErrorBoundary";
import { EquicordDevs } from "@utils/constants";
import { classNameFactory } from "@utils/css";
import { classes } from "@utils/misc";
import definePlugin from "@utils/types";
import { ContextMenuApi, Menu } from "@webpack/common";
import type { ReactNode, SVGProps } from "react";
import { PanelId, panelRegistry, settings, toolbarPanelOrder } from "./settings";
import managedStyle from "./style.css?managed";
const cl = classNameFactory("vc-collapsible-ui-");
type StateStore = ReturnType<typeof settings.use>;
function PanelsIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" {...props}>
<path fill="currentColor" d="M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v3H3V5Zm0 5h6v11H5a2 2 0 0 1-2-2V10Zm8 0h10v9a2 2 0 0 1-2 2H11V10Zm2-5h8v3h-8V5Z" />
</svg>
);
}
function getStateClasses(store: StateStore) {
const classNames = [cl("state"), cl(`transition-${store.transitionMs}`)];
for (const panelId of toolbarPanelOrder) {
const panel = panelRegistry[panelId];
if (store[panel.collapsedKey]) classNames.push(cl(`${panel.classId}-collapsed`));
}
return classes(...classNames);
}
function togglePanel(panelId: PanelId) {
const key = panelRegistry[panelId].collapsedKey;
settings.store[key] = !settings.store[key];
}
const ToolbarMenu = ErrorBoundary.wrap(({ onClose }: { onClose(): void; }) => {
const store = settings.use();
return (
<Menu.Menu navId="vc-collapsible-ui-toolbar-menu" onClose={onClose} aria-label="Collapsible UI">
{toolbarPanelOrder.map(panelId => {
const panel = panelRegistry[panelId];
const collapsed = store[panel.collapsedKey];
return (
<Menu.MenuCheckboxItem
key={panelId}
id={`vc-collapsible-ui-${panel.classId}`}
label={panel.label}
checked={!collapsed}
action={() => togglePanel(panelId)}
/>
);
})}
</Menu.Menu>
);
}, { noop: true });
const ToolbarButtons = ErrorBoundary.wrap(() => {
const store = settings.use();
const anyCollapsed = toolbarPanelOrder.some(panelId => store[panelRegistry[panelId].collapsedKey]);
return (
<>
<span aria-hidden className={getStateClasses(store)} style={{ display: "none" }} />
<ChannelToolbarButton
icon={PanelsIcon}
tooltip="Collapsible UI"
aria-label="Collapsible UI"
selected={anyCollapsed}
onClick={event => ContextMenuApi.openContextMenu(event, () => <ToolbarMenu onClose={ContextMenuApi.closeContextMenu} />)}
onContextMenu={event => ContextMenuApi.openContextMenu(event, () => <ToolbarMenu onClose={ContextMenuApi.closeContextMenu} />)}
/>
</>
);
}, { noop: true });
const ChatButtonsRow = ErrorBoundary.wrap(({ buttons }: { buttons: ReactNode[]; }) => {
const { chatButtonsCollapsed } = settings.use();
if (buttons.length === 0) return <>{buttons}</>;
return (
<div
className={classes(
cl("chat-buttons"),
chatButtonsCollapsed && cl("chat-buttons-collapsed")
)}
>
<div className={cl("chat-buttons-items")}>
{buttons}
</div>
</div>
);
}, { noop: true });
export default definePlugin({
name: "CollapsibleUI",
description: "Native collapsible channel, member, chat button, and user area surfaces.",
authors: [EquicordDevs.benjii],
tags: ["ui", "sidebar", "layout"],
managedStyle,
settings,
headerBarButton: {
icon: PanelsIcon,
location: "channeltoolbar",
priority: 25,
render: () => <ToolbarButtons />,
},
patches: [
{
find: ".DISPLAY_NAME_STYLES_COACHMARK)",
replacement: {
match: /className:(\i\.\i),(?=style:(\i),children:\[)/,
replace: "className:$self.userAreaControlsClass($1),"
}
},
{
find: '"sticker")',
replacement: {
match: /(?<="div",\{.{0,15}children:)(.+?)\}/,
replace: "$self.wrapChatButtons($1)}"
}
}
],
wrapChatButtons(buttons: ReactNode[]) {
if (!Array.isArray(buttons) || buttons.length === 0) return buttons;
return <ChatButtonsRow buttons={buttons} />;
},
userAreaControlsClass(className: string) {
return classes(className, cl("user-area-controls"));
},
});

View file

@ -0,0 +1,70 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { definePluginSettings } from "@api/Settings";
import { OptionType } from "@utils/types";
export const transitionMarkers = [0, 80, 120, 160, 200, 260, 320];
export const panelRegistry = {
channelList: {
id: "channelList",
classId: "channel-list",
label: "Channel List",
collapsedKey: "channelListCollapsed",
},
membersList: {
id: "membersList",
classId: "members-list",
label: "Members List",
collapsedKey: "membersListCollapsed",
},
chatButtons: {
id: "chatButtons",
classId: "chat-buttons",
label: "Message Buttons",
collapsedKey: "chatButtonsCollapsed",
},
userArea: {
id: "userArea",
classId: "user-area",
label: "User Area",
collapsedKey: "userAreaCollapsed",
},
} as const;
export type PanelId = keyof typeof panelRegistry;
export const toolbarPanelOrder = ["channelList", "membersList", "chatButtons", "userArea"] as const satisfies readonly PanelId[];
export const settings = definePluginSettings({
transitionMs: {
type: OptionType.SLIDER,
description: "Panel transition speed in milliseconds.",
default: 160,
markers: transitionMarkers,
},
channelListCollapsed: {
type: OptionType.BOOLEAN,
description: "Persist the channel list as collapsed.",
default: false,
},
membersListCollapsed: {
type: OptionType.BOOLEAN,
description: "Persist the members list as collapsed.",
default: false,
},
chatButtonsCollapsed: {
type: OptionType.BOOLEAN,
description: "Persist the message button row as collapsed.",
default: false,
},
userAreaCollapsed: {
type: OptionType.BOOLEAN,
description: "Persist the user area as collapsed.",
default: false,
},
});

View file

@ -0,0 +1,213 @@
body:has(.vc-collapsible-ui-state) {
--vc-cui-chat-buttons-width: min(320px, 36vw);
--vc-cui-collapsed-size: 12px;
--vc-cui-hover-peek: 18px;
--vc-cui-hover-delay: 120ms;
--vc-cui-user-area-collapsed-width: 52px;
--vc-cui-transition: 160ms;
--vc-cui-ease: cubic-bezier(.2, 0, 0, 1);
}
body:has(.vc-collapsible-ui-transition-0) {
--vc-cui-transition: 0ms;
}
body:has(.vc-collapsible-ui-transition-80) {
--vc-cui-transition: 80ms;
}
body:has(.vc-collapsible-ui-transition-120) {
--vc-cui-transition: 120ms;
}
body:has(.vc-collapsible-ui-transition-160) {
--vc-cui-transition: 160ms;
}
body:has(.vc-collapsible-ui-transition-200) {
--vc-cui-transition: 200ms;
}
body:has(.vc-collapsible-ui-transition-260) {
--vc-cui-transition: 260ms;
}
body:has(.vc-collapsible-ui-transition-320) {
--vc-cui-transition: 320ms;
}
body:has(.vc-collapsible-ui-channel-list-collapsed):has([class*="sidebarList_"]:hover) [class*="sidebarList_"] {
transition-delay: var(--vc-cui-hover-delay);
}
body:has(.vc-collapsible-ui-channel-list-collapsed):has([class*="sidebarList_"]:hover) [class*="sidebarList_"] > * {
opacity: 1;
pointer-events: auto;
transition: opacity var(--vc-cui-transition) var(--vc-cui-ease);
transition-delay: var(--vc-cui-hover-delay);
}
body:has(.vc-collapsible-ui-channel-list-collapsed):not(:has([class*="sidebarList_"]:hover), :has([class*="sidebar_"] > [class*="panels_"]:hover)) [class*="sidebarList_"] {
width: var(--vc-cui-collapsed-size) !important;
inline-size: var(--vc-cui-collapsed-size) !important;
min-width: var(--vc-cui-collapsed-size) !important;
min-inline-size: var(--vc-cui-collapsed-size) !important;
max-width: var(--vc-cui-collapsed-size) !important;
max-inline-size: var(--vc-cui-collapsed-size) !important;
flex: 0 0 var(--vc-cui-collapsed-size) !important;
overflow: hidden !important;
transition:
width var(--vc-cui-transition) var(--vc-cui-ease),
inline-size var(--vc-cui-transition) var(--vc-cui-ease),
min-width var(--vc-cui-transition) var(--vc-cui-ease),
min-inline-size var(--vc-cui-transition) var(--vc-cui-ease),
max-width var(--vc-cui-transition) var(--vc-cui-ease),
max-inline-size var(--vc-cui-transition) var(--vc-cui-ease),
flex-basis var(--vc-cui-transition) var(--vc-cui-ease) !important;
}
body:has(.vc-collapsible-ui-channel-list-collapsed):not(:has([class*="sidebarList_"]:hover), :has([class*="sidebar_"] > [class*="panels_"]:hover)) [class*="sidebarList_"] > * {
opacity: 0;
pointer-events: none;
transition: opacity var(--vc-cui-transition) var(--vc-cui-ease);
}
body:has(.vc-collapsible-ui-channel-list-collapsed):not(:has([class*="sidebar_"] > [class*="panels_"]:hover)) [class*="sidebar_"] > [class*="panels_"] {
width: var(--vc-cui-user-area-collapsed-width) !important;
inline-size: var(--vc-cui-user-area-collapsed-width) !important;
min-width: var(--vc-cui-user-area-collapsed-width) !important;
min-inline-size: var(--vc-cui-user-area-collapsed-width) !important;
max-width: var(--vc-cui-user-area-collapsed-width) !important;
max-inline-size: var(--vc-cui-user-area-collapsed-width) !important;
overflow: hidden !important;
transition:
width var(--vc-cui-transition) var(--vc-cui-ease),
inline-size var(--vc-cui-transition) var(--vc-cui-ease),
min-width var(--vc-cui-transition) var(--vc-cui-ease),
min-inline-size var(--vc-cui-transition) var(--vc-cui-ease),
max-width var(--vc-cui-transition) var(--vc-cui-ease),
max-inline-size var(--vc-cui-transition) var(--vc-cui-ease) !important;
}
body:has(.vc-collapsible-ui-channel-list-collapsed):not(:has([class*="sidebar_"] > [class*="panels_"]:hover)) .vc-collapsible-ui-user-area-controls {
max-width: 0 !important;
min-width: 0 !important;
overflow: hidden !important;
opacity: 0;
pointer-events: none;
transition:
max-width var(--vc-cui-transition) var(--vc-cui-ease),
opacity var(--vc-cui-transition) var(--vc-cui-ease) !important;
}
body:has(.vc-collapsible-ui-channel-list-collapsed):has([class*="sidebar_"] > [class*="panels_"]:hover) .vc-collapsible-ui-user-area-controls {
opacity: 1;
pointer-events: auto;
}
body:has(.vc-collapsible-ui-members-list-collapsed) [class*="membersWrap"] {
overflow: hidden !important;
transition:
width var(--vc-cui-transition) var(--vc-cui-ease),
min-width var(--vc-cui-transition) var(--vc-cui-ease),
max-width var(--vc-cui-transition) var(--vc-cui-ease) !important;
}
body:has(.vc-collapsible-ui-members-list-collapsed):has([class*="membersWrap"]:hover) [class*="membersWrap"],
body:has(.vc-collapsible-ui-members-list-collapsed):has([class*="membersWrap"]:focus-within) [class*="membersWrap"] {
transition-delay: var(--vc-cui-hover-delay);
}
body:has(.vc-collapsible-ui-members-list-collapsed):has([class*="membersWrap"]:hover) [class*="membersWrap"] > :where(*),
body:has(.vc-collapsible-ui-members-list-collapsed):has([class*="membersWrap"]:focus-within) [class*="membersWrap"] > :where(*) {
opacity: 1;
pointer-events: auto;
transition: opacity var(--vc-cui-transition) var(--vc-cui-ease);
transition-delay: var(--vc-cui-hover-delay);
}
body:has(.vc-collapsible-ui-members-list-collapsed):not(:has([class*="membersWrap"]:hover), :has([class*="membersWrap"]:focus-within)) [class*="membersWrap"] {
width: var(--vc-cui-collapsed-size) !important;
min-width: var(--vc-cui-collapsed-size) !important;
max-width: var(--vc-cui-collapsed-size) !important;
}
body:has(.vc-collapsible-ui-members-list-collapsed):not(:has([class*="membersWrap"]:hover), :has([class*="membersWrap"]:focus-within)) [class*="membersWrap"] > :where(*) {
opacity: 0;
pointer-events: none;
transition: opacity var(--vc-cui-transition) var(--vc-cui-ease);
}
body:has(.vc-collapsible-ui-user-area-collapsed):has([class*="sidebar_"] > [class*="panels_"]:hover) [class*="sidebar_"] > [class*="panels_"] > :where(*),
body:has(.vc-collapsible-ui-user-area-collapsed):has([class*="sidebar_"] > [class*="panels_"]:focus-within) [class*="sidebar_"] > [class*="panels_"] > :where(*) {
opacity: 1;
pointer-events: auto;
transition: opacity var(--vc-cui-transition) var(--vc-cui-ease);
}
body:has(.vc-collapsible-ui-user-area-collapsed):not(:has([class*="sidebar_"] > [class*="panels_"]:hover), :has([class*="sidebar_"] > [class*="panels_"]:focus-within)) [class*="sidebar_"] > [class*="panels_"] {
height: var(--vc-cui-hover-peek) !important;
block-size: var(--vc-cui-hover-peek) !important;
min-height: var(--vc-cui-hover-peek) !important;
min-block-size: var(--vc-cui-hover-peek) !important;
max-height: var(--vc-cui-hover-peek) !important;
max-block-size: var(--vc-cui-hover-peek) !important;
overflow: hidden !important;
border-top-width: 0 !important;
padding-block: 0 !important;
transition:
height var(--vc-cui-transition) var(--vc-cui-ease),
block-size var(--vc-cui-transition) var(--vc-cui-ease),
min-height var(--vc-cui-transition) var(--vc-cui-ease),
min-block-size var(--vc-cui-transition) var(--vc-cui-ease),
max-height var(--vc-cui-transition) var(--vc-cui-ease),
max-block-size var(--vc-cui-transition) var(--vc-cui-ease),
padding var(--vc-cui-transition) var(--vc-cui-ease) !important;
}
body:has(.vc-collapsible-ui-user-area-collapsed):not(:has([class*="sidebar_"] > [class*="panels_"]:hover), :has([class*="sidebar_"] > [class*="panels_"]:focus-within)) [class*="sidebar_"] > [class*="panels_"] > :where(*) {
opacity: 0;
pointer-events: none;
transition: opacity var(--vc-cui-transition) var(--vc-cui-ease);
}
.vc-collapsible-ui-chat-buttons {
display: flex;
align-items: center;
min-inline-size: 0;
}
.vc-collapsible-ui-chat-buttons-collapsed {
min-inline-size: var(--vc-cui-hover-peek);
}
.vc-collapsible-ui-chat-buttons-items {
display: flex;
align-items: center;
min-inline-size: 0;
max-inline-size: var(--vc-cui-chat-buttons-width);
overflow: hidden;
transition:
max-inline-size var(--vc-cui-transition) var(--vc-cui-ease),
opacity var(--vc-cui-transition) var(--vc-cui-ease),
margin var(--vc-cui-transition) var(--vc-cui-ease);
}
.vc-collapsible-ui-chat-buttons-collapsed .vc-collapsible-ui-chat-buttons-items {
max-inline-size: 0;
opacity: 0;
pointer-events: none;
}
.vc-collapsible-ui-chat-buttons-collapsed:hover .vc-collapsible-ui-chat-buttons-items {
max-inline-size: var(--vc-cui-chat-buttons-width);
opacity: 1;
pointer-events: auto;
transition-delay: var(--vc-cui-hover-delay);
}
@media (prefers-reduced-motion: reduce) {
body:has(.vc-collapsible-ui-state) {
--vc-cui-transition: 0ms;
}
}

View file

@ -74,7 +74,7 @@ function ButtonsInnerComponent({ buttons }: { buttons: ReactNode[]; }) {
export default definePlugin({
name: "HideChatButtons",
description: "able to hide the chat buttons",
description: "Able to hide the chat buttons",
settings: settings,
authors: [EquicordDevs.iamme],
patches: [

View file

@ -256,9 +256,11 @@ export async function deleteMessagesBulkIDB(message_ids: string[]) {
message_ids.forEach(id => cachedMessages.delete(id));
}
export async function clearMessagesIDB() {
await db.clear("messages");
export async function clearMessagesIDB(showToast = true) {
cachedMessages.clear();
await db.clear("messages");
if (!showToast) return;
Toasts.show({
type: Toasts.Type.MESSAGE,
message: "Cleared message log database and cache.",

View file

@ -21,8 +21,7 @@ import { getCustomColorString } from "@equicordplugins/customUserColors";
import { hash as h64 } from "@intrnl/xxhash64";
import { Devs } from "@utils/constants";
import definePlugin, { OptionType } from "@utils/types";
import { useMemo } from "@webpack/common";
import { UserStore } from "@webpack/common";
import { useMemo, UserStore } from "@webpack/common";
// Calculate a CSS color string based on the user ID
function calculateNameColorForUser(id?: string) {
@ -139,7 +138,7 @@ export default definePlugin({
}
}
}
return dmColor;
},