Message Logger: add cloud sync, restructure flux intercept

This commit is contained in:
Ally Founder 2026-08-20 11:56:03 -04:00
parent aa3b96dfed
commit 35972b836d
5 changed files with 460 additions and 115 deletions

View 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);
};
}

View file

@ -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);
} }

View file

@ -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();
} }

View file

@ -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[] = [];

View file

@ -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