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:
parent
29bf8dba1d
commit
018a994d5b
3 changed files with 297 additions and 47 deletions
169
src/plugins/messageClickActions/ReactEmojiSetting.tsx
Normal file
169
src/plugins/messageClickActions/ReactEmojiSetting.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 =
|
||||
|
|
|
|||
7
src/plugins/messageClickActions/styles.css
Normal file
7
src/plugins/messageClickActions/styles.css
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue