From c8f214111434e20a2a1610fdc1d6cc1276dbbce9 Mon Sep 17 00:00:00 2001 From: ActuallyTheSun <78964224+ActuallyTheSun@users.noreply.github.com> Date: Thu, 15 Dec 2022 00:44:58 +0200 Subject: [PATCH] feat(plugin): add MessageLinkEmbeds (#264) Co-authored-by: Ven --- src/api/MessageAccessories.ts | 13 +- src/plugins/messageLinkEmbeds.tsx | 315 ++++++++++++++++++++++++++++++ src/utils/constants.ts | 4 + src/webpack/common.tsx | 2 + 4 files changed, 332 insertions(+), 2 deletions(-) create mode 100644 src/plugins/messageLinkEmbeds.tsx diff --git a/src/api/MessageAccessories.ts b/src/api/MessageAccessories.ts index ee74af5f5..19026cfbe 100644 --- a/src/api/MessageAccessories.ts +++ b/src/api/MessageAccessories.ts @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -export type AccessoryCallback = (props: Record) => JSX.Element; +export type AccessoryCallback = (props: Record) => JSX.Element | null | Array; export type Accessory = { callback: AccessoryCallback; position?: number; @@ -44,6 +44,15 @@ export function _modifyAccessories( props: Record ) { for (const accessory of accessories.values()) { + let accessories = accessory.callback(props); + if (accessories == null) + continue; + + if (!Array.isArray(accessories)) + accessories = [accessories]; + else if (accessories.length === 0) + continue; + elements.splice( accessory.position != null ? accessory.position < 0 @@ -51,7 +60,7 @@ export function _modifyAccessories( : accessory.position : elements.length, 0, - accessory.callback(props) + ...accessories.filter(e => e != null) as JSX.Element[] ); } diff --git a/src/plugins/messageLinkEmbeds.tsx b/src/plugins/messageLinkEmbeds.tsx new file mode 100644 index 000000000..f57b5d0e8 --- /dev/null +++ b/src/plugins/messageLinkEmbeds.tsx @@ -0,0 +1,315 @@ +/* + * 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 } from "@api/MessageAccessories"; +import { Settings } from "@api/settings"; +import { Devs } from "@utils/constants.js"; +import { Queue } from "@utils/Queue"; +import definePlugin, { OptionType } from "@utils/types"; +import { filters, findByPropsLazy, waitFor } from "@webpack"; +import { + Button, + ChannelStore, + FluxDispatcher, + GuildStore, + MessageStore, + Parser, + PermissionStore, + RestAPI, + Text, + UserStore +} from "@webpack/common"; +import { Channel, Guild, Message } from "discord-types/general"; + +let messageCache: { [id: string]: { message?: Message, fetched: boolean; }; } = {}; + +let AutomodEmbed: React.ComponentType, + Embed: React.ComponentType, + ChannelMessage: React.ComponentType, + Endpoints: Record; + +waitFor(["mle_AutomodEmbed"], m => (AutomodEmbed = m.mle_AutomodEmbed)); +waitFor(filters.byCode("().inlineMediaEmbed"), m => Embed = m); +waitFor(m => m.type?.toString()?.includes('["message","compact","className",'), m => ChannelMessage = m); +waitFor(["MESSAGE_CREATE_ATTACHMENT_UPLOAD"], _ => Endpoints = _); +const SearchResultClasses = findByPropsLazy("message", "searchResult"); + +const messageFetchQueue = new Queue(); +async function fetchMessage(channelID: string, messageID: string): Promise { + if (messageID in messageCache && !messageCache[messageID].fetched) return Promise.resolve(); + if (messageCache[messageID]?.fetched) return Promise.resolve(messageCache[messageID].message); + + messageCache[messageID] = { fetched: false }; + const res = await RestAPI.get({ + url: Endpoints.MESSAGES(channelID), + query: { + limit: 1, + around: messageID + }, + retries: 2 + }).catch(() => { }); + const apiMessage = res.body?.[0]; + const message: Message = MessageStore.getMessages(apiMessage.channel_id).receiveMessage(apiMessage).get(apiMessage.id); + messageCache[message.id] = { + message: message, + fetched: true + }; + return Promise.resolve(message); +} + +interface Attachment { + height: number; + width: number; + url: string; + proxyURL?: string; +} + +const isTenorGif = /https:\/\/(?:www.)?tenor\.com/; +function getImages(message: Message): Attachment[] { + const attachments: Attachment[] = []; + message.attachments?.forEach(a => { + if (a.content_type!.startsWith("image/")) attachments.push({ + height: a.height!, + width: a.width!, + url: a.url, + proxyURL: a.proxy_url! + }); + }); + message.embeds?.forEach(e => { + if (e.type === "image") attachments.push( + e.image ? { ...e.image } : { ...e.thumbnail! } + ); + if (e.type === "gifv" && !isTenorGif.test(e.url!)) { + attachments.push({ + height: e.thumbnail!.height, + width: e.thumbnail!.width, + url: e.url! + }); + } + }); + return attachments; +} + +const noContent = (attachments: number, embeds: number): string => { + 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.attachments.every(a => a.content_type?.startsWith("image/")) + && message.embeds.every(e => e.type === "image" || (e.type === "gifv" && !isTenorGif.test(e.url!))) + && !message.components.length + ) return false; + return true; +} + +const computeWidthAndHeight = (width: number, height: number) => { + const maxWidth = 400, maxHeight = 300; + let newWidth: number, newHeight: number; + if (width > height) { + newWidth = Math.min(width, maxWidth); + newHeight = Math.round(height / (width / newWidth)); + } else { + newHeight = Math.min(height, maxHeight); + newWidth = Math.round(width / (height / newHeight)); + } + return { width: newWidth, height: newHeight }; +}; + +interface MessageEmbedProps { + message: Message; + channel: Channel; + guildID: string; +} + +export default definePlugin({ + name: "MessageLinkEmbeds", + description: "Adds a preview to messages that link another message", + authors: [Devs.TheSun], + dependencies: ["MessageAccessoriesAPI"], + patches: [ + { + find: "().embedCard", + replacement: [{ + match: /{"use strict";(.{0,10})\(\)=>(.{1,2})}\);/, + replace: '{"use strict";$1()=>$2,me:()=>messageEmbed});' + }, { + match: /function (.{1,2})\((.{1,2})\){var (.{1,2})=.{1,2}\.message,(.{1,2})=.{1,2}\.channel(.{0,300})\(\)\.embedCard(.{0,500})}\)}/, + replace: "function $1($2){var $3=$2.message,$4=$2.channel$5().embedCard$6})}\ +var messageEmbed={mle_AutomodEmbed:$1};" + }] + } + ], + options: { + 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 + }] + }, + clearMessageCache: { + type: OptionType.COMPONENT, + description: "Clear the linked message cache", + component: () => + + } + }, + + start() { + addAccessory("messageLinkEmbed", props => this.messageEmbedAccessory(props), 4 /* just above rich embeds*/); + }, + + messageLinkRegex: /(? fetchMessage(channelID, messageID) + .then(m => m && FluxDispatcher.dispatch({ + type: "MESSAGE_UPDATE", + message: msg + })) + ); + continue; + } + } + const messageProps: MessageEmbedProps = { + message: linkedMessage, + channel: linkedChannel, + guildID + }; + + const type = Settings.plugins[this.name].automodEmbeds; + accessories.push( + type === "always" || (type === "prefer" && !requiresRichEmbed(linkedMessage)) + ? this.automodEmbedAccessory(messageProps) + : this.channelMessageEmbedAccessory(messageProps) + ); + } + return accessories; + }, + + channelMessageEmbedAccessory(props: MessageEmbedProps): JSX.Element | null { + const { message, channel, guildID } = props; + + const isDM = guildID === "@me"; + const guild = !isDM && GuildStore.getGuild(channel.guild_id); + const dmReceiver = UserStore.getUser(ChannelStore.getChannel(channel.id).recipients?.[0]); + const classNames = [SearchResultClasses.message]; + if (Settings.plugins[this.name].messageBackgroundColor) classNames.push(SearchResultClasses.searchResult); + + return + {[ + {isDM ? "Direct Message - " : (guild as Guild).name + " - "}, + ...(isDM + ? Parser.parse(`<@${dmReceiver.id}>`) + : Parser.parse(`<#${channel.id}>`) + ) + ]} + , + 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={() => { + return
+ +
; + }} + />; + }, + + automodEmbedAccessory(props: MessageEmbedProps): JSX.Element | null { + const { message, channel, guildID } = props; + + const isDM = guildID === "@me"; + const images = getImages(message); + const { parse } = Parser; + + return + {[ + ...(isDM ? parse(`<@${ChannelStore.getChannel(channel.id).recipients[0]}>`) : parse(`<#${channel.id}>`)), + {isDM ? " - Direct Message" : " - " + GuildStore.getGuild(channel.guild_id)?.name} + ]} + } + compact={false} + 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" + />; + }, +}); diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 3fbfe5a71..faff73213 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -169,4 +169,8 @@ export const Devs = Object.freeze({ name: "Commandtechno", id: 296776625432035328n, }, + TheSun: { + name: "ActuallyTheSun", + id: 406028027768733696n + }, }); diff --git a/src/webpack/common.tsx b/src/webpack/common.tsx index 2ee2d5d2d..81bea3170 100644 --- a/src/webpack/common.tsx +++ b/src/webpack/common.tsx @@ -52,6 +52,7 @@ export let UserStore: Stores.UserStore; export let SelectedChannelStore: Stores.SelectedChannelStore; export let SelectedGuildStore: any; export let ChannelStore: Stores.ChannelStore; +export let GuildMemberStore: Stores.GuildMemberStore; export let RelationshipStore: Stores.RelationshipStore & { /** Get the date (as a string) that the relationship was created */ getSince(userId: string): string; @@ -163,6 +164,7 @@ waitFor("getSortedPrivateChannels", m => ChannelStore = m); waitFor("getCurrentlySelectedChannelId", m => SelectedChannelStore = m); waitFor("getLastSelectedGuildId", m => SelectedGuildStore = m); waitFor("getGuildCount", m => GuildStore = m); +waitFor(["getMember", "initialize"], m => GuildMemberStore = m); waitFor("getRelationshipType", m => RelationshipStore = m); waitFor(["Hovers", "Looks", "Sizes"], m => Button = m);