RoseUtils: add Message Logger (deleted/edited messages, persisted, searchable)
This commit is contained in:
parent
66d1777e44
commit
3574c4b21e
12 changed files with 727 additions and 10 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
252
plugins/rose-utils/src/modules/MessageLogger/index.ts
Normal file
252
plugins/rose-utils/src/modules/MessageLogger/index.ts
Normal 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() {},
|
||||
},
|
||||
});
|
||||
63
plugins/rose-utils/src/modules/MessageLogger/lib/capture.ts
Normal file
63
plugins/rose-utils/src/modules/MessageLogger/lib/capture.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
54
plugins/rose-utils/src/modules/MessageLogger/lib/filters.ts
Normal file
54
plugins/rose-utils/src/modules/MessageLogger/lib/filters.ts
Normal 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;
|
||||
}
|
||||
54
plugins/rose-utils/src/modules/MessageLogger/lib/store.ts
Normal file
54
plugins/rose-utils/src/modules/MessageLogger/lib/store.ts
Normal 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 = [];
|
||||
}
|
||||
43
plugins/rose-utils/src/modules/MessageLogger/lib/types.ts
Normal file
43
plugins/rose-utils/src/modules/MessageLogger/lib/types.ts
Normal 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";
|
||||
}
|
||||
|
|
@ -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 />,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
|
@ -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,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue