Plugin

MessageLogger

Temporarily logs deleted and edited messages.

Chat Utility
index.tsx
Download

Source

src/plugins/messageLogger/index.tsx
1/*
2 * FiveCord — a Discord client mod
3 * Copyright (c) 2025 FiveCord
4 * SPDX-License-Identifier: GPL-3.0-or-later
5 */
6
7import "./messageLogger.css";
8
9import { findGroupChildrenByChildId, NavContextMenuPatchCallback } from "@api/ContextMenu";
10import { updateMessage } from "@api/MessageUpdater";
11import { definePluginSettings } from "@api/Settings";
12import { disableStyle, enableStyle } from "@api/Styles";
13import ErrorBoundary from "@components/ErrorBoundary";
14import { Devs, SUPPORT_CATEGORY_ID, VENBOT_USER_ID } from "@utils/constants";
15import { getIntlMessage } from "@utils/discord";
16import { Logger } from "@utils/Logger";
17import { classes } from "@utils/misc";
18import definePlugin, { OptionType } from "@utils/types";
19import { Message } from "@vencord/discord-types";
20import { findCssClassesLazy } from "@webpack";
21import { ChannelStore, FluxDispatcher, Menu, MessageStore, Parser, SelectedChannelStore, Timestamp, UserStore, useStateFromStores } from "@webpack/common";
22
23import overlayStyle from "./deleteStyleOverlay.css?managed";
24import textStyle from "./deleteStyleText.css?managed";
25import { openHistoryModal } from "./HistoryModal";
26
27interface MLMessage extends Message {
28 deleted?: boolean;
29 editHistory?: { timestamp: Date; content: string; }[];
30 firstEditTimestamp?: Date;
31}
32
33const MessageClasses = findCssClassesLazy("edited", "communicationDisabled", "isSystemMessage");
34
35const settings = definePluginSettings({
36 deleteStyle: {
37 type: OptionType.SELECT,
38 description: "The style of deleted messages",
39 default: "text",
40 options: [
41 { label: "Red text", value: "text", default: true },
42 { label: "Red overlay", value: "overlay" }
43 ],
44 onChange: () => addDeleteStyle()
45 },
46 logDeletes: {
47 type: OptionType.BOOLEAN,
48 description: "Whether to log deleted messages",
49 default: true,
50 },
51 collapseDeleted: {
52 type: OptionType.BOOLEAN,
53 description: "Whether to collapse deleted messages, similar to blocked messages",
54 default: false,
55 restartNeeded: true,
56 },
57 logEdits: {
58 type: OptionType.BOOLEAN,
59 description: "Whether to log edited messages",
60 default: true,
61 },
62 inlineEdits: {
63 type: OptionType.BOOLEAN,
64 description: "Whether to display edit history as part of message content",
65 default: true
66 },
67 ignoreBots: {
68 type: OptionType.BOOLEAN,
69 description: "Whether to ignore messages by bots",
70 default: false
71 },
72 ignoreSelf: {
73 type: OptionType.BOOLEAN,
74 description: "Whether to ignore messages by yourself",
75 default: false
76 },
77 ignoreUsers: {
78 type: OptionType.STRING,
79 description: "Comma-separated list of user IDs to ignore",
80 default: "",
81 multiline: true
82 },
83 ignoreChannels: {
84 type: OptionType.STRING,
85 description: "Comma-separated list of channel IDs to ignore",
86 default: "",
87 multiline: true
88 },
89 ignoreGuilds: {
90 type: OptionType.STRING,
91 description: "Comma-separated list of guild IDs to ignore",
92 default: "",
93 multiline: true
94 },
95});
96
97function addDeleteStyle() {
98 if (settings.store.deleteStyle === "text") {
99 enableStyle(textStyle);
100 disableStyle(overlayStyle);
101 } else {
102 disableStyle(textStyle);
103 enableStyle(overlayStyle);
104 }
105}
106
107const REMOVE_HISTORY_ID = "ml-remove-history";
108const TOGGLE_DELETE_STYLE_ID = "ml-toggle-style";
109const patchMessageContextMenu: NavContextMenuPatchCallback = (children, props) => {
110 const { message } = props;
111 const { deleted, editHistory, id, channel_id } = message;
112
113 if (!deleted && !editHistory?.length) return;
114
115 toggle: {
116 if (!deleted) break toggle;
117
118 const domElement = document.getElementById(`chat-messages-${channel_id}-${id}`);
119 if (!domElement) break toggle;
120
121 children.push((
122 <Menu.MenuItem
123 id={TOGGLE_DELETE_STYLE_ID}
124 key={TOGGLE_DELETE_STYLE_ID}
125 label="Toggle Deleted Highlight"
126 action={() => domElement.classList.toggle("messagelogger-deleted")}
127 />
128 ));
129 }
130
131 children.push((
132 <Menu.MenuItem
133 id={REMOVE_HISTORY_ID}
134 key={REMOVE_HISTORY_ID}
135 label="Remove Message History"
136 color="danger"
137 action={() => {
138 if (deleted) {
139 FluxDispatcher.dispatch({
140 type: "MESSAGE_DELETE",
141 channelId: channel_id,
142 id,
143 mlDeleted: true
144 });
145 } else {
146 updateMessage(channel_id, id, { editHistory: [] });
147 }
148 }}
149 />
150 ));
151};
152
153const patchChannelContextMenu: NavContextMenuPatchCallback = (children, { channel }) => {
154 const messages = MessageStore.getMessages(channel?.id) as MLMessage[];
155 if (!messages?.some(msg => msg.deleted || msg.editHistory?.length)) return;
156
157 const group = findGroupChildrenByChildId("mark-channel-read", children) ?? children;
158 group.push(
159 <Menu.MenuItem
160 id="vc-ml-clear-channel"
161 label="Clear Message Log"
162 color="danger"
163 action={() => {
164 messages.forEach(msg => {
165 if (msg.deleted)
166 FluxDispatcher.dispatch({
167 type: "MESSAGE_DELETE",
168 channelId: channel.id,
169 id: msg.id,
170 mlDeleted: true
171 });
172 else
173 updateMessage(channel.id, msg.id, {
174 editHistory: []
175 });
176 });
177 }}
178 />
179 );
180};
181
182export function parseEditContent(content: string, message: Message) {
183 return Parser.parse(content, true, {
184 channelId: message.channel_id,
185 messageId: message.id,
186 allowLinks: true,
187 allowHeading: true,
188 allowList: true,
189 allowEmojiLinks: true,
190 viewingChannelId: SelectedChannelStore.getChannelId(),
191 });
192}
193
194export default definePlugin({
195 name: "MessageLogger",
196 description: "Temporarily logs deleted and edited messages.",
197 tags: ["Chat", "Utility"],
198 authors: [Devs.rushii, Devs.Ven, Devs.AutumnVN, Devs.Nickyux, Devs.Kyuuhachi],
199 dependencies: ["MessageUpdaterAPI"],
200 settings,
201 contextMenus: {
202 "message": patchMessageContextMenu,
203 "channel-context": patchChannelContextMenu,
204 "thread-context": patchChannelContextMenu,
205 "user-context": patchChannelContextMenu,
206 "gdm-context": patchChannelContextMenu
207 },
208
209 start() {
210 addDeleteStyle();
211 },
212
213 renderEdits: ErrorBoundary.wrap(({ message: { id: messageId, channel_id: channelId } }: { message: Message; }) => {
214 const message = useStateFromStores(
215 [MessageStore],
216 () => MessageStore.getMessage(channelId, messageId) as MLMessage,
217 null,
218 (oldMsg, newMsg) => oldMsg?.editHistory === newMsg?.editHistory
219 );
220
221 return settings.store.inlineEdits && (
222 <>
223 {message.editHistory?.map((edit, idx) => (
224 <div key={idx} className="messagelogger-edited">
225 {parseEditContent(edit.content, message)}
226 <Timestamp
227 timestamp={edit.timestamp}
228 isEdited={true}
229 isInline={false}
230 >
231 <span className={MessageClasses.edited}>{" "}({getIntlMessage("MESSAGE_EDITED")})</span>
232 </Timestamp>
233 </div>
234 ))}
235 </>
236 );
237 }, { noop: true }),
238
239 makeEdit(newMessage: any, oldMessage: any): any {
240 return {
241 timestamp: new Date(newMessage.edited_timestamp),
242 content: oldMessage.content
243 };
244 },
245
246 handleDelete(cache: any, data: { ids: string[], id: string; mlDeleted?: boolean; }, isBulk: boolean) {
247 try {
248 if (cache == null || (!isBulk && !cache.has(data.id))) return cache;
249
250 const mutate = (id: string) => {
251 const msg = cache.get(id);
252 if (!msg) return;
253
254 const EPHEMERAL = 64;
255 const shouldIgnore = data.mlDeleted ||
256 (msg.flags & EPHEMERAL) === EPHEMERAL ||
257 this.shouldIgnore(msg);
258
259 if (shouldIgnore) {
260 cache = cache.remove(id);
261 } else {
262 cache = cache.update(id, m => m
263 .set("deleted", true)
264 .set("attachments", m.attachments.map(a => (a.deleted = true, a))));
265 }
266 };
267
268 if (isBulk) {
269 data.ids.forEach(mutate);
270 } else {
271 mutate(data.id);
272 }
273 } catch (e) {
274 new Logger("MessageLogger").error("Error during handleDelete", e);
275 }
276 return cache;
277 },
278
279 shouldIgnore(message: any, isEdit = false) {
280 try {
281 const { ignoreBots, ignoreSelf, ignoreUsers, ignoreChannels, ignoreGuilds, logEdits, logDeletes } = settings.store;
282 const myId = UserStore.getCurrentUser().id;
283
284 return ignoreBots && message.author?.bot ||
285 ignoreSelf && message.author?.id === myId ||
286 ignoreUsers.includes(message.author?.id) ||
287 ignoreChannels.includes(message.channel_id) ||
288 ignoreChannels.includes(ChannelStore.getChannel(message.channel_id)?.parent_id) ||
289 (isEdit ? !logEdits : !logDeletes) ||
290 ignoreGuilds.includes(ChannelStore.getChannel(message.channel_id)?.guild_id) ||
291 // Ignore Venbot in the support channels
292 (message.author?.id === VENBOT_USER_ID && ChannelStore.getChannel(message.channel_id)?.parent_id === SUPPORT_CATEGORY_ID);
293 } catch (e) {
294 return false;
295 }
296 },
297
298 EditMarker({ message, className, children, ...props }: any) {
299 return (
300 <span
301 {...props}
302 className={classes("messagelogger-edit-marker", className)}
303 onClick={() => openHistoryModal(message)}
304 role="button"
305 >
306 {children}
307 </span>
308 );
309 },
310
311 // DELETED_MESSAGE_COUNT: getMessage("{count, plural, =0 {No deleted messages} one {{count} deleted message} other {{count} deleted messages}}")
312 // TODO: Find a better way to generate intl messages
313 DELETED_MESSAGE_COUNT: () => ({
314 ast: [[
315 6,
316 "count",
317 {
318 "=0": ["No deleted messages"],
319 one: [
320 [
321 1,
322 "count"
323 ],
324 " deleted message"
325 ],
326 other: [
327 [
328 1,
329 "count"
330 ],
331 " deleted messages"
332 ]
333 },
334 0,
335 "cardinal"
336 ]]
337 }),
338
339 patches: [
340 {
341 find: &#039;"MessageStore"&#039;,
342 replacement: [
343 {
344 // Add deleted=true to all target messages in the MESSAGE_DELETE event
345 match: /(?<=MESSAGE_DELETE:function\((\i)\)\{)(?=let.{0,100}(\i\.\i)\.getOrCreate)/,
346 replace: `
347 let cache = $2.getOrCreate($1.channelId);
348 cache = $self.handleDelete(cache, $1, false);
349 $2.commit(cache);
350 return;
351 `
352 },
353 {
354 // Add deleted=true to all target messages in the MESSAGE_DELETE_BULK event
355 match: /(?<=MESSAGE_DELETE_BULK:function\((\i)\){)(?=let.{0,100}(\i\.\i)\.getOrCreate)/,
356 replace: `
357 let cache = $2.getOrCreate($1.channelId);
358 cache = $self.handleDelete(cache, $1, true);
359 $2.commit(cache);
360 return;
361 `
362 },
363 {
364 // Add current cached content + new edit time to cached message's editHistory
365 match: /(MESSAGE_UPDATE:function\((\i)\).+?)\.update\((\i)/,
366 replace: `
367 $1
368 .update($3, m =>
369 (($2.message.flags & 64) === 64 || $self.shouldIgnore($2.message, true)) ? m :
370 $2.message.edited_timestamp && $2.message.content !== m.content ?
371 m.set(&#039;editHistory&#039;,[...(m.editHistory || []), $self.makeEdit($2.message, m)]) :
372 m
373 )
374 .update($3
375 `
376 },
377 {
378 // fix up key (edit last message) attempting to edit a deleted message
379 match: /(?<=getLastEditableMessage\(\i\)\{.{0,200}\.find\((\i)=>)/,
380 replace: "!$1.deleted &&"
381 }
382 ]
383 },
384
385 {
386 // Message domain model
387 find: "}addReaction(",
388 replacement: [
389 {
390 match: /this\.customRenderedContent=(\i)\.customRenderedContent,/,
391 replace: "this.customRenderedContent = $1.customRenderedContent," +
392 "this.deleted = $1.deleted || false," +
393 "this.editHistory = $1.editHistory || []," +
394 "this.firstEditTimestamp = $1.firstEditTimestamp || this.editedTimestamp || this.timestamp,"
395 }
396 ]
397 },
398
399 {
400 // Updated message transformer(?)
401 find: ".PREMIUM_REFERRAL&&(",
402 replacement: [
403 {
404 // Pass through editHistory & deleted & original attachments to the "edited message" transformer
405 match: /(?<=null!=\i\.edited_timestamp\)return )\i\(\i,\{reactions:(\i)\.reactions.{0,50}\}\)/,
406 replace:
407 "Object.assign($&,{ deleted:$1.deleted, editHistory:$1.editHistory, firstEditTimestamp:$1.firstEditTimestamp })"
408 },
409
410 {
411 // Construct new edited message and add editHistory & deleted (ref above)
412 // Pass in custom data to attachment parser to mark attachments deleted as well
413 match: /attachments:(\i)\((\i)\)/,
414 replace:
415 "attachments: $1((() => {" +
416 " if ($self.shouldIgnore($2)) return $2;" +
417 " let old = arguments[1]?.attachments;" +
418 " if (!old) return $2;" +
419 " let new_ = $2.attachments?.map(a => a.id) ?? [];" +
420 " let diff = old.filter(a => !new_.includes(a.id));" +
421 " old.forEach(a => a.deleted = true);" +
422 " $2.attachments = [...diff, ...$2.attachments];" +
423 " return $2;" +
424 "})())," +
425 "deleted: arguments[1]?.deleted," +
426 "editHistory: arguments[1]?.editHistory," +
427 "firstEditTimestamp: new Date(arguments[1]?.firstEditTimestamp ?? $2.editedTimestamp ?? $2.timestamp)"
428 },
429 {
430 // Preserve deleted attribute on attachments
431 match: /(\((\i)\){return null==\2\.attachments.+?)spoiler:/,
432 replace:
433 "$1deleted: arguments[0]?.deleted," +
434 "spoiler:"
435 }
436 ]
437 },
438
439 {
440 // Attachment renderer
441 find: "#{intl::REMOVE_ATTACHMENT_TOOLTIP_TEXT}",
442 replacement: [
443 {
444 match: /\.SPOILER,(?=\[\i\.\i\]:)/,
445 replace: &#039;$&"messagelogger-deleted-attachment":arguments[0]?.item?.originalItem?.deleted,&#039;
446 }
447 ]
448 },
449
450 {
451 // Base message component renderer
452 find: "Message must not be a thread starter message",
453 replacement: [
454 {
455 // Append messagelogger-deleted to classNames if deleted
456 match: /\)\("li",\{(.+?),className:/,
457 replace: ")(\"li\",{$1,className:(arguments[0].message.deleted ? \"messagelogger-deleted \" : \"\")+"
458 }
459 ]
460 },
461
462 {
463 // Message content renderer
464 find: ".SEND_FAILED,",
465 replacement: {
466 // Render editHistory behind the message content
467 match: /\]:\i.isUnsupported.{0,20}?,children:\[/,
468 replace: "$&arguments[0]?.message?.editHistory?.length>0&&$self.renderEdits(arguments[0]),"
469 }
470 },
471
472 {
473 find: "#{intl::MESSAGE_EDITED}",
474 replacement: {
475 // Make edit marker clickable
476 match: /(isInline:!1,children:.{0,50}?)"span",\{(?=className:)/,
477 replace: "$1$self.EditMarker,{message:arguments[0].message,"
478 }
479 },
480
481 {
482 // ReferencedMessageStore
483 find: &#039;"ReferencedMessageStore"&#039;,
484 replacement: [
485 {
486 match: /(?<=MESSAGE_DELETE:function\(\i\)\{)/,
487 replace: "return;"
488 },
489 {
490 match: /(?<=MESSAGE_DELETE_BULK:function\(\i\)\{)/,
491 replace: "return;"
492 }
493 ]
494 },
495
496 {
497 // Message context base menu
498 find: ".MESSAGE,commandTargetId:",
499 replacement: [
500 {
501 // Remove the first section if message is deleted
502 match: /children:(\[""===.+?\])/,
503 replace: "children:arguments[0].message.deleted?[]:$1"
504 }
505 ]
506 },
507 {
508 // Message grouping
509 find: "NON_COLLAPSIBLE.has(",
510 replacement: {
511 match: /if\((\i)\.blocked\)return \i\.\i\.MESSAGE_GROUP_BLOCKED;/,
512 replace: &#039;$&else if($1.deleted) return"MESSAGE_GROUP_DELETED";&#039;,
513 },
514 predicate: () => settings.store.collapseDeleted
515 },
516 {
517 // Message group rendering
518 find: "#{intl::NEW_MESSAGES_ESTIMATED_WITH_DATE}",
519 replacement: [
520 {
521 match: /(\i).type===\i\.\i\.MESSAGE_GROUP_BLOCKED\|\|/,
522 replace: &#039;$&$1.type==="MESSAGE_GROUP_DELETED"||&#039;,
523 },
524 {
525 match: /(\i).type===\i\.\i\.MESSAGE_GROUP_BLOCKED\?(\i)=.*?:/,
526 replace: &#039;$&$1.type==="MESSAGE_GROUP_DELETED"?$2=$self.DELETED_MESSAGE_COUNT:&#039;,
527 },
528 ],
529 predicate: () => settings.store.collapseDeleted
530 }
531 ]
532});
533