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/` |
|
| 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/` |
|
| 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.
|
"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
|
"Rebuilt" means it's a from-scratch reimplementation of a previously-broken plugin with expanded features. "New" means
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "RoseUtils",
|
"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": [
|
"authors": [
|
||||||
{ "name": "everestmcarthur" },
|
{ "name": "everestmcarthur" },
|
||||||
{ "name": "nexpid" }
|
{ "name": "nexpid" }
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ export type ModuleSetting =
|
||||||
| { type: "toggle"; default: boolean }
|
| { type: "toggle"; default: boolean }
|
||||||
| { type: "button"; action: (this: AnyModule) => void }
|
| { type: "button"; action: (this: AnyModule) => void }
|
||||||
| { type: "choose"; choices: string[]; default: string }
|
| { type: "choose"; choices: string[]; default: string }
|
||||||
|
| { type: "text"; default: string; placeholder?: string }
|
||||||
);
|
);
|
||||||
|
|
||||||
export interface ModuleMeta {
|
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 MessageDevTools from "./MessageDevTools";
|
||||||
import SnowflakeTools from "./SnowflakeTools";
|
import SnowflakeTools from "./SnowflakeTools";
|
||||||
import ServerInfoTools from "./ServerInfoTools";
|
import ServerInfoTools from "./ServerInfoTools";
|
||||||
|
import MessageLogger from "./MessageLogger";
|
||||||
|
|
||||||
// BetterComponents (Material-style Switch/Toast/Alert redesign) isn't ported - it needs
|
// 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
|
// react-native-reanimated, which isn't part of this build, and it was already disabled by
|
||||||
|
|
@ -20,4 +21,5 @@ export default [
|
||||||
MessageDevTools,
|
MessageDevTools,
|
||||||
SnowflakeTools,
|
SnowflakeTools,
|
||||||
ServerInfoTools,
|
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.
|
// "choose" - cycles through choices on tap instead of a full picker sheet.
|
||||||
return (
|
return (
|
||||||
<FormRow
|
<FormRow
|
||||||
|
|
|
||||||
|
|
@ -256,20 +256,21 @@
|
||||||
"category": "Utility",
|
"category": "Utility",
|
||||||
"status": "revived",
|
"status": "revived",
|
||||||
"accent": "#F0637E",
|
"accent": "#F0637E",
|
||||||
"tagline": "Spotify listen-along, a Tenor fix, colorful channels, and more - now actually working.",
|
"tagline": "Spotify listen-along, colorful channels, a full Message Logger, and more - independently toggleable modules.",
|
||||||
"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.",
|
"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: 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.",
|
"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": [
|
"features": [
|
||||||
"Spotify listen-along buttons on activities (fixed)",
|
"Spotify listen-along buttons on activities, plus an invite button in the attachment menu",
|
||||||
"Tenor GIF download fix",
|
"Message Logger: deleted/edited message log, persisted on-device, searchable viewer, ignore lists, keyword filters, per-channel/total/age limits",
|
||||||
"Send-a-Spotify-invite button in the attachment menu",
|
|
||||||
"Minimod: shows member timeouts in chat",
|
"Minimod: shows member timeouts in chat",
|
||||||
"Colorful, NSFW-tagged channel icons",
|
"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",
|
"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": [],
|
"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": {
|
"server-drawer": {
|
||||||
"category": "Moderation & Appearance",
|
"category": "Moderation & Appearance",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue