Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions src/api/ChatButtons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -190,3 +190,30 @@ addContextMenuPatch("textarea-context", (children, args) => {
</Menu.MenuItem>
);
});

export type ChatBarButtonWrapper = (buttons: ReactNode) => ReactNode;

export interface ChatBarButtonWrapperData {
wrapper: ChatBarButtonWrapper;
priority: number;
}

/**
* Registry for plugins that need to wrap the entire chat bar button container.
* Wrappers are applied in ascending priority order (lower number = outermost wrapper).
*/
export const ChatBarButtonWrappers = new Map<string, ChatBarButtonWrapperData>();

export const addChatBarButtonWrapper = (id: string, wrapper: ChatBarButtonWrapper, priority: number = 0) => ChatBarButtonWrappers.set(id, { wrapper, priority });
export const removeChatBarButtonWrapper = (id: string) => ChatBarButtonWrappers.delete(id);

export function _wrapButtons(buttons: ReactNode) {
const sorted = [...ChatBarButtonWrappers.values()]
.sort((a, b) => a.priority - b.priority);

let wrapped = buttons;
for (const { wrapper } of sorted) {
wrapped = wrapper(wrapped);
}
return wrapped;
}
250 changes: 250 additions & 0 deletions src/api/HeaderBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/

import ErrorBoundary from "@components/ErrorBoundary";
import { Logger } from "@utils/Logger";
import { classes } from "@utils/misc";
import { findComponentByCodeLazy, findCssClassesLazy } from "@webpack";
import { Clickable, Tooltip, useEffect, useState } from "@webpack/common";
import type { ComponentType, JSX, MouseEventHandler, ReactNode } from "react";

const logger = new Logger("HeaderBarAPI");

const HeaderBarClasses = findCssClassesLazy("clickable", "selected", "badge", "badgeContainer");
const HeaderBarIcon = findComponentByCodeLazy(".HEADER_BAR_BADGE_TOP:", '"aria-haspopup":') as ComponentType<ChannelToolbarButtonProps>;

export interface HeaderBarButtonProps {
/** The icon component to render inside the button */
icon: ComponentType<any>;
/** Tooltip text shown on hover. Pass null to disable tooltip */
tooltip: ReactNode;
/** Called when the button is clicked */
onClick?: MouseEventHandler<HTMLDivElement>;
/** Called when the button is right-clicked */
onContextMenu?: MouseEventHandler<HTMLDivElement>;
/** Additional CSS class names */
className?: string;
/** Size of the icon in pixels */
iconSize?: number;
/** Tooltip position relative to the button */
position?: "top" | "bottom" | "left" | "right";
/** Whether the button appears in a selected/active state */
selected?: boolean;
/** Aria label for accessibility */
"aria-label"?: string;
}

export interface ChannelToolbarButtonProps extends HeaderBarButtonProps {
/** CSS class name for the icon element */
iconClassName?: string;
/** Tooltip position relative to the button */
position?: "top" | "bottom" | "left" | "right";
/** Whether the button appears in a selected/active state */
selected?: boolean;
/** Whether the button is disabled */
disabled?: boolean;
/** Whether to show a notification badge */
showBadge?: boolean;
/** Position of the notification badge */
badgePosition?: "top" | "bottom";
}

export type HeaderBarButtonFactory = () => JSX.Element | null;

export interface HeaderBarButtonData {
/** Function that renders the button component */
render: HeaderBarButtonFactory;
/** Icon component used for settings UI display */
icon: ComponentType<any>;
/** Higher priority buttons appear further right. Default: 0 */
priority?: number;
/** Where to render the button. Default: "headerbar" */
location?: "headerbar" | "channeltoolbar";
}

interface ButtonEntry {
render: HeaderBarButtonFactory;
priority: number;
}

/**
* Button component for the top header bar (title bar area).
*
* @example
* <HeaderBarButton
* icon={MyIcon}
* tooltip="My Button"
* onClick={() => console.log("clicked")}
* />
*/
export function HeaderBarButton(props: HeaderBarButtonProps & { ref?: React.RefObject<any>; }) {
const {
icon: Icon,
tooltip,
onClick,
onContextMenu,
className,
iconSize = 18,
position = "bottom",
selected,
ref,
"aria-label": ariaLabel,
} = props;

const label = ariaLabel ?? (typeof tooltip === "string" ? tooltip : undefined);

return (
<Tooltip text={tooltip ?? ""} position={position} shouldShow={tooltip != null}>
{({ onMouseEnter, onMouseLeave }) => (
<Clickable
{...{ innerRef: ref } as any}
className={classes(HeaderBarClasses.clickable, className)}
style={{ width: Math.max(iconSize, 24), height: Math.max(iconSize, 24), boxSizing: "content-box", justifyContent: "center" }}
onClick={onClick}
onContextMenu={onContextMenu}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
role="button"
tabIndex={0}
aria-label={label}
aria-expanded={selected}
>
<Icon size="custom" width={iconSize} height={iconSize} color="currentColor" />
</Clickable>
)}
</Tooltip>
);
}

/**
* Button component for the channel toolbar (below the search bar).
* Automatically handles selected state styling.
*
* @example
* <ChannelToolbarButton
* icon={MyIcon}
* tooltip={isOpen ? null : "My Button"}
* onClick={() => setOpen(v => !v)}
* selected={isOpen}
* />
*/
export function ChannelToolbarButton(props: ChannelToolbarButtonProps) {
return <HeaderBarIcon {...props} />;
}

const headerBarButtons = new Map<string, ButtonEntry>();
const channelToolbarButtons = new Map<string, ButtonEntry>();

const headerBarListeners = new Set<() => void>();
const channelToolbarListeners = new Set<() => void>();

/**
* Adds a button to the header bar (title bar area).
*
* @param id - Unique identifier for the button (e.g., "my-plugin-button")
* @param render - Function that returns the button JSX
* @param priority - Higher values appear further right. Default: 0
*
* @example
* addHeaderBarButton("my-button", () => (
* <HeaderBarButton
* icon={MyIcon}
* tooltip="My Button"
* onClick={handleClick}
* />
* ));
*/
export function addHeaderBarButton(id: string, render: HeaderBarButtonFactory, priority = 0) {
headerBarButtons.set(id, { render, priority });
headerBarListeners.forEach(listener => listener());
}

/**
* Removes a button from the header bar.
*
* @param id - The identifier used when adding the button
*/
export function removeHeaderBarButton(id: string) {
headerBarButtons.delete(id);
headerBarListeners.forEach(listener => listener());
}

/**
* Adds a button to the channel toolbar (below the search bar, next to pins/members).
*
* @param id - Unique identifier for the button (e.g., "my-plugin-toolbar")
* @param render - Function that returns the button JSX
* @param priority - Higher values appear further right. Default: 0
*
* @example
* addChannelToolbarButton("my-toolbar", () => (
* <ChannelToolbarButton
* icon={MyIcon}
* tooltip="My Button"
* onClick={handleClick}
* />
* ));
*/
export function addChannelToolbarButton(id: string, render: HeaderBarButtonFactory, priority = 0) {
channelToolbarButtons.set(id, { render, priority });
channelToolbarListeners.forEach(listener => listener());
}

/**
* Removes a button from the channel toolbar.
*
* @param id - The identifier used when adding the button
*/
export function removeChannelToolbarButton(id: string) {
channelToolbarButtons.delete(id);
channelToolbarListeners.forEach(listener => listener());
}

function HeaderBarButtons() {
const [, forceUpdate] = useState(0);

useEffect(() => {
const listener = () => forceUpdate(n => n + 1);
headerBarListeners.add(listener);
return () => { headerBarListeners.delete(listener); };
}, []);

return Array.from(headerBarButtons)
.sort(([, a], [, b]) => a.priority - b.priority)
.map(([id, { render: Button }]) => (
<ErrorBoundary noop key={id} onError={e => logger.error(`Failed to render header bar button: ${id}`, e.error)}>
<Button />
</ErrorBoundary>
));
}

function ChannelToolbarButtons() {
const [, forceUpdate] = useState(0);

useEffect(() => {
const listener = () => forceUpdate(n => n + 1);
channelToolbarListeners.add(listener);
return () => { channelToolbarListeners.delete(listener); };
}, []);

return Array.from(channelToolbarButtons)
.sort(([, a], [, b]) => a.priority - b.priority)
.map(([id, { render: Button }]) => (
<ErrorBoundary noop key={id} onError={e => logger.error(`Failed to render channel toolbar button: ${id}`, e.error)}>
<Button />
</ErrorBoundary>
));
}

/** @internal Injected by HeaderBarAPI patch (do NOT call directly) */
export function _addHeaderBarButtons() {
return [<HeaderBarButtons key="vc-header-bar-buttons" />];
}

/** @internal Injected by HeaderBarAPI patch (do NOT call directly) */
export function _addChannelToolbarButtons(toolbar: ReactNode[]) {
toolbar.push(<ChannelToolbarButtons key="vc-channel-toolbar-buttons" />);
}
39 changes: 33 additions & 6 deletions src/api/PluginManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,19 @@
*/

import { addProfileBadge, removeProfileBadge } from "@api/Badges";
import { addChatBarButton, removeChatBarButton } from "@api/ChatButtons";
import { addChatBarButton, addChatBarButtonWrapper, removeChatBarButton, removeChatBarButtonWrapper } from "@api/ChatButtons";
import { registerCommand, unregisterCommand } from "@api/Commands";
import { addContextMenuPatch, removeContextMenuPatch } from "@api/ContextMenu";
import { addChannelToolbarButton, addHeaderBarButton, removeChannelToolbarButton, removeHeaderBarButton } from "@api/HeaderBar";
import { addMemberListDecorator, removeMemberListDecorator } from "@api/MemberListDecorators";
import { addMessageAccessory, removeMessageAccessory } from "@api/MessageAccessories";
import { addMessageDecoration, removeMessageDecoration } from "@api/MessageDecorations";
import { addMessageClickListener, addMessagePreEditListener, addMessagePreSendListener, removeMessageClickListener, removeMessagePreEditListener, removeMessagePreSendListener } from "@api/MessageEvents";
import { addMessagePopoverButton, removeMessagePopoverButton } from "@api/MessagePopover";
import { addProfileCollection, removeProfileCollection } from "@api/ProfileCollections";
import { Settings, SettingsStore } from "@api/Settings";
import { disableStyle, enableStyle } from "@api/Styles";
import { addUserAreaButton, removeUserAreaButton } from "@api/UserArea";
import { traceFunction } from "@debug/Tracer";
import { Logger } from "@utils/Logger";
import { onlyOnce } from "@utils/onlyOnce";
Expand All @@ -37,6 +40,8 @@ import { FluxDispatcher } from "@webpack/common";
import { patches } from "@webpack/patcher";

import Plugins from "~plugins";

import { addProfileSection, removeProfileSection } from "./ProfileSections";
export { Plugins as plugins };
const logger = new Logger("PluginManager", "#a6d189");

Expand Down Expand Up @@ -203,8 +208,10 @@ export function subscribeAllPluginsFluxEvents(fluxDispatcher: typeof FluxDispatc
export const startPlugin = traceFunction("startPlugin", function startPlugin(p: Plugin) {
const {
name, commands, contextMenus, managedStyle, userProfileBadge,
onBeforeMessageEdit, onBeforeMessageSend, onMessageClick,
chatBarButton, renderMemberListDecorator, renderMessageAccessory, renderMessageDecoration, messagePopoverButton
onBeforeMessageEdit, onBeforeMessageSend, onMessageClick, chatBarButton,
renderMemberListDecorator, renderMessageAccessory, renderMessageDecoration,
messagePopoverButton, headerBarButton, userAreaButton, renderProfileCollection,
chatBarButtonWrapper, renderProfileSection
} = p;

if (p.start) {
Expand Down Expand Up @@ -259,6 +266,13 @@ export const startPlugin = traceFunction("startPlugin", function startPlugin(p:
if (renderMessageDecoration) addMessageDecoration(name, renderMessageDecoration);
if (renderMessageAccessory) addMessageAccessory(name, renderMessageAccessory);
if (messagePopoverButton) addMessagePopoverButton(name, messagePopoverButton.render, messagePopoverButton.icon);
if (headerBarButton) headerBarButton.location === "channeltoolbar"
? addChannelToolbarButton(name, headerBarButton.render, headerBarButton.priority)
: addHeaderBarButton(name, headerBarButton.render, headerBarButton.priority);
if (userAreaButton) addUserAreaButton(name, userAreaButton.render, userAreaButton.priority);
if (renderProfileCollection) addProfileCollection(name, renderProfileCollection.render, renderProfileCollection.priority);
if (chatBarButtonWrapper) addChatBarButtonWrapper(name, chatBarButtonWrapper.wrapper, chatBarButtonWrapper.priority);
if (renderProfileSection) addProfileSection(name, renderProfileSection.render, renderProfileSection.priority);

return true;
}, p => `startPlugin ${p.name}`);
Expand All @@ -267,7 +281,9 @@ export const stopPlugin = traceFunction("stopPlugin", function stopPlugin(p: Plu
const {
name, commands, contextMenus, managedStyle, userProfileBadge,
onBeforeMessageEdit, onBeforeMessageSend, onMessageClick,
chatBarButton, renderMemberListDecorator, renderMessageAccessory, renderMessageDecoration, messagePopoverButton
chatBarButton, renderMemberListDecorator, renderMessageAccessory, renderMessageDecoration,
messagePopoverButton, headerBarButton, userAreaButton, renderProfileCollection,
chatBarButtonWrapper, renderProfileSection
} = p;

if (p.stop) {
Expand Down Expand Up @@ -320,6 +336,11 @@ export const stopPlugin = traceFunction("stopPlugin", function stopPlugin(p: Plu
if (renderMessageDecoration) removeMessageDecoration(name);
if (renderMessageAccessory) removeMessageAccessory(name);
if (messagePopoverButton) removeMessagePopoverButton(name);
if (headerBarButton) headerBarButton.location === "channeltoolbar" ? removeChannelToolbarButton(name) : removeHeaderBarButton(name);
if (userAreaButton) removeUserAreaButton(name);
if (renderProfileCollection) removeProfileCollection(name);
if (chatBarButtonWrapper) removeChatBarButtonWrapper(name);
if (renderProfileSection) removeProfileSection(name);

return true;
}, p => `stopPlugin ${p.name}`);
Expand All @@ -330,7 +351,8 @@ export const initPluginManager = onlyOnce(function init() {

const pluginKeysToBind: Array<keyof PluginDef & `${"on" | "render"}${string}`> = [
"onBeforeMessageEdit", "onBeforeMessageSend", "onMessageClick",
"renderMemberListDecorator", "renderMessageAccessory", "renderMessageDecoration"
"renderMemberListDecorator", "renderMessageAccessory",
"renderMessageDecoration"
];

const neededApiPlugins = new Set<string>();
Expand Down Expand Up @@ -366,9 +388,14 @@ export const initPluginManager = onlyOnce(function init() {
if (p.renderMessageDecoration) neededApiPlugins.add("MessageDecorationsAPI");
if (p.messagePopoverButton) neededApiPlugins.add("MessagePopoverAPI");
if (p.userProfileBadge) neededApiPlugins.add("BadgeAPI");
if (p.headerBarButton) neededApiPlugins.add("HeaderBarAPI");
if (p.userAreaButton) neededApiPlugins.add("UserAreaAPI");
if (p.renderProfileCollection) neededApiPlugins.add("ProfileCollectionsAPI");
if (p.chatBarButtonWrapper) neededApiPlugins.add("ChatInputButtonAPI");
if (p.renderProfileSection) neededApiPlugins.add("ProfileSectionsAPI");

for (const key of pluginKeysToBind) {
p[key] &&= p[key].bind(p) as any;
p[key] &&= (p[key] as Function).bind(p) as any;
}
}

Expand Down
Loading
Loading