diff --git a/src/plugins/messageLogger/HistoryModal.tsx b/src/plugins/messageLogger/HistoryModal.tsx index 5c49d72a7..c0b4d3dea 100644 --- a/src/plugins/messageLogger/HistoryModal.tsx +++ b/src/plugins/messageLogger/HistoryModal.tsx @@ -84,7 +84,7 @@ export function HistoryModal({ modalProps, message }: { modalProps: ModalProps;
- {parseEditContent(contents[currentTab], message)} + {parseEditContent(contents[currentTab], message, currentTab === contents.length - 1 ? undefined : contents[contents.length - 1])}
diff --git a/src/plugins/messageLogger/deleteStyleOverlay.css b/src/plugins/messageLogger/deleteStyleOverlay.css index 59e4ac666..9fca958b2 100644 --- a/src/plugins/messageLogger/deleteStyleOverlay.css +++ b/src/plugins/messageLogger/deleteStyleOverlay.css @@ -1,3 +1,3 @@ .messagelogger-deleted { - background-color: hsla(var(--red-430-hsl, 0 85% 61%) / 15%) !important; -} + background-color: hsl(3deg 86% 68%) !important; +} \ No newline at end of file diff --git a/src/plugins/messageLogger/diffUtils.ts b/src/plugins/messageLogger/diffUtils.ts new file mode 100644 index 000000000..3713058a3 --- /dev/null +++ b/src/plugins/messageLogger/diffUtils.ts @@ -0,0 +1,115 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +export interface DiffPart { + type: "added" | "removed" | "unchanged"; + text: string; +} + +export function createWordDiff(oldText: string, newText: string): DiffPart[] { + // suffix shit, if oldText is shorter than newText and newText starts with oldText + if (oldText.length < newText.length && newText.startsWith(oldText)) { + const added = newText.slice(oldText.length); + const parts: DiffPart[] = []; + if (oldText.length > 0) { + parts.push({ type: "unchanged", text: oldText }); + } + parts.push({ type: "added", text: added }); + return parts; + } + + // same as above + if (oldText.length < newText.length && newText.endsWith(oldText)) { + const added = newText.slice(0, newText.length - oldText.length); + const parts: DiffPart[] = []; + parts.push({ type: "added", text: added }); + if (oldText.length > 0) { + parts.push({ type: "unchanged", text: oldText }); + } + return parts; + } + + // Handle simple prefix removal: "watch this" -> "watch" + if (newText.length < oldText.length && oldText.startsWith(newText)) { + const removed = oldText.slice(newText.length); + const parts: DiffPart[] = []; + if (newText.length > 0) { + parts.push({ type: "unchanged", text: newText }); + } + parts.push({ type: "removed", text: removed }); + return parts; + } + + // Handle simple suffix removal: "prefix watch" -> "watch" + if (newText.length < oldText.length && oldText.endsWith(newText)) { + const removed = oldText.slice(0, oldText.length - newText.length); + const parts: DiffPart[] = []; + parts.push({ type: "removed", text: removed }); + if (newText.length > 0) { + parts.push({ type: "unchanged", text: newText }); + } + return parts; + } + + // For complex cases, fall back to LCS algorithm + const oldChars = oldText.split(""); + const newChars = newText.split(""); + + const result: DiffPart[] = []; + const dp: number[][] = []; + + for (let i = 0; i <= oldChars.length; i++) { + dp[i] = []; + for (let j = 0; j <= newChars.length; j++) { + dp[i][j] = 0; + } + } + + for (let i = 1; i <= oldChars.length; i++) { + for (let j = 1; j <= newChars.length; j++) { + if (oldChars[i - 1] === newChars[j - 1]) { + dp[i][j] = dp[i - 1][j - 1] + 1; + } else { + dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]); + } + } + } + + let i = oldChars.length; + let j = newChars.length; + + const diffParts: DiffPart[] = []; + + while (i > 0 || j > 0) { + if (i > 0 && j > 0 && oldChars[i - 1] === newChars[j - 1]) { + diffParts.unshift({ type: "unchanged", text: oldChars[i - 1] }); + i--; + j--; + } else if (j > 0 && (i === 0 || dp[i][j - 1] > dp[i - 1][j])) { + diffParts.unshift({ type: "added", text: newChars[j - 1] }); + j--; + } else if (i > 0) { + diffParts.unshift({ type: "removed", text: oldChars[i - 1] }); + i--; + } + } + + const groupedParts: DiffPart[] = []; + for (const part of diffParts) { + const lastPart = groupedParts[groupedParts.length - 1]; + if (lastPart && lastPart.type === part.type) { + lastPart.text += part.text; + } else { + groupedParts.push(part); + } + } + + return groupedParts; +} + +export function createMessageDiff(previousContent: string, currentContent: string): DiffPart[] { + return createWordDiff(previousContent, currentContent); +} diff --git a/src/plugins/messageLogger/index.tsx b/src/plugins/messageLogger/index.tsx index 7f2de1e67..57ab3f10b 100644 --- a/src/plugins/messageLogger/index.tsx +++ b/src/plugins/messageLogger/index.tsx @@ -21,20 +21,25 @@ import { classes } from "@utils/misc"; import definePlugin, { OptionType } from "@utils/types"; import { Message } from "@vencord/discord-types"; import { findByPropsLazy } from "@webpack"; -import { ChannelStore, FluxDispatcher, Menu, MessageStore, Parser, SelectedChannelStore, Timestamp, UserStore, useStateFromStores } from "@webpack/common"; +import { ChannelStore, FluxDispatcher, Menu, MessageStore, Parser, React, SelectedChannelStore, Timestamp, UserStore, useStateFromStores } from "@webpack/common"; import overlayStyle from "./deleteStyleOverlay.css?managed"; import textStyle from "./deleteStyleText.css?managed"; +import { createMessageDiff, DiffPart } from "./diffUtils"; import { openHistoryModal } from "./HistoryModal"; interface MLMessage extends Message { deleted?: boolean; editHistory?: { timestamp: Date; content: string; }[]; firstEditTimestamp?: Date; + diffViewDisabled?: boolean; } const styles = findByPropsLazy("edited", "communicationDisabled", "isSystemMessage"); +// track messages where the user disabled diffs for this session +const disabledDiffMessages = new Set(); + function addDeleteStyle() { if (Settings.plugins.MessageLogger.deleteStyle === "text") { enableStyle(textStyle); @@ -47,6 +52,7 @@ function addDeleteStyle() { const REMOVE_HISTORY_ID = "ml-remove-history"; const TOGGLE_DELETE_STYLE_ID = "ml-toggle-style"; +const TOGGLE_DIFF_VIEW_ID = "ml-toggle-diff"; const patchMessageContextMenu: NavContextMenuPatchCallback = ( children, props, @@ -74,6 +80,28 @@ const patchMessageContextMenu: NavContextMenuPatchCallback = ( ); } + // toggle per-message diff rendering when the message has an edit history + if (editHistory?.length) { + const isDisabled = disabledDiffMessages.has(id); + children.push( + { + if (isDisabled) disabledDiffMessages.delete(id); + else disabledDiffMessages.add(id); + // also toggle a CSS class on the message element for an immediate visual effect (basically shows things work lol) + const domElement = document.getElementById(`chat-messages-${channel_id}-${id}`); + domElement?.classList.toggle("messagelogger-diff-disabled", disabledDiffMessages.has(id)); + // force a re-render without mutating message fields + updateMessage(channel_id, id); + }} + />, + ); + } + let label; if (!Vencord.Plugins.isPluginEnabled("MessageLoggerEnhanced")) { @@ -137,7 +165,32 @@ const patchChannelContextMenu: NavContextMenuPatchCallback = ( ); }; -export function parseEditContent(content: string, message: Message) { +function renderDiffParts(diffParts: DiffPart[]) { + return diffParts.map((part, index) => { + if (part.type === "unchanged") { + return React.createElement("span", { key: index }, part.text); + } else if (part.type === "added") { + return React.createElement("span", { + key: index, + className: "messagelogger-diff-added" + }, part.text); + } else if (part.type === "removed") { + return React.createElement("span", { + key: index, + className: "messagelogger-diff-removed" + }, part.text); + } + return React.createElement("span", { key: index }, part.text); + }); +} + +export function parseEditContent(content: string, message: Message, previousContent?: string) { + const perMessageDiffEnabled = !disabledDiffMessages.has(message.id); + if (previousContent && content !== previousContent && Settings.plugins.MessageLogger.showEditDiffs && perMessageDiffEnabled) { + const diffParts = createMessageDiff(content, previousContent); + return renderDiffParts(diffParts); + } + return Parser.parse(content, true, { channelId: message.channel_id, messageId: message.id, @@ -177,24 +230,33 @@ export default definePlugin({ [MessageStore], () => MessageStore.getMessage(channelId, messageId) as MLMessage, null, - (oldMsg, newMsg) => oldMsg?.editHistory === newMsg?.editHistory, + (oldMsg, newMsg) => + oldMsg?.editHistory === newMsg?.editHistory && + oldMsg?.diffViewDisabled === newMsg?.diffViewDisabled && + oldMsg === newMsg, ); return Settings.plugins.MessageLogger.inlineEdits && ( - <> - {message.editHistory?.map((edit, idx) => ( -
- {parseEditContent(edit.content, message)} - - {" "}({getIntlMessage("MESSAGE_EDITED")}) - -
- ))} - + + {message.editHistory?.map((edit, idx) => { + const nextContent = idx === (message.editHistory?.length ?? 0) - 1 + ? message.content + : message.editHistory?.[idx + 1]?.content; + + return ( +
+ {parseEditContent(edit.content, message, nextContent)} + + {" "}({getIntlMessage("MESSAGE_EDITED")}) + +
+ ); + })} +
); }, { noop: true }), @@ -262,6 +324,11 @@ export default definePlugin({ description: "Comma-separated list of guild IDs to ignore", default: "", }, + showEditDiffs: { + type: OptionType.BOOLEAN, + description: "Show visual differences between edited message versions", + default: false, + }, }, handleDelete( @@ -327,7 +394,7 @@ export default definePlugin({ ) || (isEdit ? !logEdits : !logDeletes) || ignoreGuilds.includes(ChannelStore.getChannel(message.channel_id)?.guild_id) || - // Ignore Venbot in the support channels + // Ignore Venbot in the support channels (love you venbot!!!) (message.author?.id === VENBOT_USER_ID && ChannelStore.getChannel(message.channel_id)?.parent_id === VC_SUPPORT_CATEGORY_ID)); }, @@ -427,7 +494,8 @@ export default definePlugin({ "this.customRenderedContent = $1.customRenderedContent," + "this.deleted = $1.deleted || false," + "this.editHistory = $1.editHistory || []," + - "this.firstEditTimestamp = $1.firstEditTimestamp || this.editedTimestamp || this.timestamp,", + "this.firstEditTimestamp = $1.firstEditTimestamp || this.editedTimestamp || this.timestamp," + + "this.diffViewDisabled = $1.diffViewDisabled || false,", }, ], }, @@ -441,7 +509,7 @@ export default definePlugin({ match: /(?<=null!=\i\.edited_timestamp\)return )\i\(\i,\{reactions:(\i)\.reactions.{0,50}\}\)/, replace: - "Object.assign($&,{ deleted:$1.deleted, editHistory:$1.editHistory, firstEditTimestamp:$1.firstEditTimestamp })", + "Object.assign($&,{ deleted:$1.deleted, editHistory:$1.editHistory, firstEditTimestamp:$1.firstEditTimestamp, diffViewDisabled:$1.diffViewDisabled })", }, { @@ -461,7 +529,8 @@ export default definePlugin({ "})())," + "deleted: arguments[1]?.deleted," + "editHistory: arguments[1]?.editHistory," + - "firstEditTimestamp: new Date(arguments[1]?.firstEditTimestamp ?? $2.editedTimestamp ?? $2.timestamp)", + "firstEditTimestamp: new Date(arguments[1]?.firstEditTimestamp ?? $2.editedTimestamp ?? $2.timestamp)," + + "diffViewDisabled: arguments[1]?.diffViewDisabled", }, { // Preserve deleted attribute on attachments diff --git a/src/plugins/messageLogger/messageLogger.css b/src/plugins/messageLogger/messageLogger.css index a76e98888..5f4e5bb13 100644 --- a/src/plugins/messageLogger/messageLogger.css +++ b/src/plugins/messageLogger/messageLogger.css @@ -2,15 +2,12 @@ display: none; } -.messagelogger-deleted -:is( - .messagelogger-deleted-attachment, +.messagelogger-deleted:is(.messagelogger-deleted-attachment, .emoji, [data-type="sticker"], [class*="embedIframe"], [class*="embedSpotify"], - [class*="imageContainer"] -) { + [class*="imageContainer"]) { filter: grayscale(1) !important; transition: 150ms filter ease-in-out; @@ -48,3 +45,17 @@ flex-wrap: wrap; gap: 16px; } + +.messagelogger-diff-added { + color: #85E89D; +} + +.messagelogger-diff-removed { + color: #F47068; +} + +/* when diffs are disabled for a specific message, remove diff colors */ +.messagelogger-diff-disabled .messagelogger-diff-added, +.messagelogger-diff-disabled .messagelogger-diff-removed { + color: inherit; +}