Plugin

MessageLinkEmbeds

Adds a preview to messages that link another message

Chat Appearance
index.tsx
Download

Source

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