MessageLogger: restore plugin (RosePlugs crash fixed, unrelated to this)
This commit is contained in:
parent
2530cf8a25
commit
8eaba8d6dc
11 changed files with 946 additions and 0 deletions
11
plugins/message-logger/manifest.json
Normal file
11
plugins/message-logger/manifest.json
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"name": "MessageLogger",
|
||||||
|
"description": "Keeps deleted and edited messages visible in the chat, the way Equicord's MessageLogger does on desktop - plus a viewer for anything that couldn't be kept inline.",
|
||||||
|
"authors": [
|
||||||
|
{ "name": "Rosie" }
|
||||||
|
],
|
||||||
|
"main": "src/index.ts",
|
||||||
|
"vendetta": {
|
||||||
|
"icon": "ic_message_edit"
|
||||||
|
}
|
||||||
|
}
|
||||||
53
plugins/message-logger/src/index.ts
Normal file
53
plugins/message-logger/src/index.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { logger } from "@vendetta";
|
||||||
|
import { storage } from "@vendetta/plugin";
|
||||||
|
import Settings from "./ui/Settings";
|
||||||
|
import { patchFluxIntercept, revertFakedMessages } from "./patches/fluxIntercept";
|
||||||
|
import { patchRowStyling } from "./patches/rowStyling";
|
||||||
|
import { patchMessageRecord } from "./patches/messageRecord";
|
||||||
|
|
||||||
|
const TAG = "[MessageLogger]";
|
||||||
|
const cleanups: (() => void)[] = [];
|
||||||
|
|
||||||
|
function initStorage() {
|
||||||
|
storage.options ??= {};
|
||||||
|
const o = storage.options;
|
||||||
|
o.logDeleted ??= true;
|
||||||
|
o.keepDeletedInline ??= true;
|
||||||
|
o.logEdited ??= true;
|
||||||
|
o.ignoreBots ??= false;
|
||||||
|
o.ignoreOwnMessages ??= false;
|
||||||
|
o.ignoreOwnEdits ??= false;
|
||||||
|
o.ignoreDMs ??= false;
|
||||||
|
o.ignoredChannelIds ??= "";
|
||||||
|
o.ignoredGuildIds ??= "";
|
||||||
|
o.ignoredUserIds ??= "";
|
||||||
|
o.ignoredKeywords ??= "";
|
||||||
|
o.maxEntries ??= "2000";
|
||||||
|
o.maxEntriesPerChannel ??= "0";
|
||||||
|
o.maxAgeDays ??= "30";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
onLoad() {
|
||||||
|
initStorage();
|
||||||
|
console.log(TAG, "onLoad");
|
||||||
|
|
||||||
|
let patched = 0;
|
||||||
|
if (patchFluxIntercept(cleanups)) patched++;
|
||||||
|
if (patchRowStyling(cleanups)) patched++;
|
||||||
|
if (patchMessageRecord(cleanups)) patched++;
|
||||||
|
|
||||||
|
console.log(TAG, `onLoad done - ${patched}/3 patches applied`);
|
||||||
|
},
|
||||||
|
onUnload() {
|
||||||
|
console.log(TAG, "onUnload");
|
||||||
|
try {
|
||||||
|
revertFakedMessages();
|
||||||
|
} catch (e: any) {
|
||||||
|
logger.error("[MessageLogger] Failed to revert faked messages:", e?.message ?? e);
|
||||||
|
}
|
||||||
|
for (const fn of cleanups) fn();
|
||||||
|
cleanups.length = 0;
|
||||||
|
},
|
||||||
|
settings: Settings,
|
||||||
|
};
|
||||||
56
plugins/message-logger/src/lib/capture.ts
Normal file
56
plugins/message-logger/src/lib/capture.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
import type { LoggedAttachment, LoggedMessage } from "./types";
|
||||||
|
|
||||||
|
// Every field read below is part of Discord's own public message object schema - checked in both
|
||||||
|
// camelCase and snake_case since MessageStore's cached objects don't consistently normalize every
|
||||||
|
// field the same way across message sources (gateway vs. REST-backfilled history).
|
||||||
|
function attachmentsOf(message: any): LoggedAttachment[] {
|
||||||
|
const attachments = message?.attachments;
|
||||||
|
if (!Array.isArray(attachments)) return [];
|
||||||
|
|
||||||
|
return attachments.map((a: any) => ({
|
||||||
|
id: String(a?.id ?? ""),
|
||||||
|
filename: a?.filename ?? "attachment",
|
||||||
|
url: a?.url ?? "",
|
||||||
|
proxyUrl: a?.proxyURL ?? a?.proxy_url,
|
||||||
|
contentType: a?.contentType ?? a?.content_type,
|
||||||
|
width: a?.width,
|
||||||
|
height: a?.height,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function avatarUrlOf(author: any): string | undefined {
|
||||||
|
if (!author?.id) return undefined;
|
||||||
|
if (!author.avatar) {
|
||||||
|
// Discord's default avatar set - index derived from the discriminator/id the same way
|
||||||
|
// Discord's own client picks one for users with no avatar set.
|
||||||
|
const index = author.discriminator && author.discriminator !== "0"
|
||||||
|
? Number(author.discriminator) % 5
|
||||||
|
: Number((BigInt(author.id) >> 22n) % 6n);
|
||||||
|
return `https://cdn.discordapp.com/embed/avatars/${index}.png`;
|
||||||
|
}
|
||||||
|
const ext = author.avatar.startsWith("a_") ? "gif" : "png";
|
||||||
|
return `https://cdn.discordapp.com/avatars/${author.id}/${author.avatar}.${ext}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function snapshotMessage(message: any, kind: LoggedMessage["kind"], newContent: string | undefined, keptInline: boolean): LoggedMessage {
|
||||||
|
return {
|
||||||
|
id: String(message?.id ?? ""),
|
||||||
|
channelId: String(message?.channel_id ?? message?.channelId ?? ""),
|
||||||
|
guildId: message?.guild_id ?? message?.guildId ?? undefined,
|
||||||
|
authorId: message?.author?.id ?? "",
|
||||||
|
authorUsername: message?.author?.username,
|
||||||
|
authorDisplayName: message?.author?.globalName ?? message?.author?.global_name ?? undefined,
|
||||||
|
authorAvatarUrl: avatarUrlOf(message?.author),
|
||||||
|
content: message?.content ?? "",
|
||||||
|
newContent,
|
||||||
|
kind,
|
||||||
|
loggedAt: Date.now(),
|
||||||
|
timestamp: message?.timestamp ? String(message.timestamp) : undefined,
|
||||||
|
attachments: attachmentsOf(message),
|
||||||
|
embeds: Array.isArray(message?.embeds) ? message.embeds : [],
|
||||||
|
components: Array.isArray(message?.components) ? message.components : [],
|
||||||
|
stickerItems: message?.stickerItems ?? message?.sticker_items ?? [],
|
||||||
|
poll: message?.poll,
|
||||||
|
keptInline,
|
||||||
|
};
|
||||||
|
}
|
||||||
40
plugins/message-logger/src/lib/filters.ts
Normal file
40
plugins/message-logger/src/lib/filters.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { FilterOptions } from "./types";
|
||||||
|
|
||||||
|
function parseIdList(raw: string | undefined): Set<string> {
|
||||||
|
if (!raw) return new Set();
|
||||||
|
return new Set(raw.split(/[\s,]+/).map((s) => s.trim()).filter(Boolean));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseKeywordList(raw: string | undefined): string[] {
|
||||||
|
if (!raw) return [];
|
||||||
|
return raw.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FilterContext {
|
||||||
|
channelId?: string;
|
||||||
|
guildId?: string;
|
||||||
|
authorId?: string;
|
||||||
|
isBot: boolean;
|
||||||
|
isOwnMessage: boolean;
|
||||||
|
isEdit: boolean;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True if this message/edit/delete should be skipped entirely - never captured. */
|
||||||
|
export function shouldIgnore(ctx: FilterContext, options: FilterOptions): boolean {
|
||||||
|
if (options.ignoreBots && ctx.isBot) return true;
|
||||||
|
if (options.ignoreOwnMessages && ctx.isOwnMessage) return true;
|
||||||
|
if (options.ignoreOwnEdits && ctx.isEdit && ctx.isOwnMessage) return true;
|
||||||
|
if (options.ignoreDMs && !ctx.guildId) return true;
|
||||||
|
if (ctx.channelId && parseIdList(options.ignoredChannelIds).has(ctx.channelId)) return true;
|
||||||
|
if (ctx.guildId && parseIdList(options.ignoredGuildIds).has(ctx.guildId)) return true;
|
||||||
|
if (ctx.authorId && parseIdList(options.ignoredUserIds).has(ctx.authorId)) return true;
|
||||||
|
|
||||||
|
const keywords = parseKeywordList(options.ignoredKeywords);
|
||||||
|
if (keywords.length > 0) {
|
||||||
|
const haystack = ctx.content.toLowerCase();
|
||||||
|
if (keywords.some((kw) => haystack.includes(kw))) return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
50
plugins/message-logger/src/lib/store.ts
Normal file
50
plugins/message-logger/src/lib/store.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { storage } from "@vendetta/plugin";
|
||||||
|
import type { LoggedMessage } from "./types";
|
||||||
|
|
||||||
|
const logStorage = storage as { log?: LoggedMessage[] };
|
||||||
|
|
||||||
|
export function getLog(): LoggedMessage[] {
|
||||||
|
logStorage.log ??= [];
|
||||||
|
return logStorage.log;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneLog(maxEntries: number, maxAgeDays: number, maxPerChannel: number): void {
|
||||||
|
let log = getLog();
|
||||||
|
|
||||||
|
if (maxAgeDays > 0) {
|
||||||
|
const cutoff = Date.now() - maxAgeDays * 24 * 60 * 60 * 1000;
|
||||||
|
log = log.filter((e) => e.loggedAt >= cutoff);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maxPerChannel > 0) {
|
||||||
|
const perChannelCount = new Map<string, number>();
|
||||||
|
const kept: LoggedMessage[] = [];
|
||||||
|
for (let i = log.length - 1; i >= 0; i--) {
|
||||||
|
const entry = log[i];
|
||||||
|
const count = perChannelCount.get(entry.channelId) ?? 0;
|
||||||
|
if (count >= maxPerChannel) continue;
|
||||||
|
perChannelCount.set(entry.channelId, count + 1);
|
||||||
|
kept.push(entry);
|
||||||
|
}
|
||||||
|
log = kept.reverse();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maxEntries > 0 && log.length > maxEntries) {
|
||||||
|
log = log.slice(log.length - maxEntries);
|
||||||
|
}
|
||||||
|
|
||||||
|
logStorage.log = log;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addLogEntry(entry: LoggedMessage, limits: { maxEntries: number; maxAgeDays: number; maxPerChannel: number }): void {
|
||||||
|
getLog().push(entry);
|
||||||
|
pruneLog(limits.maxEntries, limits.maxAgeDays, limits.maxPerChannel);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeLogEntry(loggedAt: number): void {
|
||||||
|
logStorage.log = getLog().filter((e) => e.loggedAt !== loggedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearLog(): void {
|
||||||
|
logStorage.log = [];
|
||||||
|
}
|
||||||
41
plugins/message-logger/src/lib/types.ts
Normal file
41
plugins/message-logger/src/lib/types.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
export interface LoggedAttachment {
|
||||||
|
id: string;
|
||||||
|
filename: string;
|
||||||
|
url: string;
|
||||||
|
proxyUrl?: string;
|
||||||
|
contentType?: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoggedMessage {
|
||||||
|
id: string;
|
||||||
|
channelId: string;
|
||||||
|
guildId?: string;
|
||||||
|
authorId: string;
|
||||||
|
authorUsername?: string;
|
||||||
|
authorDisplayName?: string;
|
||||||
|
authorAvatarUrl?: string;
|
||||||
|
content: string;
|
||||||
|
newContent?: string;
|
||||||
|
kind: "deleted" | "edited" | "bulk-deleted";
|
||||||
|
loggedAt: number;
|
||||||
|
timestamp?: string;
|
||||||
|
attachments: LoggedAttachment[];
|
||||||
|
embeds: any[];
|
||||||
|
components: any[];
|
||||||
|
stickerItems: any[];
|
||||||
|
poll?: any;
|
||||||
|
keptInline: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FilterOptions {
|
||||||
|
ignoreBots: boolean;
|
||||||
|
ignoreOwnMessages: boolean;
|
||||||
|
ignoreOwnEdits: boolean;
|
||||||
|
ignoreDMs: boolean;
|
||||||
|
ignoredChannelIds: string;
|
||||||
|
ignoredGuildIds: string;
|
||||||
|
ignoredUserIds: string;
|
||||||
|
ignoredKeywords: string;
|
||||||
|
}
|
||||||
208
plugins/message-logger/src/patches/fluxIntercept.ts
Normal file
208
plugins/message-logger/src/patches/fluxIntercept.ts
Normal file
|
|
@ -0,0 +1,208 @@
|
||||||
|
import { findByProps, findByStoreName } from "@vendetta/metro";
|
||||||
|
import { FluxDispatcher } from "@vendetta/metro/common";
|
||||||
|
import { before } from "@vendetta/patcher";
|
||||||
|
import { storage } from "@vendetta/plugin";
|
||||||
|
import { shouldIgnore } from "../lib/filters";
|
||||||
|
import { snapshotMessage } from "../lib/capture";
|
||||||
|
import { addLogEntry } from "../lib/store";
|
||||||
|
import type { FilterOptions } from "../lib/types";
|
||||||
|
|
||||||
|
const TAG = "[MessageLogger]";
|
||||||
|
const FAKE_DELETE_FLAG = "__msgLoggerDeleted";
|
||||||
|
const CLEANUP_FLAG = "__msgLoggerCleanup";
|
||||||
|
|
||||||
|
const ChannelMessages = findByProps("_channelMessages");
|
||||||
|
const UserStore = findByStoreName("UserStore");
|
||||||
|
|
||||||
|
// Ids currently kept "alive" as a faked MESSAGE_UPDATE instead of a real delete, mapped to the
|
||||||
|
// channel they belong to - tracked so onUnload can put them back the way it found them (dispatch a
|
||||||
|
// real delete for each) rather than leaving the client's own message cache holding fake state
|
||||||
|
// forever if the plugin gets disabled.
|
||||||
|
export const fakedMessages = new Map<string, string>();
|
||||||
|
|
||||||
|
function currentUserId(): string | undefined {
|
||||||
|
return UserStore?.getCurrentUser?.()?.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberOption(raw: string | undefined, fallback: number): number {
|
||||||
|
const n = parseInt(raw ?? "", 10);
|
||||||
|
return Number.isFinite(n) && n >= 0 ? n : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function limitsFromStorage(): { maxEntries: number; maxAgeDays: number; maxPerChannel: number } {
|
||||||
|
return {
|
||||||
|
maxEntries: numberOption(storage.options?.maxEntries, 2000),
|
||||||
|
maxAgeDays: numberOption(storage.options?.maxAgeDays, 30),
|
||||||
|
maxPerChannel: numberOption(storage.options?.maxEntriesPerChannel, 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterOptionsFromStorage(): FilterOptions {
|
||||||
|
const o = storage.options ?? {};
|
||||||
|
return {
|
||||||
|
ignoreBots: !!o.ignoreBots,
|
||||||
|
ignoreOwnMessages: !!o.ignoreOwnMessages,
|
||||||
|
ignoreOwnEdits: !!o.ignoreOwnEdits,
|
||||||
|
ignoreDMs: !!o.ignoreDMs,
|
||||||
|
ignoredChannelIds: o.ignoredChannelIds ?? "",
|
||||||
|
ignoredGuildIds: o.ignoredGuildIds ?? "",
|
||||||
|
ignoredUserIds: o.ignoredUserIds ?? "",
|
||||||
|
ignoredKeywords: o.ignoredKeywords ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function ignoredFor(message: any, isEdit: boolean): boolean {
|
||||||
|
const authorId = message?.author?.id;
|
||||||
|
return shouldIgnore(
|
||||||
|
{
|
||||||
|
channelId: message?.channel_id ?? message?.channelId,
|
||||||
|
guildId: message?.guild_id ?? message?.guildId,
|
||||||
|
authorId,
|
||||||
|
isBot: !!(message?.author?.bot ?? message?.author?.isNonUserBot?.()),
|
||||||
|
isOwnMessage: !!authorId && authorId === currentUserId(),
|
||||||
|
isEdit,
|
||||||
|
content: message?.content ?? "",
|
||||||
|
},
|
||||||
|
filterOptionsFromStorage(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasLoggableContent(message: any): boolean {
|
||||||
|
return !!(
|
||||||
|
message?.content ||
|
||||||
|
message?.attachments?.length ||
|
||||||
|
message?.embeds?.length ||
|
||||||
|
message?.components?.length ||
|
||||||
|
(message?.stickerItems ?? message?.sticker_items)?.length ||
|
||||||
|
message?.poll
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeUpdateFor(original: any): any {
|
||||||
|
return { type: "MESSAGE_UPDATE", message: { ...original, [FAKE_DELETE_FLAG]: true } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function captureAndMaybeKeep(original: any, channelId: string, id: string): any | undefined {
|
||||||
|
const EPHEMERAL = 64;
|
||||||
|
if (!original?.author?.id) return undefined; // never cached, or already evicted - nothing left to do
|
||||||
|
if ((original.flags & EPHEMERAL) === EPHEMERAL) return undefined; // an ephemeral dismiss, not a real delete
|
||||||
|
if (!hasLoggableContent(original)) return undefined;
|
||||||
|
if (!storage.options?.logDeleted) return undefined;
|
||||||
|
if (ignoredFor(original, false)) return undefined;
|
||||||
|
|
||||||
|
const keepInline = !!storage.options?.keepDeletedInline;
|
||||||
|
addLogEntry(snapshotMessage(original, "deleted", undefined, keepInline), limitsFromStorage());
|
||||||
|
|
||||||
|
if (!keepInline) return undefined;
|
||||||
|
|
||||||
|
fakedMessages.set(id, channelId);
|
||||||
|
return fakeUpdateFor(original);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSingleDelete(event: any): any {
|
||||||
|
if (event[CLEANUP_FLAG]) return undefined; // our own revert dispatch on unload - pass through untouched
|
||||||
|
|
||||||
|
const channelId = event.channelId ?? event.channel_id;
|
||||||
|
const original = ChannelMessages?.get?.(channelId)?.get?.(event.id);
|
||||||
|
const fake = captureAndMaybeKeep(original, channelId, event.id);
|
||||||
|
return fake ? [fake] : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBulkDelete(event: any): any {
|
||||||
|
const channelId = event.channelId ?? event.channel_id;
|
||||||
|
const ids: string[] = Array.isArray(event.ids) ? event.ids : [];
|
||||||
|
if (!ids.length) return undefined;
|
||||||
|
|
||||||
|
const kept: string[] = [];
|
||||||
|
const dropped: string[] = [];
|
||||||
|
|
||||||
|
for (const id of ids) {
|
||||||
|
const original = ChannelMessages?.get?.(channelId)?.get?.(id);
|
||||||
|
const fake = captureAndMaybeKeep(original, channelId, id);
|
||||||
|
if (fake) kept.push(id);
|
||||||
|
else dropped.push(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!kept.length) return undefined; // nothing kept inline - let the real bulk delete proceed as-is
|
||||||
|
|
||||||
|
// Dispatching more actions from inside a dispatch-in-progress is a real re-entrancy risk (Flux
|
||||||
|
// implementations commonly disallow it) - deferred to a microtask, one dispatch per kept message,
|
||||||
|
// running after this bulk-delete's own dispatch has fully finished.
|
||||||
|
queueMicrotask(() => {
|
||||||
|
for (const id of kept) {
|
||||||
|
const original = ChannelMessages?.get?.(channelId)?.get?.(id);
|
||||||
|
if (!original) continue;
|
||||||
|
try {
|
||||||
|
FluxDispatcher.dispatch(fakeUpdateFor(original));
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(TAG, "Deferred bulk-keep dispatch failed for", id, e?.message ?? e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!dropped.length) {
|
||||||
|
// Every id in this bulk delete is being kept inline via the deferred dispatches above -
|
||||||
|
// replace the original bulk delete with a no-op so none of them get removed for real.
|
||||||
|
return [{ type: "MESSAGE_DELETE_BULK", channelId, ids: [] }];
|
||||||
|
}
|
||||||
|
return [{ type: "MESSAGE_DELETE_BULK", channelId, ids: dropped }];
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUpdate(event: any): void {
|
||||||
|
const updated = event?.message;
|
||||||
|
if (!updated?.id) return;
|
||||||
|
if (updated[FAKE_DELETE_FLAG]) return; // our own fake-delete-as-update, not a real edit
|
||||||
|
|
||||||
|
const channelId = updated.channel_id ?? updated.channelId;
|
||||||
|
const original = ChannelMessages?.get?.(channelId)?.get?.(updated.id);
|
||||||
|
if (!original) return;
|
||||||
|
|
||||||
|
const newContent = updated.content ?? original.content;
|
||||||
|
if (original.content === newContent) return; // MESSAGE_UPDATE also fires for non-content changes
|
||||||
|
if (!storage.options?.logEdited) return;
|
||||||
|
if (ignoredFor(original, true)) return;
|
||||||
|
|
||||||
|
addLogEntry(snapshotMessage(original, "edited", newContent, false), limitsFromStorage());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function patchFluxIntercept(cleanups: (() => void)[]): boolean {
|
||||||
|
if (!ChannelMessages) {
|
||||||
|
console.warn(TAG, "ChannelMessages module not found, skipping capture");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanups.push(
|
||||||
|
before("dispatch", FluxDispatcher, (args: any[]) => {
|
||||||
|
const event = args[0];
|
||||||
|
if (!event?.type) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (event.type) {
|
||||||
|
case "MESSAGE_DELETE":
|
||||||
|
return handleSingleDelete(event);
|
||||||
|
case "MESSAGE_DELETE_BULK":
|
||||||
|
return handleBulkDelete(event);
|
||||||
|
case "MESSAGE_UPDATE":
|
||||||
|
handleUpdate(event);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(TAG, "Flux intercept error:", e?.message ?? e);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reverts every message currently faked-alive back to a real delete - called on unload. */
|
||||||
|
export function revertFakedMessages(): void {
|
||||||
|
for (const [id, channelId] of fakedMessages) {
|
||||||
|
try {
|
||||||
|
FluxDispatcher.dispatch({ type: "MESSAGE_DELETE", channelId, id, [CLEANUP_FLAG]: true });
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(TAG, "Failed to revert faked message", id, e?.message ?? e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fakedMessages.clear();
|
||||||
|
}
|
||||||
48
plugins/message-logger/src/patches/messageRecord.ts
Normal file
48
plugins/message-logger/src/patches/messageRecord.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import { findByName, findByProps } from "@vendetta/metro";
|
||||||
|
import { after, instead } from "@vendetta/patcher";
|
||||||
|
|
||||||
|
const TAG = "[MessageLogger]";
|
||||||
|
const FAKE_DELETE_FLAG = "__msgLoggerDeleted";
|
||||||
|
|
||||||
|
// Discord wraps raw message JSON into a "MessageRecord" class before it ever reaches row
|
||||||
|
// generation - a plain custom field set directly on the raw object doesn't reliably survive that
|
||||||
|
// wrap. Confirmed against three independent existing plugins doing the same delete-as-update trick
|
||||||
|
// (all thread a custom flag through this exact layer) - without this, the flag rowStyling.ts looks
|
||||||
|
// for is gone by the time a row actually gets built.
|
||||||
|
export function patchMessageRecord(cleanups: (() => void)[]): boolean {
|
||||||
|
const MessageRecordUtils = findByProps("updateMessageRecord", "createMessageRecord");
|
||||||
|
const MessageRecord = findByName("MessageRecord", false);
|
||||||
|
|
||||||
|
if (!MessageRecordUtils || !MessageRecord) {
|
||||||
|
console.warn(TAG, "MessageRecord module(s) not found - deleted messages may not render styled");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
cleanups.push(
|
||||||
|
after("createMessageRecord", MessageRecordUtils, ([message]: any[], record: any) => {
|
||||||
|
if (message?.[FAKE_DELETE_FLAG]) record[FAKE_DELETE_FLAG] = true;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
cleanups.push(
|
||||||
|
instead("updateMessageRecord", MessageRecordUtils, ([oldRecord, newRecord]: any[], orig: any) => {
|
||||||
|
if (newRecord?.[FAKE_DELETE_FLAG]) {
|
||||||
|
return MessageRecordUtils.createMessageRecord(newRecord, oldRecord?.reactions);
|
||||||
|
}
|
||||||
|
return orig.apply(MessageRecordUtils, [oldRecord, newRecord]);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
cleanups.push(
|
||||||
|
after("default", MessageRecord, ([props]: any[], record: any) => {
|
||||||
|
if (props?.[FAKE_DELETE_FLAG]) record[FAKE_DELETE_FLAG] = true;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (e: any) {
|
||||||
|
console.warn(TAG, "Failed to patch MessageRecord:", e?.message ?? e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
58
plugins/message-logger/src/patches/rowStyling.ts
Normal file
58
plugins/message-logger/src/patches/rowStyling.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
import { ReactNative } from "@vendetta/metro/common";
|
||||||
|
import { after, before } from "@vendetta/patcher";
|
||||||
|
import { waitFor } from "@shared/lib/waitFor";
|
||||||
|
import { rawFindByName } from "@shared/lib/rawFind";
|
||||||
|
|
||||||
|
const TAG = "[MessageLogger]";
|
||||||
|
const FAKE_DELETE_FLAG = "__msgLoggerDeleted";
|
||||||
|
|
||||||
|
function handleRow(row: any) {
|
||||||
|
const message = row?.message;
|
||||||
|
if (!message?.[FAKE_DELETE_FLAG]) return;
|
||||||
|
|
||||||
|
message.edited = "deleted";
|
||||||
|
message.textColor = ReactNative.processColor("#E4404380");
|
||||||
|
row.backgroundHighlight ??= {};
|
||||||
|
row.backgroundHighlight.backgroundColor = ReactNative.processColor("#da373c22");
|
||||||
|
row.backgroundHighlight.gutterColor = ReactNative.processColor("#da373cff");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirmed live: shared/lib/patchRows.ts's one-shot RowManager.prototype.generate lookup missed
|
||||||
|
// entirely - the method doesn't exist yet at plugin onLoad time (not registered until Discord's own
|
||||||
|
// code naturally requires that module), and a one-shot findByName never gets a second chance. Same
|
||||||
|
// class of bug RoleColorEverywhere's own patches/rows.ts already had to work around with a raw,
|
||||||
|
// uncached, retrying lookup instead of relying on the shared helper.
|
||||||
|
export function patchRowStyling(cleanups: (() => void)[]): boolean {
|
||||||
|
const { NativeModules } = ReactNative;
|
||||||
|
const DCDChatManager = NativeModules?.DCDChatManager;
|
||||||
|
|
||||||
|
if (DCDChatManager?.updateRows) {
|
||||||
|
cleanups.push(
|
||||||
|
before("updateRows", DCDChatManager, (args: any[]) => {
|
||||||
|
try {
|
||||||
|
const rows = JSON.parse(args[1]);
|
||||||
|
for (const row of rows) handleRow(row);
|
||||||
|
args[1] = JSON.stringify(rows);
|
||||||
|
} catch {
|
||||||
|
// Leave args untouched - better to show unstyled rows than break message loading.
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handle = waitFor(
|
||||||
|
() => {
|
||||||
|
const RowManager = rawFindByName<any>("RowManager");
|
||||||
|
return RowManager?.prototype?.generate ? RowManager : undefined;
|
||||||
|
},
|
||||||
|
(RowManager) => {
|
||||||
|
cleanups.push(after("generate", RowManager.prototype, (_args: any[], row: any) => {
|
||||||
|
try { handleRow(row); } catch (e: any) { console.warn(TAG, "Row styling failed:", e?.message ?? e); }
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
cleanups.push(() => handle.cancel());
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
244
plugins/message-logger/src/ui/LogViewerPage.tsx
Normal file
244
plugins/message-logger/src/ui/LogViewerPage.tsx
Normal file
|
|
@ -0,0 +1,244 @@
|
||||||
|
import { findByName, findByProps } from "@vendetta/metro";
|
||||||
|
import { React, ReactNative, clipboard } from "@vendetta/metro/common";
|
||||||
|
import { TextInput } from "@shared/ui/table";
|
||||||
|
import { showToast } from "@vendetta/ui/toasts";
|
||||||
|
import { getAssetIDByName } from "@vendetta/ui/assets";
|
||||||
|
import { resolveSemanticColorSafe } from "@shared/lib/color";
|
||||||
|
import { getLog, removeLogEntry } from "../lib/store";
|
||||||
|
import type { LoggedMessage } from "../lib/types";
|
||||||
|
|
||||||
|
// Same Navigation/Navigator/close-button lookup already proven working elsewhere in this repo
|
||||||
|
// (ViewRaw, RoseUtils' own log viewer) - reused verbatim including the fallback chain.
|
||||||
|
const Navigation = findByProps("push", "pushLazy", "pop");
|
||||||
|
const modalCloseButton =
|
||||||
|
findByProps("getRenderCloseButton")?.getRenderCloseButton ??
|
||||||
|
findByProps("getHeaderCloseButton")?.getHeaderCloseButton;
|
||||||
|
const Navigator = findByName("Navigator") ?? findByProps("Navigator")?.Navigator;
|
||||||
|
|
||||||
|
const { View, Text, ScrollView, TouchableOpacity } = ReactNative;
|
||||||
|
|
||||||
|
const PAGE_SIZE = 100;
|
||||||
|
|
||||||
|
function textColor(): string {
|
||||||
|
return resolveSemanticColorSafe(["TEXT_NORMAL", "TEXT_DEFAULT"], "#dbdee1");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(value: number | string | undefined): string {
|
||||||
|
if (!value) return "unknown time";
|
||||||
|
try {
|
||||||
|
return new Date(value).toLocaleString();
|
||||||
|
} catch {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function kindLabel(kind: LoggedMessage["kind"]): string {
|
||||||
|
if (kind === "deleted") return "🗑 Deleted";
|
||||||
|
if (kind === "bulk-deleted") return "🗑 Bulk deleted";
|
||||||
|
return "✎ Edited";
|
||||||
|
}
|
||||||
|
|
||||||
|
function entryMatches(entry: LoggedMessage, query: string): boolean {
|
||||||
|
if (!query) return true;
|
||||||
|
const needle = query.toLowerCase();
|
||||||
|
return (
|
||||||
|
entry.content?.toLowerCase().includes(needle) ||
|
||||||
|
entry.newContent?.toLowerCase().includes(needle) ||
|
||||||
|
entry.authorUsername?.toLowerCase().includes(needle) ||
|
||||||
|
entry.authorDisplayName?.toLowerCase().includes(needle) ||
|
||||||
|
entry.channelId?.includes(needle) ||
|
||||||
|
entry.guildId?.includes(needle) ||
|
||||||
|
entry.authorId?.includes(needle) ||
|
||||||
|
false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EntryDetail({ entry, onClose, onDeleted }: { entry: LoggedMessage; onClose: () => void; onDeleted: () => void }) {
|
||||||
|
const color = textColor();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView style={{ flex: 1, padding: 16 }}>
|
||||||
|
<Text style={{ fontSize: 12, opacity: 0.6, marginBottom: 4, color }}>
|
||||||
|
{kindLabel(entry.kind)} · {formatTime(entry.timestamp)} · logged {formatTime(entry.loggedAt)}
|
||||||
|
{entry.keptInline ? " · kept in chat" : ""}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ fontSize: 15, fontWeight: "700", marginBottom: 8, color }}>
|
||||||
|
{entry.authorDisplayName || entry.authorUsername || entry.authorId || "Unknown author"}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{entry.kind === "edited" ? (
|
||||||
|
<>
|
||||||
|
<Text style={{ fontSize: 12, opacity: 0.6, marginBottom: 2, color }}>Before:</Text>
|
||||||
|
<Text style={{ fontSize: 14, marginBottom: 10, color }} selectable>{entry.content || "(empty)"}</Text>
|
||||||
|
<Text style={{ fontSize: 12, opacity: 0.6, marginBottom: 2, color }}>After:</Text>
|
||||||
|
<Text style={{ fontSize: 14, marginBottom: 10, color }} selectable>{entry.newContent || "(empty)"}</Text>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Text style={{ fontSize: 14, marginBottom: 10, color }} selectable>{entry.content || "(empty)"}</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{entry.attachments.length > 0 && (
|
||||||
|
<View style={{ marginBottom: 10 }}>
|
||||||
|
<Text style={{ fontSize: 12, opacity: 0.6, marginBottom: 2, color }}>
|
||||||
|
Attachments ({entry.attachments.length}):
|
||||||
|
</Text>
|
||||||
|
{entry.attachments.map((a, i) => (
|
||||||
|
<Text key={i} style={{ fontSize: 13, color }} selectable>
|
||||||
|
{a.filename}{a.url ? ` - ${a.url}` : ""}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{entry.embeds.length > 0 && (
|
||||||
|
<Text style={{ fontSize: 13, marginBottom: 10, color }}>
|
||||||
|
{entry.embeds.length} embed{entry.embeds.length === 1 ? "" : "s"}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{entry.components.length > 0 && (
|
||||||
|
<Text style={{ fontSize: 13, marginBottom: 10, color }}>
|
||||||
|
{entry.components.length} component{entry.components.length === 1 ? "" : "s"} (Components V2)
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{entry.stickerItems.length > 0 && (
|
||||||
|
<Text style={{ fontSize: 13, marginBottom: 10, color }}>
|
||||||
|
{entry.stickerItems.length} sticker{entry.stickerItems.length === 1 ? "" : "s"}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{entry.poll && (
|
||||||
|
<Text style={{ fontSize: 13, marginBottom: 10, color }}>Included a poll</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Text style={{ fontSize: 11, opacity: 0.5, marginBottom: 16, color }} selectable>
|
||||||
|
Message ID: {entry.id}{"\n"}Channel ID: {entry.channelId}{entry.guildId ? `\nServer ID: ${entry.guildId}` : ""}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={{ flexDirection: "row", gap: 12 }}>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
clipboard.setString(JSON.stringify(entry, null, 2));
|
||||||
|
showToast("Copied entry as JSON", getAssetIDByName("ic_copy_24px"));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 14, fontWeight: "600", color }}>Copy JSON</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
removeLogEntry(entry.loggedAt);
|
||||||
|
showToast("Removed entry", undefined);
|
||||||
|
onDeleted();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 14, fontWeight: "600", color: "#F23F42" }}>Delete entry</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity onPress={onClose}>
|
||||||
|
<Text style={{ fontSize: 14, fontWeight: "600", color }}>Back</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LogList() {
|
||||||
|
const [query, setQuery] = React.useState("");
|
||||||
|
const [kindFilter, setKindFilter] = React.useState<"all" | "deleted" | "edited">("all");
|
||||||
|
const [selected, setSelected] = React.useState<LoggedMessage | null>(null);
|
||||||
|
const [visibleCount, setVisibleCount] = React.useState(PAGE_SIZE);
|
||||||
|
const [, forceUpdate] = React.useReducer((n: number) => n + 1, 0);
|
||||||
|
const color = textColor();
|
||||||
|
|
||||||
|
const all = getLog();
|
||||||
|
const filtered = React.useMemo(() => {
|
||||||
|
return all
|
||||||
|
.filter((e) => (kindFilter === "all" ? true : kindFilter === "deleted" ? e.kind !== "edited" : e.kind === "edited"))
|
||||||
|
.filter((e) => entryMatches(e, query.trim()))
|
||||||
|
.slice()
|
||||||
|
.reverse();
|
||||||
|
}, [all.length, query, kindFilter]);
|
||||||
|
|
||||||
|
if (selected) {
|
||||||
|
return (
|
||||||
|
<EntryDetail
|
||||||
|
entry={selected}
|
||||||
|
onClose={() => setSelected(null)}
|
||||||
|
onDeleted={() => {
|
||||||
|
setSelected(null);
|
||||||
|
forceUpdate();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<View style={{ padding: 16 }}>
|
||||||
|
<TextInput
|
||||||
|
label="Search"
|
||||||
|
placeholder="Search content, author, channel/server/user ID..."
|
||||||
|
value={query}
|
||||||
|
onChange={setQuery}
|
||||||
|
autoCorrect={false}
|
||||||
|
autoCapitalize="none"
|
||||||
|
/>
|
||||||
|
<View style={{ flexDirection: "row", marginTop: 8, gap: 8 }}>
|
||||||
|
{(["all", "deleted", "edited"] as const).map((k) => (
|
||||||
|
<TouchableOpacity key={k} onPress={() => setKindFilter(k)}>
|
||||||
|
<Text style={{ fontSize: 13, fontWeight: kindFilter === k ? "700" : "400", opacity: kindFilter === k ? 1 : 0.6, color }}>
|
||||||
|
{k === "all" ? "All" : k === "deleted" ? "Deleted" : "Edited"}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
<Text style={{ fontSize: 12, opacity: 0.5, marginTop: 8, color }}>
|
||||||
|
{filtered.length} of {all.length} total
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView style={{ flex: 1 }}>
|
||||||
|
{filtered.slice(0, visibleCount).map((entry) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={entry.loggedAt}
|
||||||
|
onPress={() => setSelected(entry)}
|
||||||
|
style={{ paddingHorizontal: 16, paddingVertical: 10, borderBottomWidth: 1, borderBottomColor: "rgba(128,128,128,0.15)" }}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 12, opacity: 0.6, color }}>
|
||||||
|
{kindLabel(entry.kind)} · {entry.authorDisplayName || entry.authorUsername || entry.authorId || "Unknown"} · {formatTime(entry.timestamp)}
|
||||||
|
</Text>
|
||||||
|
<Text numberOfLines={2} style={{ fontSize: 14, color }}>
|
||||||
|
{(entry.kind === "edited" ? entry.newContent : entry.content) || "(no text content)"}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<Text style={{ padding: 16, opacity: 0.6, color }}>No log entries match.</Text>
|
||||||
|
)}
|
||||||
|
{visibleCount < filtered.length && (
|
||||||
|
<TouchableOpacity onPress={() => setVisibleCount((n) => n + PAGE_SIZE)} style={{ padding: 16, alignItems: "center" }}>
|
||||||
|
<Text style={{ fontWeight: "600", color }}>Load more ({filtered.length - visibleCount} remaining)</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function openLogViewer(): void {
|
||||||
|
if (!Navigator || !Navigation) {
|
||||||
|
showToast("Couldn't open the log viewer - Navigator not found", undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Navigation.push(() => (
|
||||||
|
<Navigator
|
||||||
|
initialRouteName="MessageLoggerViewer"
|
||||||
|
goBackOnBackPress
|
||||||
|
screens={{
|
||||||
|
MessageLoggerViewer: {
|
||||||
|
title: "Message Log",
|
||||||
|
headerLeft: modalCloseButton?.(() => Navigation.pop()),
|
||||||
|
render: () => <LogList />,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
}
|
||||||
137
plugins/message-logger/src/ui/Settings.tsx
Normal file
137
plugins/message-logger/src/ui/Settings.tsx
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
import { React, clipboard } from "@vendetta/metro/common";
|
||||||
|
import { storage } from "@vendetta/plugin";
|
||||||
|
import { useProxy } from "@vendetta/storage";
|
||||||
|
import { showToast } from "@vendetta/ui/toasts";
|
||||||
|
import SettingsScaffold from "@shared/ui/SettingsScaffold";
|
||||||
|
import NoteBox from "@shared/ui/NoteBox";
|
||||||
|
import PrimaryButton from "@shared/ui/PrimaryButton";
|
||||||
|
import { TableRow, TableRowGroup, TableSwitchRow, TextInput } from "@shared/ui/table";
|
||||||
|
import { getLog, clearLog } from "../lib/store";
|
||||||
|
import openLogViewer from "./LogViewerPage";
|
||||||
|
|
||||||
|
export default function Settings() {
|
||||||
|
useProxy(storage);
|
||||||
|
const o = storage.options;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsScaffold>
|
||||||
|
<NoteBox>
|
||||||
|
Deleted messages are kept visible in the chat (styled like a deletion, the way
|
||||||
|
Equicord's MessageLogger looks on desktop) whenever possible. Everything is also
|
||||||
|
saved to a searchable log that survives Discord reloads, since keeping a message
|
||||||
|
in the live chat only lasts until the app actually restarts and re-fetches history.
|
||||||
|
</NoteBox>
|
||||||
|
|
||||||
|
<TableRowGroup title="What to capture">
|
||||||
|
<TableSwitchRow
|
||||||
|
label="Log deleted messages"
|
||||||
|
value={!!o.logDeleted}
|
||||||
|
onValueChange={(v: boolean) => { o.logDeleted = v; }}
|
||||||
|
/>
|
||||||
|
<TableSwitchRow
|
||||||
|
label="Keep deleted messages in the chat"
|
||||||
|
subLabel="Off: only saved to the log below, removed from chat immediately like normal"
|
||||||
|
value={!!o.keepDeletedInline}
|
||||||
|
onValueChange={(v: boolean) => { o.keepDeletedInline = v; }}
|
||||||
|
/>
|
||||||
|
<TableSwitchRow
|
||||||
|
label="Log edited messages"
|
||||||
|
value={!!o.logEdited}
|
||||||
|
onValueChange={(v: boolean) => { o.logEdited = v; }}
|
||||||
|
/>
|
||||||
|
</TableRowGroup>
|
||||||
|
|
||||||
|
<TableRowGroup title="Ignore">
|
||||||
|
<TableSwitchRow
|
||||||
|
label="Ignore bots"
|
||||||
|
value={!!o.ignoreBots}
|
||||||
|
onValueChange={(v: boolean) => { o.ignoreBots = v; }}
|
||||||
|
/>
|
||||||
|
<TableSwitchRow
|
||||||
|
label="Ignore your own messages"
|
||||||
|
value={!!o.ignoreOwnMessages}
|
||||||
|
onValueChange={(v: boolean) => { o.ignoreOwnMessages = v; }}
|
||||||
|
/>
|
||||||
|
<TableSwitchRow
|
||||||
|
label="Ignore your own edits"
|
||||||
|
subLabel="Only applies if 'ignore your own messages' above is off"
|
||||||
|
value={!!o.ignoreOwnEdits}
|
||||||
|
onValueChange={(v: boolean) => { o.ignoreOwnEdits = v; }}
|
||||||
|
/>
|
||||||
|
<TableSwitchRow
|
||||||
|
label="Ignore DMs"
|
||||||
|
value={!!o.ignoreDMs}
|
||||||
|
onValueChange={(v: boolean) => { o.ignoreDMs = v; }}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Ignored channel IDs"
|
||||||
|
placeholder="comma-separated"
|
||||||
|
value={o.ignoredChannelIds}
|
||||||
|
onChange={(v: string) => { o.ignoredChannelIds = v; }}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Ignored server IDs"
|
||||||
|
placeholder="comma-separated"
|
||||||
|
value={o.ignoredGuildIds}
|
||||||
|
onChange={(v: string) => { o.ignoredGuildIds = v; }}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Ignored user IDs"
|
||||||
|
placeholder="comma-separated"
|
||||||
|
value={o.ignoredUserIds}
|
||||||
|
onChange={(v: string) => { o.ignoredUserIds = v; }}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Ignored keywords"
|
||||||
|
subLabel="Comma-separated - skips logging any message containing one of these"
|
||||||
|
placeholder="e.g. password, secret"
|
||||||
|
value={o.ignoredKeywords}
|
||||||
|
onChange={(v: string) => { o.ignoredKeywords = v; }}
|
||||||
|
/>
|
||||||
|
</TableRowGroup>
|
||||||
|
|
||||||
|
<TableRowGroup title="Log limits">
|
||||||
|
<TextInput
|
||||||
|
label="Max total entries"
|
||||||
|
subLabel="Oldest entries are dropped once this is exceeded - 0 for unlimited"
|
||||||
|
value={o.maxEntries}
|
||||||
|
onChange={(v: string) => { o.maxEntries = v; }}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Max entries per channel"
|
||||||
|
subLabel="0 for unlimited"
|
||||||
|
value={o.maxEntriesPerChannel}
|
||||||
|
onChange={(v: string) => { o.maxEntriesPerChannel = v; }}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Max age (days)"
|
||||||
|
subLabel="0 for unlimited"
|
||||||
|
value={o.maxAgeDays}
|
||||||
|
onChange={(v: string) => { o.maxAgeDays = v; }}
|
||||||
|
/>
|
||||||
|
</TableRowGroup>
|
||||||
|
|
||||||
|
<TableRowGroup title="Log">
|
||||||
|
<TableRow label="View log" onPress={() => openLogViewer()} />
|
||||||
|
<TableRow
|
||||||
|
label="Copy entire log as JSON"
|
||||||
|
onPress={() => {
|
||||||
|
const log = getLog();
|
||||||
|
clipboard.setString(JSON.stringify(log, null, 2));
|
||||||
|
showToast(`Copied ${log.length} log entr${log.length === 1 ? "y" : "ies"} to clipboard`, undefined);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TableRowGroup>
|
||||||
|
|
||||||
|
<PrimaryButton
|
||||||
|
label="Clear entire log"
|
||||||
|
onPress={() => {
|
||||||
|
const count = getLog().length;
|
||||||
|
clearLog();
|
||||||
|
showToast(`Cleared ${count} log entr${count === 1 ? "y" : "ies"}`, undefined);
|
||||||
|
}}
|
||||||
|
style={{ margin: 16, backgroundColor: "#F23F42" }}
|
||||||
|
/>
|
||||||
|
</SettingsScaffold>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue