/* * Vencord, a modification for Discord's desktop app * Copyright (c) 2022 Vendicated and contributors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ import { addAccessory, removeAccessory } from "@api/MessageAccessories"; import { definePluginSettings } from "@api/Settings"; import ErrorBoundary from "@components/ErrorBoundary"; import { Devs } from "@utils/constants.js"; import { classes } from "@utils/misc"; import { Queue } from "@utils/Queue"; import definePlugin, { OptionType } from "@utils/types"; import { findByProps, findComponentByCode } from "@webpack"; import { Button, ChannelStore, Constants, FluxDispatcher, GuildStore, IconUtils, MessageStore, Parser, PermissionsBits, PermissionStore, RestAPI, Text, TextAndImagesSettingsStores, UserStore } from "@webpack/common"; import { Channel, Message } from "discord-types/general"; const messageCache = new Map(); const Embed = findComponentByCode(".inlineMediaEmbed"); const AutoModEmbed = findComponentByCode(".withFooter]:", "childrenMessageContent:"); const ChannelMessage = findComponentByCode("renderSimpleAccessories)"); const SearchResultClasses = findByProps("message", "searchResult"); const EmbedClasses = findByProps("embedAuthorIcon", "embedAuthor", "embedAuthor"); const messageLinkRegex = /(? } }); async function fetchMessage(channelID: string, messageID: string) { const cached = messageCache.get(messageID); if (cached) return cached.message; messageCache.set(messageID, { fetched: false }); const res = await RestAPI.get({ url: Constants.Endpoints.MESSAGES(channelID), query: { limit: 1, around: messageID }, retries: 2 }).catch(() => null); const msg = res?.body?.[0]; if (!msg) return; const message: Message = MessageStore.getMessages(msg.channel_id).receiveMessage(msg).get(msg.id); messageCache.set(message.id, { message, fetched: true }); return message; } function getImages(message: Message): Attachment[] { const attachments: Attachment[] = []; for (const { content_type, height, width, url, proxy_url } of message.attachments ?? []) { if (content_type?.startsWith("image/")) attachments.push({ height: height!, width: width!, url: url, proxyURL: proxy_url! }); } for (const { type, image, thumbnail, url } of message.embeds ?? []) { if (type === "image") attachments.push({ ...(image ?? thumbnail!) }); else if (url && type === "gifv" && !tenorRegex.test(url)) attachments.push({ height: thumbnail!.height, width: thumbnail!.width, url }); } return attachments; } function noContent(attachments: number, embeds: number) { if (!attachments && !embeds) return ""; if (!attachments) return `[no content, ${embeds} embed${embeds !== 1 ? "s" : ""}]`; if (!embeds) return `[no content, ${attachments} attachment${attachments !== 1 ? "s" : ""}]`; return `[no content, ${attachments} attachment${attachments !== 1 ? "s" : ""} and ${embeds} embed${embeds !== 1 ? "s" : ""}]`; } function requiresRichEmbed(message: Message) { if (message.components.length) return true; if (message.attachments.some(a => !a.content_type?.startsWith("image/"))) return true; if (message.embeds.some(e => e.type !== "image" && (e.type !== "gifv" || tenorRegex.test(e.url!)))) return true; return false; } function computeWidthAndHeight(width: number, height: number) { const maxWidth = 400; const maxHeight = 300; if (width > height) { const adjustedWidth = Math.min(width, maxWidth); return { width: adjustedWidth, height: Math.round(height / (width / adjustedWidth)) }; } const adjustedHeight = Math.min(height, maxHeight); return { width: Math.round(width / (height / adjustedHeight)), height: adjustedHeight }; } function withEmbeddedBy(message: Message, embeddedBy: string[]) { return new Proxy(message, { get(_, prop) { if (prop === "vencordEmbeddedBy") return embeddedBy; // @ts-ignore ts so bad return Reflect.get(...arguments); } }); } function MessageEmbedAccessory({ message }: { message: Message; }) { // @ts-ignore const embeddedBy: string[] = message.vencordEmbeddedBy ?? []; const accessories = [] as (JSX.Element | null)[]; for (const [_, channelID, messageID] of message.content!.matchAll(messageLinkRegex)) { if (embeddedBy.includes(messageID) || embeddedBy.length > 2) { continue; } const linkedChannel = ChannelStore.getChannel(channelID); if (!linkedChannel || (!linkedChannel.isPrivate() && !PermissionStore.can(PermissionsBits.VIEW_CHANNEL, linkedChannel))) { continue; } const { listMode, idList } = settings.store; const isListed = [linkedChannel.guild_id, channelID, message.author.id].some(id => id && idList.includes(id)); if (listMode === "blacklist" && isListed) continue; if (listMode === "whitelist" && !isListed) continue; let linkedMessage = messageCache.get(messageID)?.message; if (!linkedMessage) { linkedMessage ??= MessageStore.getMessage(channelID, messageID); if (linkedMessage) { messageCache.set(messageID, { message: linkedMessage, fetched: true }); } else { const msg = { ...message } as any; delete msg.embeds; delete msg.interaction; messageFetchQueue.unshift(() => fetchMessage(channelID, messageID) .then(m => m && FluxDispatcher.dispatch({ type: "MESSAGE_UPDATE", message: msg })) ); continue; } } const messageProps: MessageEmbedProps = { message: withEmbeddedBy(linkedMessage, [...embeddedBy, message.id]), channel: linkedChannel }; const type = settings.store.automodEmbeds; accessories.push( type === "always" || (type === "prefer" && !requiresRichEmbed(linkedMessage)) ? : ); } return accessories.length ? <>{accessories} : null; } function getChannelLabelAndIconUrl(channel: Channel) { if (channel.isDM()) return ["Direct Message", IconUtils.getUserAvatarURL(UserStore.getUser(channel.recipients[0]))]; if (channel.isGroupDM()) return ["Group DM", IconUtils.getChannelIconURL(channel)]; return ["Server", IconUtils.getGuildIconURL(GuildStore.getGuild(channel.guild_id))]; } function ChannelMessageEmbedAccessory({ message, channel }: MessageEmbedProps): JSX.Element | null { const dmReceiver = UserStore.getUser(ChannelStore.getChannel(channel.id).recipients?.[0]); const [channelLabel, iconUrl] = getChannelLabelAndIconUrl(channel); return ( {channelLabel} - {Parser.parse(channel.isDM() ? `<@${dmReceiver.id}>` : `<#${channel.id}>`)} , iconProxyURL: iconUrl } }} renderDescription={() => (
)} /> ); } function AutomodEmbedAccessory(props: MessageEmbedProps): JSX.Element | null { const { message, channel } = props; const compact = TextAndImagesSettingsStores.MessageDisplayCompact.useSetting(); const images = getImages(message); const { parse } = Parser; const [channelLabel, iconUrl] = getChannelLabelAndIconUrl(channel); return {iconUrl && } {channelLabel} - {channel.isDM() ? Parser.parse(`<@${ChannelStore.getChannel(channel.id).recipients[0]}>`) : Parser.parse(`<#${channel.id}>`) } } compact={compact} content={ <> {message.content || message.attachments.length <= images.length ? parse(message.content) : [noContent(message.attachments.length, message.embeds.length)] } {images.map(a => { const { width, height } = computeWidthAndHeight(a.width, a.height); return (
); })} } hideTimestamp={false} message={message} _messageEmbed="automod" />; } export default definePlugin({ name: "MessageLinkEmbeds", description: "Adds a preview to messages that link another message", authors: [Devs.TheSun, Devs.Ven, Devs.RyanCaoDev], dependencies: ["MessageAccessoriesAPI"], settings, start() { addAccessory("messageLinkEmbed", props => { if (!messageLinkRegex.test(props.message.content)) return null; return ( ); }, 4 /* just above rich embeds */); }, stop() { removeAccessory("messageLinkEmbed"); } });