From 0dbec8d0cd3554ec66030ed6fe2649f90308d950 Mon Sep 17 00:00:00 2001 From: rushii <33725716+DiamondMiner88@users.noreply.github.com> Date: Sun, 13 Nov 2022 14:13:32 -0800 Subject: feat: message logger plugin (#49) Co-authored-by: Ven --- src/plugins/EmoteYoink.tsx | 243 -------------------------- src/plugins/emoteYoink.tsx | 244 ++++++++++++++++++++++++++ src/plugins/messageLogger/index.tsx | 336 ++++++++++++++++++++++++++++++++++++ 3 files changed, 580 insertions(+), 243 deletions(-) delete mode 100644 src/plugins/EmoteYoink.tsx create mode 100644 src/plugins/emoteYoink.tsx create mode 100644 src/plugins/messageLogger/index.tsx (limited to 'src/plugins') diff --git a/src/plugins/EmoteYoink.tsx b/src/plugins/EmoteYoink.tsx deleted file mode 100644 index 405f383..0000000 --- a/src/plugins/EmoteYoink.tsx +++ /dev/null @@ -1,243 +0,0 @@ -/* - * 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 { Settings } from "../api/settings"; -import { CheckedTextInput } from "../components/CheckedTextInput"; -import { Devs } from "../utils/constants"; -import { lazyWebpack, makeLazy } from "../utils/misc"; -import { ModalContent, ModalHeader, ModalRoot, openModal } from "../utils/modal"; -import definePlugin from "../utils/types"; -import { filters } from "../webpack"; -import { Forms, GuildStore, Margins, Menu, PermissionStore, React, Toasts, Tooltip, UserStore } from "../webpack/common"; - -const MANAGE_EMOJIS_AND_STICKERS = 1n << 30n; - -const GuildEmojiStore = lazyWebpack(filters.byProps("getGuilds", "getGuildEmoji")); -const uploadEmoji = lazyWebpack(filters.byCode('"EMOJI_UPLOAD_START"', "GUILD_EMOJIS(")); - -function getGuildCandidates(isAnimated: boolean) { - const meId = UserStore.getCurrentUser().id; - - return Object.values(GuildStore.getGuilds()).filter(g => { - const canCreate = g.ownerId === meId || - BigInt(PermissionStore.getGuildPermissions({ id: g.id }) & MANAGE_EMOJIS_AND_STICKERS) === MANAGE_EMOJIS_AND_STICKERS; - if (!canCreate) return false; - - const emojiSlots = g.getMaxEmojiSlots(); - const { emojis } = GuildEmojiStore.getGuilds()[g.id]; - - let count = 0; - for (const emoji of emojis) - if (emoji.animated === isAnimated) count++; - return count < emojiSlots; - }).sort((a, b) => a.name.localeCompare(b.name)); -} - -async function doClone(guildId: string, id: string, name: string, isAnimated: boolean) { - const data = await fetch(`https://cdn.discordapp.com/emojis/${id}.${isAnimated ? "gif" : "png"}`) - .then(r => r.blob()); - const reader = new FileReader(); - - reader.onload = () => { - uploadEmoji({ - guildId, - name, - image: reader.result - }).then(() => { - Toasts.show({ - message: `Successfully yoinked ${name}!`, - type: Toasts.Type.SUCCESS, - id: Toasts.genId() - }); - }).catch(e => { - console.error("[EmoteYoink] Failed to upload emoji", e); - Toasts.show({ - message: "Oopsie something went wrong :( Check console!!!", - type: Toasts.Type.FAILURE, - id: Toasts.genId() - }); - }); - }; - - reader.readAsDataURL(data); -} - -const getFontSize = (s: string) => { - // [18, 18, 16, 16, 14, 12, 10] - const sizes = [20, 20, 18, 18, 16, 14, 12]; - return sizes[s.length] ?? 4; -}; - -const nameValidator = /^\w+$/i; - -function CloneModal({ id, name: emojiName, isAnimated }: { id: string; name: string; isAnimated: boolean; }) { - const [isCloning, setIsCloning] = React.useState(false); - const [name, setName] = React.useState(emojiName); - - const [x, invalidateMemo] = React.useReducer(x => x + 1, 0); - - const guilds = React.useMemo(() => getGuildCandidates(isAnimated), [isAnimated, x]); - - return ( - <> - Custom Name - - (v.length > 1 && v.length < 32 && nameValidator.test(v)) - || "Name must be between 2 and 32 characters and only contain alphanumeric characters" - } - /> -
- {guilds.map(g => ( - - {({ onMouseLeave, onMouseEnter }) => ( -
{ - setIsCloning(true); - doClone(g.id, id, name, isAnimated).finally(() => { - invalidateMemo(); - setIsCloning(false); - }); - }} - > - {g.icon ? ( - {g.name} - ) : ( - - {g.acronym} - - )} -
- )} -
- ))} -
- - ); -} - -export default definePlugin({ - name: "EmoteYoink", - description: "Clone emotes to your own server", - authors: [Devs.Ven], - dependencies: ["MenuItemDeobfuscatorApi"], - - patches: [{ - // Literally copy pasted from ReverseImageSearch lol - find: "open-native-link", - replacement: { - match: /id:"open-native-link".{0,200}\(\{href:(.{0,3}),.{0,200}\},"open-native-link"\)/, - replace: "$&,Vencord.Plugins.plugins.EmoteYoink.makeMenu(arguments[2])" - }, - - }, - // Also copy pasted from Reverse Image Search - { - // pass the target to the open link menu so we can grab its data - find: "REMOVE_ALL_REACTIONS_CONFIRM_BODY,", - predicate: makeLazy(() => !Settings.plugins.ReverseImageSearch.enabled), - replacement: { - match: /(?.).onHeightUpdate.{0,200}(.)=(.)=.\.url;.+?\(null!=\3\?\3:\2[^)]+/, - replace: "$&,$.target" - } - }], - - makeMenu(htmlElement: HTMLImageElement) { - if (htmlElement?.dataset.type !== "emoji") - return null; - - const { id } = htmlElement.dataset; - const name = htmlElement.alt.match(/:(.*)(?:~\d+)?:/)?.[1]; - - if (!name || !id) - return null; - - const isAnimated = new URL(htmlElement.src).pathname.endsWith(".gif"); - - return - openModal(modalProps => ( - - - - Clone {name} - - - - - - )) - } - > - ; - }, -}); diff --git a/src/plugins/emoteYoink.tsx b/src/plugins/emoteYoink.tsx new file mode 100644 index 0000000..becc6d1 --- /dev/null +++ b/src/plugins/emoteYoink.tsx @@ -0,0 +1,244 @@ +/* + * 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 { Settings } from "../api/settings"; +import { CheckedTextInput } from "../components/CheckedTextInput"; +import { Devs } from "../utils/constants"; +import { lazyWebpack, makeLazy } from "../utils/misc"; +import { ModalContent, ModalHeader, ModalRoot, openModal } from "../utils/modal"; +import definePlugin from "../utils/types"; +import { filters } from "../webpack"; +import { Forms, GuildStore, Margins, Menu, PermissionStore, React, Toasts, Tooltip, UserStore } from "../webpack/common"; + +const MANAGE_EMOJIS_AND_STICKERS = 1n << 30n; + +const GuildEmojiStore = lazyWebpack(filters.byProps("getGuilds", "getGuildEmoji")); +const uploadEmoji = lazyWebpack(filters.byCode('"EMOJI_UPLOAD_START"', "GUILD_EMOJIS(")); + +function getGuildCandidates(isAnimated: boolean) { + const meId = UserStore.getCurrentUser().id; + + return Object.values(GuildStore.getGuilds()).filter(g => { + const canCreate = g.ownerId === meId || + BigInt(PermissionStore.getGuildPermissions({ id: g.id }) & MANAGE_EMOJIS_AND_STICKERS) === MANAGE_EMOJIS_AND_STICKERS; + if (!canCreate) return false; + + const emojiSlots = g.getMaxEmojiSlots(); + const { emojis } = GuildEmojiStore.getGuilds()[g.id]; + + let count = 0; + for (const emoji of emojis) + if (emoji.animated === isAnimated) count++; + return count < emojiSlots; + }).sort((a, b) => a.name.localeCompare(b.name)); +} + +async function doClone(guildId: string, id: string, name: string, isAnimated: boolean) { + const data = await fetch(`https://cdn.discordapp.com/emojis/${id}.${isAnimated ? "gif" : "png"}`) + .then(r => r.blob()); + const reader = new FileReader(); + + reader.onload = () => { + uploadEmoji({ + guildId, + name, + image: reader.result + }).then(() => { + Toasts.show({ + message: `Successfully yoinked ${name}!`, + type: Toasts.Type.SUCCESS, + id: Toasts.genId() + }); + }).catch(e => { + console.error("[EmoteYoink] Failed to upload emoji", e); + Toasts.show({ + message: "Oopsie something went wrong :( Check console!!!", + type: Toasts.Type.FAILURE, + id: Toasts.genId() + }); + }); + }; + + reader.readAsDataURL(data); +} + +const getFontSize = (s: string) => { + // [18, 18, 16, 16, 14, 12, 10] + const sizes = [20, 20, 18, 18, 16, 14, 12]; + return sizes[s.length] ?? 4; +}; + +const nameValidator = /^\w+$/i; + +function CloneModal({ id, name: emojiName, isAnimated }: { id: string; name: string; isAnimated: boolean; }) { + const [isCloning, setIsCloning] = React.useState(false); + const [name, setName] = React.useState(emojiName); + + const [x, invalidateMemo] = React.useReducer(x => x + 1, 0); + + const guilds = React.useMemo(() => getGuildCandidates(isAnimated), [isAnimated, x]); + + return ( + <> + Custom Name + + (v.length > 1 && v.length < 32 && nameValidator.test(v)) + || "Name must be between 2 and 32 characters and only contain alphanumeric characters" + } + /> +
+ {guilds.map(g => ( + + {({ onMouseLeave, onMouseEnter }) => ( +
{ + setIsCloning(true); + doClone(g.id, id, name, isAnimated).finally(() => { + invalidateMemo(); + setIsCloning(false); + }); + }} + > + {g.icon ? ( + {g.name} + ) : ( + + {g.acronym} + + )} +
+ )} +
+ ))} +
+ + ); +} + +export default definePlugin({ + name: "EmoteYoink", + description: "Clone emotes to your own server", + authors: [Devs.Ven], + dependencies: ["MenuItemDeobfuscatorApi"], + + patches: [{ + // Literally copy pasted from ReverseImageSearch lol + find: "open-native-link", + replacement: { + match: /id:"open-native-link".{0,200}\(\{href:(.{0,3}),.{0,200}\},"open-native-link"\)/, + replace: "$&,Vencord.Plugins.plugins.EmoteYoink.makeMenu(arguments[2])" + }, + + }, + // Also copy pasted from Reverse Image Search + { + // pass the target to the open link menu so we can grab its data + find: "REMOVE_ALL_REACTIONS_CONFIRM_BODY,", + predicate: makeLazy(() => !Settings.plugins.ReverseImageSearch.enabled), + noWarn: true, + replacement: { + match: /(?.).onHeightUpdate.{0,200}(.)=(.)=.\.url;.+?\(null!=\3\?\3:\2[^)]+/, + replace: "$&,$.target" + } + }], + + makeMenu(htmlElement: HTMLImageElement) { + if (htmlElement?.dataset.type !== "emoji") + return null; + + const { id } = htmlElement.dataset; + const name = htmlElement.alt.match(/:(.*)(?:~\d+)?:/)?.[1]; + + if (!name || !id) + return null; + + const isAnimated = new URL(htmlElement.src).pathname.endsWith(".gif"); + + return + openModal(modalProps => ( + + + + Clone {name} + + + + + + )) + } + > + ; + }, +}); diff --git a/src/plugins/messageLogger/index.tsx b/src/plugins/messageLogger/index.tsx new file mode 100644 index 0000000..476d092 --- /dev/null +++ b/src/plugins/messageLogger/index.tsx @@ -0,0 +1,336 @@ +/* + * 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 { Settings } from "../../api/settings"; +import ErrorBoundary from "../../components/ErrorBoundary"; +import { Devs } from "../../utils/constants"; +import { lazyWebpack } from "../../utils/misc"; +import definePlugin, { OptionType } from "../../utils/types"; +import { filters } from "../../webpack"; + +function addDeleteStyleClass() { + if (Settings.plugins.MessageLogger.deleteStyle === "text") { + document.body.classList.remove("messagelogger-red-overlay"); + document.body.classList.add("messagelogger-red-text"); + } else { + document.body.classList.remove("messagelogger-red-text"); + document.body.classList.add("messagelogger-red-overlay"); + } +} + +export default definePlugin({ + name: "MessageLogger", + description: "Temporarily logs deleted and edited messages.", + authors: [Devs.rushii], + + timestampModule: null as any, + moment: null as Function | null, + + css: ` + .messagelogger-red-overlay .messageLogger-deleted { + background-color: rgba(240, 71, 71, 0.15); + } + .messagelogger-red-text .messageLogger-deleted div { + color: #f04747; + } + + .messageLogger-deleted-attachment { + filter: grayscale(1); + } + + .messageLogger-deleted-attachment:hover { + filter: grayscale(0); + transition: 250ms filter linear; + } + + .theme-dark .messageLogger-edited { + filter: brightness(80%); + } + + .theme-light .messageLogger-edited { + opacity: 0.5; + } + `, + + start() { + this.moment = lazyWebpack(filters.byProps("relativeTimeRounding", "relativeTimeThreshold")); + this.timestampModule = lazyWebpack(filters.byProps("messageLogger_TimestampComponent")); + + const style = this.style = document.createElement("style"); + style.textContent = this.css; + style.id = "MessageLogger-css"; + document.head.appendChild(style); + + addDeleteStyleClass(); + }, + + stop() { + this.style?.remove(); + + document.querySelectorAll(".messageLogger-deleted").forEach(e => e.remove()); + document.querySelectorAll(".messageLogger-edited").forEach(e => e.remove()); + document.body.classList.remove("messagelogger-red-overlay"); + document.body.classList.remove("messagelogger-red-text"); + }, + + renderEdit(edit: { timestamp: any, content: string; }) { + const Timestamp = this.timestampModule.messageLogger_TimestampComponent; + return ( + +
+ {edit.content} + + {" "}(edited) + +
+
+ ); + }, + + makeEdit(newMessage: any, oldMessage: any): any { + return { + timestamp: this.moment?.call(newMessage.edited_timestamp), + content: oldMessage.content + }; + }, + + options: { + deleteStyle: { + type: OptionType.SELECT, + description: "The style of deleted messages", + default: "text", + options: [ + { label: "Red text", value: "text", default: true }, + { label: "Red overlay", value: "overlay" } + ], + onChange: () => addDeleteStyleClass() + } + }, + + // Based on canary 9ab8626bcebceaea6da570b9c586172d02b9c996 + patches: [ + { + // MessageStore + // Module 171447 + find: "displayName=\"MessageStore\"", + replacement: [ + { + // Add deleted=true to all target messages in the MESSAGE_DELETE event + match: /MESSAGE_DELETE:function\((\w)\){var .+?((?:\w{1,2}\.){2})getOrCreate.+?},/, + replace: + "MESSAGE_DELETE:function($1){" + + " var cache = $2getOrCreate($1.channelId);" + + " cache = cache.update($1.id,m=>m.set('deleted', true).set('attachments', m.attachments.map(a => (a.deleted = true, a))));" + + " $2commit(cache);" + + "}," + }, + { + // Add deleted=true to all target messages in the MESSAGE_DELETE_BULK event + match: /MESSAGE_DELETE_BULK:function\((\w)\){var .+?((?:\w{1,2}\.){2})getOrCreate.+?},/, + replace: + "MESSAGE_DELETE_BULK:function($1){" + + " var cache = $2getOrCreate($1.channelId);" + + " cache = $1.ids.reduce((pv,cv) => pv.update(cv, m => m.set('deleted', true).set('attachments', m.attachments.map(a => (a.deleted = true, a)))), cache);" + + " $2commit(cache);" + + "}," + }, + { + // Add current cached content + new edit time to cached message's editHistory + match: /(MESSAGE_UPDATE:function\((\w)\).+?)\.update\((\w)/, + replace: "$1" + + ".update($3,m =>" + + " $2.message.content !== m.editHistory?.[0]?.content && $2.message.content !== m.content ?" + + " m.set('editHistory',[...(m.editHistory || []), Vencord.Plugins.plugins.MessageLogger.makeEdit($2.message, m)]) :" + + " m" + + ")" + + ".update($3" + } + ] + }, + + { + // Message domain model + // Module 451 + find: "isFirstMessageInForumPost=function", + replacement: [ + { + match: /(\w)\.customRenderedContent=(\w)\.customRenderedContent;/, + replace: "$1.customRenderedContent = $2.customRenderedContent;" + + "$1.deleted = $2.deleted || false;" + + "$1.editHistory = $2.editHistory || [];" + } + ] + }, + + { + // Updated message transformer(?) + // Module 819525 + find: "THREAD_STARTER_MESSAGE?null===", + replacement: [ + // { + // // DEBUG: Log the params of the target function to the patch below + // match: /function N\(e,t\){/, + // replace: "function L(e,t){console.log('pre-transform', e, t);" + // }, + { + // Pass through editHistory & deleted & original attachments to the "edited message" transformer + match: /interactionData:(\w)\.interactionData/, + replace: + "interactionData:$1.interactionData," + + "deleted:$1.deleted," + + "editHistory:$1.editHistory," + + "attachments:$1.attachments" + }, + + // { + // // DEBUG: Log the params of the target function to the patch below + // match: /function R\(e\){/, + // replace: "function R(e){console.log('after-edit-transform', arguments);" + // }, + { + // Construct new edited message and add editHistory & deleted (ref above) + // Pass in custom data to attachment parser to mark attachments deleted as well + match: /attachments:(\w{1,2})\((\w)\)/, + replace: + "attachments: $1((() => {" + + " let old = arguments[1]?.attachments;" + + " if (!old) return $2;" + + " let new_ = $2.attachments?.map(a => a.id) ?? [];" + + " let diff = old.filter(a => !new_.includes(a.id));" + + " old.forEach(a => a.deleted = true);" + + " $2.attachments = [...diff, ...$2.attachments];" + + " return $2;" + + "})())," + + "deleted: arguments[1]?.deleted," + + "editHistory: arguments[1]?.editHistory" + }, + { + // Preserve deleted attribute on attachments + match: /(\((\w)\){return null==\2\.attachments.+?)spoiler:/, + replace: + "$1deleted: arguments[0]?.deleted," + + "spoiler:" + } + ] + }, + + { + // Attachment renderer + // Module 96063 + find: "[\"className\",\"attachment\",\"inlineMedia\"]", + replacement: [ + { + match: /((\w)\.className,\w=\2\.attachment),/, + replace: "$1,deleted=$2.attachment?.deleted," + }, + { + match: /(hiddenSpoilers:\w,className:)/, + replace: "$1 (deleted ? 'messageLogger-deleted-attachment ' : '') +" + } + ] + }, + + { + // Base message component renderer + // Module 748241 + find: "Message must not be a thread starter message", + replacement: [ + { + // Write message.deleted to deleted var + match: /var (\w)=(\w).id,(?=\w=\w.message)/, + replace: "var $1=$2.id,deleted=$2.message.deleted," + }, + { + // Append messageLogger-deleted to classNames if deleted + match: /\)\("li",\{(.+?),className:/, + replace: ")(\"li\",{$1,className:(deleted ? \"messageLogger-deleted \" : \"\")+" + } + ] + }, + + { + // Message content renderer + // Module 43016 + find: "Messages.MESSAGE_EDITED,\")\"", + replacement: [ + { + // Render editHistory in the deepest div for message content + match: /(\)\("div",\{id:.+?children:\[)/, + replace: "$1 (arguments[0].message.editHistory.length > 0 ? arguments[0].message.editHistory.map(edit => Vencord.Plugins.plugins.MessageLogger.renderEdit(edit)) : null), " + } + ] + }, + + { + // ReferencedMessageStore + // Module 778667 + find: "displayName=\"ReferencedMessageStore\"", + replacement: [ + { + match: /MESSAGE_DELETE:function\((\w)\).+?},/, + replace: "MESSAGE_DELETE:function($1){}," + }, + { + match: /MESSAGE_DELETE_BULK:function\((\w)\).+?},/, + replace: "MESSAGE_DELETE_BULK:function($1){}," + } + ] + }, + + { + // Message "(edited)" timestamp component + // Module 23552 + find: "Messages.MESSAGE_EDITED_TIMESTAMP_A11Y_LABEL.format", + replacement: { + // Re-export the timestamp component under a findable name + match: /{(\w{1,2}:\(\)=>(\w{1,2}))}/, + replace: "{$1,messageLogger_TimestampComponent:()=>$2}" + } + }, + + { + // Message context base menu + // Module 600300 + find: "id:\"remove-reactions\"", + replacement: [ + { + // Remove the first section if message is deleted + match: /children:(\[""===.+?\])/, + replace: "children:arguments[0].message.deleted?[]:$1" + } + ] + } + + // { + // // MessageStore caching internals + // // Module 819525 + // find: "e.getOrCreate=function(t)", + // replacement: [ + // // { + // // // DEBUG: log getOrCreate return values from MessageStore caching internals + // // match: /getOrCreate=function(.+?)return/, + // // replace: "getOrCreate=function$1console.log('getOrCreate',n);return" + // // } + // ] + // } + ] +}); -- cgit