Plugin
MessageLinkEmbeds
Adds a preview to messages that link another message
1
import { addMessageAccessory, removeMessageAccessory } from "@api/MessageAccessories";2
import { updateMessage } from "@api/MessageUpdater";3
import { definePluginSettings } from "@api/Settings";4
import { getUserSettingLazy } from "@api/UserSettings";5
import { Devs } from "@utils/constants.js";6
import { classes } from "@utils/misc";7
import { Queue } from "@utils/Queue";8
import definePlugin, { OptionType } from "@utils/types";9
import { Channel, Message } from "@vencord/discord-types";10
import { findComponentByCodeLazy, findComponentLazy, findCssClassesLazy } from "@webpack";11
import {12
Button,13
ChannelStore,14
Constants,15
GuildStore,16
IconUtils,17
MessageStore,18
Parser,19
PermissionsBits,20
PermissionStore,21
RestAPI,22
Text,23
UserStore24
} from "@webpack/common";25
import { ComponentType, JSX } from "react";26
27
const messageCache = new Map<string, {28
message?: Message;29
fetched: boolean;30
}>();31
32
const Embed = findComponentLazy(m => m.prototype?.renderSuppressButton);33
const ChannelMessage = findComponentByCodeLazy("childrenExecutedCommand:", ".hideAccessories");34
let AutoModEmbed: ComponentType<any> = () => null;35
36
const SearchResultClasses = findCssClassesLazy("message", "searchResult");37
const EmbedClasses = findCssClassesLazy("embedAuthorIcon", "embedAuthor", "embedAuthor", "embedMargin");38
39
const MessageDisplayCompact = getUserSettingLazy("textAndImages", "messageDisplayCompact")!;40
41
const messageLinkRegex = /(?<!<)https?:\/\/(?:\w+\.)?discord(?:app)?\.com\/channels\/(?:\d{17,20}|@me)\/(\d{17,20})\/(\d{17,20})/g;42
const tenorRegex = /^https:\/\/(?:www\.)?tenor\.com\class="ts-cmt">//;43
44
interface Attachment {45
height: number;46
width: number;47
url: string;48
proxyURL?: string;49
}50
51
interface MessageEmbedProps {52
message: Message;53
channel: Channel;54
}55
56
const messageFetchQueue = new Queue();57
58
const settings = definePluginSettings({59
messageBackgroundColor: {60
description: "Background color for messages in rich embeds",61
type: OptionType.BOOLEAN62
},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 can039;t be shown",73
value: "prefer"74
},75
{76
label: "Never use automod embeds",77
value: "never",78
default: true79
}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: true90
},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 cache109
</Button>110
)111
}112
});113
114
115
async 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: messageID126
},127
retries: 2128
}).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: true139
});140
141
return message;142
}143
144
145
function 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
url166
});167
}168
169
return attachments;170
}171
172
function 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
179
function 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
187
function 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
200
function 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 bad205
return Reflect.get(...arguments);206
}207
});208
}209
210
211
function MessageEmbedAccessory({ message }: { message: Message; }) {212
// @ts-expect-error213
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: linkedChannel251
};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
264
function 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
270
function 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
<Embed279
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: iconUrl288
}289
}}290
renderDescription={() => (291
<div key={message.id} className={classes(SearchResultClasses.message, settings.store.messageBackgroundColor && SearchResultClasses.searchResult)}>292
<ChannelMessage293
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
305
function 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 <AutoModEmbed314
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.length331
? 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
350
export 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 global379
messageLinkRegex.lastIndex = 0;380
381
return (382
<MessageEmbedAccessory383
message={props.message}384
/>385
);386
}, 4 /* just above rich embeds */);387
},388
389
stop() {390
removeMessageAccessory("MessageLinkEmbeds");391
}392
});393