Plugin

MessageLogger

Temporarily logs deleted and edited messages.

Chat Utility
index.tsx
Download

Source

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