Plugin
AllowedMentions
Fine grained control over whom to ping when sending or editing a message.
1
/*2
* FiveCord — a Discord client mod3
* Copyright (c) 2025 FiveCord4
* SPDX-License-Identifier: GPL-3.0-or-later5
*/6
7
import { addPreSendListener, MessageExtra, removePreSendListener } from "@api/MessageEvents";8
import { definePluginSettings } from "@api/Settings";9
import { Flex } from "@components/Flex";10
import { Devs } from "@utils/constants";11
import { isNonNullish } from "@utils/guards";12
import definePlugin, { OptionType } from "@utils/types";13
import { Alerts, GuildStore, PermissionsBits, PermissionStore } from "@webpack/common";14
import { Channel } from "discord-types/general";15
16
import { AllowedMentions, AllowedMentionsBar, AllowedMentionsProps, AllowedMentionsStore as store } from "./AllowedMentions";17
18
export 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 wrapper45
{46
match: /,children:\(0,\i.jsx\)\(\i.\i,{/,47
replace: "$& type: arguments[0].type,"48
}49
]50
},51
{52
find: 039;"chat input type must be set");039;,53
replacement: [54
// Set allowedMentions & populate attachments store55
{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 visible60
{61
match: /.hasConnectedBar]:\i/,62
replace: 039;$& || $self.getAllowedMentions(arguments[0].channel.id, arguments[0].type?.analyticsName === "edit"),039;63
},64
// Pass mentions to attached bars component65
// Would do a simple isEdit but the component is memo'd66
{67
match: /activeCommand:\i,pendingReply:\i/,68
replace: 039;$&, mentions: $self.getAllowedMentions(arguments[0].channel.id, arguments[0].type?.analyticsName === "edit"),039;69
},70
]71
},72
{73
find: ".stackedAttachedBar]:!",74
replacement: [75
// Add AllowedMentionsBar when not replying76
// Will never render if above patch fails77
{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 replying87
{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 off97
{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 creation107
{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 post117
{118
match: /.trackForumNewPostCleared\)\(\{guildId:\i.guild_id,channelId:(\i.id)\}\)/,119
replace: "$&; $self.onForumCancel($1);"120
},121
// Fail creating forum if tooManyUsers or tooManyRoles122
{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 twice160
// 1. unparsed raw text161
// 2. parsed text (we need this)162
// We skip setting allowed mentions for unparsed text cause there can be potential unparsed mentions163
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.hasEveryone195
&& mentions.meta.userIds.size === 0196
&& mentions.meta.roleIds.size === 0197
) {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: `You039;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 this243
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