MessageLogger: restore plugin (RosePlugs crash fixed, unrelated to this)

This commit is contained in:
Ally Founder 2026-08-11 19:56:24 -04:00
parent 2530cf8a25
commit 8eaba8d6dc
11 changed files with 946 additions and 0 deletions

View 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"
}
}

View 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,
};

View 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,
};
}

View 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;
}

View 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 = [];
}

View 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;
}

View 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();
}

View 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;
}

View 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;
}

View 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 />,
},
}}
/>
));
}

View 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>
);
}