Plugin

MessageLinkEmbeds

Adds a preview to messages that link another message

Chat Appearance
index.tsx
Download

Source

src/plugins/messageLinkEmbeds/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 { addMessageAccessory, removeMessageAccessory } from "@api/MessageAccessories";
8import { updateMessage } from "@api/MessageUpdater";
9import { definePluginSettings } from "@api/Settings";
10import { getUserSettingLazy } from "@api/UserSettings";
11import { Devs } from "@utils/constants.js";
12import { classes } from "@utils/misc";
13import { Queue } from "@utils/Queue";
14import definePlugin, { OptionType } from "@utils/types";
15import { Channel, Message } from "@vencord/discord-types";
16import { findComponentByCodeLazy, findComponentLazy, findCssClassesLazy } from "@webpack";
17import {
18 Button,
19 ChannelStore,
20 Constants,
21 GuildStore,
22 IconUtils,
23 MessageStore,
24 Parser,
25 PermissionsBits,
26 PermissionStore,
27 RestAPI,
28 Text,
29 UserStore
30} from "@webpack/common";
31import { ComponentType, JSX } from "react";
32
33const messageCache = new Map<string, {
34 message?: Message;
35 fetched: boolean;
36}>();
37
38const Embed = findComponentLazy(m => m.prototype?.renderSuppressButton);
39const ChannelMessage = findComponentByCodeLazy("childrenExecutedCommand:", ".hideAccessories");
40let AutoModEmbed: ComponentType<any> = () => null;
41
42const SearchResultClasses = findCssClassesLazy("message", "searchResult");
43const EmbedClasses = findCssClassesLazy("embedAuthorIcon", "embedAuthor", "embedAuthor", "embedMargin");
44
45const MessageDisplayCompact = getUserSettingLazy("textAndImages", "messageDisplayCompact")!;
46
47const messageLinkRegex = /(?<!<)https?:\/\/(?:\w+\.)?discord(?:app)?\.com\/channels\/(?:\d{17,20}|@me)\/(\d{17,20})\/(\d{17,20})/g;
48const tenorRegex = /^https:\/\/(?:www\.)?tenor\.com\class="ts-cmt">//;
49
50interface Attachment {
51 height: number;
52 width: number;
53 url: string;
54 proxyURL?: string;
55}
56
57interface MessageEmbedProps {
58 message: Message;
59 channel: Channel;
60}
61
62const messageFetchQueue = new Queue();
63
64const settings = definePluginSettings({
65 messageBackgroundColor: {
66 description: "Background color for messages in rich embeds",
67 type: OptionType.BOOLEAN
68 },
69 automodEmbeds: {
70 description: "Use automod embeds instead of rich embeds (smaller but less info)",
71 type: OptionType.SELECT,
72 options: [
73 {
74 label: "Always use automod embeds",
75 value: "always"
76 },
77 {
78 label: "Prefer automod embeds, but use rich embeds if some content can&#039;t be shown",
79 value: "prefer"
80 },
81 {
82 label: "Never use automod embeds",
83 value: "never",
84 default: true
85 }
86 ]
87 },
88 listMode: {
89 description: "Whether to use ID list as blacklist or whitelist",
90 type: OptionType.SELECT,
91 options: [
92 {
93 label: "Blacklist",
94 value: "blacklist",
95 default: true
96 },
97 {
98 label: "Whitelist",
99 value: "whitelist"
100 }
101 ]
102 },
103 idList: {
104 displayName: "ID List",
105 description: "Guild/channel/user IDs to blacklist or whitelist (separate with comma)",
106 type: OptionType.STRING,
107 default: "",
108 multiline: true,
109 },
110 clearMessageCache: {
111 type: OptionType.COMPONENT,
112 component: () => (
113 <Button onClick={() => messageCache.clear()}>
114 Clear the linked message cache
115 </Button>
116 )
117 }
118});
119
120
121async function fetchMessage(channelID: string, messageID: string) {
122 const cached = messageCache.get(messageID);
123 if (cached) return cached.message;
124
125 messageCache.set(messageID, { fetched: false });
126
127 const res = await RestAPI.get({
128 url: Constants.Endpoints.MESSAGES(channelID),
129 query: {
130 limit: 1,
131 around: messageID
132 },
133 retries: 2
134 }).catch(() => null);
135
136 const msg = res?.body?.[0];
137 if (!msg) return;
138
139 const message: Message = MessageStore.getMessages(msg.channel_id).receiveMessage(msg).get(msg.id);
140 if (!message) return;
141
142 messageCache.set(message.id, {
143 message,
144 fetched: true
145 });
146
147 return message;
148}
149
150
151function getImages(message: Message): Attachment[] {
152 const attachments: Attachment[] = [];
153
154 for (const { content_type, height, width, url, proxy_url } of message.attachments ?? []) {
155 if (content_type?.startsWith("image/"))
156 attachments.push({
157 height: height!,
158 width: width!,
159 url: url,
160 proxyURL: proxy_url!
161 });
162 }
163
164 for (const { type, image, thumbnail, url } of message.embeds ?? []) {
165 if (type === "image")
166 attachments.push({ ...(image ?? thumbnail!) });
167 else if (url && type === "gifv" && !tenorRegex.test(url))
168 attachments.push({
169 height: thumbnail!.height,
170 width: thumbnail!.width,
171 url
172 });
173 }
174
175 return attachments;
176}
177
178function noContent(attachments: number, embeds: number) {
179 if (!attachments && !embeds) return "";
180 if (!attachments) return `[no content, ${embeds} embed${embeds !== 1 ? "s" : ""}]`;
181 if (!embeds) return `[no content, ${attachments} attachment${attachments !== 1 ? "s" : ""}]`;
182 return `[no content, ${attachments} attachment${attachments !== 1 ? "s" : ""} and ${embeds} embed${embeds !== 1 ? "s" : ""}]`;
183}
184
185function requiresRichEmbed(message: Message) {
186 if (message.components.length) return true;
187 if (message.attachments.some(a => !a.content_type?.startsWith("image/"))) return true;
188 if (message.embeds.some(e => e.type !== "image" && (e.type !== "gifv" || tenorRegex.test(e.url!)))) return true;
189
190 return false;
191}
192
193function computeWidthAndHeight(width: number, height: number) {
194 const maxWidth = 400;
195 const maxHeight = 300;
196
197 if (width > height) {
198 const adjustedWidth = Math.min(width, maxWidth);
199 return { width: adjustedWidth, height: Math.round(height / (width / adjustedWidth)) };
200 }
201
202 const adjustedHeight = Math.min(height, maxHeight);
203 return { width: Math.round(width / (height / adjustedHeight)), height: adjustedHeight };
204}
205
206function withEmbeddedBy(message: Message, embeddedBy: string[]) {
207 return new Proxy(message, {
208 get(_, prop) {
209 if (prop === "vencordEmbeddedBy") return embeddedBy;
210 // @ts-expect-error ts so bad
211 return Reflect.get(...arguments);
212 }
213 });
214}
215
216
217function MessageEmbedAccessory({ message }: { message: Message; }) {
218 // @ts-expect-error
219 const embeddedBy: string[] = message.vencordEmbeddedBy ?? [];
220
221 const accessories = [] as (JSX.Element | null)[];
222
223 for (const [_, channelID, messageID] of message.content!.matchAll(messageLinkRegex)) {
224 if (embeddedBy.includes(messageID) || embeddedBy.length > 2) {
225 continue;
226 }
227
228 const linkedChannel = ChannelStore.getChannel(channelID);
229 if (!linkedChannel || (!linkedChannel.isPrivate() && !PermissionStore.can(PermissionsBits.VIEW_CHANNEL, linkedChannel))) {
230 continue;
231 }
232
233 const { listMode, idList } = settings.store;
234
235 const isListed = [linkedChannel.guild_id, channelID, message.author.id].some(id => id && idList.includes(id));
236
237 if (listMode === "blacklist" && isListed) continue;
238 if (listMode === "whitelist" && !isListed) continue;
239
240 let linkedMessage = messageCache.get(messageID)?.message;
241 if (!linkedMessage) {
242 linkedMessage ??= MessageStore.getMessage(channelID, messageID);
243 if (linkedMessage) {
244 messageCache.set(messageID, { message: linkedMessage, fetched: true });
245 } else {
246
247 messageFetchQueue.unshift(() => fetchMessage(channelID, messageID)
248 .then(m => m && updateMessage(message.channel_id, message.id))
249 );
250 continue;
251 }
252 }
253
254 const messageProps: MessageEmbedProps = {
255 message: withEmbeddedBy(linkedMessage, [...embeddedBy, message.id]),
256 channel: linkedChannel
257 };
258
259 const type = settings.store.automodEmbeds;
260 accessories.push(
261 type === "always" || (type === "prefer" && !requiresRichEmbed(linkedMessage))
262 ? <AutomodEmbedAccessory {...messageProps} />
263 : <ChannelMessageEmbedAccessory {...messageProps} />
264 );
265 }
266
267 return accessories.length ? <>{accessories}</> : null;
268}
269
270function getChannelLabelAndIconUrl(channel: Channel) {
271 if (channel.isDM()) return ["Direct Message", IconUtils.getUserAvatarURL(UserStore.getUser(channel.recipients[0]))];
272 if (channel.isGroupDM()) return ["Group DM", IconUtils.getChannelIconURL(channel)];
273 return ["Server", IconUtils.getGuildIconURL(GuildStore.getGuild(channel.guild_id))];
274}
275
276function ChannelMessageEmbedAccessory({ message, channel }: MessageEmbedProps): JSX.Element | null {
277 const compact = MessageDisplayCompact.useSetting();
278
279 const dmReceiver = UserStore.getUser(ChannelStore.getChannel(channel.id).recipients?.[0]);
280
281 const [channelLabel, iconUrl] = getChannelLabelAndIconUrl(channel);
282
283 return (
284 <Embed
285 embed={{
286 rawDescription: "",
287 color: "var(--background-base-lower)",
288 author: {
289 name: <Text variant="text-xs/medium" tag="span">
290 <span>{channelLabel} - </span>
291 {Parser.parse(channel.isDM() ? `<@${dmReceiver.id}>` : `<#${channel.id}>`)}
292 </Text>,
293 iconProxyURL: iconUrl
294 }
295 }}
296 renderDescription={() => (
297 <div key={message.id} className={classes(SearchResultClasses.message, settings.store.messageBackgroundColor && SearchResultClasses.searchResult)}>
298 <ChannelMessage
299 id={`message-link-embeds-${message.id}`}
300 message={message}
301 channel={channel}
302 subscribeToComponentDispatch={false}
303 compact={compact}
304 />
305 </div>
306 )}
307 />
308 );
309}
310
311function AutomodEmbedAccessory(props: MessageEmbedProps): JSX.Element | null {
312 const { message, channel } = props;
313 const compact = MessageDisplayCompact.useSetting();
314 const images = getImages(message);
315 const { parse } = Parser;
316
317 const [channelLabel, iconUrl] = getChannelLabelAndIconUrl(channel);
318
319 return <AutoModEmbed
320 channel={channel}
321 childrenAccessories={
322 <Text color="text-muted" variant="text-xs/medium" tag="span" className={`${EmbedClasses.embedAuthor} ${EmbedClasses.embedMargin}`}>
323 {iconUrl && <img src={iconUrl} className={EmbedClasses.embedAuthorIcon} alt="" />}
324 <span>
325 <span>{channelLabel} - </span>
326 {channel.isDM()
327 ? Parser.parse(`<@${ChannelStore.getChannel(channel.id).recipients[0]}>`)
328 : Parser.parse(`<#${channel.id}>`)
329 }
330 </span>
331 </Text>
332 }
333 compact={compact}
334 content={
335 <>
336 {message.content || message.attachments.length <= images.length
337 ? parse(message.content)
338 : [noContent(message.attachments.length, message.embeds.length)]
339 }
340 {images.map((a, idx) => {
341 const { width, height } = computeWidthAndHeight(a.width, a.height);
342 return (
343 <div key={idx}>
344 <img src={a.url} width={width} height={height} />
345 </div>
346 );
347 })}
348 </>
349 }
350 hideTimestamp={false}
351 message={message}
352 _messageEmbed="automod"
353 />;
354}
355
356export default definePlugin({
357 name: "MessageLinkEmbeds",
358 description: "Adds a preview to messages that link another message",
359 tags: ["Chat", "Appearance"],
360 authors: [Devs.TheSun, Devs.Ven, Devs.RyanCaoDev],
361 dependencies: ["MessageAccessoriesAPI", "MessageUpdaterAPI", "UserSettingsAPI"],
362
363 settings,
364
365 patches: [
366 {
367 find: "!1,withFooter:",
368 replacement: {
369 match: /(?=function (\i)\(\i\){let{message:\i,channel:\i,[^}]+?withFooter:)/,
370 replace: "$self.AutoModEmbed=$1;"
371 }
372 }
373 ],
374
375 set AutoModEmbed(value: any) {
376 AutoModEmbed = value;
377 },
378
379 start() {
380 addMessageAccessory("MessageLinkEmbeds", props => {
381 if (!messageLinkRegex.test(props.message.content))
382 return null;
383
384 // need to reset the regex because it's global
385 messageLinkRegex.lastIndex = 0;
386
387 return (
388 <MessageEmbedAccessory
389 message={props.message}
390 />
391 );
392 }, 4 /* just above rich embeds */);
393 },
394
395 stop() {
396 removeMessageAccessory("MessageLinkEmbeds");
397 }
398});
399