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