Message Logger: add cloud sync, restructure flux intercept
This commit is contained in:
parent
aa3b96dfed
commit
35972b836d
5 changed files with 460 additions and 115 deletions
187
plugins/message-logger/src/lib/cloudSync.ts
Normal file
187
plugins/message-logger/src/lib/cloudSync.ts
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
import { findByName, findByProps } from "@vendetta/metro";
|
||||||
|
import { storage } from "@vendetta/plugin";
|
||||||
|
import { showToast } from "@vendetta/ui/toasts";
|
||||||
|
import { getLog, logLimitsFromStorage, replaceLog, setOnMutate } from "./store";
|
||||||
|
import type { LoggedMessage } from "./types";
|
||||||
|
|
||||||
|
const DEFAULT_HOST = "https://message-logger-sync.allyapp.workers.dev/";
|
||||||
|
const DEFAULT_CLIENT_ID = "1538636934568607744";
|
||||||
|
const DEBOUNCE_MS = 1500;
|
||||||
|
|
||||||
|
export interface CloudSyncStorage {
|
||||||
|
enabled: boolean;
|
||||||
|
token?: string;
|
||||||
|
lastSyncedAt?: number;
|
||||||
|
customHost?: string;
|
||||||
|
customClientId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cloudSyncStorage(): CloudSyncStorage {
|
||||||
|
(storage as any).cloudSync ??= { enabled: false };
|
||||||
|
return (storage as any).cloudSync;
|
||||||
|
}
|
||||||
|
|
||||||
|
function host(): string {
|
||||||
|
const h = cloudSyncStorage().customHost?.trim();
|
||||||
|
if (!h) return DEFAULT_HOST;
|
||||||
|
return h.endsWith("/") ? h : `${h}/`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clientId(): string {
|
||||||
|
return cloudSyncStorage().customClientId?.trim() || DEFAULT_CLIENT_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function redirectURL(): string {
|
||||||
|
return `${host()}api/auth/authorize`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isConnected(): boolean {
|
||||||
|
return !!cloudSyncStorage().token;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isEnabled(): boolean {
|
||||||
|
return !!cloudSyncStorage().enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lastSyncedAt(): number | undefined {
|
||||||
|
return cloudSyncStorage().lastSyncedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function authFetch(path: string, options?: RequestInit): Promise<Response | null> {
|
||||||
|
const token = cloudSyncStorage().token;
|
||||||
|
if (!token) return null;
|
||||||
|
|
||||||
|
const res = await fetch(`${host()}${path}`, {
|
||||||
|
...options,
|
||||||
|
headers: { ...options?.headers, authorization: token } as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) return res;
|
||||||
|
if (res.status === 401) {
|
||||||
|
cloudSyncStorage().token = undefined;
|
||||||
|
showToast("Cloud sync: disconnected (your session expired, reconnect in Message Logger settings)", undefined);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await res.text().catch(() => "");
|
||||||
|
console.error("[MessageLogger] Cloud sync request failed", res.status, text);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getRemoteLog(): Promise<LoggedMessage[] | null> {
|
||||||
|
const res = await authFetch("api/data");
|
||||||
|
if (!res) return null;
|
||||||
|
return await res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pushLog(log: LoggedMessage[]): Promise<boolean> {
|
||||||
|
const res = await authFetch("api/data", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(log),
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
return !!res;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteRemoteLog(): Promise<boolean> {
|
||||||
|
const res = await authFetch("api/data", { method: "DELETE" });
|
||||||
|
return !!res;
|
||||||
|
}
|
||||||
|
|
||||||
|
function entryKey(e: LoggedMessage): string {
|
||||||
|
return `${e.id}:${e.kind}:${e.loggedAt}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeLogs(local: LoggedMessage[], remote: LoggedMessage[]): LoggedMessage[] {
|
||||||
|
const byKey = new Map<string, LoggedMessage>();
|
||||||
|
for (const e of local) byKey.set(entryKey(e), e);
|
||||||
|
for (const e of remote) byKey.set(entryKey(e), e);
|
||||||
|
return [...byKey.values()].sort((a, b) => a.loggedAt - b.loggedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
let syncTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
function pushDebounced(): void {
|
||||||
|
if (!isEnabled() || !isConnected()) return;
|
||||||
|
|
||||||
|
clearTimeout(syncTimer);
|
||||||
|
syncTimer = setTimeout(async () => {
|
||||||
|
const ok = await pushLog(getLog());
|
||||||
|
if (ok) cloudSyncStorage().lastSyncedAt = Date.now();
|
||||||
|
}, DEBOUNCE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncNow(): Promise<boolean> {
|
||||||
|
if (!isConnected()) return false;
|
||||||
|
|
||||||
|
const remote = await getRemoteLog();
|
||||||
|
if (remote === null) return false;
|
||||||
|
|
||||||
|
const merged = mergeLogs(getLog(), remote);
|
||||||
|
replaceLog(merged, logLimitsFromStorage());
|
||||||
|
|
||||||
|
const ok = await pushLog(getLog());
|
||||||
|
if (ok) cloudSyncStorage().lastSyncedAt = Date.now();
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function disconnect(): void {
|
||||||
|
cloudSyncStorage().token = undefined;
|
||||||
|
cloudSyncStorage().lastSyncedAt = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openOauth2Modal(): void {
|
||||||
|
const { pushModal, popModal } = findByProps("pushModal", "popModal") as any;
|
||||||
|
const OAuth2AuthorizeModal = findByName("OAuth2AuthorizeModal") as any;
|
||||||
|
|
||||||
|
if (!pushModal || !OAuth2AuthorizeModal) {
|
||||||
|
showToast("Couldn't open the Discord authorization screen", undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pushModal({
|
||||||
|
key: "message-logger-cloud-sync-authorize",
|
||||||
|
modal: {
|
||||||
|
key: "message-logger-cloud-sync-authorize",
|
||||||
|
modal: OAuth2AuthorizeModal,
|
||||||
|
animation: "slide-up",
|
||||||
|
shouldPersistUnderModals: false,
|
||||||
|
props: {
|
||||||
|
clientId: clientId(),
|
||||||
|
redirectUri: redirectURL(),
|
||||||
|
scopes: ["identify"],
|
||||||
|
responseType: "code",
|
||||||
|
permissions: 0n,
|
||||||
|
cancelCompletesFlow: false,
|
||||||
|
callback: async ({ location }: { location?: string }) => {
|
||||||
|
if (!location) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(location);
|
||||||
|
const token = await res.text();
|
||||||
|
if (!res.ok) throw new Error(token);
|
||||||
|
|
||||||
|
cloudSyncStorage().token = token;
|
||||||
|
showToast("Cloud sync connected", undefined);
|
||||||
|
await syncNow();
|
||||||
|
} catch (e: any) {
|
||||||
|
showToast(`Cloud sync: failed to connect (${e?.message ?? e})`, undefined);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissOAuthModal: () => popModal("message-logger-cloud-sync-authorize"),
|
||||||
|
},
|
||||||
|
closable: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initCloudSync(): () => void {
|
||||||
|
cloudSyncStorage();
|
||||||
|
setOnMutate(pushDebounced);
|
||||||
|
|
||||||
|
if (isEnabled() && isConnected()) syncNow();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(syncTimer);
|
||||||
|
setOnMutate(undefined);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,31 @@ import type { LoggedMessage } from "./types";
|
||||||
|
|
||||||
const logStorage = storage as { log?: LoggedMessage[] };
|
const logStorage = storage as { log?: LoggedMessage[] };
|
||||||
|
|
||||||
|
export interface LogLimits {
|
||||||
|
maxEntries: number;
|
||||||
|
maxAgeDays: number;
|
||||||
|
maxPerChannel: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberOption(raw: string | undefined, fallback: number): number {
|
||||||
|
const n = parseInt(raw ?? "", 10);
|
||||||
|
return Number.isFinite(n) && n >= 0 ? n : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logLimitsFromStorage(): LogLimits {
|
||||||
|
const o = (storage as any).options ?? {};
|
||||||
|
return {
|
||||||
|
maxEntries: numberOption(o.maxEntries, 2000),
|
||||||
|
maxAgeDays: numberOption(o.maxAgeDays, 30),
|
||||||
|
maxPerChannel: numberOption(o.maxEntriesPerChannel, 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let onMutate: (() => void) | undefined;
|
||||||
|
export function setOnMutate(cb: (() => void) | undefined): void {
|
||||||
|
onMutate = cb;
|
||||||
|
}
|
||||||
|
|
||||||
export function getLog(): LoggedMessage[] {
|
export function getLog(): LoggedMessage[] {
|
||||||
logStorage.log ??= [];
|
logStorage.log ??= [];
|
||||||
return logStorage.log;
|
return logStorage.log;
|
||||||
|
|
@ -36,15 +61,24 @@ function pruneLog(maxEntries: number, maxAgeDays: number, maxPerChannel: number)
|
||||||
logStorage.log = log;
|
logStorage.log = log;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function addLogEntry(entry: LoggedMessage, limits: { maxEntries: number; maxAgeDays: number; maxPerChannel: number }): void {
|
export function addLogEntry(entry: LoggedMessage, limits: LogLimits): void {
|
||||||
getLog().push(entry);
|
getLog().push(entry);
|
||||||
pruneLog(limits.maxEntries, limits.maxAgeDays, limits.maxPerChannel);
|
pruneLog(limits.maxEntries, limits.maxAgeDays, limits.maxPerChannel);
|
||||||
|
onMutate?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function removeLogEntry(loggedAt: number): void {
|
export function removeLogEntry(loggedAt: number): void {
|
||||||
logStorage.log = getLog().filter((e) => e.loggedAt !== loggedAt);
|
logStorage.log = getLog().filter((e) => e.loggedAt !== loggedAt);
|
||||||
|
onMutate?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearLog(): void {
|
export function clearLog(): void {
|
||||||
logStorage.log = [];
|
logStorage.log = [];
|
||||||
|
onMutate?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replaces the entire log (used when merging in a remote copy) and re-applies the usual limits. */
|
||||||
|
export function replaceLog(entries: LoggedMessage[], limits: LogLimits): void {
|
||||||
|
logStorage.log = entries;
|
||||||
|
pruneLog(limits.maxEntries, limits.maxAgeDays, limits.maxPerChannel);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,12 @@
|
||||||
import { findByProps, findByStoreName } from "@vendetta/metro";
|
import { findByProps, findByStoreName } from "@vendetta/metro";
|
||||||
import { FluxDispatcher } from "@vendetta/metro/common";
|
import { FluxDispatcher } from "@vendetta/metro/common";
|
||||||
import { before } from "@vendetta/patcher";
|
|
||||||
import { storage } from "@vendetta/plugin";
|
import { storage } from "@vendetta/plugin";
|
||||||
import { shouldIgnore } from "../lib/filters";
|
import { shouldIgnore } from "../lib/filters";
|
||||||
import { snapshotMessage } from "../lib/capture";
|
import { snapshotMessage } from "../lib/capture";
|
||||||
import { addLogEntry } from "../lib/store";
|
import { addLogEntry, getLog, logLimitsFromStorage } from "../lib/store";
|
||||||
import type { FilterOptions } from "../lib/types";
|
import type { FilterOptions, LoggedMessage } from "../lib/types";
|
||||||
|
|
||||||
const TAG = "[MessageLogger]";
|
const TAG = "[MessageLogger]";
|
||||||
const CLEANUP_FLAG = "__msgLoggerCleanup";
|
|
||||||
|
|
||||||
// A busy, high-traffic channel (flooding, spam, price-ticker bots editing the same message
|
// A busy, high-traffic channel (flooding, spam, price-ticker bots editing the same message
|
||||||
// repeatedly) can grow these maps without bound over a long session - capped so memory and the
|
// repeatedly) can grow these maps without bound over a long session - capped so memory and the
|
||||||
|
|
@ -16,16 +14,37 @@ const CLEANUP_FLAG = "__msgLoggerCleanup";
|
||||||
const MAX_EDIT_HISTORY_PER_MESSAGE = 10;
|
const MAX_EDIT_HISTORY_PER_MESSAGE = 10;
|
||||||
const MAX_TRACKED_MESSAGES = 500;
|
const MAX_TRACKED_MESSAGES = 500;
|
||||||
|
|
||||||
const ChannelMessages = findByProps("_channelMessages");
|
// Reinserting a message is a synchronous store write that forces the channel's row list to
|
||||||
const UserStore = findByStoreName("UserStore");
|
// recompute - fine for a handful of messages, but a bot's bulk delete can hand us up to 100 ids in
|
||||||
|
// one event, and doing that many synchronous reinsertions back to back froze the app for many
|
||||||
|
// seconds. Past this many ids in a single bulk delete, everything still gets logged, just not
|
||||||
|
// visually kept inline.
|
||||||
|
const MAX_INLINE_BULK_KEEP = 5;
|
||||||
|
|
||||||
// Ids currently kept "alive" as a faked MESSAGE_UPDATE instead of a real delete, mapped to their
|
// Ids currently kept "alive" inline instead of a real delete, mapped to their channel - tracked so
|
||||||
// channel - tracked so onUnload can dispatch a real delete for each on unload.
|
// onUnload can remove each for real on unload, and so rowStyling.ts knows which rows to style.
|
||||||
export const fakedMessages = new Map<string, string>();
|
export const fakedMessages = new Map<string, string>();
|
||||||
|
|
||||||
// Past content versions per message, oldest first, current content not included.
|
// Past content versions per message, oldest first, current content not included.
|
||||||
export const editHistory = new Map<string, string[]>();
|
export const editHistory = new Map<string, string[]>();
|
||||||
|
|
||||||
|
// Our own last-known copy of each message we've seen, keyed by id. Populated on MESSAGE_CREATE and
|
||||||
|
// refreshed on MESSAGE_UPDATE - this is what lets deletes/edits be logged (and, when enabled,
|
||||||
|
// reinserted) from a cheap, per-type FluxDispatcher.subscribe() listener instead of a global
|
||||||
|
// before("dispatch", ...) hook, since a subscriber only ever sees the message *after* MessageStore
|
||||||
|
// has already applied the mutation (real content/object is gone by then unless we already have our
|
||||||
|
// own copy of what it used to be).
|
||||||
|
const shadowMessages = new Map<string, any>();
|
||||||
|
|
||||||
|
const UserStore = findByStoreName("UserStore");
|
||||||
|
|
||||||
|
// The store's own per-channel message collection - live-verified (via mcp devtools against a real
|
||||||
|
// client) that ChannelMessages.get(channelId).receiveMessage(msg) returns a new record and
|
||||||
|
// ChannelMessages.commit(record) writes it back and updates MessageStore.getMessage() results,
|
||||||
|
// with no FluxDispatcher.dispatch call anywhere in that path. This is what real reinsertion (the
|
||||||
|
// "keep deleted inline" feature) uses instead of substituting the real delete event.
|
||||||
|
const ChannelMessages = findByProps("_channelMessages");
|
||||||
|
|
||||||
function evictOldest<K, V>(map: Map<K, V>, max: number): void {
|
function evictOldest<K, V>(map: Map<K, V>, max: number): void {
|
||||||
while (map.size > max) {
|
while (map.size > max) {
|
||||||
const oldestKey = map.keys().next().value;
|
const oldestKey = map.keys().next().value;
|
||||||
|
|
@ -38,19 +57,6 @@ function currentUserId(): string | undefined {
|
||||||
return UserStore?.getCurrentUser?.()?.id;
|
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 {
|
function filterOptionsFromStorage(): FilterOptions {
|
||||||
const o = storage.options ?? {};
|
const o = storage.options ?? {};
|
||||||
return {
|
return {
|
||||||
|
|
@ -92,92 +98,93 @@ function hasLoggableContent(message: any): boolean {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Any action type other than MESSAGE_DELETE stops the real removal - live-verified that
|
function rememberMessage(message: any): void {
|
||||||
// MESSAGE_EDIT_FAILED_AUTOMOD (the same technique meqativ/dumsane's NoDelete uses) is a genuine
|
if (!message?.id) return;
|
||||||
// no-op for a message not already in a pending-edit state: dispatching it left the cached
|
shadowMessages.set(message.id, message);
|
||||||
// MessageRecord byte-for-byte identical. No need to reconstruct/spread the message into a fake
|
evictOldest(shadowMessages, MAX_TRACKED_MESSAGES);
|
||||||
// MESSAGE_UPDATE - that also avoids waking every other MESSAGE_UPDATE listener in the app (unread
|
|
||||||
// counts, embed refresh, etc.) for something that isn't really an edit.
|
|
||||||
function suppressionEventFor(original: any): any {
|
|
||||||
return {
|
|
||||||
type: "MESSAGE_EDIT_FAILED_AUTOMOD",
|
|
||||||
messageData: { type: 0, message: { channelId: original.channel_id, messageId: original.id, content: original.content } },
|
|
||||||
errorResponseBody: { code: 200000, message: "Deleted message kept inline by MessageLogger" },
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function captureAndMaybeKeep(original: any, channelId: string, id: string): any | undefined {
|
function logDeletion(original: any, keepInline: boolean): void {
|
||||||
|
addLogEntry(snapshotMessage(original, "deleted", undefined, keepInline), logLimitsFromStorage());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Writes every given message straight back into the store's own per-channel collection in one
|
||||||
|
// commit, bypassing Flux entirely - no dispatch call, so no other store (unread counts,
|
||||||
|
// notifications, etc.) ever learns about it, and only one native row re-render happens per event
|
||||||
|
// no matter how many messages are being kept inline, instead of one per message.
|
||||||
|
function reinsertMessages(channelId: string, messages: any[]): string[] {
|
||||||
|
const record = ChannelMessages?.get?.(channelId);
|
||||||
|
if (!record || !messages.length) return [];
|
||||||
|
try {
|
||||||
|
let next = record;
|
||||||
|
for (const message of messages) next = next.receiveMessage(message);
|
||||||
|
ChannelMessages.commit(next);
|
||||||
|
return messages.map((m) => m.id);
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(TAG, "Failed to reinsert messages", e?.message ?? e);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// True if this deletion is real (not an ephemeral dismiss) and worth logging/keeping at all.
|
||||||
|
function eligibleForCapture(original: any): boolean {
|
||||||
const EPHEMERAL = 64;
|
const EPHEMERAL = 64;
|
||||||
if (!original?.author?.id) return undefined; // never cached, or already evicted - nothing left to do
|
if (!original?.author?.id) return false;
|
||||||
if ((original.flags & EPHEMERAL) === EPHEMERAL) return undefined; // an ephemeral dismiss, not a real delete
|
if ((original.flags & EPHEMERAL) === EPHEMERAL) return false;
|
||||||
if (!hasLoggableContent(original)) return undefined;
|
if (!hasLoggableContent(original)) return false;
|
||||||
if (!storage.options?.logDeleted) return undefined;
|
if (!storage.options?.logDeleted) return false;
|
||||||
if (ignoredFor(original, false)) return undefined;
|
if (ignoredFor(original, false)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
const keepInline = !!storage.options?.keepDeletedInline;
|
// Logs the deletion and, if eligible to be kept inline, returns the original message so the
|
||||||
addLogEntry(snapshotMessage(original, "deleted", undefined, keepInline), limitsFromStorage());
|
// caller can batch it into a single reinsertMessages() call - never reinserts by itself.
|
||||||
|
function processDelete(id: string, allowInline: boolean): any | undefined {
|
||||||
|
const original = shadowMessages.get(id);
|
||||||
|
shadowMessages.delete(id);
|
||||||
|
if (!original) return undefined;
|
||||||
|
if (!eligibleForCapture(original)) return undefined;
|
||||||
|
|
||||||
if (!keepInline) return undefined;
|
const keepInline = allowInline && !!storage.options?.keepDeletedInline;
|
||||||
|
logDeletion(original, keepInline);
|
||||||
|
return keepInline ? original : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
fakedMessages.set(id, channelId);
|
function keepInline(channelId: string, messages: any[]): void {
|
||||||
|
const inserted = reinsertMessages(channelId, messages);
|
||||||
|
if (!inserted.length) return;
|
||||||
|
for (const id of inserted) fakedMessages.set(id, channelId);
|
||||||
evictOldest(fakedMessages, MAX_TRACKED_MESSAGES);
|
evictOldest(fakedMessages, MAX_TRACKED_MESSAGES);
|
||||||
return suppressionEventFor(original);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSingleDelete(event: any): any {
|
function subscribeSingleDelete(event: any): void {
|
||||||
if (event[CLEANUP_FLAG]) return undefined; // our own revert dispatch on unload - pass through untouched
|
|
||||||
|
|
||||||
const channelId = event.channelId ?? event.channel_id;
|
const channelId = event.channelId ?? event.channel_id;
|
||||||
const original = ChannelMessages?.get?.(channelId)?.get?.(event.id);
|
const original = processDelete(event.id, true);
|
||||||
const fake = captureAndMaybeKeep(original, channelId, event.id);
|
if (original) keepInline(channelId, [original]);
|
||||||
return fake ? [fake] : undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleBulkDelete(event: any): any {
|
function subscribeBulkDelete(event: any): void {
|
||||||
const channelId = event.channelId ?? event.channel_id;
|
const channelId = event.channelId ?? event.channel_id;
|
||||||
const ids: string[] = Array.isArray(event.ids) ? event.ids : [];
|
const ids: string[] = Array.isArray(event.ids) ? event.ids : [];
|
||||||
if (!ids.length) return undefined;
|
const allowInline = ids.length <= MAX_INLINE_BULK_KEEP;
|
||||||
|
const toKeep: any[] = [];
|
||||||
const kept: string[] = [];
|
|
||||||
const dropped: string[] = [];
|
|
||||||
|
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
const original = ChannelMessages?.get?.(channelId)?.get?.(id);
|
const original = processDelete(id, allowInline);
|
||||||
const fake = captureAndMaybeKeep(original, channelId, id);
|
if (original) toKeep.push(original);
|
||||||
if (fake) kept.push(id);
|
|
||||||
else dropped.push(id);
|
|
||||||
}
|
}
|
||||||
|
keepInline(channelId, toKeep);
|
||||||
if (!kept.length) return undefined; // nothing kept inline - let the real bulk delete proceed as-is
|
|
||||||
|
|
||||||
// Dispatching from inside a dispatch-in-progress risks Flux's re-entrancy guard - deferred to
|
|
||||||
// a microtask, after this bulk-delete's own dispatch has finished.
|
|
||||||
queueMicrotask(() => {
|
|
||||||
for (const id of kept) {
|
|
||||||
const original = ChannelMessages?.get?.(channelId)?.get?.(id);
|
|
||||||
if (!original) continue;
|
|
||||||
try {
|
|
||||||
FluxDispatcher.dispatch(suppressionEventFor(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 {
|
function subscribeCreate(event: any): void {
|
||||||
|
rememberMessage(event?.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribeUpdate(event: any): void {
|
||||||
const updated = event?.message;
|
const updated = event?.message;
|
||||||
if (!updated?.id) return;
|
if (!updated?.id) return;
|
||||||
|
|
||||||
const channelId = updated.channel_id ?? updated.channelId;
|
const original = shadowMessages.get(updated.id);
|
||||||
const original = ChannelMessages?.get?.(channelId)?.get?.(updated.id);
|
rememberMessage(updated);
|
||||||
if (!original) return;
|
if (!original) return;
|
||||||
|
|
||||||
const newContent = updated.content ?? original.content;
|
const newContent = updated.content ?? original.content;
|
||||||
|
|
@ -191,48 +198,78 @@ function handleUpdate(event: any): void {
|
||||||
editHistory.set(updated.id, history);
|
editHistory.set(updated.id, history);
|
||||||
evictOldest(editHistory, MAX_TRACKED_MESSAGES);
|
evictOldest(editHistory, MAX_TRACKED_MESSAGES);
|
||||||
|
|
||||||
addLogEntry(snapshotMessage(original, "edited", newContent, false), limitsFromStorage());
|
addLogEntry(snapshotMessage(original, "edited", newContent, false), logLimitsFromStorage());
|
||||||
}
|
}
|
||||||
|
|
||||||
export function patchFluxIntercept(cleanups: (() => void)[]): boolean {
|
// Rebuilds editHistory from every "edited" log entry, oldest first per message - the in-memory
|
||||||
if (!ChannelMessages) {
|
// map only ever reflects edits witnessed live, so it's empty again after every restart.
|
||||||
console.warn(TAG, "ChannelMessages module not found, skipping capture");
|
function rebuildEditHistory(): void {
|
||||||
return false;
|
const byId = new Map<string, LoggedMessage[]>();
|
||||||
|
for (const entry of getLog()) {
|
||||||
|
if (entry.kind !== "edited") continue;
|
||||||
|
const list = byId.get(entry.id) ?? [];
|
||||||
|
list.push(entry);
|
||||||
|
byId.set(entry.id, list);
|
||||||
}
|
}
|
||||||
|
|
||||||
cleanups.push(
|
for (const [id, entries] of byId) {
|
||||||
before("dispatch", FluxDispatcher, (args: any[]) => {
|
entries.sort((a, b) => a.loggedAt - b.loggedAt);
|
||||||
const event = args[0];
|
const history = entries.map((e) => e.content ?? "").slice(-MAX_EDIT_HISTORY_PER_MESSAGE);
|
||||||
if (!event?.type) return;
|
editHistory.set(id, history);
|
||||||
|
}
|
||||||
|
evictOldest(editHistory, MAX_TRACKED_MESSAGES);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
// Kept-inline deletions are NOT rehydrated here - reinserting a synthetic message only becomes
|
||||||
switch (event.type) {
|
// visible today by riding along a native row update that's already happening for some other
|
||||||
case "MESSAGE_DELETE":
|
// reason (confirmed live: writing to ChannelMessages/MessageStore directly, forcing a change
|
||||||
return handleSingleDelete(event);
|
// emit, and dispatching a synthetic MESSAGE_UPDATE for it all failed to surface a test message
|
||||||
case "MESSAGE_DELETE_BULK":
|
// with no real event behind it). Needs its own investigation into injecting a row directly via
|
||||||
return handleBulkDelete(event);
|
// the same before("updateRows", ...) hook rowStyling.ts already uses successfully, rather than
|
||||||
case "MESSAGE_UPDATE":
|
// trying to get Discord's own store to organically pick it up.
|
||||||
handleUpdate(event);
|
export function rehydrateFromLog(): void {
|
||||||
return;
|
try {
|
||||||
}
|
rebuildEditHistory();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error(TAG, "Flux intercept error:", e?.message ?? e);
|
console.error(TAG, "Failed to rehydrate edit history:", e?.message ?? e);
|
||||||
}
|
}
|
||||||
}),
|
}
|
||||||
);
|
|
||||||
|
// FluxDispatcher.subscribe() only calls us for the specific action types we ask for, unlike
|
||||||
|
// before("dispatch", ...) which wraps every single action in the entire app - this plugin never
|
||||||
|
// patches FluxDispatcher.dispatch at all, regardless of settings.
|
||||||
|
export function patchFluxIntercept(cleanups: (() => void)[]): boolean {
|
||||||
|
if (!ChannelMessages) {
|
||||||
|
console.warn(TAG, "ChannelMessages module not found, keep-inline reinsertion will be skipped");
|
||||||
|
}
|
||||||
|
|
||||||
|
const subs: [string, (event: any) => void][] = [
|
||||||
|
["MESSAGE_CREATE", subscribeCreate],
|
||||||
|
["MESSAGE_UPDATE", subscribeUpdate],
|
||||||
|
["MESSAGE_DELETE", subscribeSingleDelete],
|
||||||
|
["MESSAGE_DELETE_BULK", subscribeBulkDelete],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [type, handler] of subs) {
|
||||||
|
FluxDispatcher.subscribe(type, handler);
|
||||||
|
cleanups.push(() => FluxDispatcher.unsubscribe(type, handler));
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reverts every message currently faked-alive back to a real delete - called on unload. */
|
/** Removes every message currently kept-alive-inline for real - called on unload. */
|
||||||
export function revertFakedMessages(): void {
|
export function revertFakedMessages(): void {
|
||||||
for (const [id, channelId] of fakedMessages) {
|
for (const [id, channelId] of fakedMessages) {
|
||||||
|
const record = ChannelMessages?.get?.(channelId);
|
||||||
|
if (!record) continue;
|
||||||
try {
|
try {
|
||||||
FluxDispatcher.dispatch({ type: "MESSAGE_DELETE", channelId, id, [CLEANUP_FLAG]: true });
|
ChannelMessages.commit(record.remove(id));
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error(TAG, "Failed to revert faked message", id, e?.message ?? e);
|
console.error(TAG, "Failed to revert faked message", id, e?.message ?? e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fakedMessages.clear();
|
fakedMessages.clear();
|
||||||
editHistory.clear();
|
editHistory.clear();
|
||||||
|
shadowMessages.clear();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,9 +40,17 @@ function editedTagNode(processedColor: any) {
|
||||||
// message.content in the row JSON is an array of {content, type} rich-text nodes, not a plain
|
// message.content in the row JSON is an array of {content, type} rich-text nodes, not a plain
|
||||||
// string. Old versions get their own muted "(edited)" tag stacked above the current content, which
|
// string. Old versions get their own muted "(edited)" tag stacked above the current content, which
|
||||||
// keeps Discord's native tag since that already renders correctly on its own.
|
// keeps Discord's native tag since that already renders correctly on its own.
|
||||||
|
//
|
||||||
|
// This row gets reprocessed by updateRows more than once while it's on screen (confirmed live -
|
||||||
|
// some layer between here and the native renderer reuses/re-diffs against what we last sent, not
|
||||||
|
// a fresh copy of the source message). Array.isArray(message.content) can't tell "Discord's own
|
||||||
|
// rich content" apart from "our own history nodes from last time", so a marker is required -
|
||||||
|
// without it, every reprocess re-detects the previous output as the current content and prepends
|
||||||
|
// another copy of the history block on top of it.
|
||||||
function applyEditHistory(message: any, editedColor: any) {
|
function applyEditHistory(message: any, editedColor: any) {
|
||||||
const history = editHistory.get(message.id);
|
const history = editHistory.get(message.id);
|
||||||
if (!history?.length) return;
|
if (!history?.length || message.__loggerEditApplied) return;
|
||||||
|
message.__loggerEditApplied = true;
|
||||||
|
|
||||||
const currentContent: any[] = Array.isArray(message.content) ? message.content : [textNode(String(message.content ?? ""))];
|
const currentContent: any[] = Array.isArray(message.content) ? message.content : [textNode(String(message.content ?? ""))];
|
||||||
const nodes: any[] = [];
|
const nodes: any[] = [];
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,34 @@ import NoteBox from "@shared/ui/NoteBox";
|
||||||
import PrimaryButton from "@shared/ui/PrimaryButton";
|
import PrimaryButton from "@shared/ui/PrimaryButton";
|
||||||
import { TableRow, TableRowGroup, TableSwitchRow, TextInput } from "@shared/ui/table";
|
import { TableRow, TableRowGroup, TableSwitchRow, TextInput } from "@shared/ui/table";
|
||||||
import { getLog, clearLog } from "../lib/store";
|
import { getLog, clearLog } from "../lib/store";
|
||||||
|
import {
|
||||||
|
cloudSyncStorage,
|
||||||
|
deleteRemoteLog,
|
||||||
|
disconnect,
|
||||||
|
isConnected,
|
||||||
|
openOauth2Modal,
|
||||||
|
syncNow,
|
||||||
|
} from "../lib/cloudSync";
|
||||||
import openLogViewer from "./LogViewerPage";
|
import openLogViewer from "./LogViewerPage";
|
||||||
|
|
||||||
|
function formatLastSynced(at: number | undefined): string {
|
||||||
|
if (!at) return "Never synced";
|
||||||
|
|
||||||
|
const mins = Math.round((Date.now() - at) / 60000);
|
||||||
|
if (mins < 1) return "Synced just now";
|
||||||
|
if (mins < 60) return `Synced ${mins}m ago`;
|
||||||
|
|
||||||
|
const hours = Math.round(mins / 60);
|
||||||
|
if (hours < 24) return `Synced ${hours}h ago`;
|
||||||
|
|
||||||
|
return `Synced ${Math.round(hours / 24)}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
export default function Settings() {
|
export default function Settings() {
|
||||||
useProxy(storage);
|
useProxy(storage);
|
||||||
const o = storage.options;
|
const o = storage.options;
|
||||||
|
const cloud = cloudSyncStorage();
|
||||||
|
useProxy(cloud);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsScaffold>
|
<SettingsScaffold>
|
||||||
|
|
@ -30,7 +53,7 @@ export default function Settings() {
|
||||||
/>
|
/>
|
||||||
<TableSwitchRow
|
<TableSwitchRow
|
||||||
label="Keep deleted messages in the chat"
|
label="Keep deleted messages in the chat"
|
||||||
subLabel="Off: only saved to the log below, removed from chat immediately like normal"
|
subLabel="Off: only saved to the log below, removed from chat immediately like normal. Large bulk deletes (e.g. a bot purge) are always just logged, never kept inline"
|
||||||
value={!!o.keepDeletedInline}
|
value={!!o.keepDeletedInline}
|
||||||
onValueChange={(v: boolean) => { o.keepDeletedInline = v; }}
|
onValueChange={(v: boolean) => { o.keepDeletedInline = v; }}
|
||||||
/>
|
/>
|
||||||
|
|
@ -111,6 +134,62 @@ export default function Settings() {
|
||||||
/>
|
/>
|
||||||
</TableRowGroup>
|
</TableRowGroup>
|
||||||
|
|
||||||
|
<TableRowGroup title="Cloud sync">
|
||||||
|
<NoteBox>
|
||||||
|
Off by default. When on, your log (subject to the same limits above) is sent to a
|
||||||
|
server so it survives reinstalls and syncs between devices - nothing else in this
|
||||||
|
plugin leaves your device.
|
||||||
|
</NoteBox>
|
||||||
|
<TableSwitchRow
|
||||||
|
label="Enable cloud sync"
|
||||||
|
value={!!cloud.enabled}
|
||||||
|
onValueChange={(v: boolean) => { cloud.enabled = v; }}
|
||||||
|
/>
|
||||||
|
{cloud.enabled && !isConnected() && (
|
||||||
|
<TableRow label="Connect Discord account" onPress={() => openOauth2Modal()} />
|
||||||
|
)}
|
||||||
|
{cloud.enabled && isConnected() && (
|
||||||
|
<>
|
||||||
|
<TableRow
|
||||||
|
label={formatLastSynced(cloud.lastSyncedAt)}
|
||||||
|
subLabel="Tap to sync now"
|
||||||
|
onPress={async () => {
|
||||||
|
showToast("Syncing…", undefined);
|
||||||
|
showToast(await syncNow() ? "Synced" : "Sync failed", undefined);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<TableRow
|
||||||
|
label="Disconnect"
|
||||||
|
subLabel="Stops syncing - what's already on the server is kept"
|
||||||
|
onPress={() => {
|
||||||
|
disconnect();
|
||||||
|
showToast("Disconnected", undefined);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<TableRow
|
||||||
|
label="Delete cloud data"
|
||||||
|
subLabel="Permanently removes your synced log from the server"
|
||||||
|
onPress={async () => {
|
||||||
|
showToast(await deleteRemoteLog() ? "Cloud data deleted" : "Failed to delete cloud data", undefined);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<TextInput
|
||||||
|
label="Custom server URL"
|
||||||
|
subLabel="Point at your own self-hosted instance instead - leave blank for the default"
|
||||||
|
placeholder="https://your-worker.workers.dev"
|
||||||
|
value={cloud.customHost ?? ""}
|
||||||
|
onChange={(v: string) => { cloud.customHost = v; }}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Custom OAuth2 client ID"
|
||||||
|
subLabel="Only needed alongside a custom server above"
|
||||||
|
value={cloud.customClientId ?? ""}
|
||||||
|
onChange={(v: string) => { cloud.customClientId = v; }}
|
||||||
|
/>
|
||||||
|
</TableRowGroup>
|
||||||
|
|
||||||
<TableRowGroup title="Log">
|
<TableRowGroup title="Log">
|
||||||
<TableRow label="View log" onPress={() => openLogViewer()} />
|
<TableRow label="View log" onPress={() => openLogViewer()} />
|
||||||
<TableRow
|
<TableRow
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue