Plugin
MessageLogger
Temporarily logs deleted and edited messages.
1
/*2
* FiveCord — a Discord client mod3
* Copyright (c) 2025 FiveCord4
* SPDX-License-Identifier: GPL-3.0-or-later5
*/6
7
import "./messageLogger.css";8
9
import { findGroupChildrenByChildId, NavContextMenuPatchCallback } from "@api/ContextMenu";10
import { updateMessage } from "@api/MessageUpdater";11
import { definePluginSettings } from "@api/Settings";12
import { disableStyle, enableStyle } from "@api/Styles";13
import ErrorBoundary from "@components/ErrorBoundary";14
import { Devs, SUPPORT_CATEGORY_ID, VENBOT_USER_ID } from "@utils/constants";15
import { getIntlMessage } from "@utils/discord";16
import { Logger } from "@utils/Logger";17
import { classes } from "@utils/misc";18
import definePlugin, { OptionType } from "@utils/types";19
import { Message } from "@vencord/discord-types";20
import { findCssClassesLazy } from "@webpack";21
import { ChannelStore, FluxDispatcher, Menu, MessageStore, Parser, SelectedChannelStore, Timestamp, UserStore, useStateFromStores } from "@webpack/common";22
23
import overlayStyle from "./deleteStyleOverlay.css?managed";24
import textStyle from "./deleteStyleText.css?managed";25
import { openHistoryModal } from "./HistoryModal";26
27
interface MLMessage extends Message {28
deleted?: boolean;29
editHistory?: { timestamp: Date; content: string; }[];30
firstEditTimestamp?: Date;31
}32
33
const MessageClasses = findCssClassesLazy("edited", "communicationDisabled", "isSystemMessage");34
35
const 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: true66
},67
ignoreBots: {68
type: OptionType.BOOLEAN,69
description: "Whether to ignore messages by bots",70
default: false71
},72
ignoreSelf: {73
type: OptionType.BOOLEAN,74
description: "Whether to ignore messages by yourself",75
default: false76
},77
ignoreUsers: {78
type: OptionType.STRING,79
description: "Comma-separated list of user IDs to ignore",80
default: "",81
multiline: true82
},83
ignoreChannels: {84
type: OptionType.STRING,85
description: "Comma-separated list of channel IDs to ignore",86
default: "",87
multiline: true88
},89
ignoreGuilds: {90
type: OptionType.STRING,91
description: "Comma-separated list of guild IDs to ignore",92
default: "",93
multiline: true94
},95
});96
97
function addDeleteStyle() {98
if (settings.store.deleteStyle === "text") {99
enableStyle(textStyle);100
disableStyle(overlayStyle);101
} else {102
disableStyle(textStyle);103
enableStyle(overlayStyle);104
}105
}106
107
const REMOVE_HISTORY_ID = "ml-remove-history";108
const TOGGLE_DELETE_STYLE_ID = "ml-toggle-style";109
const 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.MenuItem123
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.MenuItem133
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: true144
});145
} else {146
updateMessage(channel_id, id, { editHistory: [] });147
}148
}}149
/>150
));151
};152
153
const 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.MenuItem160
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: true171
});172
else173
updateMessage(channel.id, msg.id, {174
editHistory: []175
});176
});177
}}178
/>179
);180
};181
182
export 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
194
export 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": patchChannelContextMenu207
},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?.editHistory219
);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
<Timestamp227
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.content243
};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 => m263
.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 channels292
(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
<span301
{...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 messages313
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 event345
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 event355
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 editHistory365
match: /(MESSAGE_UPDATE:function\((\i)\).+?)\.update\((\i)/,366
replace: `367
$1368
.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;editHistory039;,[...(m.editHistory || []), $self.makeEdit($2.message, m)]) :372
m373
)374
.update($3375
`376
},377
{378
// fix up key (edit last message) attempting to edit a deleted message379
match: /(?<=getLastEditableMessage\(\i\)\{.{0,200}\.find\((\i)=>)/,380
replace: "!$1.deleted &&"381
}382
]383
},384
385
{386
// Message domain model387
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" transformer405
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 well413
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 attachments431
match: /(\((\i)\){return null==\2\.attachments.+?)spoiler:/,432
replace:433
"$1deleted: arguments[0]?.deleted," +434
"spoiler:"435
}436
]437
},438
439
{440
// Attachment renderer441
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 renderer452
find: "Message must not be a thread starter message",453
replacement: [454
{455
// Append messagelogger-deleted to classNames if deleted456
match: /\)\("li",\{(.+?),className:/,457
replace: ")(\"li\",{$1,className:(arguments[0].message.deleted ? \"messagelogger-deleted \" : \"\")+"458
}459
]460
},461
462
{463
// Message content renderer464
find: ".SEND_FAILED,",465
replacement: {466
// Render editHistory behind the message content467
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 clickable476
match: /(isInline:!1,children:.{0,50}?)"span",\{(?=className:)/,477
replace: "$1$self.EditMarker,{message:arguments[0].message,"478
}479
},480
481
{482
// ReferencedMessageStore483
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 menu498
find: ".MESSAGE,commandTargetId:",499
replacement: [500
{501
// Remove the first section if message is deleted502
match: /children:(\[""===.+?\])/,503
replace: "children:arguments[0].message.deleted?[]:$1"504
}505
]506
},507
{508
// Message grouping509
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.collapseDeleted515
},516
{517
// Message group rendering518
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.collapseDeleted530
}531
]532
});533