Plugin

AllowedMentions

Fine grained control over whom to ping when sending or editing a message.

index.tsx
Download

Source

src/plugins/allowedMentions/index.tsx
1/*
2 * FiveCord — a Discord client mod
3 * Copyright (c) 2025 FiveCord
4 * SPDX-License-Identifier: GPL-3.0-or-later
5 */
6
7import { addPreSendListener, MessageExtra, removePreSendListener } from "@api/MessageEvents";
8import { definePluginSettings } from "@api/Settings";
9import { Flex } from "@components/Flex";
10import { Devs } from "@utils/constants";
11import { isNonNullish } from "@utils/guards";
12import definePlugin, { OptionType } from "@utils/types";
13import { Alerts, GuildStore, PermissionsBits, PermissionStore } from "@webpack/common";
14import { Channel } from "discord-types/general";
15
16import { AllowedMentions, AllowedMentionsBar, AllowedMentionsProps, AllowedMentionsStore as store } from "./AllowedMentions";
17
18export default definePlugin({
19 name: "AllowedMentions",
20 authors: [Devs.FiveCord],
21 description: "Fine grained control over whom to ping when sending or editing a message.",
22 dependencies: ["MessageEventsAPI"],
23 settings: definePluginSettings({
24 pingEveryone: {
25 type: OptionType.BOOLEAN,
26 description: "Mention everyone by default",
27 default: false,
28 },
29 pingAllUsers: {
30 type: OptionType.BOOLEAN,
31 description: "Mention all users by default",
32 default: true,
33 },
34 pingAllRoles: {
35 type: OptionType.BOOLEAN,
36 description: "Mention all roles by default",
37 default: true,
38 }
39 }),
40 patches: [
41 {
42 find: ".slateContainer)",
43 replacement: [
44 // Pass type prop to slate wrapper
45 {
46 match: /,children:\(0,\i.jsx\)\(\i.\i,{/,
47 replace: "$& type: arguments[0].type,"
48 }
49 ]
50 },
51 {
52 find: '"chat input type must be set");',
53 replacement: [
54 // Set allowedMentions & populate attachments store
55 {
56 match: /"chat input type must be set"\);/,
57 replace: "$& $self.allowedSlateTypes.includes(arguments[0].type?.analyticsName) && $self.setAllowedMentions(arguments[0]);"
58 },
59 // Add the hasConnectedBar class when AllowedMentionsBar is visible
60 {
61 match: /.hasConnectedBar]:\i/,
62 replace: '$& || $self.getAllowedMentions(arguments[0].channel.id, arguments[0].type?.analyticsName === "edit"),'
63 },
64 // Pass mentions to attached bars component
65 // Would do a simple isEdit but the component is memo'd
66 {
67 match: /activeCommand:\i,pendingReply:\i/,
68 replace: '$&, mentions: $self.getAllowedMentions(arguments[0].channel.id, arguments[0].type?.analyticsName === "edit"),'
69 },
70 ]
71 },
72 {
73 find: ".stackedAttachedBar]:!",
74 replacement: [
75 // Add AllowedMentionsBar when not replying
76 // Will never render if above patch fails
77 {
78 match: /(?<=pendingReply:\i}=(\i),.+?)null!=(\i)&&(\i).push\(\(0,\i.jsx\)\(\i.\i,{reply:\i,/,
79 replace: "null == $2 && null != $1.mentions && $3.push($self.AllowedMentionsBar({ mentions: $1.mentions, channel: $1.channel })), $&"
80 }
81 ]
82 },
83 {
84 find: ".Messages.REPLYING_TO.format({",
85 replacement: [
86 // Add AllowedMentionsBar to reply bar when replying
87 {
88 match: /(?<="div",\{className:\i.actions,children:\[)(?=\i&&)/,
89 replace: "null != $self.getAllowedMentions(arguments[0].reply.channel.id, false) && $self.AllowedMentionsBarInner({ mentions: $self.getAllowedMentions(arguments[0].reply.channel.id, false), channel: arguments[0].reply.channel, trailingSeparator: true }),",
90 }
91 ]
92 },
93 {
94 find: ".Messages.EVERYONE_POPOUT_BODY",
95 replacement: [
96 // Remove the warning popout for large server when @everyone mention is off
97 {
98 match: /(?<=shouldShowEveryoneGuard\(\i,(\i)\))/,
99 replace: "|| $self.skipEveryoneContentWarningPopout($1.id)"
100 }
101 ]
102 },
103 {
104 find: &#039;"?use_nested_fields=true"&#039;,
105 replacement: [
106 // Patch sending allowed_mentions for forum creation
107 {
108 match: /(?<=.Endpoints.CHANNEL_THREADS\((\i.id)\)\+"\?use_nested_fields=true".+?message:\{)/,
109 replace: "allowed_mentions: $self.patchForumAllowedMentions($1),"
110 }
111 ]
112 },
113 {
114 find: ".ComponentActions.FOCUS_COMPOSER_TITLE,",
115 replacement: [
116 // Clear entry on cancelling new forum post
117 {
118 match: /.trackForumNewPostCleared\)\(\{guildId:\i.guild_id,channelId:(\i.id)\}\)/,
119 replace: "$&; $self.onForumCancel($1);"
120 },
121 // Fail creating forum if tooManyUsers or tooManyRoles
122 {
123 match: /applyChatRestrictions\)\(\{.+?channel:(\i)\}\);if\(!\i/,
124 replace: "$& || !$self.validateForum($1.id)"
125 }
126 ]
127 }
128 ],
129 allowedSlateTypes: ["normal", "sidebar", "thread_creation", "create_forum_post"],
130 getAllowedMentions(channelId: string, shouldDelete?: boolean) {
131 const mentions = store.get(channelId);
132
133 if (shouldDelete) { store.delete(channelId); }
134
135 return mentions;
136 },
137 setAllowedMentions({ richValue, channel: { id: channelId, guild_id: guildId } }: { richValue: any, channel: Channel; }) {
138 const previous = store.get(channelId);
139
140 const canMentionEveryone = isNonNullish(guildId) ? PermissionStore.can(PermissionsBits.MENTION_EVERYONE, GuildStore.getGuild(guildId)) as boolean : true;
141
142 const mentions: AllowedMentions = {
143 parse: new Set(),
144 users: previous?.users ?? new Set(),
145 roles: previous?.roles ?? new Set(),
146 meta: {
147 hasEveryone: false,
148 userIds: new Set(),
149 roleIds: new Set(),
150 tooManyUsers: false,
151 tooManyRoles: false,
152 }
153 };
154
155 if (!isNonNullish(richValue[0]?.children)) {
156 return undefined;
157 }
158
159 // Discord renders the slate wrapper twice
160 // 1. unparsed raw text
161 // 2. parsed text (we need this)
162 // We skip setting allowed mentions for unparsed text cause there can be potential unparsed mentions
163 if (richValue[0]?.children.length === 1 && typeof richValue[0]?.children[0].text === "string") {
164 // This is the case where the input is empty (no potential unparsed mentions)
165 if (richValue[0]?.children[0].text === "") { store.delete(channelId); }
166
167 return;
168 }
169
170 for (const node of richValue[0].children) {
171 switch (node.type) {
172 case "userMention":
173 mentions.meta.userIds.add(node.userId);
174 break;
175 case "roleMention":
176 mentions.meta.roleIds.add(node.roleId);
177 break;
178 case "textMention":
179 if (node.name === "@everyone" || node.name === "@here") {
180 mentions.meta.hasEveryone = canMentionEveryone;
181
182 if (canMentionEveryone && (previous?.parse.has?.("everyone") ?? this.settings.store.pingEveryone)) {
183 mentions.parse.add("everyone");
184 }
185 }
186 break;
187 }
188 }
189
190 if (this.settings.store.pingAllUsers) { mentions.users = mentions.meta.userIds; }
191 if (this.settings.store.pingAllRoles) { mentions.roles = mentions.meta.roleIds; }
192
193 if (
194 !mentions.meta.hasEveryone
195 && mentions.meta.userIds.size === 0
196 && mentions.meta.roleIds.size === 0
197 ) {
198 store.delete(channelId);
199 } else {
200 store.set(channelId, mentions, true);
201 }
202 },
203 skipEveryoneContentWarningPopout(channelId: string) {
204 const mentions = store.get(channelId);
205 return isNonNullish(mentions) && !mentions.parse.has("everyone");
206 },
207 tooManyAlert(tooManyUsers: boolean, tooManyRoles: boolean) {
208 const type = [
209 tooManyUsers && "users",
210 tooManyRoles && "roles"
211 ].filter(x => x).join(" and ");
212
213 Alerts.show({
214 title: "Uh oh!",
215 body: `You&#039;ve selected too many individual ${type} to mention!\nYou may only select all or up to 100 items in each category.`
216 });
217 },
218 validateForum(channelId: string) {
219 const mentions = this.getAllowedMentions(channelId, true);
220 if (!isNonNullish(mentions)) return;
221
222 if (mentions.meta.tooManyUsers || mentions.meta.tooManyRoles) {
223 this.tooManyAlert(mentions.meta.tooManyUsers, mentions.meta.tooManyRoles);
224 return false;
225 }
226
227 return true;
228 },
229 patchSendAllowedMentions(channelId: string, extra: MessageExtra) {
230 const mentions = this.getAllowedMentions(channelId, true);
231 if (!isNonNullish(mentions)) return;
232
233 if (mentions.meta.tooManyUsers || mentions.meta.tooManyRoles) {
234 this.tooManyAlert(mentions.meta.tooManyUsers, mentions.meta.tooManyRoles);
235 return { cancel: true };
236 }
237
238 extra.replyOptions.allowedMentions = {
239 parse: Array.from(mentions.parse),
240 users: mentions.users ? Array.from(mentions.users) : undefined,
241 roles: mentions.roles ? Array.from(mentions.roles) : undefined,
242 // Don't override this for send! Discord already has a UI for this
243 repliedUser: extra.replyOptions.allowedMentions?.repliedUser ?? false,
244 };
245 },
246 patchForumAllowedMentions(channelId: string) {
247 const mentions = this.getAllowedMentions(channelId, true);
248 if (!isNonNullish(mentions)) return;
249
250 return {
251 parse: Array.from(mentions.parse),
252 users: mentions.users ? Array.from(mentions.users) : undefined,
253 roles: mentions.roles ? Array.from(mentions.roles) : undefined,
254 };
255 },
256 onForumCancel(channelId: string) {
257 store.delete(channelId);
258 },
259 AllowedMentionsBar(props: AllowedMentionsProps) {
260 return <Flex style={{ padding: "0.45rem 1rem", lineHeight: "16px" }}>
261 {<this.AllowedMentionsBarInner {...props} />}
262 </Flex>;
263 },
264 AllowedMentionsBarInner(props: AllowedMentionsProps) {
265 return <AllowedMentionsBar {...props} />;
266 },
267 start() {
268 this.preSend = addPreSendListener((channelId, _, extra) => this.patchSendAllowedMentions(channelId, extra));
269 },
270 stop() {
271 removePreSendListener(this.preSend);
272 store.clear();
273 },
274});
275