Plugin

FakeNitro

Allows you to send fake emojis/stickers, use nitro themes, and stream in nitro quality

Emotes Appearance Customisation Chat
index.tsx
Download

Source

src/plugins/fakeNitro/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 { addMessagePreEditListener, addMessagePreSendListener, removeMessagePreEditListener, removeMessagePreSendListener } from "@api/MessageEvents";
8import { definePluginSettings } from "@api/Settings";
9import { ApngBlendOp, ApngDisposeOp, parseAPNG } from "@utils/apng";
10import { Devs } from "@utils/constants";
11import { getCurrentGuild } from "@utils/discord";
12import { Logger } from "@utils/Logger";
13import definePlugin, { OptionType } from "@utils/types";
14import type { Emoji, Message, RenderModalProps, Sticker } from "@vencord/discord-types";
15import { StickerFormatType } from "@vencord/discord-types/enums";
16import { findByCodeLazy, findByPropsLazy, proxyLazyWebpack } from "@webpack";
17import { ChannelStore, ConfirmModal, DraftType, EmojiStore, FluxDispatcher, Forms, GuildMemberStore, IconUtils, lodash, openModal, Parser, PermissionsBits, PermissionStore, StickersStore, UploadHandler, UserSettingsActionCreators, UserSettingsProtoStore, UserStore } from "@webpack/common";
18import { applyPalette, GIFEncoder, quantize } from "gifenc";
19import type { ReactElement, ReactNode } from "react";
20
21const BINARY_READ_OPTIONS = findByPropsLazy("readerFactory");
22
23function searchProtoClassField(localName: string, protoClass: any) {
24 const field = protoClass?.fields?.find((field: any) => field.localName === localName);
25 if (!field) return;
26
27 const fieldGetter = Object.values(field).find(value => typeof value === "function") as any;
28 return fieldGetter?.();
29}
30
31const PreloadedUserSettingsActionCreators = proxyLazyWebpack(() => UserSettingsActionCreators.PreloadedUserSettingsActionCreators);
32const AppearanceSettingsActionCreators = proxyLazyWebpack(() => searchProtoClassField("appearance", PreloadedUserSettingsActionCreators.ProtoClass));
33const ClientThemeSettingsActionsCreators = proxyLazyWebpack(() => searchProtoClassField("clientThemeSettings", AppearanceSettingsActionCreators));
34
35const isUnusableRoleSubscriptionEmoji = findByCodeLazy(".getUserIsAdmin(");
36
37const enum EmojiIntentions {
38 REACTION,
39 STATUS,
40 COMMUNITY_CONTENT,
41 CHAT,
42 GUILD_STICKER_RELATED_EMOJI,
43 GUILD_ROLE_BENEFIT_EMOJI,
44 COMMUNITY_CONTENT_ONLY,
45 SOUNDBOARD,
46 VOICE_CHANNEL_TOPIC,
47 GIFT,
48 AUTO_SUGGESTION,
49 POLLS
50}
51
52const IS_BYPASSEABLE_INTENTION = `[${EmojiIntentions.CHAT},${EmojiIntentions.GUILD_STICKER_RELATED_EMOJI}].includes(fakeNitroIntention)`;
53
54const enum FakeNoticeType {
55 Sticker,
56 Emoji
57}
58
59const fakeNitroEmojiRegex = /\/emojis\/(\d+?)\.(png|webp|gif)/;
60const fakeNitroStickerRegex = /\/stickers\/(\d+?)\./;
61const fakeNitroGifStickerRegex = /\/attachments\/\d+?\/\d+?\/(\d+?)\.gif/;
62const hyperLinkRegex = /\[.+?\]\((https?:\/\/.+?)\)/;
63
64const settings = definePluginSettings({
65 enableEmojiBypass: {
66 description: "Allows sending fake emojis (also bypasses missing permission to use custom emojis)",
67 type: OptionType.BOOLEAN,
68 default: true,
69 restartNeeded: true
70 },
71 emojiSize: {
72 description: "Size of the emojis when sending",
73 type: OptionType.SLIDER,
74 default: 48,
75 markers: [32, 48, 56, 64, 96, 128, 160, 256, 512]
76 },
77 transformEmojis: {
78 description: "Whether to transform fake emojis into real ones",
79 type: OptionType.BOOLEAN,
80 default: true,
81 restartNeeded: true
82 },
83 enableStickerBypass: {
84 description: "Allows sending fake stickers (also bypasses missing permission to use stickers)",
85 type: OptionType.BOOLEAN,
86 default: true,
87 restartNeeded: true
88 },
89 stickerSize: {
90 description: "Size of the stickers when sending",
91 type: OptionType.SLIDER,
92 default: 160,
93 markers: [32, 64, 128, 160, 256, 512]
94 },
95 transformStickers: {
96 description: "Whether to transform fake stickers into real ones",
97 type: OptionType.BOOLEAN,
98 default: true,
99 restartNeeded: true
100 },
101 transformCompoundSentence: {
102 description: "Whether to transform fake stickers and emojis in compound sentences (sentences with more content than just the fake emoji or sticker link)",
103 type: OptionType.BOOLEAN,
104 default: false
105 },
106 enableStreamQualityBypass: {
107 description: "Allow streaming in nitro quality",
108 type: OptionType.BOOLEAN,
109 default: true,
110 restartNeeded: true
111 },
112 useHyperLinks: {
113 description: "Whether to use hyperlinks when sending fake emojis and stickers",
114 type: OptionType.BOOLEAN,
115 default: true
116 },
117 hyperLinkText: {
118 description: "What text the hyperlink should use. {{NAME}} will be replaced with the emoji/sticker name.",
119 type: OptionType.STRING,
120 default: "{{NAME}}"
121 },
122 disableEmbedPermissionCheck: {
123 description: "Whether to disable the embed permission check when sending fake emojis and stickers",
124 type: OptionType.BOOLEAN,
125 default: false
126 }
127});
128
129function hasPermission(channelId: string, permission: bigint) {
130 const channel = ChannelStore.getChannel(channelId);
131
132 if (!channel || channel.isPrivate()) return true;
133
134 return PermissionStore.can(permission, channel);
135}
136
137const hasExternalEmojiPerms = (channelId: string) => hasPermission(channelId, PermissionsBits.USE_EXTERNAL_EMOJIS);
138const hasExternalStickerPerms = (channelId: string) => hasPermission(channelId, PermissionsBits.USE_EXTERNAL_STICKERS);
139const hasEmbedPerms = (channelId: string) => hasPermission(channelId, PermissionsBits.EMBED_LINKS);
140const hasAttachmentPerms = (channelId: string) => hasPermission(channelId, PermissionsBits.ATTACH_FILES);
141
142function getWordBoundary(origStr: string, offset: number) {
143 return (!origStr[offset] || /\s/.test(origStr[offset])) ? "" : " ";
144}
145
146function CannotEmbedNoticeModal({ modalProps, resolve }: { modalProps: RenderModalProps; resolve: (value: boolean) => void; }) {
147 const s = settings.use(["disableEmbedPermissionCheck"]);
148 return (
149 <ConfirmModal
150 {...modalProps}
151 title="Hold on!"
152 subtitle="You are trying to send/edit a message that contains a FakeNitro emoji or sticker, however you do not have permissions to embed links in the current channel. Are you sure you want to send this message? Your FakeNitro items will appear as a link only."
153 confirmText="Send Anyway"
154 cancelText="Cancel"
155 onConfirm={() => resolve(true)}
156 onCloseCallback={() => setImmediate(() => resolve(false))}
157 checkboxProps={{
158 checked: s.disableEmbedPermissionCheck === true,
159 onChange: checked => s.disableEmbedPermissionCheck = checked
160 }}
161 />
162 );
163}
164
165function showCannotEmbedNotice() {
166 return new Promise<boolean>(resolve => {
167 openModal(props => <CannotEmbedNoticeModal modalProps={props} resolve={resolve} />);
168 });
169}
170
171export default definePlugin({
172 name: "FakeNitro",
173 authors: [Devs.Arjix, Devs.D3SOX, Devs.Ven, Devs.fawn, Devs.captain, Devs.Nuckyz, Devs.AutumnVN, Devs.sadan],
174 description: "Allows you to send fake emojis/stickers, use nitro themes, and stream in nitro quality",
175 tags: ["Emotes", "Appearance", "Customisation", "Chat"],
176 dependencies: ["MessageEventsAPI"],
177
178 settings,
179
180 patches: [
181 {
182 find: "canUseCustomStickersEverywhere:",
183 replacement: [
184 {
185 match: /(?<=canUseCustomStickersEverywhere:function\(\i\)\{)/,
186 replace: "return true;",
187 predicate: () => settings.store.enableStickerBypass
188 },
189 {
190 match: /(?<=canUseHighVideoUploadQuality:function\(\i\)\{)/,
191 replace: "return true;",
192 predicate: () => settings.store.enableStreamQualityBypass
193 },
194 {
195 match: /(?<=canStreamQuality:function\(\i,\i\)\{)/,
196 replace: "return true;",
197 predicate: () => settings.store.enableStreamQualityBypass
198 },
199 {
200 match: /(?<=canUseClientThemes:function\(\i\)\{)/,
201 replace: "return true;"
202 },
203 {
204 match: /(?<=canUsePremiumAppIcons:function\(\i\)\{)/,
205 replace: "return true;"
206 }
207 ],
208 },
209 // Patch the emoji picker in voice calls to not be bypassed by fake nitro
210 {
211 find: &#039;.getByName("fork_and_knife")&#039;,
212 predicate: () => settings.store.enableEmojiBypass,
213 replacement: {
214 match: ".CHAT",
215 replace: ".STATUS"
216 }
217 },
218 {
219 find: ".GUILD_SUBSCRIPTION_UNAVAILABLE;",
220 group: true,
221 predicate: () => settings.store.enableEmojiBypass,
222 replacement: [
223 {
224 // Create a variable for the intention of using the emoji
225 match: /(?<=\.USE_EXTERNAL_EMOJIS.+?;)(?<=intention:(\i).+?)/,
226 replace: (_, intention) => `const fakeNitroIntention=${intention};`
227 },
228 {
229 // Disallow the emoji for external if the intention doesn't allow it
230 match: /&&!\i&&!\i(?=\)return \i\.\i\.DISALLOW_EXTERNAL;)/,
231 replace: m => `${m}&&!${IS_BYPASSEABLE_INTENTION}`
232 },
233 {
234 // Disallow the emoji for unavailable if the intention doesn't allow it
235 match: /!\i\.available(?=\)return \i\.\i\.GUILD_SUBSCRIPTION_UNAVAILABLE;)/,
236 replace: m => `${m}&&!${IS_BYPASSEABLE_INTENTION}`
237 },
238 {
239 // Disallow the emoji for premium locked if the intention doesn't allow it
240 match: /!(\i\.\i\.canUseEmojisEverywhere\(\i\))/,
241 replace: m => `(${m}&&!${IS_BYPASSEABLE_INTENTION})`
242 },
243 {
244 // Allow animated emojis to be used if the intention allows it
245 match: /(?<=\|\|)\i\.\i\.canUseAnimatedEmojis\(\i\)/,
246 replace: m => `(${m}||${IS_BYPASSEABLE_INTENTION})`
247 }
248 ]
249 },
250 // Allows the usage of subscription-locked emojis
251 {
252 find: ".getUserIsAdmin(",
253 replacement: {
254 match: /(function \i\(\i,\i)\){(.{0,250}.getUserIsAdmin\(.+?return!1})/,
255 replace: (_, rest1, rest2) => `${rest1},fakeNitroOriginal){if(!fakeNitroOriginal)return false;${rest2}`
256 }
257 },
258 // Make stickers always available
259 {
260 find: &#039;"SENDABLE"&#039;,
261 predicate: () => settings.store.enableStickerBypass,
262 replacement: {
263 match: /\i\.available\?/,
264 replace: "true?"
265 }
266 },
267 // Remove boost requirements to stream with high quality
268 {
269 find: "#{intl::STREAM_FPS_OPTION}",
270 predicate: () => settings.store.enableStreamQualityBypass,
271 replacement: {
272 match: /guildPremiumTier:\i\.\i\.TIER_\d,?/g,
273 replace: ""
274 }
275 },
276 {
277 find: &#039;"UserSettingsProtoStore"&#039;,
278 replacement: [
279 {
280 // Overwrite incoming connection settings proto with our local settings
281 match: /(?<=CONNECTION_OPEN:function\((\i)\){)/,
282 replace: (_, props) => `$self.handleProtoChange(${props}.userSettingsProto,${props}.user);`
283 },
284 {
285 // Overwrite non local proto changes with our local settings
286 match: /let{settings:/,
287 replace: "arguments[0].local||$self.handleProtoChange(arguments[0].settings.proto);$&"
288 }
289 ]
290 },
291 // Call our function to handle changing the gradient theme when selecting a new one
292 {
293 find: ",updateTheme(",
294 replacement: {
295 match: /(function \i\(\i\){let{backgroundGradientPresetId:(\i).+?)(\i\.\i\.updateAsync.+?theme=(.+?),.+?},\i\))/,
296 replace: (_, rest, backgroundGradientPresetId, originalCall, theme) => `${rest}$self.handleGradientThemeSelect(${backgroundGradientPresetId},${theme},()=>${originalCall});`
297 }
298 },
299 // Allow users to use custom client themes
300 {
301 find: &#039;("custom_themes_editor_footer")&#039;,
302 replacement: {
303 match: /(?<=\i=)\(0,\i\.\i\)\(\i\.\i\.TIER_2\)(?=,|;)/g,
304 replace: "true"
305 }
306 },
307 {
308 find: &#039;["strong","em","u","text","inlineCode","s","spoiler"]&#039;,
309 replacement: [
310 {
311 // Call our function to decide whether the emoji link should be kept or not
312 predicate: () => settings.store.transformEmojis,
313 match: /1!==(\i)\.length\|\|1!==\i\.length/,
314 replace: (m, content) => `${m}||$self.shouldKeepEmojiLink(${content}[0])`
315 },
316 {
317 // Patch the rendered message content to add fake nitro emojis or remove sticker links
318 predicate: () => settings.store.transformEmojis || settings.store.transformStickers,
319 match: /(?=return{hasSpoilerEmbeds:\i,hasBailedAst:\i,content:(\i))/,
320 replace: (_, content) => `${content}=$self.patchFakeNitroEmojisOrRemoveStickersLinks(${content},arguments[2]?.formatInline);`
321 }
322 ]
323 },
324 {
325 find: "}renderStickersAccessories(",
326 replacement: [
327 {
328 // Call our function to decide whether the embed should be ignored or not
329 predicate: () => settings.store.transformEmojis || settings.store.transformStickers,
330 match: /(renderEmbeds\((\i)\){)(.+?embeds\.map\(\((\i),\i\)?=>{)/,
331 replace: (_, rest1, message, rest2, embed) => `${rest1}const fakeNitroMessage=${message};${rest2}if($self.shouldIgnoreEmbed(${embed},fakeNitroMessage))return null;`
332 },
333 {
334 // Patch the stickers array to add fake nitro stickers
335 predicate: () => settings.store.transformStickers,
336 match: /renderStickersAccessories\((\i)\){let (\i)=\(0,\i\.\i\)\(\i\).+?;/,
337 replace: (m, message, stickers) => `${m}${stickers}=$self.patchFakeNitroStickers(${stickers},${message});`
338 },
339 {
340 // Filter attachments to remove fake nitro stickers or emojis
341 predicate: () => settings.store.transformStickers,
342 match: /renderAttachments\(\i\){.+?{attachments:(\i).+?;/,
343 replace: (m, attachments) => `${m}${attachments}=$self.filterAttachments(${attachments});`
344 }
345 ]
346 },
347 {
348 find: "#{intl::STICKER_POPOUT_UNJOINED_PRIVATE_GUILD_DESCRIPTION}",
349 predicate: () => settings.store.transformStickers,
350 replacement: [
351 {
352 // Export the renderable sticker to be used in the fake nitro sticker notice
353 match: /let{renderableSticker:(\i).{0,270}sticker:\i,channel:\i,/,
354 replace: (m, renderableSticker) => `${m}fakeNitroRenderableSticker:${renderableSticker},`
355 },
356 {
357 // Add the fake nitro sticker notice
358 match: /(let \i,{sticker:\i,channel:\i,closePopout:\i.+?}=(\i).+?;)(.+?description:)(\i)(?=,sticker:\i)/,
359 replace: (_, rest, props, rest2, reactNode) => `${rest}let{fakeNitroRenderableSticker}=${props};${rest2}$self.addFakeNotice(${FakeNoticeType.Sticker},${reactNode},!!fakeNitroRenderableSticker?.fake)`
360 }
361 ]
362 },
363 {
364 find: ".EMOJI_UPSELL_POPOUT_MORE_EMOJIS_OPENED,",
365 predicate: () => settings.store.transformEmojis,
366 replacement: {
367 // Export the emoji node to be used in the fake nitro emoji notice
368 match: /isDiscoverable:\i,shouldHideRoleSubscriptionCTA:\i,(?<={node:(\i),.+?)/,
369 replace: (m, node) => `${m}fakeNitroNode:${node},`
370 }
371 },
372 {
373 find: "#{intl::EMOJI_POPOUT_UNJOINED_DISCOVERABLE_GUILD_DESCRIPTION}",
374 predicate: () => settings.store.transformEmojis,
375 replacement: {
376 // Add the fake nitro emoji notice
377 match: /(?<=emojiDescription:)(\i)(?<=\1=\(\i=>\{.+?\}\)\((\i)\)[,;].+?)/,
378 replace: (_, reactNode, props) => `$self.addFakeNotice(${FakeNoticeType.Emoji},${reactNode},!!${props}?.fakeNitroNode?.fake)`
379 }
380 },
381 // Separate patch for allowing using custom app icons
382 {
383 find: "getCurrentDesktopIcon(),",
384 replacement: {
385 match: /\i\.\i\.isPremium\(\i\.\i\.getCurrentUser\(\)\)/,
386 replace: "true"
387 }
388 },
389 // Make all Soundboard sounds available
390 {
391 find: &#039;type:"GUILD_SOUNDBOARD_SOUND_CREATE"&#039;,
392 replacement: {
393 match: /(?<=type:"(?:SOUNDBOARD_SOUNDS_RECEIVED|GUILD_SOUNDBOARD_SOUND_CREATE|GUILD_SOUNDBOARD_SOUND_UPDATE|GUILD_SOUNDBOARD_SOUNDS_UPDATE)".+?available:)\i\.available/g,
394 replace: "true"
395 }
396 }
397 ],
398
399 get guildId() {
400 return getCurrentGuild()?.id;
401 },
402
403 get canUseEmotes() {
404 return (UserStore.getCurrentUser().premiumType ?? 0) > 0;
405 },
406
407 get canUseStickers() {
408 return (UserStore.getCurrentUser().premiumType ?? 0) > 1;
409 },
410
411 handleProtoChange(proto: any, user: any) {
412 try {
413 if (proto == null || typeof proto === "string") return;
414
415 const premiumType: number = user?.premium_type ?? UserStore?.getCurrentUser()?.premiumType ?? 0;
416
417 if (premiumType !== 2) {
418 proto.appearance ??= AppearanceSettingsActionCreators.create();
419
420 const protoStoreAppearenceSettings = UserSettingsProtoStore.settings.appearance;
421
422 const appearanceSettingsOverwrite = AppearanceSettingsActionCreators.create({
423 ...proto.appearance,
424 theme: protoStoreAppearenceSettings?.theme,
425 clientThemeSettings: protoStoreAppearenceSettings?.clientThemeSettings
426 });
427
428 proto.appearance = appearanceSettingsOverwrite;
429 }
430 } catch (err) {
431 new Logger("FakeNitro").error(err);
432 }
433 },
434
435 handleGradientThemeSelect(backgroundGradientPresetId: number | undefined, theme: number, original: () => void) {
436 const premiumType = UserStore?.getCurrentUser()?.premiumType ?? 0;
437 if (premiumType === 2 || backgroundGradientPresetId == null) return original();
438
439 if (!PreloadedUserSettingsActionCreators || !AppearanceSettingsActionCreators || !ClientThemeSettingsActionsCreators || !BINARY_READ_OPTIONS) return;
440
441 const currentAppearanceSettings = PreloadedUserSettingsActionCreators.getCurrentValue().appearance;
442
443 const newAppearanceProto = currentAppearanceSettings != null
444 ? AppearanceSettingsActionCreators.fromBinary(AppearanceSettingsActionCreators.toBinary(currentAppearanceSettings), BINARY_READ_OPTIONS)
445 : AppearanceSettingsActionCreators.create();
446
447 newAppearanceProto.theme = theme;
448
449 const clientThemeSettingsDummy = ClientThemeSettingsActionsCreators.create({
450 backgroundGradientPresetId: {
451 value: backgroundGradientPresetId
452 }
453 });
454
455 newAppearanceProto.clientThemeSettings ??= clientThemeSettingsDummy;
456 newAppearanceProto.clientThemeSettings.backgroundGradientPresetId = clientThemeSettingsDummy.backgroundGradientPresetId;
457
458 const proto = PreloadedUserSettingsActionCreators.ProtoClass.create();
459 proto.appearance = newAppearanceProto;
460
461 FluxDispatcher.dispatch({
462 type: "USER_SETTINGS_PROTO_UPDATE",
463 local: true,
464 partial: true,
465 settings: {
466 type: 1,
467 proto
468 }
469 });
470 },
471
472 trimContent(content: Array<any>) {
473 const firstContent = content[0];
474 if (typeof firstContent === "string") {
475 content[0] = firstContent.trimStart();
476 content[0] || content.shift();
477 } else if (typeof firstContent?.props?.children === "string") {
478 firstContent.props.children = firstContent.props.children.trimStart();
479 firstContent.props.children || content.shift();
480 }
481
482 const lastIndex = content.length - 1;
483 const lastContent = content[lastIndex];
484 if (typeof lastContent === "string") {
485 content[lastIndex] = lastContent.trimEnd();
486 content[lastIndex] || content.pop();
487 } else if (typeof lastContent?.props?.children === "string") {
488 lastContent.props.children = lastContent.props.children.trimEnd();
489 lastContent.props.children || content.pop();
490 }
491 },
492
493 clearEmptyArrayItems(array: Array<any>) {
494 return array.filter(item => item != null);
495 },
496
497 ensureChildrenIsArray(child: ReactElement<any>) {
498 if (!Array.isArray(child.props.children)) child.props.children = [child.props.children];
499 },
500
501 patchFakeNitroEmojisOrRemoveStickersLinks(content: Array<any>, inline: boolean) {
502 // If content has more than one child or it's a single ReactElement like a header, list or span
503 if ((content.length > 1 || typeof content[0]?.type === "string") && !settings.store.transformCompoundSentence) return content;
504
505 let nextIndex = content.length;
506
507 const transformLinkChild = (child: ReactElement<any>) => {
508 if (settings.store.transformEmojis) {
509 const fakeNitroMatch = child.props.href.match(fakeNitroEmojiRegex);
510 if (fakeNitroMatch) {
511 let url: URL | null = null;
512 try {
513 url = new URL(child.props.href);
514 } catch { }
515
516 const emojiName = EmojiStore.getCustomEmojiById(fakeNitroMatch[1])?.name ?? url?.searchParams.get("name") ?? "FakeNitroEmoji";
517 const isAnimated = fakeNitroMatch[2] === "gif" || url?.searchParams.get("animated") === "true";
518
519 return Parser.defaultRules.customEmoji.react({
520 jumboable: !inline && content.length === 1 && typeof content[0].type !== "string",
521 animated: isAnimated,
522 emojiId: fakeNitroMatch[1],
523 name: emojiName,
524 fake: true
525 }, void 0, { key: String(nextIndex++) });
526 }
527 }
528
529 if (settings.store.transformStickers) {
530 if (fakeNitroStickerRegex.test(child.props.href)) return null;
531
532 const gifMatch = child.props.href.match(fakeNitroGifStickerRegex);
533 if (gifMatch) {
534 // There is no way to differentiate a regular gif attachment from a fake nitro animated sticker, so we check if the StickersStore contains the id of the fake sticker
535 if (StickersStore.getStickerById(gifMatch[1])) return null;
536 }
537 }
538
539 return child;
540 };
541
542 const transformChild = (child: ReactElement<any>) => {
543 if (child?.props?.trusted != null) return transformLinkChild(child);
544 if (child?.props?.children != null) {
545 if (!Array.isArray(child.props.children)) {
546 child.props.children = modifyChild(child.props.children);
547 return child;
548 }
549
550 child.props.children = modifyChildren(child.props.children);
551 if (child.props.children.length === 0) return null;
552 return child;
553 }
554
555 return child;
556 };
557
558 const modifyChild = (child: ReactElement<any>) => {
559 const newChild = transformChild(child);
560
561 if (newChild?.type === "ul" || newChild?.type === "ol") {
562 this.ensureChildrenIsArray(newChild);
563 if (newChild.props.children.length === 0) return null;
564
565 let listHasAnItem = false;
566 for (const [index, child] of newChild.props.children.entries()) {
567 if (child == null) {
568 delete newChild.props.children[index];
569 continue;
570 }
571
572 this.ensureChildrenIsArray(child);
573 if (child.props.children.length > 0) listHasAnItem = true;
574 else delete newChild.props.children[index];
575 }
576
577 if (!listHasAnItem) return null;
578
579 newChild.props.children = this.clearEmptyArrayItems(newChild.props.children);
580 }
581
582 return newChild;
583 };
584
585 const modifyChildren = (children: Array<ReactElement<any>>) => {
586 for (const [index, child] of children.entries()) children[index] = modifyChild(child);
587
588 children = this.clearEmptyArrayItems(children);
589
590 return children;
591 };
592
593 try {
594 const newContent = modifyChildren(lodash.cloneDeep(content));
595 this.trimContent(newContent);
596
597 return newContent;
598 } catch (err) {
599 new Logger("FakeNitro").error(err);
600 return content;
601 }
602 },
603
604 patchFakeNitroStickers(stickers: Array<any>, message: Message) {
605 const itemsToMaybePush: Array<string> = [];
606
607 const contentItems = message.content.split(/\s/);
608 if (settings.store.transformCompoundSentence) itemsToMaybePush.push(...contentItems);
609 else if (contentItems.length === 1) itemsToMaybePush.push(contentItems[0]);
610
611 itemsToMaybePush.push(...message.attachments.filter(attachment => attachment.content_type === "image/gif").map(attachment => attachment.url));
612
613 for (const item of itemsToMaybePush) {
614 if (!settings.store.transformCompoundSentence && !item.startsWith("http") && !hyperLinkRegex.test(item)) continue;
615
616 const imgMatch = item.match(fakeNitroStickerRegex);
617 if (imgMatch) {
618 let url: URL | null = null;
619 try {
620 url = new URL(item);
621 } catch { }
622
623 const stickerName = StickersStore.getStickerById(imgMatch[1])?.name ?? url?.searchParams.get("name") ?? "FakeNitroSticker";
624 stickers.push({
625 format_type: 1,
626 id: imgMatch[1],
627 name: stickerName,
628 fake: true
629 });
630
631 continue;
632 }
633
634 const gifMatch = item.match(fakeNitroGifStickerRegex);
635 if (gifMatch) {
636 if (!StickersStore.getStickerById(gifMatch[1])) continue;
637
638 const stickerName = StickersStore.getStickerById(gifMatch[1])?.name ?? "FakeNitroSticker";
639 stickers.push({
640 format_type: 2,
641 id: gifMatch[1],
642 name: stickerName,
643 fake: true
644 });
645 }
646 }
647
648 return stickers;
649 },
650
651 shouldIgnoreEmbed(embed: Message["embeds"][number], message: Message) {
652 try {
653 const contentItems = message.content.split(/\s/);
654 if (contentItems.length > 1 && !settings.store.transformCompoundSentence) return false;
655
656 switch (embed.type) {
657 case "image": {
658 const url = embed.url ?? embed.image?.url;
659 if (!url) return false;
660 if (
661 !settings.store.transformCompoundSentence
662 && !contentItems.some(item => item === url || item.match(hyperLinkRegex)?.[1] === url)
663 ) return false;
664
665 if (settings.store.transformEmojis) {
666 if (fakeNitroEmojiRegex.test(url)) return true;
667 }
668
669 if (settings.store.transformStickers) {
670 if (fakeNitroStickerRegex.test(url)) return true;
671
672 const gifMatch = url.match(fakeNitroGifStickerRegex);
673 if (gifMatch) {
674 // There is no way to differentiate a regular gif attachment from a fake nitro animated sticker, so we check if the StickersStore contains the id of the fake sticker
675 if (StickersStore.getStickerById(gifMatch[1])) return true;
676 }
677 }
678
679 break;
680 }
681 }
682 } catch (e) {
683 new Logger("FakeNitro").error("Error in shouldIgnoreEmbed:", e);
684 }
685
686 return false;
687 },
688
689 filterAttachments(attachments: Message["attachments"]) {
690 return attachments.filter(attachment => {
691 if (attachment.content_type !== "image/gif") return true;
692
693 const match = attachment.url.match(fakeNitroGifStickerRegex);
694 if (match) {
695 // There is no way to differentiate a regular gif attachment from a fake nitro animated sticker, so we check if the StickersStore contains the id of the fake sticker
696 if (StickersStore.getStickerById(match[1])) return false;
697 }
698
699 return true;
700 });
701 },
702
703 shouldKeepEmojiLink(link: any) {
704 return link.target && fakeNitroEmojiRegex.test(link.target);
705 },
706
707 addFakeNotice(type: FakeNoticeType, node: Array<ReactNode>, fake: boolean) {
708 if (!fake) return node;
709
710 node = Array.isArray(node) ? node : [node];
711
712 switch (type) {
713 case FakeNoticeType.Sticker: {
714 node.push(" This is a FakeNitro sticker and renders like a real sticker only for you. Appears as a link to non-plugin users.");
715
716 return node;
717 }
718 case FakeNoticeType.Emoji: {
719 node.push(" This is a FakeNitro emoji and renders like a real emoji only for you. Appears as a link to non-plugin users.");
720
721 return node;
722 }
723 }
724 },
725
726 getStickerLink({ format_type, id }: Sticker) {
727 const ext = format_type === StickerFormatType.GIF ? "gif" : "png";
728 return `https:class="ts-cmt">//media.discordapp.net/stickers/${id}.${ext}?size=${settings.store.stickerSize}`;
729 },
730
731 async sendAnimatedSticker(stickerLink: string, stickerId: string, channelId: string) {
732
733 const { frames, width, height } = await fetch(stickerLink)
734 .then(res => res.arrayBuffer())
735 .then(parseAPNG);
736
737 const gif = GIFEncoder();
738 const resolution = settings.store.stickerSize;
739
740 const canvas = document.createElement("canvas");
741 canvas.width = resolution;
742 canvas.height = resolution;
743
744 const ctx = canvas.getContext("2d", {
745 willReadFrequently: true
746 })!;
747
748 const scale = resolution / Math.max(width, height);
749 ctx.scale(scale, scale);
750
751 let previousFrameData: ImageData;
752
753 for (const frame of frames) {
754 const { left, top, width, height, img, delay, blendOp, disposeOp } = frame;
755
756 previousFrameData = ctx.getImageData(left, top, width, height);
757
758 if (blendOp === ApngBlendOp.SOURCE) {
759 ctx.clearRect(left, top, width, height);
760 }
761
762 ctx.drawImage(img, left, top, width, height);
763
764 const { data } = ctx.getImageData(0, 0, resolution, resolution);
765
766 const palette = quantize(data, 256);
767 const index = applyPalette(data, palette);
768
769 gif.writeFrame(index, resolution, resolution, {
770 transparent: true,
771 palette,
772 delay
773 });
774
775 if (disposeOp === ApngDisposeOp.BACKGROUND) {
776 ctx.clearRect(left, top, width, height);
777 } else if (disposeOp === ApngDisposeOp.PREVIOUS) {
778 ctx.putImageData(previousFrameData, left, top);
779 }
780 }
781
782 gif.finish();
783
784 const file = new File([gif.bytesView() as Uint8Array<ArrayBuffer>], `${stickerId}.gif`, { type: "image/gif" });
785 UploadHandler.promptToUpload([file], ChannelStore.getChannel(channelId), DraftType.ChannelMessage);
786 },
787
788 canUseEmote(e: Emoji, channelId: string) {
789 if (e.type === 0) return true;
790 if (e.available === false) return false;
791
792 if (isUnusableRoleSubscriptionEmoji(e, this.guildId, true)) return false;
793
794 let isUsableTwitchSubEmote = false;
795 if (e.managed && e.guildId) {
796 const myRoles = GuildMemberStore.getSelfMember(e.guildId)?.roles ?? [];
797 isUsableTwitchSubEmote = e.roles.some(r => myRoles.includes(r));
798 }
799
800 if (this.canUseEmotes || isUsableTwitchSubEmote)
801 return e.guildId === this.guildId || hasExternalEmojiPerms(channelId);
802 else
803 return !e.animated && e.guildId === this.guildId;
804 },
805
806 start() {
807 const s = settings.store;
808
809 if (!s.enableEmojiBypass && !s.enableStickerBypass) {
810 return;
811 }
812
813 this.preSend = addMessagePreSendListener(async (channelId, messageObj, extra) => {
814 const { guildId } = this;
815
816 let hasBypass = false;
817
818 stickerBypass: {
819 if (!s.enableStickerBypass)
820 break stickerBypass;
821
822 const sticker = StickersStore.getStickerById(extra.stickers?.[0]!);
823 if (!sticker)
824 break stickerBypass;
825
826 // Discord Stickers are now free yayyy!! :D
827 if ("pack_id" in sticker)
828 break stickerBypass;
829
830 const canUseStickers = this.canUseStickers && hasExternalStickerPerms(channelId);
831 if (sticker.available !== false && (canUseStickers || sticker.guild_id === guildId))
832 break stickerBypass;
833
834 const link = this.getStickerLink(sticker);
835
836 if (sticker.format_type === StickerFormatType.APNG) {
837 if (!hasAttachmentPerms(channelId)) {
838 openModal(props => (
839 <ConfirmModal
840 {...props}
841 title="Hold on!"
842 confirmText="OK"
843 variant="primary"
844 >
845 <div>
846 <Forms.FormText>
847 You cannot send this message because it contains an animated FakeNitro sticker,
848 and you do not have permissions to attach files in the current channel. Please remove the sticker to proceed.
849 </Forms.FormText>
850 </div>
851 </ConfirmModal>
852 ));
853 } else {
854 this.sendAnimatedSticker(link, sticker.id, channelId);
855 }
856
857 return { cancel: true };
858 } else {
859 hasBypass = true;
860
861 const url = new URL(link);
862 url.searchParams.set("name", sticker.name);
863 url.searchParams.set("lossless", "true");
864
865 const linkText = s.hyperLinkText.replaceAll("{{NAME}}", sticker.name);
866
867 messageObj.content += `${getWordBoundary(messageObj.content, messageObj.content.length - 1)}${s.useHyperLinks ? `[${linkText}](${url})` : url}`;
868 extra.stickers!.length = 0;
869 }
870 }
871
872 if (s.enableEmojiBypass) {
873 for (const emoji of messageObj.validNonShortcutEmojis) {
874 if (this.canUseEmote(emoji, channelId)) continue;
875
876 hasBypass = true;
877
878 const emojiString = `<${emoji.animated ? "a" : ""}:${emoji.originalName || emoji.name}:${emoji.id}>`;
879
880 const url = new URL(IconUtils.getEmojiURL({ id: emoji.id, animated: emoji.animated, size: s.emojiSize }));
881 url.searchParams.set("size", s.emojiSize.toString());
882 url.searchParams.set("name", emoji.name);
883 url.searchParams.set("lossless", "true");
884
885 const linkText = s.hyperLinkText.replaceAll("{{NAME}}", emoji.name);
886
887 messageObj.content = messageObj.content.replace(emojiString, (match, offset, origStr) => {
888 return `${getWordBoundary(origStr, offset - 1)}${s.useHyperLinks ? `[${linkText}](${url})` : url}${getWordBoundary(origStr, offset + match.length)}`;
889 });
890 }
891 }
892
893 if (hasBypass && !s.disableEmbedPermissionCheck && !hasEmbedPerms(channelId)) {
894 if (!await showCannotEmbedNotice()) {
895 return { cancel: true };
896 }
897 }
898
899 return { cancel: false };
900 });
901
902 this.preEdit = addMessagePreEditListener(async (channelId, __, messageObj) => {
903 if (!s.enableEmojiBypass) return;
904
905 let hasBypass = false;
906
907 messageObj.content = messageObj.content.replace(/(?<!\\)<a?:(?:\w+):(\d+)>/ig, (emojiStr, emojiId, offset, origStr) => {
908 const emoji = EmojiStore.getCustomEmojiById(emojiId);
909 if (emoji == null) return emojiStr;
910 if (this.canUseEmote(emoji, channelId)) return emojiStr;
911
912 hasBypass = true;
913
914 const url = new URL(IconUtils.getEmojiURL({ id: emoji.id, animated: emoji.animated, size: s.emojiSize }));
915 url.searchParams.set("size", s.emojiSize.toString());
916 url.searchParams.set("name", emoji.name);
917 url.searchParams.set("lossless", "true");
918
919 const linkText = s.hyperLinkText.replaceAll("{{NAME}}", emoji.name);
920
921 return `${getWordBoundary(origStr, offset - 1)}${s.useHyperLinks ? `[${linkText}](${url})` : url}${getWordBoundary(origStr, offset + emojiStr.length)}`;
922 });
923
924 if (hasBypass && !s.disableEmbedPermissionCheck && !hasEmbedPerms(channelId)) {
925 if (!await showCannotEmbedNotice()) {
926 return { cancel: true };
927 }
928 }
929
930 return { cancel: false };
931 });
932 },
933
934 stop() {
935 removeMessagePreSendListener(this.preSend);
936 removeMessagePreEditListener(this.preEdit);
937 }
938});
939