aboutsummaryrefslogtreecommitdiff
path: root/src/plugins/messageLinkEmbeds/index.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'src/plugins/messageLinkEmbeds/index.tsx')
-rw-r--r--src/plugins/messageLinkEmbeds/index.tsx402
1 files changed, 402 insertions, 0 deletions
diff --git a/src/plugins/messageLinkEmbeds/index.tsx b/src/plugins/messageLinkEmbeds/index.tsx
new file mode 100644
index 0000000..c7b3bd0
--- /dev/null
+++ b/src/plugins/messageLinkEmbeds/index.tsx
@@ -0,0 +1,402 @@
+/*
+ * 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 <https://www.gnu.org/licenses/>.
+*/
+
+import { addAccessory } from "@api/MessageAccessories";
+import { definePluginSettings } from "@api/Settings";
+import { getSettingStoreLazy } from "@api/SettingsStore";
+import ErrorBoundary from "@components/ErrorBoundary";
+import { Devs } from "@utils/constants.js";
+import { classes } from "@utils/misc";
+import { Queue } from "@utils/Queue";
+import { LazyComponent } from "@utils/react";
+import definePlugin, { OptionType } from "@utils/types";
+import { find, findByCode, findByPropsLazy } from "@webpack";
+import {
+ Button,
+ ChannelStore,
+ FluxDispatcher,
+ GuildStore,
+ MessageStore,
+ Parser,
+ PermissionStore,
+ RestAPI,
+ Text,
+ UserStore
+} from "@webpack/common";
+import { Channel, Guild, Message } from "discord-types/general";
+
+const messageCache = new Map<string, {
+ message?: Message;
+ fetched: boolean;
+}>();
+
+const Embed = LazyComponent(() => findByCode(".inlineMediaEmbed"));
+const ChannelMessage = LazyComponent(() => find(m => m.type?.toString()?.includes('["message","compact","className",')));
+
+const SearchResultClasses = findByPropsLazy("message", "searchResult");
+
+let AutoModEmbed: React.ComponentType<any> = () => null;
+
+const messageLinkRegex = /(?<!<)https?:\/\/(?:\w+\.)?discord(?:app)?\.com\/channels\/(\d{17,20}|@me)\/(\d{17,20})\/(\d{17,20})/g;
+const tenorRegex = /^https:\/\/(?:www\.)?tenor\.com\//;
+
+interface Attachment {
+ height: number;
+ width: number;
+ url: string;
+ proxyURL?: string;
+}
+
+interface MessageEmbedProps {
+ message: Message;
+ channel: Channel;
+ guildID: string;
+}
+
+const messageFetchQueue = new Queue();
+
+const settings = definePluginSettings({
+ messageBackgroundColor: {
+ description: "Background color for messages in rich embeds",
+ type: OptionType.BOOLEAN
+ },
+ automodEmbeds: {
+ description: "Use automod embeds instead of rich embeds (smaller but less info)",
+ type: OptionType.SELECT,
+ options: [
+ {
+ label: "Always use automod embeds",
+ value: "always"
+ },
+ {
+ label: "Prefer automod embeds, but use rich embeds if some content can't be shown",
+ value: "prefer"
+ },
+ {
+ label: "Never use automod embeds",
+ value: "never",
+ default: true
+ }
+ ]
+ },
+ listMode: {
+ description: "Whether to use ID list as blacklist or whitelist",
+ type: OptionType.SELECT,
+ options: [
+ {
+ label: "Blacklist",
+ value: "blacklist",
+ default: true
+ },
+ {
+ label: "Whitelist",
+ value: "whitelist"
+ }
+ ]
+ },
+ idList: {
+ description: "Guild/channel/user IDs to blacklist or whitelist (separate with comma)",
+ type: OptionType.STRING,
+ default: ""
+ },
+ clearMessageCache: {
+ type: OptionType.COMPONENT,
+ description: "Clear the linked message cache",
+ component: () =>
+ <Button onClick={() => messageCache.clear()}>
+ Clear the linked message cache
+ </Button>
+ }
+});
+
+
+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: `/channels/${channelID}/messages`,
+ 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)[];
+
+ let match = null as RegExpMatchArray | null;
+ while ((match = messageLinkRegex.exec(message.content!)) !== null) {
+ const [_, guildID, channelID, messageID] = match;
+ if (embeddedBy.includes(messageID)) {
+ continue;
+ }
+
+ const linkedChannel = ChannelStore.getChannel(channelID);
+ if (!linkedChannel || (guildID !== "@me" && !PermissionStore.can(1024n /* view channel */, linkedChannel))) {
+ continue;
+ }
+
+ const { listMode, idList } = settings.store;
+
+ const isListed = [guildID, 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.push(() => 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,
+ guildID
+ };
+
+ const type = settings.store.automodEmbeds;
+ accessories.push(
+ type === "always" || (type === "prefer" && !requiresRichEmbed(linkedMessage))
+ ? <AutomodEmbedAccessory {...messageProps} />
+ : <ChannelMessageEmbedAccessory {...messageProps} />
+ );
+ }
+
+ return accessories.length ? <>{accessories}</> : null;
+}
+
+function ChannelMessageEmbedAccessory({ message, channel, guildID }: MessageEmbedProps): JSX.Element | null {
+ const isDM = guildID === "@me";
+
+ const guild = !isDM && GuildStore.getGuild(channel.guild_id);
+ const dmReceiver = UserStore.getUser(ChannelStore.getChannel(channel.id).recipients?.[0]);
+
+
+ return <Embed
+ embed={{
+ rawDescription: "",
+ color: "var(--background-secondary)",
+ author: {
+ name: <Text variant="text-xs/medium" tag="span">
+ <span>{isDM ? "Direct Message - " : (guild as Guild).name + " - "}</span>
+ {isDM
+ ? Parser.parse(`<@${dmReceiver.id}>`)
+ : Parser.parse(`<#${channel.id}>`)
+ }
+ </Text>,
+ iconProxyURL: guild
+ ? `https://${window.GLOBAL_ENV.CDN_HOST}/icons/${guild.id}/${guild.icon}.png`
+ : `https://${window.GLOBAL_ENV.CDN_HOST}/avatars/${dmReceiver.id}/${dmReceiver.avatar}`
+ }
+ }}
+ renderDescription={() => (
+ <div key={message.id} className={classes(SearchResultClasses.message, settings.store.messageBackgroundColor && SearchResultClasses.searchResult)}>
+ <ChannelMessage
+ id={`message-link-embeds-${message.id}`}
+ message={message}
+ channel={channel}
+ subscribeToComponentDispatch={false}
+ />
+ </div>
+ )}
+ />;
+}
+
+const compactModeEnabled = getSettingStoreLazy<boolean>("textAndImages", "messageDisplayCompact")!;
+
+function AutomodEmbedAccessory(props: MessageEmbedProps): JSX.Element | null {
+ const { message, channel, guildID } = props;
+ const isDM = guildID === "@me";
+ const images = getImages(message);
+ const { parse } = Parser;
+
+ return <AutoModEmbed
+ channel={channel}
+ childrenAccessories={
+ <Text color="text-muted" variant="text-xs/medium" tag="span">
+ {isDM
+ ? parse(`<@${ChannelStore.getChannel(channel.id).recipients[0]}>`)
+ : parse(`<#${channel.id}>`)
+ }
+ <span>{isDM ? " - Direct Message" : " - " + GuildStore.getGuild(channel.guild_id)?.name}</span>
+ </Text>
+ }
+ compact={compactModeEnabled.getSetting()}
+ 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 (
+ <div>
+ <img src={a.url} width={width} height={height} />
+ </div>
+ );
+ })}
+ </>
+ }
+ 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", "SettingsStoreAPI"],
+ patches: [
+ {
+ find: ".embedCard",
+ replacement: [{
+ match: /function (\i)\(\i\){var \i=\i\.message,\i=\i\.channel.{0,200}\.hideTimestamp/,
+ replace: "$self.AutoModEmbed=$1;$&"
+ }]
+ }
+ ],
+
+ set AutoModEmbed(e: any) {
+ AutoModEmbed = e;
+ },
+
+ settings,
+
+ start() {
+ addAccessory("messageLinkEmbed", props => {
+ if (!messageLinkRegex.test(props.message.content))
+ return null;
+
+ // need to reset the regex because it's global
+ messageLinkRegex.lastIndex = 0;
+
+ return (
+ <ErrorBoundary>
+ <MessageEmbedAccessory
+ message={props.message}
+ />
+ </ErrorBoundary>
+ );
+ }, 4 /* just above rich embeds */);
+ },
+});