From 018a994d5b7db026c312effa87ae17b00ca4fd4a Mon Sep 17 00:00:00 2001 From: Key Date: Sun, 22 Feb 2026 00:24:54 +0000 Subject: [PATCH] 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 --- .../messageClickActions/ReactEmojiSetting.tsx | 169 ++++++++++++++++++ src/plugins/messageClickActions/index.ts | 168 ++++++++++++----- src/plugins/messageClickActions/styles.css | 7 + 3 files changed, 297 insertions(+), 47 deletions(-) create mode 100644 src/plugins/messageClickActions/ReactEmojiSetting.tsx create mode 100644 src/plugins/messageClickActions/styles.css 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; +}