Plugin

QuickReply

Reply to (ctrl + up/down) and edit (ctrl + shift + up/down) messages via keybinds

Chat Shortcuts
index.ts
Download

Source

src/plugins/quickReply/index.ts
1/*
2 * FiveCord — a Discord client mod
3 * Copyright (c) 2025 FiveCord
4 * SPDX-License-Identifier: GPL-3.0-or-later
5 */
6
7import { isPluginEnabled } from "@api/PluginManager";
8import { definePluginSettings } from "@api/Settings";
9import NoBlockedMessagesPlugin from "@plugins/noBlockedMessages";
10import NoReplyMentionPlugin from "@plugins/noReplyMention";
11import { Devs, IS_MAC } from "@utils/constants";
12import definePlugin, { OptionType } from "@utils/types";
13import { Message } from "@vencord/discord-types";
14import { MessageFlags } from "@vencord/discord-types/enums";
15import { ChannelStore, ComponentDispatch, FluxDispatcher as Dispatcher, MessageActions, MessageStore, MessageTypeSets, PermissionsBits, PermissionStore, RelationshipStore, SelectedChannelStore, UserStore } from "@webpack/common";
16
17let currentlyReplyingId: string | null = null;
18let currentlyEditingId: string | null = null;
19
20const enum MentionOptions {
21 DISABLED,
22 ENABLED,
23 NO_REPLY_MENTION_PLUGIN
24}
25
26const settings = definePluginSettings({
27 shouldMention: {
28 type: OptionType.SELECT,
29 description: "Ping reply by default",
30 options: [
31 {
32 label: "Follow NoReplyMention plugin (if enabled)",
33 value: MentionOptions.NO_REPLY_MENTION_PLUGIN,
34 default: true
35 },
36 { label: "Enabled", value: MentionOptions.ENABLED },
37 { label: "Disabled", value: MentionOptions.DISABLED },
38 ]
39 },
40 ignoreBlockedAndIgnored: {
41 type: OptionType.BOOLEAN,
42 description: "Ignore messages by blocked/ignored users when navigating",
43 default: true
44 }
45});
46
47export default definePlugin({
48 name: "QuickReply",
49 authors: [Devs.fawn, Devs.Ven, Devs.pylix],
50 description: "Reply to (ctrl + up/down) and edit (ctrl + shift + up/down) messages via keybinds",
51 tags: ["Chat", "Shortcuts"],
52 settings,
53
54 start() {
55 document.addEventListener("keydown", onKeydown);
56 },
57
58 stop() {
59 document.removeEventListener("keydown", onKeydown);
60 },
61
62 flux: {
63 DELETE_PENDING_REPLY() {
64 currentlyReplyingId = null;
65 },
66 MESSAGE_END_EDIT() {
67 currentlyEditingId = null;
68 },
69 CHANNEL_SELECT() {
70 currentlyReplyingId = null;
71 currentlyEditingId = null;
72 },
73 MESSAGE_START_EDIT: onStartEdit,
74 CREATE_PENDING_REPLY: onCreatePendingReply
75 }
76});
77
78function onStartEdit({ messageId, _isQuickEdit }: any) {
79 if (_isQuickEdit) return;
80 currentlyEditingId = messageId;
81}
82
83function onCreatePendingReply({ message, _isQuickReply }: { message: Message; _isQuickReply: boolean; }) {
84 if (_isQuickReply) return;
85
86 currentlyReplyingId = message.id;
87}
88
89const isCtrl = (e: KeyboardEvent) => IS_MAC ? e.metaKey : e.ctrlKey;
90const isAltOrMeta = (e: KeyboardEvent) => e.altKey || (!IS_MAC && e.metaKey);
91
92function onKeydown(e: KeyboardEvent) {
93 const isUp = e.key === "ArrowUp";
94 if (!isUp && e.key !== "ArrowDown") return;
95 if (!isCtrl(e) || isAltOrMeta(e)) return;
96
97 e.preventDefault();
98
99 if (e.shiftKey)
100 nextEdit(isUp);
101 else
102 nextReply(isUp);
103}
104
105function jumpIfOffScreen(channelId: string, messageId: string) {
106 const element = document.getElementById("message-content-" + messageId);
107 if (!element) return;
108
109 const vh = Math.max(document.documentElement.clientHeight, window.innerHeight);
110 const rect = element.getBoundingClientRect();
111 const isOffscreen = rect.bottom < 150 || rect.top - vh >= -150;
112
113 if (isOffscreen) {
114 MessageActions.jumpToMessage({
115 channelId,
116 messageId,
117 flash: false,
118 jumpType: "INSTANT"
119 });
120 }
121}
122
123function getNextMessage(isUp: boolean, isReply: boolean) {
124 let messages: Message[] = MessageStore.getMessages(SelectedChannelStore.getChannelId())._array;
125
126 const meId = UserStore.getCurrentUser().id;
127 const hasNoBlockedMessages = isPluginEnabled(NoBlockedMessagesPlugin.name);
128
129 messages = messages.filter(m => {
130 if (m.deleted) return false;
131 if (!isReply && m.author.id !== meId) return false; class="ts-cmt">// editing only own messages
132 if (!MessageTypeSets.REPLYABLE.has(m.type) || m.hasFlag(MessageFlags.EPHEMERAL)) return false;
133 if (settings.store.ignoreBlockedAndIgnored && RelationshipStore.isBlockedOrIgnored(m.author.id)) return false;
134 if (hasNoBlockedMessages && NoBlockedMessagesPlugin.shouldIgnoreMessage(m)) return false;
135
136 return true;
137 });
138
139 const findNextNonDeleted = (id: string | null) => {
140 if (id === null) return messages[messages.length - 1];
141
142 const idx = messages.findIndex(m => m.id === id);
143 if (idx === -1) return messages[messages.length - 1];
144
145 const i = isUp ? idx - 1 : idx + 1;
146 return messages[i] ?? null;
147 };
148
149 if (isReply) {
150 const msg = findNextNonDeleted(currentlyReplyingId);
151 currentlyReplyingId = msg?.id ?? null;
152 return msg;
153 } else {
154 const msg = findNextNonDeleted(currentlyEditingId);
155 currentlyEditingId = msg?.id ?? null;
156 return msg;
157 }
158}
159
160function shouldMention(message: Message) {
161 switch (settings.store.shouldMention) {
162 case MentionOptions.NO_REPLY_MENTION_PLUGIN:
163 if (!isPluginEnabled(NoReplyMentionPlugin.name)) return true;
164 return NoReplyMentionPlugin.shouldMention(message, false);
165 case MentionOptions.DISABLED:
166 return false;
167 default:
168 return true;
169 }
170}
171
172// handle next/prev reply
173function nextReply(isUp: boolean) {
174 const currChannel = ChannelStore.getChannel(SelectedChannelStore.getChannelId());
175 if (currChannel.guild_id && !PermissionStore.can(PermissionsBits.SEND_MESSAGES, currChannel)) return;
176
177 const message = getNextMessage(isUp, true);
178
179 if (!message) {
180 return void Dispatcher.dispatch({
181 type: "DELETE_PENDING_REPLY",
182 channelId: SelectedChannelStore.getChannelId(),
183 });
184 }
185
186 const channel = ChannelStore.getChannel(message.channel_id);
187 const meId = UserStore.getCurrentUser().id;
188
189 Dispatcher.dispatch({
190 type: "CREATE_PENDING_REPLY",
191 channel,
192 message,
193 shouldMention: shouldMention(message),
194 showMentionToggle: !channel.isPrivate() && message.author.id !== meId,
195 _isQuickReply: true
196 });
197
198 ComponentDispatch.dispatchToLastSubscribed("TEXTAREA_FOCUS");
199 jumpIfOffScreen(channel.id, message.id);
200}
201
202// handle next/prev edit
203function nextEdit(isUp: boolean) {
204 const currChannel = ChannelStore.getChannel(SelectedChannelStore.getChannelId());
205 if (currChannel.guild_id && !PermissionStore.can(PermissionsBits.SEND_MESSAGES, currChannel)) return;
206 const message = getNextMessage(isUp, false);
207
208 if (!message) {
209 return Dispatcher.dispatch({
210 type: "MESSAGE_END_EDIT",
211 channelId: SelectedChannelStore.getChannelId()
212 });
213 }
214
215 Dispatcher.dispatch({
216 type: "MESSAGE_START_EDIT",
217 channelId: message.channel_id,
218 messageId: message.id,
219 content: message.content,
220 _isQuickEdit: true
221 });
222
223 jumpIfOffScreen(message.channel_id, message.id);
224}
225