diff --git a/plugins/message-logger/manifest.json b/plugins/message-logger/manifest.json new file mode 100644 index 0000000..7b34adb --- /dev/null +++ b/plugins/message-logger/manifest.json @@ -0,0 +1,11 @@ +{ + "name": "MessageLogger", + "description": "Keeps deleted and edited messages visible in the chat, the way Equicord's MessageLogger does on desktop - plus a viewer for anything that couldn't be kept inline.", + "authors": [ + { "name": "Rosie" } + ], + "main": "src/index.ts", + "vendetta": { + "icon": "ic_message_edit" + } +} diff --git a/plugins/message-logger/src/index.ts b/plugins/message-logger/src/index.ts new file mode 100644 index 0000000..5fddfde --- /dev/null +++ b/plugins/message-logger/src/index.ts @@ -0,0 +1,53 @@ +import { logger } from "@vendetta"; +import { storage } from "@vendetta/plugin"; +import Settings from "./ui/Settings"; +import { patchFluxIntercept, revertFakedMessages } from "./patches/fluxIntercept"; +import { patchRowStyling } from "./patches/rowStyling"; +import { patchMessageRecord } from "./patches/messageRecord"; + +const TAG = "[MessageLogger]"; +const cleanups: (() => void)[] = []; + +function initStorage() { + storage.options ??= {}; + const o = storage.options; + o.logDeleted ??= true; + o.keepDeletedInline ??= true; + o.logEdited ??= true; + o.ignoreBots ??= false; + o.ignoreOwnMessages ??= false; + o.ignoreOwnEdits ??= false; + o.ignoreDMs ??= false; + o.ignoredChannelIds ??= ""; + o.ignoredGuildIds ??= ""; + o.ignoredUserIds ??= ""; + o.ignoredKeywords ??= ""; + o.maxEntries ??= "2000"; + o.maxEntriesPerChannel ??= "0"; + o.maxAgeDays ??= "30"; +} + +export default { + onLoad() { + initStorage(); + console.log(TAG, "onLoad"); + + let patched = 0; + if (patchFluxIntercept(cleanups)) patched++; + if (patchRowStyling(cleanups)) patched++; + if (patchMessageRecord(cleanups)) patched++; + + console.log(TAG, `onLoad done - ${patched}/3 patches applied`); + }, + onUnload() { + console.log(TAG, "onUnload"); + try { + revertFakedMessages(); + } catch (e: any) { + logger.error("[MessageLogger] Failed to revert faked messages:", e?.message ?? e); + } + for (const fn of cleanups) fn(); + cleanups.length = 0; + }, + settings: Settings, +}; diff --git a/plugins/message-logger/src/lib/capture.ts b/plugins/message-logger/src/lib/capture.ts new file mode 100644 index 0000000..c13d64d --- /dev/null +++ b/plugins/message-logger/src/lib/capture.ts @@ -0,0 +1,56 @@ +import type { LoggedAttachment, LoggedMessage } from "./types"; + +// Every field read below is part of Discord's own public message object schema - checked in both +// camelCase and snake_case since MessageStore's cached objects don't consistently normalize every +// field the same way across message sources (gateway vs. REST-backfilled history). +function attachmentsOf(message: any): LoggedAttachment[] { + const attachments = message?.attachments; + if (!Array.isArray(attachments)) return []; + + return attachments.map((a: any) => ({ + id: String(a?.id ?? ""), + filename: a?.filename ?? "attachment", + url: a?.url ?? "", + proxyUrl: a?.proxyURL ?? a?.proxy_url, + contentType: a?.contentType ?? a?.content_type, + width: a?.width, + height: a?.height, + })); +} + +function avatarUrlOf(author: any): string | undefined { + if (!author?.id) return undefined; + if (!author.avatar) { + // Discord's default avatar set - index derived from the discriminator/id the same way + // Discord's own client picks one for users with no avatar set. + const index = author.discriminator && author.discriminator !== "0" + ? Number(author.discriminator) % 5 + : Number((BigInt(author.id) >> 22n) % 6n); + return `https://cdn.discordapp.com/embed/avatars/${index}.png`; + } + const ext = author.avatar.startsWith("a_") ? "gif" : "png"; + return `https://cdn.discordapp.com/avatars/${author.id}/${author.avatar}.${ext}`; +} + +export function snapshotMessage(message: any, kind: LoggedMessage["kind"], newContent: string | undefined, keptInline: boolean): LoggedMessage { + return { + id: String(message?.id ?? ""), + channelId: String(message?.channel_id ?? message?.channelId ?? ""), + guildId: message?.guild_id ?? message?.guildId ?? undefined, + authorId: message?.author?.id ?? "", + authorUsername: message?.author?.username, + authorDisplayName: message?.author?.globalName ?? message?.author?.global_name ?? undefined, + authorAvatarUrl: avatarUrlOf(message?.author), + content: message?.content ?? "", + newContent, + kind, + loggedAt: Date.now(), + timestamp: message?.timestamp ? String(message.timestamp) : undefined, + attachments: attachmentsOf(message), + embeds: Array.isArray(message?.embeds) ? message.embeds : [], + components: Array.isArray(message?.components) ? message.components : [], + stickerItems: message?.stickerItems ?? message?.sticker_items ?? [], + poll: message?.poll, + keptInline, + }; +} diff --git a/plugins/message-logger/src/lib/filters.ts b/plugins/message-logger/src/lib/filters.ts new file mode 100644 index 0000000..e31611f --- /dev/null +++ b/plugins/message-logger/src/lib/filters.ts @@ -0,0 +1,40 @@ +import { FilterOptions } from "./types"; + +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 FilterContext { + channelId?: string; + guildId?: string; + authorId?: string; + isBot: boolean; + isOwnMessage: boolean; + isEdit: boolean; + content: string; +} + +/** True if this message/edit/delete should be skipped entirely - never captured. */ +export function shouldIgnore(ctx: FilterContext, options: FilterOptions): boolean { + if (options.ignoreBots && ctx.isBot) return true; + if (options.ignoreOwnMessages && ctx.isOwnMessage) return true; + if (options.ignoreOwnEdits && ctx.isEdit && ctx.isOwnMessage) return true; + if (options.ignoreDMs && !ctx.guildId) return true; + if (ctx.channelId && parseIdList(options.ignoredChannelIds).has(ctx.channelId)) return true; + if (ctx.guildId && parseIdList(options.ignoredGuildIds).has(ctx.guildId)) return true; + if (ctx.authorId && parseIdList(options.ignoredUserIds).has(ctx.authorId)) return true; + + const keywords = parseKeywordList(options.ignoredKeywords); + if (keywords.length > 0) { + const haystack = ctx.content.toLowerCase(); + if (keywords.some((kw) => haystack.includes(kw))) return true; + } + + return false; +} diff --git a/plugins/message-logger/src/lib/store.ts b/plugins/message-logger/src/lib/store.ts new file mode 100644 index 0000000..ba50f8e --- /dev/null +++ b/plugins/message-logger/src/lib/store.ts @@ -0,0 +1,50 @@ +import { storage } from "@vendetta/plugin"; +import type { LoggedMessage } from "./types"; + +const logStorage = storage as { log?: LoggedMessage[] }; + +export function getLog(): LoggedMessage[] { + logStorage.log ??= []; + return logStorage.log; +} + +function pruneLog(maxEntries: number, maxAgeDays: number, maxPerChannel: number): void { + let log = getLog(); + + if (maxAgeDays > 0) { + const cutoff = Date.now() - maxAgeDays * 24 * 60 * 60 * 1000; + log = log.filter((e) => e.loggedAt >= cutoff); + } + + if (maxPerChannel > 0) { + const perChannelCount = new Map(); + const kept: LoggedMessage[] = []; + for (let i = log.length - 1; i >= 0; i--) { + const entry = log[i]; + const count = perChannelCount.get(entry.channelId) ?? 0; + if (count >= maxPerChannel) continue; + perChannelCount.set(entry.channelId, count + 1); + kept.push(entry); + } + log = kept.reverse(); + } + + if (maxEntries > 0 && log.length > maxEntries) { + log = log.slice(log.length - maxEntries); + } + + logStorage.log = log; +} + +export function addLogEntry(entry: LoggedMessage, limits: { maxEntries: number; maxAgeDays: number; maxPerChannel: number }): void { + getLog().push(entry); + pruneLog(limits.maxEntries, limits.maxAgeDays, limits.maxPerChannel); +} + +export function removeLogEntry(loggedAt: number): void { + logStorage.log = getLog().filter((e) => e.loggedAt !== loggedAt); +} + +export function clearLog(): void { + logStorage.log = []; +} diff --git a/plugins/message-logger/src/lib/types.ts b/plugins/message-logger/src/lib/types.ts new file mode 100644 index 0000000..51394bf --- /dev/null +++ b/plugins/message-logger/src/lib/types.ts @@ -0,0 +1,41 @@ +export interface LoggedAttachment { + id: string; + filename: string; + url: string; + proxyUrl?: string; + contentType?: string; + width?: number; + height?: number; +} + +export interface LoggedMessage { + id: string; + channelId: string; + guildId?: string; + authorId: string; + authorUsername?: string; + authorDisplayName?: string; + authorAvatarUrl?: string; + content: string; + newContent?: string; + kind: "deleted" | "edited" | "bulk-deleted"; + loggedAt: number; + timestamp?: string; + attachments: LoggedAttachment[]; + embeds: any[]; + components: any[]; + stickerItems: any[]; + poll?: any; + keptInline: boolean; +} + +export interface FilterOptions { + ignoreBots: boolean; + ignoreOwnMessages: boolean; + ignoreOwnEdits: boolean; + ignoreDMs: boolean; + ignoredChannelIds: string; + ignoredGuildIds: string; + ignoredUserIds: string; + ignoredKeywords: string; +} diff --git a/plugins/message-logger/src/patches/fluxIntercept.ts b/plugins/message-logger/src/patches/fluxIntercept.ts new file mode 100644 index 0000000..3e4b4f3 --- /dev/null +++ b/plugins/message-logger/src/patches/fluxIntercept.ts @@ -0,0 +1,208 @@ +import { findByProps, findByStoreName } from "@vendetta/metro"; +import { FluxDispatcher } from "@vendetta/metro/common"; +import { before } from "@vendetta/patcher"; +import { storage } from "@vendetta/plugin"; +import { shouldIgnore } from "../lib/filters"; +import { snapshotMessage } from "../lib/capture"; +import { addLogEntry } from "../lib/store"; +import type { FilterOptions } from "../lib/types"; + +const TAG = "[MessageLogger]"; +const FAKE_DELETE_FLAG = "__msgLoggerDeleted"; +const CLEANUP_FLAG = "__msgLoggerCleanup"; + +const ChannelMessages = findByProps("_channelMessages"); +const UserStore = findByStoreName("UserStore"); + +// Ids currently kept "alive" as a faked MESSAGE_UPDATE instead of a real delete, mapped to the +// channel they belong to - tracked so onUnload can put them back the way it found them (dispatch a +// real delete for each) rather than leaving the client's own message cache holding fake state +// forever if the plugin gets disabled. +export const fakedMessages = new Map(); + +function currentUserId(): string | undefined { + return UserStore?.getCurrentUser?.()?.id; +} + +function numberOption(raw: string | undefined, fallback: number): number { + const n = parseInt(raw ?? "", 10); + return Number.isFinite(n) && n >= 0 ? n : fallback; +} + +function limitsFromStorage(): { maxEntries: number; maxAgeDays: number; maxPerChannel: number } { + return { + maxEntries: numberOption(storage.options?.maxEntries, 2000), + maxAgeDays: numberOption(storage.options?.maxAgeDays, 30), + maxPerChannel: numberOption(storage.options?.maxEntriesPerChannel, 0), + }; +} + +function filterOptionsFromStorage(): FilterOptions { + const o = storage.options ?? {}; + return { + ignoreBots: !!o.ignoreBots, + ignoreOwnMessages: !!o.ignoreOwnMessages, + ignoreOwnEdits: !!o.ignoreOwnEdits, + ignoreDMs: !!o.ignoreDMs, + ignoredChannelIds: o.ignoredChannelIds ?? "", + ignoredGuildIds: o.ignoredGuildIds ?? "", + ignoredUserIds: o.ignoredUserIds ?? "", + ignoredKeywords: o.ignoredKeywords ?? "", + }; +} + +function ignoredFor(message: any, isEdit: boolean): boolean { + const authorId = message?.author?.id; + return shouldIgnore( + { + channelId: message?.channel_id ?? message?.channelId, + guildId: message?.guild_id ?? message?.guildId, + authorId, + isBot: !!(message?.author?.bot ?? message?.author?.isNonUserBot?.()), + isOwnMessage: !!authorId && authorId === currentUserId(), + isEdit, + content: message?.content ?? "", + }, + filterOptionsFromStorage(), + ); +} + +function hasLoggableContent(message: any): boolean { + return !!( + message?.content || + message?.attachments?.length || + message?.embeds?.length || + message?.components?.length || + (message?.stickerItems ?? message?.sticker_items)?.length || + message?.poll + ); +} + +function fakeUpdateFor(original: any): any { + return { type: "MESSAGE_UPDATE", message: { ...original, [FAKE_DELETE_FLAG]: true } }; +} + +function captureAndMaybeKeep(original: any, channelId: string, id: string): any | undefined { + const EPHEMERAL = 64; + if (!original?.author?.id) return undefined; // never cached, or already evicted - nothing left to do + if ((original.flags & EPHEMERAL) === EPHEMERAL) return undefined; // an ephemeral dismiss, not a real delete + if (!hasLoggableContent(original)) return undefined; + if (!storage.options?.logDeleted) return undefined; + if (ignoredFor(original, false)) return undefined; + + const keepInline = !!storage.options?.keepDeletedInline; + addLogEntry(snapshotMessage(original, "deleted", undefined, keepInline), limitsFromStorage()); + + if (!keepInline) return undefined; + + fakedMessages.set(id, channelId); + return fakeUpdateFor(original); +} + +function handleSingleDelete(event: any): any { + if (event[CLEANUP_FLAG]) return undefined; // our own revert dispatch on unload - pass through untouched + + const channelId = event.channelId ?? event.channel_id; + const original = ChannelMessages?.get?.(channelId)?.get?.(event.id); + const fake = captureAndMaybeKeep(original, channelId, event.id); + return fake ? [fake] : undefined; +} + +function handleBulkDelete(event: any): any { + const channelId = event.channelId ?? event.channel_id; + const ids: string[] = Array.isArray(event.ids) ? event.ids : []; + if (!ids.length) return undefined; + + const kept: string[] = []; + const dropped: string[] = []; + + for (const id of ids) { + const original = ChannelMessages?.get?.(channelId)?.get?.(id); + const fake = captureAndMaybeKeep(original, channelId, id); + if (fake) kept.push(id); + else dropped.push(id); + } + + if (!kept.length) return undefined; // nothing kept inline - let the real bulk delete proceed as-is + + // Dispatching more actions from inside a dispatch-in-progress is a real re-entrancy risk (Flux + // implementations commonly disallow it) - deferred to a microtask, one dispatch per kept message, + // running after this bulk-delete's own dispatch has fully finished. + queueMicrotask(() => { + for (const id of kept) { + const original = ChannelMessages?.get?.(channelId)?.get?.(id); + if (!original) continue; + try { + FluxDispatcher.dispatch(fakeUpdateFor(original)); + } catch (e: any) { + console.error(TAG, "Deferred bulk-keep dispatch failed for", id, e?.message ?? e); + } + } + }); + + if (!dropped.length) { + // Every id in this bulk delete is being kept inline via the deferred dispatches above - + // replace the original bulk delete with a no-op so none of them get removed for real. + return [{ type: "MESSAGE_DELETE_BULK", channelId, ids: [] }]; + } + return [{ type: "MESSAGE_DELETE_BULK", channelId, ids: dropped }]; +} + +function handleUpdate(event: any): void { + const updated = event?.message; + if (!updated?.id) return; + if (updated[FAKE_DELETE_FLAG]) return; // our own fake-delete-as-update, not a real edit + + const channelId = updated.channel_id ?? updated.channelId; + const original = ChannelMessages?.get?.(channelId)?.get?.(updated.id); + if (!original) return; + + const newContent = updated.content ?? original.content; + if (original.content === newContent) return; // MESSAGE_UPDATE also fires for non-content changes + if (!storage.options?.logEdited) return; + if (ignoredFor(original, true)) return; + + addLogEntry(snapshotMessage(original, "edited", newContent, false), limitsFromStorage()); +} + +export function patchFluxIntercept(cleanups: (() => void)[]): boolean { + if (!ChannelMessages) { + console.warn(TAG, "ChannelMessages module not found, skipping capture"); + return false; + } + + cleanups.push( + before("dispatch", FluxDispatcher, (args: any[]) => { + const event = args[0]; + if (!event?.type) return; + + try { + switch (event.type) { + case "MESSAGE_DELETE": + return handleSingleDelete(event); + case "MESSAGE_DELETE_BULK": + return handleBulkDelete(event); + case "MESSAGE_UPDATE": + handleUpdate(event); + return; + } + } catch (e: any) { + console.error(TAG, "Flux intercept error:", e?.message ?? e); + } + }), + ); + + return true; +} + +/** Reverts every message currently faked-alive back to a real delete - called on unload. */ +export function revertFakedMessages(): void { + for (const [id, channelId] of fakedMessages) { + try { + FluxDispatcher.dispatch({ type: "MESSAGE_DELETE", channelId, id, [CLEANUP_FLAG]: true }); + } catch (e: any) { + console.error(TAG, "Failed to revert faked message", id, e?.message ?? e); + } + } + fakedMessages.clear(); +} diff --git a/plugins/message-logger/src/patches/messageRecord.ts b/plugins/message-logger/src/patches/messageRecord.ts new file mode 100644 index 0000000..48298e9 --- /dev/null +++ b/plugins/message-logger/src/patches/messageRecord.ts @@ -0,0 +1,48 @@ +import { findByName, findByProps } from "@vendetta/metro"; +import { after, instead } from "@vendetta/patcher"; + +const TAG = "[MessageLogger]"; +const FAKE_DELETE_FLAG = "__msgLoggerDeleted"; + +// Discord wraps raw message JSON into a "MessageRecord" class before it ever reaches row +// generation - a plain custom field set directly on the raw object doesn't reliably survive that +// wrap. Confirmed against three independent existing plugins doing the same delete-as-update trick +// (all thread a custom flag through this exact layer) - without this, the flag rowStyling.ts looks +// for is gone by the time a row actually gets built. +export function patchMessageRecord(cleanups: (() => void)[]): boolean { + const MessageRecordUtils = findByProps("updateMessageRecord", "createMessageRecord"); + const MessageRecord = findByName("MessageRecord", false); + + if (!MessageRecordUtils || !MessageRecord) { + console.warn(TAG, "MessageRecord module(s) not found - deleted messages may not render styled"); + return false; + } + + try { + cleanups.push( + after("createMessageRecord", MessageRecordUtils, ([message]: any[], record: any) => { + if (message?.[FAKE_DELETE_FLAG]) record[FAKE_DELETE_FLAG] = true; + }), + ); + + cleanups.push( + instead("updateMessageRecord", MessageRecordUtils, ([oldRecord, newRecord]: any[], orig: any) => { + if (newRecord?.[FAKE_DELETE_FLAG]) { + return MessageRecordUtils.createMessageRecord(newRecord, oldRecord?.reactions); + } + return orig.apply(MessageRecordUtils, [oldRecord, newRecord]); + }), + ); + + cleanups.push( + after("default", MessageRecord, ([props]: any[], record: any) => { + if (props?.[FAKE_DELETE_FLAG]) record[FAKE_DELETE_FLAG] = true; + }), + ); + } catch (e: any) { + console.warn(TAG, "Failed to patch MessageRecord:", e?.message ?? e); + return false; + } + + return true; +} diff --git a/plugins/message-logger/src/patches/rowStyling.ts b/plugins/message-logger/src/patches/rowStyling.ts new file mode 100644 index 0000000..2ce7bd8 --- /dev/null +++ b/plugins/message-logger/src/patches/rowStyling.ts @@ -0,0 +1,58 @@ +import { ReactNative } from "@vendetta/metro/common"; +import { after, before } from "@vendetta/patcher"; +import { waitFor } from "@shared/lib/waitFor"; +import { rawFindByName } from "@shared/lib/rawFind"; + +const TAG = "[MessageLogger]"; +const FAKE_DELETE_FLAG = "__msgLoggerDeleted"; + +function handleRow(row: any) { + const message = row?.message; + if (!message?.[FAKE_DELETE_FLAG]) return; + + message.edited = "deleted"; + message.textColor = ReactNative.processColor("#E4404380"); + row.backgroundHighlight ??= {}; + row.backgroundHighlight.backgroundColor = ReactNative.processColor("#da373c22"); + row.backgroundHighlight.gutterColor = ReactNative.processColor("#da373cff"); +} + +// Confirmed live: shared/lib/patchRows.ts's one-shot RowManager.prototype.generate lookup missed +// entirely - the method doesn't exist yet at plugin onLoad time (not registered until Discord's own +// code naturally requires that module), and a one-shot findByName never gets a second chance. Same +// class of bug RoleColorEverywhere's own patches/rows.ts already had to work around with a raw, +// uncached, retrying lookup instead of relying on the shared helper. +export function patchRowStyling(cleanups: (() => void)[]): boolean { + const { NativeModules } = ReactNative; + const DCDChatManager = NativeModules?.DCDChatManager; + + if (DCDChatManager?.updateRows) { + cleanups.push( + before("updateRows", DCDChatManager, (args: any[]) => { + try { + const rows = JSON.parse(args[1]); + for (const row of rows) handleRow(row); + args[1] = JSON.stringify(rows); + } catch { + // Leave args untouched - better to show unstyled rows than break message loading. + } + }), + ); + return true; + } + + const handle = waitFor( + () => { + const RowManager = rawFindByName("RowManager"); + return RowManager?.prototype?.generate ? RowManager : undefined; + }, + (RowManager) => { + cleanups.push(after("generate", RowManager.prototype, (_args: any[], row: any) => { + try { handleRow(row); } catch (e: any) { console.warn(TAG, "Row styling failed:", e?.message ?? e); } + })); + }, + ); + cleanups.push(() => handle.cancel()); + + return true; +} diff --git a/plugins/message-logger/src/ui/LogViewerPage.tsx b/plugins/message-logger/src/ui/LogViewerPage.tsx new file mode 100644 index 0000000..0a9114e --- /dev/null +++ b/plugins/message-logger/src/ui/LogViewerPage.tsx @@ -0,0 +1,244 @@ +import { findByName, findByProps } from "@vendetta/metro"; +import { React, ReactNative, clipboard } from "@vendetta/metro/common"; +import { TextInput } from "@shared/ui/table"; +import { showToast } from "@vendetta/ui/toasts"; +import { getAssetIDByName } from "@vendetta/ui/assets"; +import { resolveSemanticColorSafe } from "@shared/lib/color"; +import { getLog, removeLogEntry } from "../lib/store"; +import type { LoggedMessage } from "../lib/types"; + +// Same Navigation/Navigator/close-button lookup already proven working elsewhere in this repo +// (ViewRaw, RoseUtils' own log viewer) - reused verbatim including the fallback chain. +const Navigation = findByProps("push", "pushLazy", "pop"); +const modalCloseButton = + findByProps("getRenderCloseButton")?.getRenderCloseButton ?? + findByProps("getHeaderCloseButton")?.getHeaderCloseButton; +const Navigator = findByName("Navigator") ?? findByProps("Navigator")?.Navigator; + +const { View, Text, ScrollView, TouchableOpacity } = ReactNative; + +const PAGE_SIZE = 100; + +function textColor(): string { + return resolveSemanticColorSafe(["TEXT_NORMAL", "TEXT_DEFAULT"], "#dbdee1"); +} + +function formatTime(value: number | string | undefined): string { + if (!value) return "unknown time"; + try { + return new Date(value).toLocaleString(); + } catch { + return String(value); + } +} + +function kindLabel(kind: LoggedMessage["kind"]): string { + if (kind === "deleted") return "🗑 Deleted"; + if (kind === "bulk-deleted") return "🗑 Bulk deleted"; + return "✎ Edited"; +} + +function entryMatches(entry: LoggedMessage, query: string): boolean { + if (!query) return true; + const needle = query.toLowerCase(); + return ( + entry.content?.toLowerCase().includes(needle) || + entry.newContent?.toLowerCase().includes(needle) || + entry.authorUsername?.toLowerCase().includes(needle) || + entry.authorDisplayName?.toLowerCase().includes(needle) || + entry.channelId?.includes(needle) || + entry.guildId?.includes(needle) || + entry.authorId?.includes(needle) || + false + ); +} + +function EntryDetail({ entry, onClose, onDeleted }: { entry: LoggedMessage; onClose: () => void; onDeleted: () => void }) { + const color = textColor(); + + return ( + + + {kindLabel(entry.kind)} · {formatTime(entry.timestamp)} · logged {formatTime(entry.loggedAt)} + {entry.keptInline ? " · kept in chat" : ""} + + + {entry.authorDisplayName || entry.authorUsername || entry.authorId || "Unknown author"} + + + {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 && ( + + {entry.embeds.length} embed{entry.embeds.length === 1 ? "" : "s"} + + )} + {entry.components.length > 0 && ( + + {entry.components.length} component{entry.components.length === 1 ? "" : "s"} (Components V2) + + )} + {entry.stickerItems.length > 0 && ( + + {entry.stickerItems.length} sticker{entry.stickerItems.length === 1 ? "" : "s"} + + )} + {entry.poll && ( + Included a poll + )} + + + Message ID: {entry.id}{"\n"}Channel ID: {entry.channelId}{entry.guildId ? `\nServer ID: ${entry.guildId}` : ""} + + + + { + 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 color = textColor(); + + const all = getLog(); + const filtered = React.useMemo(() => { + return all + .filter((e) => (kindFilter === "all" ? true : kindFilter === "deleted" ? e.kind !== "edited" : e.kind === "edited")) + .filter((e) => entryMatches(e, query.trim())) + .slice() + .reverse(); + }, [all.length, query, kindFilter]); + + if (selected) { + return ( + 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)" }} + > + + {kindLabel(entry.kind)} · {entry.authorDisplayName || entry.authorUsername || entry.authorId || "Unknown"} · {formatTime(entry.timestamp)} + + + {(entry.kind === "edited" ? entry.newContent : entry.content) || "(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/message-logger/src/ui/Settings.tsx b/plugins/message-logger/src/ui/Settings.tsx new file mode 100644 index 0000000..fe33f1e --- /dev/null +++ b/plugins/message-logger/src/ui/Settings.tsx @@ -0,0 +1,137 @@ +import { React, clipboard } from "@vendetta/metro/common"; +import { storage } from "@vendetta/plugin"; +import { useProxy } from "@vendetta/storage"; +import { showToast } from "@vendetta/ui/toasts"; +import SettingsScaffold from "@shared/ui/SettingsScaffold"; +import NoteBox from "@shared/ui/NoteBox"; +import PrimaryButton from "@shared/ui/PrimaryButton"; +import { TableRow, TableRowGroup, TableSwitchRow, TextInput } from "@shared/ui/table"; +import { getLog, clearLog } from "../lib/store"; +import openLogViewer from "./LogViewerPage"; + +export default function Settings() { + useProxy(storage); + const o = storage.options; + + return ( + + + Deleted messages are kept visible in the chat (styled like a deletion, the way + Equicord's MessageLogger looks on desktop) whenever possible. Everything is also + saved to a searchable log that survives Discord reloads, since keeping a message + in the live chat only lasts until the app actually restarts and re-fetches history. + + + + { o.logDeleted = v; }} + /> + { o.keepDeletedInline = v; }} + /> + { o.logEdited = v; }} + /> + + + + { o.ignoreBots = v; }} + /> + { o.ignoreOwnMessages = v; }} + /> + { o.ignoreOwnEdits = v; }} + /> + { o.ignoreDMs = v; }} + /> + { o.ignoredChannelIds = v; }} + /> + { o.ignoredGuildIds = v; }} + /> + { o.ignoredUserIds = v; }} + /> + { o.ignoredKeywords = v; }} + /> + + + + { o.maxEntries = v; }} + /> + { o.maxEntriesPerChannel = v; }} + /> + { o.maxAgeDays = v; }} + /> + + + + openLogViewer()} /> + { + const log = getLog(); + clipboard.setString(JSON.stringify(log, null, 2)); + showToast(`Copied ${log.length} log entr${log.length === 1 ? "y" : "ies"} to clipboard`, undefined); + }} + /> + + + { + const count = getLog().length; + clearLog(); + showToast(`Cleared ${count} log entr${count === 1 ? "y" : "ies"}`, undefined); + }} + style={{ margin: 16, backgroundColor: "#F23F42" }} + /> + + ); +}