Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
48eca4e
add plugin ui customisation options
Vendicated Sep 30, 2025
3e4aa50
improve layout
Vendicated Oct 1, 2025
93b5b1a
Merge branch 'dev' into plugin-ui-elements
Vendicated Oct 1, 2025
7e7b4c0
make all plugins export their chatbar and popover button icons
Vendicated Oct 1, 2025
a7e27cc
combine render & icon props into single object prop
Vendicated Oct 1, 2025
a777634
update ui
Vendicated Oct 1, 2025
1e10a1c
finish
Vendicated Oct 2, 2025
e915a63
add context menu
Vendicated Oct 2, 2025
8f1ddb5
fix typo
Vendicated Oct 2, 2025
5da34c4
Merge branch 'dev' into plugin-ui-elements
Vendicated Oct 22, 2025
1df486e
Merge branch 'dev' into plugin-ui-elements
Vendicated Nov 8, 2025
5b61419
temporarily drop plugins/index.ts changes
Vendicated Nov 16, 2025
c1e6b72
Merge branch 'dev' into plugin-ui-elements
Vendicated Nov 16, 2025
2b7f031
add back PluginManager changes
Vendicated Nov 16, 2025
1db9a95
fix outdated import
Vendicated Nov 16, 2025
5ab7356
useSettings: add prefix path matching
Vendicated Nov 16, 2025
f6320d8
Merge branch 'useSettings-wildcard' into plugin-ui-elements
Vendicated Nov 16, 2025
c971def
use wildcards in useSettings
Vendicated Nov 16, 2025
208b0f6
fix useSettings type
Vendicated Nov 16, 2025
024c1c0
Merge branch 'useSettings-wildcard' into plugin-ui-elements
Vendicated Nov 16, 2025
7c0176c
fix incorrect merge
Vendicated Nov 16, 2025
cf015e5
use wildcards in more places
Vendicated Nov 16, 2025
faf8d28
lmao
Vendicated Nov 16, 2025
760b329
Merge branch 'dev' into plugin-ui-elements
Vendicated Nov 21, 2025
054e741
how did this get here
Vendicated Nov 21, 2025
4469a00
improve descriptions and use card component
Vendicated Nov 21, 2025
9455b67
don't use Iterator array methods (too new)
Vendicated Nov 21, 2025
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
50 changes: 40 additions & 10 deletions src/api/ChatButtons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,14 @@ import "./ChatButton.css";

import ErrorBoundary from "@components/ErrorBoundary";
import { Logger } from "@utils/Logger";
import { IconComponent } from "@utils/types";
import { Channel } from "@vencord/discord-types";
import { waitFor } from "@webpack";
import { Button, ButtonWrapperClasses, Tooltip } from "@webpack/common";
import { HTMLProps, JSX, MouseEventHandler, ReactNode } from "react";

import { useSettings } from "./Settings";

let ChannelTextAreaClasses: Record<"button" | "buttonContainer", string>;
waitFor(["buttonContainer", "channelTextArea"], m => ChannelTextAreaClasses = m);

Expand Down Expand Up @@ -75,24 +78,51 @@ export interface ChatBarProps {
}

export type ChatBarButtonFactory = (props: ChatBarProps & { isMainChat: boolean; }) => JSX.Element | null;
export type ChatBarButtonData = {
render: ChatBarButtonFactory;
/**
* This icon is used only for Settings UI. Your render function must still render an icon,
* and it can be different from this one.
*/
icon: IconComponent;
};

const buttonFactories = new Map<string, ChatBarButtonFactory>();
/**
* Don't use this directly, use {@link addChatBarButton} and {@link removeChatBarButton} instead.
*/
export const ChatBarButtonMap = new Map<string, ChatBarButtonData>();
const logger = new Logger("ChatButtons");

function VencordChatBarButtons(props: ChatBarProps) {
// FIXME: subscribing to all settings here is bad, but the settings api currently
// only supports exact key subscriptions, which doesn't work for our use case
const { chatBarButtons } = useSettings().uiElements;

return (
<>
{ChatBarButtonMap.entries()
.filter(([key]) => chatBarButtons[key]?.enabled !== false)
.map(([key, { render: Button }]) => (
<ErrorBoundary noop key={key} onError={e => logger.error(`Failed to render ${key}`, e.error)}>
<Button {...props} isMainChat={props.type.analyticsName === "normal"} />
</ErrorBoundary>
))}
</>
);
}

export function _injectButtons(buttons: ReactNode[], props: ChatBarProps) {
if (props.disabled) return;

for (const [key, Button] of buttonFactories) {
buttons.push(
<ErrorBoundary noop key={key} onError={e => logger.error(`Failed to render ${key}`, e.error)}>
<Button {...props} isMainChat={props.type.analyticsName === "normal"} />
</ErrorBoundary>
);
}
buttons.push(<VencordChatBarButtons key="vencord-chat-buttons" {...props} />);
}

export const addChatBarButton = (id: string, button: ChatBarButtonFactory) => buttonFactories.set(id, button);
export const removeChatBarButton = (id: string) => buttonFactories.delete(id);
/**
* The icon argument is used only for Settings UI. Your render function must still render an icon,
* and it can be different from this one.
*/
export const addChatBarButton = (id: string, render: ChatBarButtonFactory, icon: IconComponent) => ChatBarButtonMap.set(id, { render, icon });
export const removeChatBarButton = (id: string) => ChatBarButtonMap.delete(id);

export interface ChatBarButtonProps {
children: ReactNode;
Expand Down
68 changes: 47 additions & 21 deletions src/api/MessagePopover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,12 @@

import ErrorBoundary from "@components/ErrorBoundary";
import { Logger } from "@utils/Logger";
import { IconComponent } from "@utils/types";
import { Channel, Message } from "@vencord/discord-types";
import type { ComponentType, MouseEventHandler } from "react";

import { useSettings } from "./Settings";

const logger = new Logger("MessagePopover");

export interface MessagePopoverButtonItem {
Expand All @@ -34,41 +37,64 @@ export interface MessagePopoverButtonItem {
}

export type MessagePopoverButtonFactory = (message: Message) => MessagePopoverButtonItem | null;
export type MessagePopoverButtonData = {
render: MessagePopoverButtonFactory;
/**
* This icon is used only for Settings UI. Your render function must still return an icon,
* and it can be different from this one.
*/
icon: IconComponent;
};

export const buttons = new Map<string, MessagePopoverButtonFactory>();
export const MessagePopoverButtonMap = new Map<string, MessagePopoverButtonData>();

/**
* The icon argument is used only for Settings UI. Your render function must still return an icon,
* and it can be different from this one.
*/
export function addMessagePopoverButton(
identifier: string,
item: MessagePopoverButtonFactory,
render: MessagePopoverButtonFactory,
icon: IconComponent
) {
buttons.set(identifier, item);
MessagePopoverButtonMap.set(identifier, { render, icon });
}

export function removeMessagePopoverButton(identifier: string) {
buttons.delete(identifier);
MessagePopoverButtonMap.delete(identifier);
}

export function _buildPopoverElements(
Component: React.ComponentType<MessagePopoverButtonItem>,
message: Message
) {
const items: React.ReactNode[] = [];
function VencordPopoverButtons(props: { Component: React.ComponentType<MessagePopoverButtonItem>, message: Message; }) {
const { Component, message } = props;

// FIXME: subscribing to all settings here is bad, but the settings api currently
// only supports exact key subscriptions, which doesn't work for our use case
const { messagePopoverButtons } = useSettings().uiElements;

const elements = MessagePopoverButtonMap.entries()
.filter(([key]) => messagePopoverButtons[key]?.enabled !== false)
.map(([key, { render }]) => {
try {
// FIXME: this should use proper React to ensure hooks work
const item = render(message);
if (!item) return null;

for (const [identifier, getItem] of buttons.entries()) {
try {
const item = getItem(message);
if (item) {
item.key ??= identifier;
items.push(
return (
<ErrorBoundary noop>
<Component {...item} />
<Component key={key} {...item} />
</ErrorBoundary>
);
} catch (err) {
logger.error(`[${key}]`, err);
}
} catch (err) {
logger.error(`[${identifier}]`, err);
}
}
});

return <>{items}</>;
return <>{elements}</>;
}

export function _buildPopoverElements(
Component: React.ComponentType<MessagePopoverButtonItem>,
message: Message
) {
return <VencordPopoverButtons Component={Component} message={message} />;
}
22 changes: 22 additions & 0 deletions src/api/Settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,18 @@ import { React, useEffect } from "@webpack/common";
import plugins from "~plugins";

const logger = new Logger("Settings");

export interface SettingsPluginUiElement {
enabled: boolean;
// TODO
/** not implemented for now */
order?: number;
}
export type SettingsPluginUiElements = {
/** id will be whatever id the element was registered with. Usually, but not always, the plugin name */
[id: string]: SettingsPluginUiElement;
};

export interface Settings {
autoUpdate: boolean;
autoUpdateNotification: boolean,
Expand Down Expand Up @@ -62,6 +74,11 @@ export interface Settings {
};
};

uiElements: {
messagePopoverButtons: SettingsPluginUiElements;
chatBarButtons: SettingsPluginUiElements;
},

notifications: {
timeout: number;
position: "top-right" | "bottom-right";
Expand Down Expand Up @@ -93,6 +110,11 @@ const DefaultSettings: Settings = {
winNativeTitleBar: false,
plugins: {},

uiElements: {
chatBarButtons: {},
messagePopoverButtons: {}
},

notifications: {
timeout: 5000,
position: "bottom-right",
Expand Down
14 changes: 14 additions & 0 deletions src/components/Icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -433,3 +433,17 @@ export function WebsiteIcon(props: IconProps) {
</Icon>
);
}

/**
* A question mark inside a square, used as a placeholder icon when no other icon is available
*/
export function PlaceholderIcon(props: IconProps) {
return (
<Icon
{...props}
viewBox="0 0 24 24"
>
<path fill={props.fill || "currentColor"} fillRule="evenodd" d="M5 2a3 3 0 0 0-3 3v14a3 3 0 0 0 3 3h14a3 3 0 0 0 3-3V5a3 3 0 0 0-3-3H5Zm6.81 7c-.54 0-1 .26-1.23.61A1 1 0 0 1 8.92 8.5 3.49 3.49 0 0 1 11.82 7c1.81 0 3.43 1.38 3.43 3.25 0 1.45-.98 2.61-2.27 3.06a1 1 0 0 1-1.96.37l-.19-1a1 1 0 0 1 .98-1.18c.87 0 1.44-.63 1.44-1.25S12.68 9 11.81 9ZM13 16a1 1 0 1 1-2 0 1 1 0 0 1 2 0Zm7-10.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0ZM18.5 20a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3ZM7 18.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0ZM5.5 7a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z" clipRule="evenodd" />
</Icon>
);
}
46 changes: 46 additions & 0 deletions src/components/settings/tabs/plugins/UIElements.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
.vc-plugin-ui-elements-button {
display: flex;
align-items: center;
background-color: var(--card-primary-bg);
color: var(--interactive-normal);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
padding: 1em;
margin-top: 0.5em;
cursor: pointer;

&:hover {
background-color: var(--background-modifier-hover);
color: var(--interactive-hover);
}
}

.vc-plugin-ui-elements-button-arrow {
margin-left: auto;
width: 24px;
height: 24px;
}

.vc-plugin-ui-elements-modal-content {
padding: 1em;
display: flex;
flex-direction: column;
gap: 1.5em;
}

.vc-plugin-ui-elements-switches {
display: flex;
flex-direction: column;
gap: 1em;
}

.vc-plugin-ui-elements-switches-row {
display: flex;
gap: 1em;
width: 100%;
align-items: center;

:last-child {
margin-left: auto;
}
}
105 changes: 105 additions & 0 deletions src/components/settings/tabs/plugins/UIElements.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/

import "./UIElements.css";

import { ChatBarButtonMap } from "@api/ChatButtons";
import { MessagePopoverButtonMap } from "@api/MessagePopover";
import { SettingsPluginUiElements, useSettings } from "@api/Settings";
import { classNameFactory } from "@api/Styles";
import { PlaceholderIcon } from "@components/Icons";
import { Switch } from "@components/settings/Switch";
import { Margins } from "@utils/margins";
import { classes } from "@utils/misc";
import { ModalContent, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal";
import { IconComponent } from "@utils/types";
import { Clickable, Text } from "@webpack/common";


const cl = classNameFactory("vc-plugin-ui-elements-");

export function UIElementsButton() {
return (
<Clickable
className={cl("button")}
onClick={() => openModal(modalProps => <UIElementsModal {...modalProps} />)}
>
<div className={cl("button-description")}>
<Text variant="text-md/semibold">
Manage plugin UI elements
</Text>
<Text variant="text-xs/normal">
Allows you to hide buttons you don't like
</Text>
</div>
<svg
className={cl("button-arrow")}
aria-hidden="true"
viewBox="0 0 24 24"
>
<path fill="currentColor" d="M9.3 5.3a1 1 0 0 0 0 1.4l5.29 5.3-5.3 5.3a1 1 0 1 0 1.42 1.4l6-6a1 1 0 0 0 0-1.4l-6-6a1 1 0 0 0-1.42 0Z" />
</svg>
</Clickable >
);
}

function Section(props: {
title: string;
description: string;
settings: SettingsPluginUiElements;
buttonMap: Map<string, { icon: IconComponent; }>;
}) {
const { buttonMap, description, title, settings } = props;

return (
<section>
<Text tag="h3" variant="heading-xl/bold">{title}</Text>
<Text variant="text-sm/normal" className={classes(Margins.top8, Margins.bottom20)}>{description}</Text>

<div className={cl("switches")}>
{buttonMap.entries().map(([name, { icon }]) => {
const Icon = icon ?? PlaceholderIcon;
return (
<Text variant="text-md/semibold" key={name} className={cl("switches-row")}>
<Icon height={20} width={20} />
{name}
<Switch
checked={settings[name]?.enabled ?? true}
onChange={() => {
settings[name] ??= { enabled: true };
settings[name].enabled = !settings[name].enabled;
}}
/>
</Text>
);
})}
</div>
</section>
);
}

function UIElementsModal(props: ModalProps) {
const { uiElements } = useSettings();

return (
<ModalRoot {...props} size={ModalSize.MEDIUM}>
<ModalContent className={cl("modal-content")}>
<Section
title="Chatbar Buttons"
description="These buttons appear in the chat input."
buttonMap={ChatBarButtonMap}
settings={uiElements.chatBarButtons}
/>
<Section
title="Message Popover Buttons"
description="These buttons appear when you hover over a message."
buttonMap={MessagePopoverButtonMap}
settings={uiElements.messagePopoverButtons}
/>
</ModalContent>
</ModalRoot>
);
}
Loading