diff --git a/README.md b/README.md index 8c297d7..34ed0e9 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/plugins/rose-utils/manifest.json b/plugins/rose-utils/manifest.json index f758625..1e9ebf5 100644 --- a/plugins/rose-utils/manifest.json +++ b/plugins/rose-utils/manifest.json @@ -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" } diff --git a/plugins/rose-utils/src/lib/Module.ts b/plugins/rose-utils/src/lib/Module.ts index cc9789d..5320a49 100644 --- a/plugins/rose-utils/src/lib/Module.ts +++ b/plugins/rose-utils/src/lib/Module.ts @@ -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 { diff --git a/plugins/rose-utils/src/modules/MessageLogger/index.ts b/plugins/rose-utils/src/modules/MessageLogger/index.ts new file mode 100644 index 0000000..4fd297e --- /dev/null +++ b/plugins/rose-utils/src/modules/MessageLogger/index.ts @@ -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() {}, + }, +}); diff --git a/plugins/rose-utils/src/modules/MessageLogger/lib/capture.ts b/plugins/rose-utils/src/modules/MessageLogger/lib/capture.ts new file mode 100644 index 0000000..2f9c507 --- /dev/null +++ b/plugins/rose-utils/src/modules/MessageLogger/lib/capture.ts @@ -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, + }; +} diff --git a/plugins/rose-utils/src/modules/MessageLogger/lib/filters.ts b/plugins/rose-utils/src/modules/MessageLogger/lib/filters.ts new file mode 100644 index 0000000..b646a82 --- /dev/null +++ b/plugins/rose-utils/src/modules/MessageLogger/lib/filters.ts @@ -0,0 +1,54 @@ +function parseIdList(raw: string | undefined): Set { + 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; +} diff --git a/plugins/rose-utils/src/modules/MessageLogger/lib/store.ts b/plugins/rose-utils/src/modules/MessageLogger/lib/store.ts new file mode 100644 index 0000000..996fc6a --- /dev/null +++ b/plugins/rose-utils/src/modules/MessageLogger/lib/store.ts @@ -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(); + 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 = []; +} diff --git a/plugins/rose-utils/src/modules/MessageLogger/lib/types.ts b/plugins/rose-utils/src/modules/MessageLogger/lib/types.ts new file mode 100644 index 0000000..7f812d9 --- /dev/null +++ b/plugins/rose-utils/src/modules/MessageLogger/lib/types.ts @@ -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"; +} diff --git a/plugins/rose-utils/src/modules/MessageLogger/ui/LogViewerPage.tsx b/plugins/rose-utils/src/modules/MessageLogger/ui/LogViewerPage.tsx new file mode 100644 index 0000000..92ca482 --- /dev/null +++ b/plugins/rose-utils/src/modules/MessageLogger/ui/LogViewerPage.tsx @@ -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 ( + + + {entry.kind.toUpperCase()} · {formatTime(entry.messageTimestamp)} · logged {formatTime(entry.loggedAt)} + + + {entry.authorDisplayName || entry.authorUsername || entry.authorId || "Unknown author"} + {entry.authorIsBot ? " [BOT]" : ""} + + + {entry.kind === "edited" ? ( + <> + Before: + {entry.content || "(empty)"} + After: + {entry.newContent || "(empty)"} + + ) : ( + {entry.content || "(empty)"} + )} + + {entry.attachments.length > 0 && ( + + + Attachments ({entry.attachments.length}): + + {entry.attachments.map((a, i) => ( + + {a.filename}{a.url ? ` - ${a.url}` : ""} + + ))} + + )} + + {entry.embeds.length > 0 && ( + + + Embeds ({entry.embeds.length}): + + {entry.embeds.map((e, i) => ( + + {e.title || e.url || "(untitled embed)"} + + ))} + + )} + + {entry.reactions.length > 0 && ( + + Reactions: {entry.reactions.map((r) => `${r.emoji} x${r.count}`).join(" ")} + + )} + + + Message ID: {entry.id}{"\n"}Channel ID: {entry.channelId}{entry.guildId ? `\nServer ID: ${entry.guildId}` : ""} + {entry.referencedMessageId ? `\nReplying to: ${entry.referencedMessageId}` : ""} + + + + { + clipboard.setString(JSON.stringify(entry, null, 2)); + showToast("Copied entry as JSON", getAssetIDByName("ic_copy_24px")); + }} + > + Copy JSON + + { + removeLogEntry(entry.loggedAt); + showToast("Removed entry", undefined); + onDeleted(); + }} + > + Delete entry + + + Back + + + + ); +} + +function LogList() { + const [query, setQuery] = React.useState(""); + const [kindFilter, setKindFilter] = React.useState<"all" | "deleted" | "edited">("all"); + const [selected, setSelected] = React.useState(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 ( + setSelected(null)} + onDeleted={() => { + setSelected(null); + forceUpdate(); + }} + /> + ); + } + + return ( + + + + + {(["all", "deleted", "edited"] as const).map((k) => ( + setKindFilter(k)}> + + {k === "all" ? "All" : k === "deleted" ? "Deleted" : "Edited"} + + + ))} + + + {filtered.length} of {all.length} total + + + + + {filtered.slice(0, visibleCount).map((entry) => ( + setSelected(entry)} + style={{ paddingHorizontal: 16, paddingVertical: 10, borderBottomWidth: 1, borderBottomColor: "rgba(128,128,128,0.15)" }} + > + + {entry.kind === "deleted" ? "🗑" : "✎"} {entry.authorDisplayName || entry.authorUsername || entry.authorId || "Unknown"} · {formatTime(entry.messageTimestamp)} + + + {(entry.kind === "deleted" ? entry.content : entry.newContent) || "(no text content)"} + + + ))} + {filtered.length === 0 && ( + No log entries match. + )} + {visibleCount < filtered.length && ( + setVisibleCount((n) => n + PAGE_SIZE)} style={{ padding: 16, alignItems: "center" }}> + Load more ({filtered.length - visibleCount} remaining) + + )} + + + ); +} + +export default function openLogViewer(): void { + if (!Navigator || !Navigation) { + showToast("Couldn't open the log viewer - Navigator not found", undefined); + return; + } + + Navigation.push(() => ( + Navigation.pop()), + render: () => , + }, + }} + /> + )); +} diff --git a/plugins/rose-utils/src/modules/index.ts b/plugins/rose-utils/src/modules/index.ts index e922a5c..c47e5dc 100644 --- a/plugins/rose-utils/src/modules/index.ts +++ b/plugins/rose-utils/src/modules/index.ts @@ -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, ]; diff --git a/plugins/rose-utils/src/ui/Settings.tsx b/plugins/rose-utils/src/ui/Settings.tsx index d19e1c4..59c1c33 100644 --- a/plugins/rose-utils/src/ui/Settings.tsx +++ b/plugins/rose-utils/src/ui/Settings.tsx @@ -67,6 +67,20 @@ function ModuleSettingRow({ module, settingKey }: { module: AnyModule; settingKe ); } + if (setting.type === "text") { + return ( + { + module.storage.options[settingKey] = v; + }} + /> + ); + } + // "choose" - cycles through choices on tap instead of a full picker sheet. return (