Plugin
MessageLinkEmbeds
Adds a preview to messages that link another message
1
/*2
* FiveCord — a Discord client mod3
* Copyright (c) 2025 FiveCord4
* SPDX-License-Identifier: GPL-3.0-or-later5
*/6
7
import { addMessageAccessory, removeMessageAccessory } from "@api/MessageAccessories";8
import { updateMessage } from "@api/MessageUpdater";9
import { definePluginSettings } from "@api/Settings";10
import { getUserSettingLazy } from "@api/UserSettings";11
import { Devs } from "@utils/constants.js";12
import { classes } from "@utils/misc";13
import { Queue } from "@utils/Queue";14
import definePlugin, { OptionType } from "@utils/types";15
import { Channel, Message } from "@vencord/discord-types";16
import { findComponentByCodeLazy, findComponentLazy, findCssClassesLazy } from "@webpack";17
import {18
Button,19
ChannelStore,20
Constants,21
GuildStore,22
IconUtils,23
MessageStore,24
Parser,25
PermissionsBits,26
PermissionStore,27
RestAPI,28
Text,29
UserStore30
} from "@webpack/common";31
import { ComponentType, JSX } from "react";32
33
const messageCache = new Map<string, {34
message?: Message;35
fetched: boolean;36
}>();37
38
const Embed = findComponentLazy(m => m.prototype?.renderSuppressButton);39
const ChannelMessage = findComponentByCodeLazy("childrenExecutedCommand:", ".hideAccessories");40
let AutoModEmbed: ComponentType<any> = () => null;41
42
const SearchResultClasses = findCssClassesLazy("message", "searchResult");43
const EmbedClasses = findCssClassesLazy("embedAuthorIcon", "embedAuthor", "embedAuthor", "embedMargin");44
45
const MessageDisplayCompact = getUserSettingLazy("textAndImages", "messageDisplayCompact")!;46
47
const messageLinkRegex = /(?<!<)https?:\/\/(?:\w+\.)?discord(?:app)?\.com\/channels\/(?:\d{17,20}|@me)\/(\d{17,20})\/(\d{17,20})/g;48
const tenorRegex = /^https:\/\/(?:www\.)?tenor\.com\class="ts-cmt">//;49
50
interface Attachment {51
height: number;52
width: number;53
url: string;54
proxyURL?: string;55
}56
57
interface MessageEmbedProps {58
message: Message;59
channel: Channel;60
}61
62
const messageFetchQueue = new Queue();63
64
const settings = definePluginSettings({65
messageBackgroundColor: {66
description: "Background color for messages in rich embeds",67
type: OptionType.BOOLEAN68
},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 can039;t be shown",79
value: "prefer"80
},81
{82
label: "Never use automod embeds",83
value: "never",84
default: true85
}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: true96
},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 cache115
</Button>116
)117
}118
});119
120
121
async 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: messageID132
},133
retries: 2134
}).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: true145
});146
147
return message;148
}149
150
151
function 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
url172
});173
}174
175
return attachments;176
}177
178
function 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
185
function 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
193
function 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
206
function 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 bad211
return Reflect.get(...arguments);212
}213
});214
}215
216
217
function MessageEmbedAccessory({ message }: { message: Message; }) {218
// @ts-expect-error219
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: linkedChannel257
};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
270
function 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
276
function 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
<Embed285
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: iconUrl294
}295
}}296
renderDescription={() => (297
<div key={message.id} className={classes(SearchResultClasses.message, settings.store.messageBackgroundColor && SearchResultClasses.searchResult)}>298
<ChannelMessage299
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
311
function 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 <AutoModEmbed320
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.length337
? 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
356
export 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 global385
messageLinkRegex.lastIndex = 0;386
387
return (388
<MessageEmbedAccessory389
message={props.message}390
/>391
);392
}, 4 /* just above rich embeds */);393
},394
395
stop() {396
removeMessageAccessory("MessageLinkEmbeds");397
}398
});399