RoseUtils: add Message Logger (deleted/edited messages, persisted, searchable)

This commit is contained in:
Ally Founder 2026-08-02 19:52:34 -04:00
parent 66d1777e44
commit 3574c4b21e
12 changed files with 727 additions and 10 deletions

View file

@ -46,7 +46,7 @@ To install: open Discord with Revenge installed, go to **Settings → Plugins**,
| --- | --- | --- | --- |
| ViewRaw | Revived | See a message's raw JSON via long-press - searchable, syntax-highlighted, and copyable. Fork of [bwlok's ViewRaw](https://github.com/bwlok/revenge-plugins/tree/master/plugins/ViewRaw), GPLv3. | `https://rp.jarviscli.dev/view-raw/install/` |
| Key Inspector | New | One-tap scan of every internal key this repo's plugins depend on, plus a full dump of Revenge's plugin API tree - for diagnosing crashes entirely from a phone. | `https://rp.jarviscli.dev/key-inspector/install/` |
| RoseUtils | Revived | Spotify listen-along, a Tenor GIF fix, colorful channels, enforced Developer Mode, and more - independently toggleable modules. Fork of [nexpid's NexxUtils](https://github.com/nexpid/RevengePlugins/tree/main/src/plugins/nexxutils), CC-BY 4.0 - fixed a build bug that crashed the settings screen and silently dropped every module's patches. | `https://rp.jarviscli.dev/rose-utils/install/` |
| RoseUtils | Revived | Spotify listen-along, colorful channels, an expanded dev-tools/Developer Mode, a full Message Logger, and more - independently toggleable modules. Fork of [nexpid's NexxUtils](https://github.com/nexpid/RevengePlugins/tree/main/src/plugins/nexxutils), CC-BY 4.0 - fixed a build bug that crashed the settings screen and silently dropped every module's patches. | `https://rp.jarviscli.dev/rose-utils/install/` |
"Revived" means the plugin previously existed elsewhere, stopped working after a Discord/API update, and has been rebuilt here.
"Rebuilt" means it's a from-scratch reimplementation of a previously-broken plugin with expanded features. "New" means

View file

@ -1,6 +1,6 @@
{
"name": "RoseUtils",
"description": "A small collection of independent utilities - Spotify listen-along, a Tenor download fix, colorful channel icons, and more. Fork of nexpid's NexxUtils.",
"description": "A small collection of independent utilities - Spotify listen-along, colorful channel icons, a Message Logger for deleted/edited messages, and more. Fork of nexpid's NexxUtils.",
"authors": [
{ "name": "everestmcarthur" },
{ "name": "nexpid" }

View file

@ -22,6 +22,7 @@ export type ModuleSetting =
| { type: "toggle"; default: boolean }
| { type: "button"; action: (this: AnyModule) => void }
| { type: "choose"; choices: string[]; default: string }
| { type: "text"; default: string; placeholder?: string }
);
export interface ModuleMeta {

View file

@ -0,0 +1,252 @@
import { findByStoreName } from "@vendetta/metro";
import { FluxDispatcher, clipboard } from "@vendetta/metro/common";
import { before } from "@vendetta/patcher";
import { showToast } from "@vendetta/ui/toasts";
import { getAssetIDByName } from "@vendetta/ui/assets";
import { Module, ModuleCategory } from "../../lib/Module";
import { snapshotMessage } from "./lib/capture";
import { shouldIgnore, type FilterOptions } from "./lib/filters";
import { addLogEntry, clearLog, getLog } from "./lib/store";
import openLogViewer from "./ui/LogViewerPage";
// Same core stores this plugin already relies on elsewhere (GuildStore/UserStore/etc. in
// server-info-tools.ts) - MessageStore is equally fundamental, the client's own cache of every
// message it currently has loaded. The whole capture mechanism depends on reading a message from
// here *before* Discord's own MESSAGE_DELETE/MESSAGE_UPDATE handling removes/overwrites it - which
// is exactly why this patches FluxDispatcher.dispatch itself (the same technique Key Inspector's own
// flux event logger already proved out in this repo) instead of subscribing normally: a normal
// FluxDispatcher.subscribe callback fires in registration order alongside every other listener,
// including MessageStore's own, with no guarantee this one runs first - patching dispatch runs
// before any listener at all, while the store's cache still holds the pre-delete/pre-edit message.
const MessageStore = findByStoreName("MessageStore");
const UserStore = findByStoreName("UserStore");
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 handleDelete(action: any, options: FilterOptions & { notifyOnDelete: boolean }, limits: any) {
const message = MessageStore?.getMessage?.(action.channelId, action.id);
if (!message) return; // not cached (never loaded, or already evicted) - nothing left to log
logIfAllowed(message, "deleted", undefined, options, limits);
}
function handleBulkDelete(action: any, options: FilterOptions & { notifyOnDelete: boolean }, limits: any) {
const ids: string[] = action?.ids ?? [];
for (const id of ids) {
const message = MessageStore?.getMessage?.(action.channelId, id);
if (message) logIfAllowed(message, "deleted", undefined, options, limits);
}
}
function handleUpdate(action: any, options: FilterOptions & { notifyOnDelete: boolean }, limits: any) {
const updated = action?.message;
if (!updated?.id || (!updated?.channel_id && !updated?.channelId)) return;
const channelId = updated.channel_id ?? updated.channelId;
const oldMessage = MessageStore?.getMessage?.(channelId, updated.id);
if (!oldMessage) return;
// MESSAGE_UPDATE also fires for things that aren't content edits at all (embeds finishing
// loading after a link unfurls, pinned state, etc) - only log when the actual text changed.
const newContent = updated.content ?? oldMessage.content;
if (oldMessage.content === newContent) return;
logIfAllowed(oldMessage, "edited", newContent, options, limits);
}
function logIfAllowed(
message: any,
kind: "deleted" | "edited",
newContent: string | undefined,
options: FilterOptions & { notifyOnDelete: boolean },
limits: { maxEntries: number; maxAgeDays: number; maxPerChannel: number },
) {
const authorId = message?.author?.id;
const guildId = message?.guild_id ?? message?.guildId;
const ignored = shouldIgnore(
{
channelId: message?.channel_id ?? message?.channelId,
guildId,
authorId,
isBot: !!(message?.author?.bot ?? message?.author?.isNonUserBot?.()),
isOwnMessage: !!authorId && authorId === currentUserId(),
content: message?.content ?? "",
},
options,
);
if (ignored) return;
const entry = snapshotMessage(message, kind, newContent);
addLogEntry(entry, limits);
if (options.notifyOnDelete) {
const who = entry.authorDisplayName || entry.authorUsername || "Someone";
const preview = (kind === "deleted" ? entry.content : entry.newContent) || "(no text content)";
showToast(
`${kind === "deleted" ? "Deleted" : "Edited"}: ${who} - ${preview.slice(0, 60)}`,
getAssetIDByName("ic_message_edit") ?? undefined,
);
}
}
export default new Module({
id: "message-logger",
label: "Message Logger",
meta: {
sublabel: "Logs deleted and edited messages, persisted on-device across restarts - searchable viewer, ignore lists, and configurable limits",
category: ModuleCategory.Useful,
},
settings: {
logDeleted: {
label: "Log deleted messages",
type: "toggle",
default: true,
},
logEdited: {
label: "Log edited messages",
type: "toggle",
default: true,
},
notifyOnDelete: {
label: "Toast on capture",
subLabel: "Shows a toast every time something gets logged - can be noisy in busy servers",
type: "toggle",
default: false,
},
ignoreBots: {
label: "Ignore bots",
type: "toggle",
default: false,
},
ignoreOwnMessages: {
label: "Ignore your own messages",
type: "toggle",
default: true,
},
ignoreDMs: {
label: "Ignore DMs",
type: "toggle",
default: false,
},
onlyLogChannelIds: {
label: "Only log these channel IDs",
subLabel: "Comma-separated - if set, every other filter below is ignored and ONLY these channels are logged",
type: "text",
default: "",
placeholder: "e.g. 123456789012345678",
},
ignoredChannelIds: {
label: "Ignored channel IDs",
type: "text",
default: "",
placeholder: "comma-separated",
},
ignoredGuildIds: {
label: "Ignored server IDs",
type: "text",
default: "",
placeholder: "comma-separated",
},
ignoredUserIds: {
label: "Ignored user IDs",
type: "text",
default: "",
placeholder: "comma-separated",
},
ignoredKeywords: {
label: "Ignored keywords",
subLabel: "Comma-separated - skip logging any message containing one of these (case-insensitive)",
type: "text",
default: "",
placeholder: "e.g. password, secret",
},
maxEntries: {
label: "Max total entries",
subLabel: "Oldest entries are dropped once this is exceeded - 0 for unlimited",
type: "text",
default: "2000",
},
maxEntriesPerChannel: {
label: "Max entries per channel",
subLabel: "0 for unlimited (only the total cap above applies)",
type: "text",
default: "0",
},
maxAgeDays: {
label: "Max age (days)",
subLabel: "Entries older than this are dropped - 0 for unlimited",
type: "text",
default: "30",
},
viewLog: {
label: "View log",
type: "button",
action() {
openLogViewer();
},
},
copyLogAsJson: {
label: "Copy entire log as JSON",
type: "button",
action() {
const log = getLog();
clipboard.setString(JSON.stringify(log, null, 2));
showToast(`Copied ${log.length} log entr${log.length === 1 ? "y" : "ies"} to clipboard`, undefined);
},
},
clearLogButton: {
label: "Clear entire log",
subLabel: "This can't be undone",
type: "button",
action() {
const count = getLog().length;
clearLog();
showToast(`Cleared ${count} log entr${count === 1 ? "y" : "ies"}`, undefined);
},
},
},
handlers: {
onStart() {
if (!MessageStore) return;
this.patches.add(
before("dispatch", FluxDispatcher, (args: any[]) => {
const action = args[0];
if (!action?.type) return;
try {
const options = this.storage.options as unknown as FilterOptions & { notifyOnDelete: boolean };
const limits = {
maxEntries: numberOption(this.storage.options.maxEntries, 2000),
maxAgeDays: numberOption(this.storage.options.maxAgeDays, 30),
maxPerChannel: numberOption(this.storage.options.maxEntriesPerChannel, 0),
};
switch (action.type) {
case "MESSAGE_DELETE":
if (this.storage.options.logDeleted) handleDelete(action, options, limits);
break;
case "MESSAGE_DELETE_BULK":
if (this.storage.options.logDeleted) handleBulkDelete(action, options, limits);
break;
case "MESSAGE_UPDATE":
if (this.storage.options.logEdited) handleUpdate(action, options, limits);
break;
}
} catch {
// A broken capture should never block the actual dispatch from proceeding.
}
}),
);
},
onStop() {},
},
});

View file

@ -0,0 +1,63 @@
import type { LoggedAttachment, LoggedEmbed, LoggedMessage, LoggedReaction } from "./types";
// Every field read below is part of Discord's own public message object schema, the same shape
// message-dev-tools.ts and server-info-tools.ts already read elsewhere in this plugin - 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 ?? ""),
url: a?.url,
proxyUrl: a?.proxyURL ?? a?.proxy_url,
filename: a?.filename ?? "attachment",
contentType: a?.contentType ?? a?.content_type,
size: a?.size,
}));
}
function embedsOf(message: any): LoggedEmbed[] {
const embeds = message?.embeds;
if (!Array.isArray(embeds)) return [];
return embeds.map((e: any) => ({
title: e?.title ?? e?.rawTitle,
description: e?.description ?? e?.rawDescription,
url: e?.url,
}));
}
function reactionsOf(message: any): LoggedReaction[] {
const reactions = message?.reactions;
if (!Array.isArray(reactions)) return [];
return reactions.map((r: any) => ({
emoji: r?.emoji?.name ?? r?.emoji?.id ?? "?",
count: r?.count ?? 0,
}));
}
export function snapshotMessage(message: any, kind: LoggedMessage["kind"], newContent?: string): LoggedMessage {
const ref = message?.messageReference ?? message?.message_reference;
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,
authorIsBot: !!(message?.author?.bot ?? message?.author?.isNonUserBot?.()),
content: message?.content ?? "",
newContent,
attachments: attachmentsOf(message),
embeds: embedsOf(message),
reactions: reactionsOf(message),
referencedMessageId: ref?.messageId ?? ref?.message_id ?? undefined,
messageTimestamp: message?.timestamp ? String(message.timestamp) : undefined,
loggedAt: new Date().toISOString(),
kind,
};
}

View file

@ -0,0 +1,54 @@
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 FilterOptions {
ignoreBots: boolean;
ignoreOwnMessages: boolean;
ignoreDMs: boolean;
ignoredChannelIds: string;
ignoredGuildIds: string;
ignoredUserIds: string;
ignoredKeywords: string;
onlyLogChannelIds: string;
}
export interface FilterContext {
channelId?: string;
guildId?: string;
authorId?: string;
isBot: boolean;
isOwnMessage: boolean;
content: string;
}
/** True if this message/edit/delete should be skipped entirely - never logged. */
export function shouldIgnore(ctx: FilterContext, options: FilterOptions): boolean {
const onlyChannels = parseIdList(options.onlyLogChannelIds);
if (onlyChannels.size > 0) {
// Allow-list mode: everything else about the ignore lists is bypassed once this is set -
// an explicit "only these channels" list is a stronger signal than any block-list.
return !ctx.channelId || !onlyChannels.has(ctx.channelId);
}
if (options.ignoreBots && ctx.isBot) return true;
if (options.ignoreOwnMessages && 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,54 @@
import { storage } from "@vendetta/plugin";
import type { LoggedMessage } from "./types";
// Its own top-level storage key, deliberately separate from Module.ts's vstorage.modules[id].options
// bag (which the generic settings UI treats as a flat map of small config values) - this can grow to
// thousands of entries, which doesn't belong mixed into that.
const logStorage = storage as { messageLoggerLog?: LoggedMessage[] };
export function getLog(): LoggedMessage[] {
logStorage.messageLoggerLog ??= [];
return logStorage.messageLoggerLog;
}
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) => new Date(e.loggedAt).getTime() >= cutoff);
}
if (maxPerChannel > 0) {
const perChannelCount = new Map<string, number>();
const kept: LoggedMessage[] = [];
// Walk newest-first so the entries kept per channel are always the most recent ones.
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.messageLoggerLog = 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: string): void {
logStorage.messageLoggerLog = getLog().filter((e) => e.loggedAt !== loggedAt);
}
export function clearLog(): void {
logStorage.messageLoggerLog = [];
}

View file

@ -0,0 +1,43 @@
export interface LoggedAttachment {
id: string;
url?: string;
proxyUrl?: string;
filename: string;
contentType?: string;
size?: number;
}
export interface LoggedEmbed {
title?: string;
description?: string;
url?: string;
}
export interface LoggedReaction {
emoji: string;
count: number;
}
export interface LoggedMessage {
id: string;
channelId: string;
guildId?: string;
authorId?: string;
authorUsername?: string;
authorDisplayName?: string;
/** True if content itself came from a bot/webhook - useful for filtering the viewer after the fact. */
authorIsBot?: boolean;
content: string;
/** Only set for "edited" entries - the content the message was changed to. */
newContent?: string;
attachments: LoggedAttachment[];
embeds: LoggedEmbed[];
reactions: LoggedReaction[];
/** The message this one was replying to, if any. */
referencedMessageId?: string;
/** When the original message was sent, if known. */
messageTimestamp?: string;
/** When this entry was captured - used for the age-based prune, distinct from the message's own timestamp. */
loggedAt: string;
kind: "deleted" | "edited";
}

View file

@ -0,0 +1,233 @@
import { findByName, findByProps } from "@vendetta/metro";
import { React, ReactNative, clipboard } from "@vendetta/metro/common";
import { Forms } from "@vendetta/ui/components";
import { showToast } from "@vendetta/ui/toasts";
import { getAssetIDByName } from "@vendetta/ui/assets";
import { getLog, removeLogEntry } from "../lib/store";
import type { LoggedMessage } from "../lib/types";
// Same Navigation/Navigator/close-button lookup pattern already proven working in this repo's
// ViewRaw plugin - reused verbatim rather than re-derived, including its fallback chain (findByName
// misses Navigator on current builds, findByProps("Navigator") is the confirmed-working fallback).
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 { FormInput } = Forms;
const PAGE_SIZE = 100;
function formatTime(iso: string | undefined): string {
if (!iso) return "unknown time";
try {
return new Date(iso).toLocaleString();
} catch {
return iso;
}
}
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 }) {
return (
<ScrollView style={{ flex: 1, padding: 16 }}>
<Text style={{ fontSize: 12, opacity: 0.6, marginBottom: 4 }}>
{entry.kind.toUpperCase()} · {formatTime(entry.messageTimestamp)} · logged {formatTime(entry.loggedAt)}
</Text>
<Text style={{ fontSize: 15, fontWeight: "700", marginBottom: 8 }}>
{entry.authorDisplayName || entry.authorUsername || entry.authorId || "Unknown author"}
{entry.authorIsBot ? " [BOT]" : ""}
</Text>
{entry.kind === "edited" ? (
<>
<Text style={{ fontSize: 12, opacity: 0.6, marginBottom: 2 }}>Before:</Text>
<Text style={{ fontSize: 14, marginBottom: 10 }} selectable>{entry.content || "(empty)"}</Text>
<Text style={{ fontSize: 12, opacity: 0.6, marginBottom: 2 }}>After:</Text>
<Text style={{ fontSize: 14, marginBottom: 10 }} selectable>{entry.newContent || "(empty)"}</Text>
</>
) : (
<Text style={{ fontSize: 14, marginBottom: 10 }} selectable>{entry.content || "(empty)"}</Text>
)}
{entry.attachments.length > 0 && (
<View style={{ marginBottom: 10 }}>
<Text style={{ fontSize: 12, opacity: 0.6, marginBottom: 2 }}>
Attachments ({entry.attachments.length}):
</Text>
{entry.attachments.map((a, i) => (
<Text key={i} style={{ fontSize: 13 }} selectable>
{a.filename}{a.url ? ` - ${a.url}` : ""}
</Text>
))}
</View>
)}
{entry.embeds.length > 0 && (
<View style={{ marginBottom: 10 }}>
<Text style={{ fontSize: 12, opacity: 0.6, marginBottom: 2 }}>
Embeds ({entry.embeds.length}):
</Text>
{entry.embeds.map((e, i) => (
<Text key={i} style={{ fontSize: 13 }} selectable>
{e.title || e.url || "(untitled embed)"}
</Text>
))}
</View>
)}
{entry.reactions.length > 0 && (
<Text style={{ fontSize: 13, marginBottom: 10 }}>
Reactions: {entry.reactions.map((r) => `${r.emoji} x${r.count}`).join(" ")}
</Text>
)}
<Text style={{ fontSize: 11, opacity: 0.5, marginBottom: 16 }} selectable>
Message ID: {entry.id}{"\n"}Channel ID: {entry.channelId}{entry.guildId ? `\nServer ID: ${entry.guildId}` : ""}
{entry.referencedMessageId ? `\nReplying to: ${entry.referencedMessageId}` : ""}
</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" }}>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" }}>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 all = getLog();
const filtered = React.useMemo(() => {
return all
.filter((e) => (kindFilter === "all" ? true : e.kind === kindFilter))
.filter((e) => entryMatches(e, query.trim()))
.slice()
.reverse(); // newest first
}, [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 }}>
<FormInput
title="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 }}>
{k === "all" ? "All" : k === "deleted" ? "Deleted" : "Edited"}
</Text>
</TouchableOpacity>
))}
</View>
<Text style={{ fontSize: 12, opacity: 0.5, marginTop: 8 }}>
{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 }}>
{entry.kind === "deleted" ? "🗑" : "✎"} {entry.authorDisplayName || entry.authorUsername || entry.authorId || "Unknown"} · {formatTime(entry.messageTimestamp)}
</Text>
<Text numberOfLines={2} style={{ fontSize: 14 }}>
{(entry.kind === "deleted" ? entry.content : entry.newContent) || "(no text content)"}
</Text>
</TouchableOpacity>
))}
{filtered.length === 0 && (
<Text style={{ padding: 16, opacity: 0.6 }}>No log entries match.</Text>
)}
{visibleCount < filtered.length && (
<TouchableOpacity onPress={() => setVisibleCount((n) => n + PAGE_SIZE)} style={{ padding: 16, alignItems: "center" }}>
<Text style={{ fontWeight: "600" }}>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

@ -6,6 +6,7 @@ import DeveloperMode from "./DeveloperMode";
import MessageDevTools from "./MessageDevTools";
import SnowflakeTools from "./SnowflakeTools";
import ServerInfoTools from "./ServerInfoTools";
import MessageLogger from "./MessageLogger";
// BetterComponents (Material-style Switch/Toast/Alert redesign) isn't ported - it needs
// react-native-reanimated, which isn't part of this build, and it was already disabled by
@ -20,4 +21,5 @@ export default [
MessageDevTools,
SnowflakeTools,
ServerInfoTools,
MessageLogger,
];

View file

@ -67,6 +67,20 @@ function ModuleSettingRow({ module, settingKey }: { module: AnyModule; settingKe
);
}
if (setting.type === "text") {
return (
<FormInput
title={setting.label}
placeholder={setting.placeholder}
value={typeof value === "string" ? value : ""}
editable={!disabled}
onChange={(v: string) => {
module.storage.options[settingKey] = v;
}}
/>
);
}
// "choose" - cycles through choices on tap instead of a full picker sheet.
return (
<FormRow

View file

@ -256,20 +256,21 @@
"category": "Utility",
"status": "revived",
"accent": "#F0637E",
"tagline": "Spotify listen-along, a Tenor fix, colorful channels, and more - now actually working.",
"note": "Fork of nexpid's NexxUtils. Fixed the reported \"render is not a function in SpotifyPlayButton\" crash - Discord converted it from a class component to a function component, confirmed against decompiled current-build source, so patching .prototype.render no longer worked. Also fixed six other lookups across the plugin that had the same unguarded pattern that crashed FPTE's whole bundle at load time - any one of them failing would have taken the entire plugin down, not just one module. A more serious bug was found and fixed after real device testing: opening RoseUtils' own settings screen crashed outright with \"Cannot convert undefined value to object\". Root cause, confirmed by reading the compiled bundle directly: this repo's SWC build config lowers class syntax to plain functions for compatibility, but doesn't also handle the separate class-fields transform, so every field initializer on the internal Module class (errors, patches, started, listeners) was silently dropped from the compiled output - every module was missing all four. Fixed by assigning them in the constructor body instead, which every build target handles the same way. Also added a new module: Enforce Developer Mode, which keeps Discord's Developer Mode switched on since it tends to reset itself on Revenge. Minimod's timeout indicator was fixed too: GuildMemberStore.getCommunicationDisabledUserMap doesn't exist anymore (confirmed against decompiled source) - it now reads getMember(guildId, userId).communicationDisabledUntil per member instead, which does. Enforce Developer Mode also gained a 5-second poll as a backup to the settings-change listener, so it can't stay off for long no matter what actually reset it.",
"howItWorks": "Each utility is its own independently toggleable module: Spotify listen-along buttons on activities, a fix for Tenor GIFs downloading as videos, an invite-to-listen-along button in the chat attachment menu, a fun \"minimod\" that shows member timeouts in chat, and colorful/NSFW-tagged channel icons. Every module tracks its own errors and shows a one-tap \"copy report\" row if something goes wrong, so a problem in one module is easy to report without digging through logs. The original's Material-Design component reskin (Switch/Toast/Alert) wasn't ported - it depends on react-native-reanimated, which isn't part of this build, and it was already disabled by default upstream due to known issues.",
"tagline": "Spotify listen-along, colorful channels, a full Message Logger, and more - independently toggleable modules.",
"note": "Fork of nexpid's NexxUtils. Originally revived by fixing a class-vs-function-component crash in Spotify listen-along and a build bug that silently dropped every module's own error/patch tracking. Since then: Developer Mode was expanded into a general dev-tools module (message and server info copy-ID tools too), the Tenor GIF fix was dropped (no longer needed), and the settings screen gained search, mass enable/disable, and collapsible categories as the module count grew. Newest addition: Message Logger, a full deleted/edited message logger with a searchable on-device viewer, persisted across restarts.",
"howItWorks": "Each utility is its own independently toggleable module, with its own settings shown once enabled: Spotify listen-along buttons on activities, a Spotify listen-along invite button in the attachment menu, a \"minimod\" showing member timeouts in chat, colorful/NSFW-tagged channel icons, an expanded Developer Mode (keeps Discord's own toggle on, adds copy-ID rows for slash-command interactions and activity/game IDs), extra message long-press copy-ID tools (webhook ID, nonce, attachment IDs, replied-to message ID, debug info), server info/friends-in-server tools, a snowflake ID timestamp decoder, and Message Logger. The settings screen has a search box, per-category collapse, and bulk enable/disable, since the module count has grown past what a flat list handles well. Every module tracks its own errors and shows a one-tap \"copy report\" row if something goes wrong.",
"features": [
"Spotify listen-along buttons on activities (fixed)",
"Tenor GIF download fix",
"Send-a-Spotify-invite button in the attachment menu",
"Spotify listen-along buttons on activities, plus an invite button in the attachment menu",
"Message Logger: deleted/edited message log, persisted on-device, searchable viewer, ignore lists, keyword filters, per-channel/total/age limits",
"Minimod: shows member timeouts in chat",
"Colorful, NSFW-tagged channel icons",
"Expanded Developer Mode with extra copy-ID tools (commands, activities, webhooks, nonces, attachments, replies, server info)",
"Snowflake ID timestamp decoder",
"Per-module error tracking with one-tap copy for bug reports",
"Enforce Developer Mode - keeps it on since it resets itself on Revenge-based clients"
"Settings screen: search, collapsible categories, bulk enable/disable"
],
"commands": [],
"limitations": "The Material-Design component reskin from the original isn't included (needs react-native-reanimated). Each module was fixed and defensively guarded, but only the specific reported crash was verified against live decompiled source - the rest are guarded, not individually confirmed working on-device."
"limitations": "The original's Material-Design component reskin isn't included (needs react-native-reanimated, not part of this build). Message Logger can only log messages Discord's own client has actually cached locally - it can't recover a deleted message it never saw, and attachment files themselves aren't re-hosted after Discord's CDN removes them (only the URL/filename/metadata are kept)."
},
"server-drawer": {
"category": "Moderation & Appearance",