diff --git a/src/plugins/messageClickActions/ReactEmojiSetting.tsx b/src/plugins/messageClickActions/ReactEmojiSetting.tsx
new file mode 100644
index 000000000..9242b8310
--- /dev/null
+++ b/src/plugins/messageClickActions/ReactEmojiSetting.tsx
@@ -0,0 +1,169 @@
+/*
+ * Vencord, a Discord client mod
+ * Copyright (c) 2026 Vendicated and contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+import { Heading } from "@components/Heading";
+import type { IPluginOptionComponentProps } from "@utils/types";
+import { findComponentByCodeLazy } from "@webpack";
+import { TextInput, useState } from "@webpack/common";
+
+import { settings } from ".";
+import "./styles.css";
+
+type EmojiData = {
+ id?: string | null;
+ name: string;
+};
+
+type EmojiSelectPayload = {
+ id: string | null;
+ name: string;
+ animated?: boolean;
+};
+
+const StatusEmojiPicker = findComponentByCodeLazy("setCustomStatusEmoji", "setIsEmojiPickerOpen", "selectedDefaultStatus");
+export const MAX_ADDITIONAL_REACT_EMOJIS = 8;
+
+function getEmojiValue(emoji: EmojiData) {
+ return emoji.id ? `${emoji.name}:${emoji.id}` : emoji.name;
+}
+
+function parseEmojiValue(value: string): EmojiSelectPayload | null {
+ const trimmed = value.trim();
+ if (!trimmed) return null;
+
+ const customEmoji = trimmed.match(/^:?([\w-]+):(\d+)$/);
+ if (customEmoji) {
+ return {
+ id: customEmoji[2],
+ name: customEmoji[1],
+ animated: false
+ };
+ }
+
+ return {
+ id: null,
+ name: trimmed,
+ animated: false
+ };
+}
+
+function parseEmojiList(value: string) {
+ return Array.from(new Set(
+ value
+ .split(/[\n,]/g)
+ .map(entry => entry.trim())
+ .filter(Boolean)
+ )).slice(0, MAX_ADDITIONAL_REACT_EMOJIS);
+}
+
+export function ReactEmojiSetting({ setValue }: IPluginOptionComponentProps) {
+ const [emoji, setEmoji] = useState(settings.store.reactEmoji ?? "💀");
+ const [isEmojiPickerOpen, setIsEmojiPickerOpen] = useState(false);
+
+ return (
+
+
Select Emoji For Reactions
+
event.stopPropagation()}
+ onMouseDown={event => event.stopPropagation()}
+ >
+ {
+ if (!selectedEmoji) {
+ setEmoji("");
+ setValue("");
+ setIsEmojiPickerOpen(false);
+ return;
+ }
+
+ const value = getEmojiValue(selectedEmoji);
+ setEmoji(value);
+ setValue(value);
+ setIsEmojiPickerOpen(false);
+ }}
+ selectedDefaultStatus={isEmojiPickerOpen ? null : "online"}
+ setIsEmojiPickerOpen={setIsEmojiPickerOpen}
+ defaultStatusVariant="small"
+ />
+
+
+ );
+}
+
+export function AdditionalReactEmojisSetting({ setValue }: IPluginOptionComponentProps) {
+ const { addAdditionalReacts } = settings.use(["addAdditionalReacts"]);
+ const [emojiList, setEmojiList] = useState(settings.store.additionalReactEmojis ?? "");
+ const [isEmojiPickerOpen, setIsEmojiPickerOpen] = useState(false);
+ const [pickerEmoji, setPickerEmoji] = useState(parseEmojiValue("😀"));
+
+ if (!addAdditionalReacts) return null;
+
+ return (
+
+
Select Additional Emojis
+
+
{
+ setEmojiList(newValue);
+ setValue(newValue);
+ const [first] = parseEmojiList(newValue);
+ setPickerEmoji(parseEmojiValue(first ?? "😀"));
+ }}
+ onClick={event => event.stopPropagation()}
+ onMouseDown={event => event.stopPropagation()}
+ style={{
+ flex: 1
+ }}
+ />
+ event.stopPropagation()}
+ onMouseDown={event => event.stopPropagation()}
+ style={{
+ display: "inline-flex",
+ transform: "scale(1.2)",
+ transformOrigin: "center"
+ }}
+ >
+ {
+ if (!selectedEmoji) {
+ setIsEmojiPickerOpen(false);
+ return;
+ }
+
+ const value = getEmojiValue(selectedEmoji);
+ const parsed = parseEmojiList(emojiList);
+ const merged = parsed.includes(value)
+ ? parsed
+ : [...parsed, value].slice(0, MAX_ADDITIONAL_REACT_EMOJIS);
+ const nextValue = merged.join(", ");
+ setEmojiList(nextValue);
+ setValue(nextValue);
+ setPickerEmoji(selectedEmoji);
+ setIsEmojiPickerOpen(false);
+ }}
+ selectedDefaultStatus={isEmojiPickerOpen ? null : "online"}
+ setIsEmojiPickerOpen={setIsEmojiPickerOpen}
+ defaultStatusVariant="small"
+ />
+
+
+
+ );
+}
diff --git a/src/plugins/messageClickActions/index.ts b/src/plugins/messageClickActions/index.ts
index 766367bca..4f7968494 100644
--- a/src/plugins/messageClickActions/index.ts
+++ b/src/plugins/messageClickActions/index.ts
@@ -15,9 +15,14 @@ import type { Channel, Message } from "@vencord/discord-types";
import { ApplicationIntegrationType, MessageFlags } from "@vencord/discord-types/enums";
import { AuthenticationStore, Constants, EditMessageStore, FluxDispatcher, MessageActions, MessageTypeSets, PermissionsBits, PermissionStore, PinActions, RestAPI, Toasts, WindowStore } from "@webpack/common";
+import { AdditionalReactEmojisSetting, MAX_ADDITIONAL_REACT_EMOJIS, ReactEmojiSetting } from "./ReactEmojiSetting";
+
type Modifier = "NONE" | "SHIFT" | "CTRL" | "ALT" | "BACKSPACE" | "DELETE";
type ClickAction = "NONE" | "DELETE" | "COPY_LINK" | "COPY_ID" | "COPY_CONTENT" | "COPY_USER_ID" | "EDIT" | "REPLY" | "REACT" | "OPEN_THREAD" | "OPEN_TAB" | "EDIT_REPLY" | "QUOTE" | "PIN";
+const logger = new Logger("MessageClickActions");
+const ADDITIONAL_REACTION_DELAY_MS = 300; // discord seems to rate limit this for 300ms but that might not be constant
+
const actions: { label: string; value: ClickAction; }[] = [
{ label: "None", value: "NONE" },
{ label: "Delete", value: "DELETE" },
@@ -130,12 +135,7 @@ let mouseDownCount = 0;
let doubleClickDetected = false;
let secondMouseDownTime = 0;
-const settings = definePluginSettings({
- reactEmoji: {
- type: OptionType.STRING,
- description: "Emoji to use for react actions",
- default: "💀"
- },
+export const settings = definePluginSettings({
singleClickAction: {
type: OptionType.SELECT,
description: "Action on single click (your messages)",
@@ -190,6 +190,26 @@ const settings = definePluginSettings({
options: modifiers,
default: "NONE"
},
+ reactEmoji: {
+ type: OptionType.COMPONENT,
+ description: "Emoji to use for react actions.",
+ component: ReactEmojiSetting,
+ default: "💀"
+ },
+ addAdditionalReacts: {
+ type: OptionType.BOOLEAN,
+ description: "Also add additional configured reaction emojis",
+ default: false
+ },
+ additionalReactEmojis: {
+ type: OptionType.COMPONENT,
+ description: `Additional emojis to add when using React action (comma/newline separated, max ${MAX_ADDITIONAL_REACT_EMOJIS})`,
+ component: AdditionalReactEmojisSetting,
+ get hidden() {
+ return !settings.store.addAdditionalReacts;
+ },
+ default: ""
+ },
disableInDms: {
type: OptionType.BOOLEAN,
description: "Disable all click actions in direct messages",
@@ -215,7 +235,7 @@ const settings = definePluginSettings({
deferDoubleClickForTriple: {
type: OptionType.BOOLEAN,
description: "Delay double-click to allow triple-click actions (disables triple-click when off)",
- default: true
+ default: false
},
selectionHoldTimeout: {
type: OptionType.NUMBER,
@@ -246,6 +266,51 @@ function showWarning(message: string) {
});
}
+function clearClickTimeouts() {
+ if (doubleClickTimeout) {
+ clearTimeout(doubleClickTimeout);
+ doubleClickTimeout = null;
+ }
+ if (singleClickTimeout) {
+ clearTimeout(singleClickTimeout);
+ singleClickTimeout = null;
+ }
+}
+
+function resetClickState() {
+ mouseDownCount = 0;
+ doubleClickDetected = false;
+ secondMouseDownTime = 0;
+}
+
+function normalizeEmoji(emoji: string): string | null {
+ const trimmed = emoji.trim();
+ if (!trimmed) return null;
+
+ const customMatch = trimmed.match(/^:?([\w-]+):(\d+)$/);
+ if (customMatch) {
+ return `${customMatch[1]}:${customMatch[2]}`;
+ }
+
+ return trimmed;
+}
+
+function getConfiguredReactionEmojis() {
+ const baseEmoji = normalizeEmoji(settings.store.reactEmoji);
+ const configured = [baseEmoji];
+
+ if (settings.store.addAdditionalReacts) {
+ const extra = settings.store.additionalReactEmojis
+ .split(/[\n,]/g)
+ .map(normalizeEmoji)
+ .filter((emoji): emoji is string => Boolean(emoji))
+ .slice(0, MAX_ADDITIONAL_REACT_EMOJIS);
+ configured.push(...extra);
+ }
+
+ return Array.from(new Set(configured.filter((emoji): emoji is string => Boolean(emoji))));
+}
+
const canSend = (channel: Channel) =>
!channel.guild_id || PermissionStore.can(PermissionsBits.SEND_MESSAGES, channel);
@@ -260,19 +325,14 @@ const canReply = (msg: Message) =>
MessageTypeSets.REPLYABLE.has(msg.type) && !msg.hasFlag(MessageFlags.EPHEMERAL);
async function toggleReaction(channelId: string, messageId: string, emoji: string, channel: Channel, msg: Message) {
- const trimmed = emoji.trim();
- if (!trimmed) return;
+ const emojiParam = normalizeEmoji(emoji);
+ if (!emojiParam) return;
if (channel.guild_id && (!PermissionStore.can(PermissionsBits.ADD_REACTIONS, channel) || !PermissionStore.can(PermissionsBits.READ_MESSAGE_HISTORY, channel))) {
showWarning("Cannot react: Missing permissions");
return;
}
- const customMatch = trimmed.match(/^:?([\w-]+):(\d+)$/);
- const emojiParam = customMatch
- ? `${customMatch[1]}:${customMatch[2]}`
- : trimmed;
-
const hasReacted = msg.reactions?.some(r => {
const reactionEmoji = r.emoji.id
? `${r.emoji.name}:${r.emoji.id}`
@@ -291,12 +351,47 @@ async function toggleReaction(channelId: string, messageId: string, emoji: strin
});
}
} catch (e) {
- new Logger("MessageClickActions").error("Failed to toggle reaction:", e);
+ logger.error("Failed to toggle reaction:", e);
}
}
+async function addReaction(channelId: string, messageId: string, emoji: string, channel: Channel) {
+ const emojiParam = normalizeEmoji(emoji);
+ if (!emojiParam) return;
+
+ if (channel.guild_id && (!PermissionStore.can(PermissionsBits.ADD_REACTIONS, channel) || !PermissionStore.can(PermissionsBits.READ_MESSAGE_HISTORY, channel))) {
+ showWarning("Cannot react: Missing permissions");
+ return;
+ }
+
+ try {
+ await RestAPI.put({
+ url: Constants.Endpoints.REACTION(channelId, messageId, emojiParam, "@me")
+ });
+ } catch (e) {
+ logger.error("Failed to add reaction:", e);
+ }
+}
+
+async function reactWithConfiguredEmojis(channel: Channel, msg: Message) {
+ const [primaryEmoji, ...additionalEmojis] = getConfiguredReactionEmojis();
+ if (!primaryEmoji) return;
+
+ await toggleReaction(channel.id, msg.id, primaryEmoji, channel, msg);
+
+ for (const emoji of additionalEmojis) {
+ await new Promise(resolve => setTimeout(resolve, ADDITIONAL_REACTION_DELAY_MS));
+ await addReaction(channel.id, msg.id, emoji, channel);
+ }
+}
+
+function getMessageLink(msg: Message, channel: Channel) {
+ const guildId = channel.guild_id ?? "@me";
+ return `${window.location.origin}/channels/${guildId}/${channel.id}/${msg.id}`;
+}
+
function copyLink(msg: Message, channel: Channel) {
- copyWithToast(`https://discord.com/channels/${channel.guild_id ?? "@me"}/${channel.id}/${msg.id}`, "Link copied!");
+ copyWithToast(getMessageLink(msg, channel), "Link copied!");
}
function togglePin(channel: Channel, msg: Message) {
@@ -343,9 +438,7 @@ function quoteMessage(channel: Channel, msg: Message) {
}
function openInNewTab(msg: Message, channel: Channel) {
- const guildId = channel.guild_id ?? "@me";
- const link = `https://discord.com/channels/${guildId}/${channel.id}/${msg.id}`;
- VencordNative.native.openExternal(link);
+ VencordNative.native.openExternal(getMessageLink(msg, channel));
}
function openInThread(msg: Message, channel: Channel) {
@@ -462,7 +555,7 @@ async function executeAction(
break;
case "REACT":
- await toggleReaction(channel.id, msg.id, settings.store.reactEmoji, channel, msg);
+ await reactWithConfiguredEmojis(channel, msg);
event.preventDefault();
break;
@@ -484,7 +577,7 @@ async function executeAction(
export default definePlugin({
name: "MessageClickActions",
description: "Customize click actions on messages.",
- authors: [Devs.Ven, EquicordDevs.keircn, EquicordDevs.ZcraftElite],
+ authors: [Devs.Ven, EquicordDevs.keircn, EquicordDevs.ZcraftElite, EquicordDevs.omaw],
isModified: true,
settings,
@@ -502,18 +595,9 @@ export default definePlugin({
document.removeEventListener("mousedown", onMouseDown);
WindowStore.removeChangeListener(focusChanged);
- if (doubleClickTimeout) {
- clearTimeout(doubleClickTimeout);
- doubleClickTimeout = null;
- }
- if (singleClickTimeout) {
- clearTimeout(singleClickTimeout);
- singleClickTimeout = null;
- }
+ clearClickTimeouts();
pendingDoubleClickAction = null;
- mouseDownCount = 0;
- doubleClickDetected = false;
- secondMouseDownTime = 0;
+ resetClickState();
},
onMessageClick(msg, channel, event) {
@@ -547,9 +631,7 @@ export default definePlugin({
if (Date.now() - lastMouseDownTime > settings.store.selectionHoldTimeout) {
pressedModifiers.clear();
- mouseDownCount = 0;
- doubleClickDetected = false;
- secondMouseDownTime = 0;
+ resetClickState();
return;
}
@@ -560,9 +642,7 @@ export default definePlugin({
if (isTripleClick) {
if (!settings.store.deferDoubleClickForTriple) {
- mouseDownCount = 0;
- doubleClickDetected = false;
- secondMouseDownTime = 0;
+ resetClickState();
return;
}
if (doubleClickTimeout) {
@@ -576,9 +656,7 @@ export default definePlugin({
pressedModifiers.clear();
}
doubleClickFired = false;
- mouseDownCount = 0;
- doubleClickDetected = false;
- secondMouseDownTime = 0;
+ resetClickState();
return;
}
@@ -628,9 +706,7 @@ export default definePlugin({
event.preventDefault();
}
- mouseDownCount = 0;
- doubleClickDetected = false;
- secondMouseDownTime = 0;
+ resetClickState();
return;
}
@@ -642,9 +718,7 @@ export default definePlugin({
executeAction(singleClickAction, msg, channel, event);
pressedModifiers.clear();
}
- mouseDownCount = 0;
- doubleClickDetected = false;
- secondMouseDownTime = 0;
+ resetClickState();
};
const canDoubleClickWithCurrentModifier =
diff --git a/src/plugins/messageClickActions/styles.css b/src/plugins/messageClickActions/styles.css
new file mode 100644
index 000000000..dc8358c3f
--- /dev/null
+++ b/src/plugins/messageClickActions/styles.css
@@ -0,0 +1,7 @@
+.vc-message-click-actions-primary-emoji-picker {
+ margin-top: 8px;
+ margin-left: 6px;
+ display: inline-flex;
+ transform: scale(1.3);
+ transform-origin: top left;
+}