Plugin
QuickReply
Reply to (ctrl + up/down) and edit (ctrl + shift + up/down) messages via keybinds
1
/*2
* FiveCord — a Discord client mod3
* Copyright (c) 2025 FiveCord4
* SPDX-License-Identifier: GPL-3.0-or-later5
*/6
7
import { isPluginEnabled } from "@api/PluginManager";8
import { definePluginSettings } from "@api/Settings";9
import NoBlockedMessagesPlugin from "@plugins/noBlockedMessages";10
import NoReplyMentionPlugin from "@plugins/noReplyMention";11
import { Devs, IS_MAC } from "@utils/constants";12
import definePlugin, { OptionType } from "@utils/types";13
import { Message } from "@vencord/discord-types";14
import { MessageFlags } from "@vencord/discord-types/enums";15
import { ChannelStore, ComponentDispatch, FluxDispatcher as Dispatcher, MessageActions, MessageStore, MessageTypeSets, PermissionsBits, PermissionStore, RelationshipStore, SelectedChannelStore, UserStore } from "@webpack/common";16
17
let currentlyReplyingId: string | null = null;18
let currentlyEditingId: string | null = null;19
20
const enum MentionOptions {21
DISABLED,22
ENABLED,23
NO_REPLY_MENTION_PLUGIN24
}25
26
const 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: true35
},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: true44
}45
});46
47
export 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: onCreatePendingReply75
}76
});77
78
function onStartEdit({ messageId, _isQuickEdit }: any) {79
if (_isQuickEdit) return;80
currentlyEditingId = messageId;81
}82
83
function onCreatePendingReply({ message, _isQuickReply }: { message: Message; _isQuickReply: boolean; }) {84
if (_isQuickReply) return;85
86
currentlyReplyingId = message.id;87
}88
89
const isCtrl = (e: KeyboardEvent) => IS_MAC ? e.metaKey : e.ctrlKey;90
const isAltOrMeta = (e: KeyboardEvent) => e.altKey || (!IS_MAC && e.metaKey);91
92
function 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
else102
nextReply(isUp);103
}104
105
function 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
123
function 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 messages132
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
160
function 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 reply173
function 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: true196
});197
198
ComponentDispatch.dispatchToLastSubscribed("TEXTAREA_FOCUS");199
jumpIfOffScreen(channel.id, message.id);200
}201
202
// handle next/prev edit203
function 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: true221
});222
223
jumpIfOffScreen(message.channel_id, message.id);224
}225