From a04f8131d3a46b6a0ac5d44ef41851b6c5e55a10 Mon Sep 17 00:00:00 2001 From: Key Date: Sat, 3 Jan 2026 17:06:04 +0000 Subject: [PATCH] MessageClickActions: Use dropdowns instead of boolean toggles for better customization of actions (#618) * fix(MessageClickActions): use reactions constant to construct PUT request for emoji click action * feat(messageClickActions): toast if we dont have permission to react to the msg * feat(messageClickActions): explicit action configuration via dropdown selectors you can now pick actions to fire when we pick up events * fix(MessageClickActions): call pins correctly and toggle reactions/pins instead of just enabling them and silently failing otherwise * feat(messageClickActions): add configurable click timeout for triple clicks * feat(messageClickActions): quoter things * fix(messageClickActions): clean up quoteMessage a little i guess * refactor(MessageClickActions): make quoteMessage stateless and a little more readable * t * use pin actions * thororen uwahhhhh * refactor(messageClickActions): addresses prisms comments (i cba to write a commit msg) * feat(messageClickActions): dont use selection in quote action by default also: - add reply, edit options to triple click action - add disable system dms boolean - fix operator precedence bug in DM check --------- Co-authored-by: thororen1234 <78185467+thororen1234@users.noreply.github.com> --- src/plugins/messageClickActions/index.ts | 339 +++++++++++++++++++---- src/webpack/common/stores.ts | 2 + 2 files changed, 287 insertions(+), 54 deletions(-) diff --git a/src/plugins/messageClickActions/index.ts b/src/plugins/messageClickActions/index.ts index 16dd6c7c5..67b0b435b 100644 --- a/src/plugins/messageClickActions/index.ts +++ b/src/plugins/messageClickActions/index.ts @@ -20,14 +20,12 @@ import { isPluginEnabled } from "@api/PluginManager"; import { definePluginSettings } from "@api/Settings"; import NoReplyMentionPlugin from "@plugins/noReplyMention"; import { Devs, EquicordDevs } from "@utils/constants"; +import { copyWithToast, insertTextIntoChatInputBox } from "@utils/discord"; import { Logger } from "@utils/Logger"; import definePlugin, { OptionType } from "@utils/types"; +import { Channel, Message } from "@vencord/discord-types"; import { ApplicationIntegrationType, MessageFlags } from "@vencord/discord-types/enums"; -import { findByPropsLazy } from "@webpack"; -import { AuthenticationStore, FluxDispatcher, MessageTypeSets, PermissionsBits, PermissionStore, RestAPI, WindowStore } from "@webpack/common"; - -const MessageActions = findByPropsLazy("deleteMessage", "startEditMessage"); -const EditStore = findByPropsLazy("isEditing", "isEditingAny"); +import { AuthenticationStore, Constants, EditStore, FluxDispatcher, MessageActions, MessageTypeSets, PermissionsBits, PermissionStore, PinActions, RestAPI, Toasts, WindowStore } from "@webpack/common"; let isDeletePressed = false; const keydown = (e: KeyboardEvent) => e.key === "Backspace" && (isDeletePressed = true); @@ -37,60 +35,231 @@ const focusChanged = () => !WindowStore.isFocused() && (isDeletePressed = false) let doubleClickTimeout: ReturnType | null = null; let pendingDoubleClickAction: (() => void) | null = null; +enum ClickAction { + NONE = "none", + DELETE = "delete", + EDIT = "edit", + REPLY = "reply", + COPY_CONTENT = "copy_content", + COPY_LINK = "copy_link", + COPY_MESSAGE_ID = "copy_message_id", + COPY_USER_ID = "copy_user_id", + QUOTE = "quote", + REACT = "react", + PIN = "pin" +} + const settings = definePluginSettings({ - enableDeleteOnClick: { - type: OptionType.BOOLEAN, - description: "Enable delete on click while holding backspace", - default: true + backspaceClickAction: { + type: OptionType.SELECT, + description: "Action when holding Backspace and clicking a message", + options: [ + { label: "Delete Message", value: ClickAction.DELETE, default: true }, + { label: "Copy Content", value: ClickAction.COPY_CONTENT }, + { label: "Copy Link", value: ClickAction.COPY_LINK }, + { label: "Copy Message ID", value: ClickAction.COPY_MESSAGE_ID }, + { label: "Copy User ID", value: ClickAction.COPY_USER_ID }, + { label: "None (Disabled)", value: ClickAction.NONE } + ] }, - enableDoubleClickToEdit: { - type: OptionType.BOOLEAN, - description: "Enable double click to edit", - default: true + doubleClickAction: { + type: OptionType.SELECT, + description: "Action on double-click (on your messages)", + options: [ + { label: "Edit Message", value: ClickAction.EDIT, default: true }, + { label: "Quote", value: ClickAction.QUOTE }, + { label: "Copy Content", value: ClickAction.COPY_CONTENT }, + { label: "Copy Link", value: ClickAction.COPY_LINK }, + { label: "Copy Message ID", value: ClickAction.COPY_MESSAGE_ID }, + { label: "Copy User ID", value: ClickAction.COPY_USER_ID }, + { label: "React", value: ClickAction.REACT }, + { label: "Pin Message", value: ClickAction.PIN }, + { label: "None (Disabled)", value: ClickAction.NONE } + ] }, - enableDoubleClickToReply: { - type: OptionType.BOOLEAN, - description: "Enable double click to reply", - default: true + doubleClickOthersAction: { + type: OptionType.SELECT, + description: "Action on double-click (on others' messages)", + options: [ + { label: "Reply", value: ClickAction.REPLY, default: true }, + { label: "Quote", value: ClickAction.QUOTE }, + { label: "Copy Content", value: ClickAction.COPY_CONTENT }, + { label: "Copy Link", value: ClickAction.COPY_LINK }, + { label: "Copy Message ID", value: ClickAction.COPY_MESSAGE_ID }, + { label: "Copy User ID", value: ClickAction.COPY_USER_ID }, + { label: "React", value: ClickAction.REACT }, + { label: "Pin Message", value: ClickAction.PIN }, + { label: "None (Disabled)", value: ClickAction.NONE } + ] }, - enableTripleClickToReact: { - type: OptionType.BOOLEAN, - description: "Enable triple click to react with an emoji", - default: false + tripleClickAction: { + type: OptionType.SELECT, + description: "Action on triple-click", + options: [ + { label: "React", value: ClickAction.REACT, default: true }, + { label: "Edit Message", value: ClickAction.EDIT }, + { label: "Reply", value: ClickAction.REPLY }, + { label: "Quote", value: ClickAction.QUOTE }, + { label: "Copy Content", value: ClickAction.COPY_CONTENT }, + { label: "Copy Link", value: ClickAction.COPY_LINK }, + { label: "Copy Message ID", value: ClickAction.COPY_MESSAGE_ID }, + { label: "Copy User ID", value: ClickAction.COPY_USER_ID }, + { label: "Pin Message", value: ClickAction.PIN }, + { label: "None (Disabled)", value: ClickAction.NONE } + ] }, reactEmoji: { type: OptionType.STRING, - description: "Emoji to react with (e.g. 💀 or pepe:123456789)", + description: "Emoji to use for react actions (e.g. 💀 or pepe:123456789)", default: "💀" }, requireModifier: { type: OptionType.BOOLEAN, - description: "Only do double click actions when shift/ctrl is held", + description: "Only perform click actions when shift or ctrl is held", + default: false + }, + disableInDms: { + type: OptionType.BOOLEAN, + description: "Disable all click actions in direct messages", + default: false + }, + disableInSystemDms: { + type: OptionType.BOOLEAN, + description: "Disable all click actions in system DMs (e.g. Clyde, Discord)", + default: true + }, + clickTimeout: { + type: OptionType.NUMBER, + description: "Timeout in milliseconds to distinguish double/triple clicks", + default: 300 + }, + quoteWithReply: { + type: OptionType.BOOLEAN, + description: "When quoting, also reply to the message", + default: true + }, + useSelectionForQuote: { + type: OptionType.BOOLEAN, + description: "When quoting, use selected text if available (otherwise quotes full message)", default: false } }); -async function react(channelId: string, messageId: string, emoji: string) { +function showWarning(message: string) { + Toasts.show({ + message, + type: Toasts.Type.FAILURE, + id: Toasts.genId(), + options: { + duration: 3000 + } + }); +} + +function executeCommonAction(action: ClickAction, channel: Channel, msg: Message): boolean { + switch (action) { + case ClickAction.COPY_CONTENT: + copyWithToast(msg.content || "", "Message content copied!"); + return true; + case ClickAction.COPY_LINK: + copyWithToast(`https://discord.com/channels/${channel.guild_id ?? "@me"}/${channel.id}/${msg.id}`, "Message link copied!"); + return true; + case ClickAction.COPY_MESSAGE_ID: + copyWithToast(msg.id, "Message ID copied!"); + return true; + case ClickAction.COPY_USER_ID: + copyWithToast(msg.author.id, "User ID copied!"); + return true; + } + return false; +} + +function isMessageReplyable(msg: Message) { + return 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; + if (!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]}` - : encodeURIComponent(trimmed); + : trimmed; + + const hasReacted = msg.reactions?.some(r => { + const reactionEmoji = r.emoji.id + ? `${r.emoji.name}:${r.emoji.id}` + : r.emoji.name; + return r.me && reactionEmoji === emojiParam; + }); try { - await RestAPI.put({ - url: `/channels/${channelId}/messages/${messageId}/reactions/${emojiParam}/@me` - }); + if (hasReacted) { + await RestAPI.del({ + url: Constants.Endpoints.REACTION(channelId, messageId, emojiParam, "@me") + }); + } else { + await RestAPI.put({ + url: Constants.Endpoints.REACTION(channelId, messageId, emojiParam, "@me") + }); + } } catch (e) { - new Logger("MessageClickActions").error("Failed to add reaction:", e); + new Logger("MessageClickActions").error("Failed to toggle reaction:", e); + } +} + +function togglePin(channel: Channel, msg: Message) { + if (!PermissionStore.can(PermissionsBits.MANAGE_MESSAGES, channel)) { + showWarning("Cannot pin: Missing permissions"); + return; + } + + if (msg.pinned) { + PinActions.unpinMessage(channel, msg.id); + } else { + PinActions.pinMessage(channel, msg.id); + } +} + +function quoteMessage(channel: Channel, msg: Message) { + if (!isMessageReplyable(msg)) { + showWarning("Cannot quote this message type"); + return; + } + + let { content } = msg; + if (settings.store.useSelectionForQuote) { + const selection = window.getSelection()?.toString().trim(); + if (selection && msg.content?.includes(selection)) { + content = selection; + } + } + if (!content) return; + + const quoteText = content.split("\n").map(line => `> ${line}`).join("\n") + "\n"; + + insertTextIntoChatInputBox(quoteText); + + if (settings.store.quoteWithReply) { + FluxDispatcher.dispatch({ + type: "CREATE_PENDING_REPLY", + channel, + message: msg, + shouldMention: false, + showMentionToggle: !channel.isPrivate() + }); } } export default definePlugin({ name: "MessageClickActions", - description: "Hold Backspace and click to delete, double click to edit/reply, triple click to react", + description: "Customize message click actions - choose what happens when you click, double-click, or hold backspace", authors: [Devs.Ven, EquicordDevs.keyages], settings, @@ -117,23 +286,35 @@ export default definePlugin({ const myId = AuthenticationStore.getId(); const isMe = msg.author.id === myId; const isSelfInvokedUserApp = msg.interactionMetadata?.authorizing_integration_owners[ApplicationIntegrationType.USER_INSTALL] === myId; + const isDM = channel.isDM(); + const isSystemDM = channel.isSystemDM(); + + if ((settings.store.disableInDms && isDM) || (settings.store.disableInSystemDms && isSystemDM)) return; if (isDeletePressed) { - if (!settings.store.enableDeleteOnClick) return; - if (!(isMe || PermissionStore.can(PermissionsBits.MANAGE_MESSAGES, channel) || isSelfInvokedUserApp)) return; + const action = settings.store.backspaceClickAction; + if (action === ClickAction.NONE) return; - if (msg.deleted) { - FluxDispatcher.dispatch({ - type: "MESSAGE_DELETE", - channelId: channel.id, - id: msg.id, - mlDeleted: true - }); - } else { - MessageActions.deleteMessage(channel.id, msg.id); + if (action === ClickAction.DELETE) { + if (!(isMe || PermissionStore.can(PermissionsBits.MANAGE_MESSAGES, channel) || isSelfInvokedUserApp)) { + showWarning("Cannot delete: Missing permissions"); + return; + } + + if (msg.deleted) { + FluxDispatcher.dispatch({ + type: "MESSAGE_DELETE", + channelId: channel.id, + id: msg.id, + mlDeleted: true + }); + } else { + MessageActions.deleteMessage(channel.id, msg.id); + } + } else if (executeCommonAction(action, channel, msg)) { + event.preventDefault(); + return; } - event.preventDefault(); - return; } if (event.detail === 3) { @@ -143,25 +324,66 @@ export default definePlugin({ pendingDoubleClickAction = null; } - if (settings.store.enableTripleClickToReact) { - react(channel.id, msg.id, settings.store.reactEmoji); + const action = settings.store.tripleClickAction; + if (action === ClickAction.NONE) return; + + if (action === ClickAction.REACT) { + toggleReaction(channel.id, msg.id, settings.store.reactEmoji, channel, msg); + } else if (action === ClickAction.EDIT) { + if (!isMe) return; + if (EditStore.isEditing(channel.id, msg.id) || msg.state !== "SENT") return; + MessageActions.startEditMessage(channel.id, msg.id, msg.content); + } else if (action === ClickAction.REPLY) { + if (channel.guild_id && !PermissionStore.can(PermissionsBits.SEND_MESSAGES, channel)) { + showWarning("Cannot reply: Missing permissions"); + return; + } + if (!isMessageReplyable(msg)) { + showWarning("Cannot reply to this message type"); + return; + } + + FluxDispatcher.dispatch({ + type: "CREATE_PENDING_REPLY", + channel, + message: msg, + shouldMention: !isMe, + showMentionToggle: !channel.isPrivate() + }); + } else if (executeCommonAction(action, channel, msg)) { event.preventDefault(); + return; + } else if (action === ClickAction.QUOTE) { + quoteMessage(channel, msg); + } else if (action === ClickAction.PIN) { + togglePin(channel, msg); } + event.preventDefault(); return; } if (event.detail !== 2) return; if (settings.store.requireModifier && !event.ctrlKey && !event.shiftKey) return; - if (channel.guild_id && !PermissionStore.can(PermissionsBits.SEND_MESSAGES, channel)) return; if (msg.deleted === true) return; const executeDoubleClick = () => { - if (isMe) { - if (!settings.store.enableDoubleClickToEdit || EditStore.isEditing(channel.id, msg.id) || msg.state !== "SENT") return; + const action = isMe ? settings.store.doubleClickAction : settings.store.doubleClickOthersAction; + if (action === ClickAction.NONE) return; + + if (action === ClickAction.EDIT) { + if (!isMe) return; + if (EditStore.isEditing(channel.id, msg.id) || msg.state !== "SENT") return; MessageActions.startEditMessage(channel.id, msg.id, msg.content); - } else { - if (!settings.store.enableDoubleClickToReply) return; - if (!MessageTypeSets.REPLYABLE.has(msg.type) || msg.hasFlag(MessageFlags.EPHEMERAL)) return; + } else if (action === ClickAction.REPLY) { + if (isMe) return; + if (channel.guild_id && !PermissionStore.can(PermissionsBits.SEND_MESSAGES, channel)) { + showWarning("Cannot reply: Missing permissions"); + return; + } + if (!isMessageReplyable(msg)) { + showWarning("Cannot reply to this message type"); + return; + } const isShiftPress = event.shiftKey && !settings.store.requireModifier; const shouldMention = isPluginEnabled(NoReplyMentionPlugin.name) @@ -173,12 +395,21 @@ export default definePlugin({ channel, message: msg, shouldMention, - showMentionToggle: channel.guild_id !== null + showMentionToggle: !channel.isPrivate() }); + } else if (executeCommonAction(action, channel, msg)) { + event.preventDefault(); + return; + } else if (action === ClickAction.QUOTE) { + quoteMessage(channel, msg); + } else if (action === ClickAction.REACT) { + toggleReaction(channel.id, msg.id, settings.store.reactEmoji, channel, msg); + } else if (action === ClickAction.PIN) { + togglePin(channel, msg); } }; - if (settings.store.enableTripleClickToReact) { + if (settings.store.tripleClickAction !== ClickAction.NONE) { if (doubleClickTimeout) { clearTimeout(doubleClickTimeout); } @@ -187,7 +418,7 @@ export default definePlugin({ pendingDoubleClickAction?.(); pendingDoubleClickAction = null; doubleClickTimeout = null; - }, 300); + }, settings.store.clickTimeout); event.preventDefault(); } else { executeDoubleClick(); diff --git a/src/webpack/common/stores.ts b/src/webpack/common/stores.ts index a796c569e..b521a4b73 100644 --- a/src/webpack/common/stores.ts +++ b/src/webpack/common/stores.ts @@ -80,6 +80,7 @@ export let RTCConnectionStore: t.RTCConnectionStore; export let SoundboardStore: t.SoundboardStore; export let PopoutWindowStore: t.PopoutWindowStore; export let ApplicationCommandIndexStore: GenericStore; +export let EditStore: GenericStore; /** * @see jsdoc of {@link t.useStateFromStores} @@ -132,6 +133,7 @@ waitForStore("RTCConnectionStore", m => RTCConnectionStore = m); waitForStore("SoundboardStore", m => SoundboardStore = m); waitForStore("PopoutWindowStore", m => PopoutWindowStore = m); waitForStore("ApplicationCommandIndexStore", m => ApplicationCommandIndexStore = m); +waitForStore("EditStore", m => EditStore = m); waitForStore("ThemeStore", m => { ThemeStore = m; // Importing this directly causes all webpack commons to be imported, which can easily cause circular dependencies.