aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorActuallyTheSun <78964224+ActuallyTheSun@users.noreply.github.com>2022-12-15 00:44:58 +0200
committerGitHub <noreply@github.com>2022-12-14 23:44:58 +0100
commitc8f214111434e20a2a1610fdc1d6cc1276dbbce9 (patch)
tree57afbb96e8b2b5db0f2e15e4f1e3a8afa18f5d48
parentfea8c60a4091e10123e15b5c403f0e3f4e15c9c8 (diff)
downloadVencord-c8f214111434e20a2a1610fdc1d6cc1276dbbce9.tar.gz
Vencord-c8f214111434e20a2a1610fdc1d6cc1276dbbce9.tar.bz2
Vencord-c8f214111434e20a2a1610fdc1d6cc1276dbbce9.zip
feat(plugin): add MessageLinkEmbeds (#264)
Co-authored-by: Ven <vendicated@riseup.net>
-rw-r--r--src/api/MessageAccessories.ts13
-rw-r--r--src/plugins/messageLinkEmbeds.tsx315
-rw-r--r--src/utils/constants.ts4
-rw-r--r--src/webpack/common.tsx2
4 files changed, 332 insertions, 2 deletions
diff --git a/src/api/MessageAccessories.ts b/src/api/MessageAccessories.ts
index ee74af5..19026cf 100644
--- a/src/api/MessageAccessories.ts
+++ b/src/api/MessageAccessories.ts
@@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
-export type AccessoryCallback = (props: Record<string, any>) => JSX.Element;
+export type AccessoryCallback = (props: Record<string, any>) => JSX.Element | null | Array<JSX.Element | null>;
export type Accessory = {
callback: AccessoryCallback;
position?: number;
@@ -44,6 +44,15 @@ export function _modifyAccessories(
props: Record<string, any>
) {
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 0000000..f57b5d0
--- /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 <https://www.gnu.org/licenses/>.
+*/
+
+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<any>,
+ Embed: React.ComponentType<any>,
+ ChannelMessage: React.ComponentType<any>,
+ Endpoints: Record<string, any>;
+
+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<Message | void> {
+ 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: () =>
+ <Button onClick={() => messageCache = {}}>
+ Clear the linked message cache
+ </Button>
+ }
+ },
+
+ start() {
+ addAccessory("messageLinkEmbed", props => this.messageEmbedAccessory(props), 4 /* just above rich embeds*/);
+ },
+
+ messageLinkRegex: /(?<!<)https?:\/\/(?:\w+\.)?discord(?:app)?\.com\/channels\/(\d{17,19}|@me)\/(\d{17,19})\/(\d{17,19})/g,
+
+ messageEmbedAccessory(props) {
+ const { message }: { message: Message; } = props;
+
+ const accessories = [] as (JSX.Element | null)[];
+
+ let match = null as RegExpMatchArray | null;
+ while ((match = this.messageLinkRegex.exec(message.content!)) !== null) {
+ const [_, guildID, channelID, messageID] = match;
+
+ const linkedChannel = ChannelStore.getChannel(channelID);
+ if (!linkedChannel || (guildID !== "@me" && !PermissionStore.can(1024n /* view channel */, linkedChannel))) {
+ continue;
+ }
+
+ let linkedMessage = messageCache[messageID]?.message as Message;
+ if (!linkedMessage) {
+ linkedMessage ??= MessageStore.getMessage(channelID, messageID);
+ if (linkedMessage) messageCache[messageID] = { message: linkedMessage, fetched: true };
+ else {
+ const msg = { ...message } as any;
+ delete msg.embeds;
+ messageFetchQueue.push(() => 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 <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={() => {
+ return <div key={message.id} className={classNames.join(" ")} >
+ <ChannelMessage
+ id={`message-link-embeds-${message.id}`}
+ message={message}
+ channel={channel}
+ subscribeToComponentDispatch={false}
+ />
+ </div >;
+ }}
+ />;
+ },
+
+ 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={false}
+ content={[
+ ...(message.content || !(message.attachments.length > images.length)
+ ? parse(message.content)
+ : [noContent(message.attachments.length, message.embeds.length)]
+ ),
+ ...(images.map<JSX.Element>(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"
+ />;
+ },
+});
diff --git a/src/utils/constants.ts b/src/utils/constants.ts
index 3fbfe5a..faff732 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 2ee2d5d..81bea31 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);