MessageClickActions: additional react emojis (#814)

* MessageClickActions: additional react emojis
optionally allow users to set additional emojis to be
triggered on react action

* fix: indi thinks limiting reacts to 5 is more sane

* add oma's changes
includes an emoji picker for the react action

* oma's fault btw
This commit is contained in:
Key 2026-02-22 00:24:54 +00:00 • committed by GitHub
parent 29bf8dba1d
commit 018a994d5b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 297 additions and 47 deletions

View file

@ -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 (
<div>
<Heading>Select Emoji For Reactions</Heading>
<div
className="vc-message-click-actions-primary-emoji-picker"
onClick={event => event.stopPropagation()}
onMouseDown={event => event.stopPropagation()}
>
<StatusEmojiPicker
customStatusEmoji={parseEmojiValue(emoji)}
setCustomStatusEmoji={(selectedEmoji: EmojiSelectPayload | null) => {
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"
/>
</div>
</div>
);
}
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<EmojiSelectPayload | null>(parseEmojiValue("😀"));
if (!addAdditionalReacts) return null;
return (
<div>
<Heading>Select Additional Emojis</Heading>
<div
style={{
marginTop: 8,
display: "flex",
alignItems: "center",
gap: 12,
maxWidth: 420
}}
>
<TextInput
value={emojiList}
placeholder={`comma/newline separated, max ${MAX_ADDITIONAL_REACT_EMOJIS}`}
onChange={newValue => {
setEmojiList(newValue);
setValue(newValue);
const [first] = parseEmojiList(newValue);
setPickerEmoji(parseEmojiValue(first ?? "😀"));
}}
onClick={event => event.stopPropagation()}
onMouseDown={event => event.stopPropagation()}
style={{
flex: 1
}}
/>
<div
onClick={event => event.stopPropagation()}
onMouseDown={event => event.stopPropagation()}
style={{
display: "inline-flex",
transform: "scale(1.2)",
transformOrigin: "center"
}}
>
<StatusEmojiPicker
customStatusEmoji={pickerEmoji}
setCustomStatusEmoji={(selectedEmoji: EmojiSelectPayload | null) => {
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"
/>
</div>
</div>
</div>
);
}

View file

@ -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<void>(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 =

View file

@ -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;
}