Plugin
MessageLogger
Temporarily logs deleted and edited messages.
1
import "./messageLogger.css";2
3
import { findGroupChildrenByChildId, NavContextMenuPatchCallback } from "@api/ContextMenu";4
import { updateMessage } from "@api/MessageUpdater";5
import { definePluginSettings } from "@api/Settings";6
import { disableStyle, enableStyle } from "@api/Styles";7
import ErrorBoundary from "@components/ErrorBoundary";8
import { Devs, SUPPORT_CATEGORY_ID, VENBOT_USER_ID } from "@utils/constants";9
import { getIntlMessage } from "@utils/discord";10
import { Logger } from "@utils/Logger";11
import { classes } from "@utils/misc";12
import definePlugin, { OptionType } from "@utils/types";13
import { Message } from "@vencord/discord-types";14
import { findCssClassesLazy } from "@webpack";15
import { ChannelStore, FluxDispatcher, Menu, MessageStore, Parser, SelectedChannelStore, Timestamp, UserStore, useStateFromStores } from "@webpack/common";16
17
import overlayStyle from "./deleteStyleOverlay.css?managed";18
import textStyle from "./deleteStyleText.css?managed";19
import { openHistoryModal } from "./HistoryModal";20
21
interface MLMessage extends Message {22
deleted?: boolean;23
editHistory?: { timestamp: Date; content: string; }[];24
firstEditTimestamp?: Date;25
}26
27
const MessageClasses = findCssClassesLazy("edited", "communicationDisabled", "isSystemMessage");28
29
const 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: true60
},61
ignoreBots: {62
type: OptionType.BOOLEAN,63
description: "Whether to ignore messages by bots",64
default: false65
},66
ignoreSelf: {67
type: OptionType.BOOLEAN,68
description: "Whether to ignore messages by yourself",69
default: false70
},71
ignoreUsers: {72
type: OptionType.STRING,73
description: "Comma-separated list of user IDs to ignore",74
default: "",75
multiline: true76
},77
ignoreChannels: {78
type: OptionType.STRING,79
description: "Comma-separated list of channel IDs to ignore",80
default: "",81
multiline: true82
},83
ignoreGuilds: {84
type: OptionType.STRING,85
description: "Comma-separated list of guild IDs to ignore",86
default: "",87
multiline: true88
},89
});90
91
function addDeleteStyle() {92
if (settings.store.deleteStyle === "text") {93
enableStyle(textStyle);94
disableStyle(overlayStyle);95
} else {96
disableStyle(textStyle);97
enableStyle(overlayStyle);98
}99
}100
101
const REMOVE_HISTORY_ID = "ml-remove-history";102
const TOGGLE_DELETE_STYLE_ID = "ml-toggle-style";103
const 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.MenuItem117
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.MenuItem127
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: true138
});139
} else {140
updateMessage(channel_id, id, { editHistory: [] });141
}142
}}143
/>144
));145
};146
147
const 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.MenuItem154
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: true165
});166
else167
updateMessage(channel.id, msg.id, {168
editHistory: []169
});170
});171
}}172
/>173
);174
};175
176
export 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
188
export 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": patchChannelContextMenu201
},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?.editHistory213
);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
<Timestamp221
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.content237
};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 => m257
.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 channels286
(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
<span295
{...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 messages307
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 event339
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 event349
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 editHistory359
match: /(MESSAGE_UPDATE:function\((\i)\).+?)\.update\((\i)/,360
replace: `361
$1362
.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;editHistory039;,[...(m.editHistory || []), $self.makeEdit($2.message, m)]) :366
m367
)368
.update($3369
`370
},371
{372
// fix up key (edit last message) attempting to edit a deleted message373
match: /(?<=getLastEditableMessage\(\i\)\{.{0,200}\.find\((\i)=>)/,374
replace: "!$1.deleted &&"375
}376
]377
},378
379
{380
// Message domain model381
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" transformer399
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 well407
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 attachments425
match: /(\((\i)\){return null==\2\.attachments.+?)spoiler:/,426
replace:427
"$1deleted: arguments[0]?.deleted," +428
"spoiler:"429
}430
]431
},432
433
{434
// Attachment renderer435
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 renderer446
find: "Message must not be a thread starter message",447
replacement: [448
{449
// Append messagelogger-deleted to classNames if deleted450
match: /\)\("li",\{(.+?),className:/,451
replace: ")(\"li\",{$1,className:(arguments[0].message.deleted ? \"messagelogger-deleted \" : \"\")+"452
}453
]454
},455
456
{457
// Message content renderer458
find: ".SEND_FAILED,",459
replacement: {460
// Render editHistory behind the message content461
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 clickable470
match: /(isInline:!1,children:.{0,50}?)"span",\{(?=className:)/,471
replace: "$1$self.EditMarker,{message:arguments[0].message,"472
}473
},474
475
{476
// ReferencedMessageStore477
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 menu492
find: ".MESSAGE,commandTargetId:",493
replacement: [494
{495
// Remove the first section if message is deleted496
match: /children:(\[""===.+?\])/,497
replace: "children:arguments[0].message.deleted?[]:$1"498
}499
]500
},501
{502
// Message grouping503
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.collapseDeleted509
},510
{511
// Message group rendering512
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.collapseDeleted524
}525
]526
});527