diff --git a/plugins/message-logger/src/lib/cloudSync.ts b/plugins/message-logger/src/lib/cloudSync.ts new file mode 100644 index 0000000..c3da0eb --- /dev/null +++ b/plugins/message-logger/src/lib/cloudSync.ts @@ -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 { + 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 { + const res = await authFetch("api/data"); + if (!res) return null; + return await res.json(); +} + +async function pushLog(log: LoggedMessage[]): Promise { + const res = await authFetch("api/data", { + method: "PUT", + body: JSON.stringify(log), + headers: { "content-type": "application/json" }, + }); + return !!res; +} + +export async function deleteRemoteLog(): Promise { + 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(); + 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 | 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 { + 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); + }; +} diff --git a/plugins/message-logger/src/lib/store.ts b/plugins/message-logger/src/lib/store.ts index ba50f8e..dbe3c1d 100644 --- a/plugins/message-logger/src/lib/store.ts +++ b/plugins/message-logger/src/lib/store.ts @@ -3,6 +3,31 @@ import type { LoggedMessage } from "./types"; 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[] { logStorage.log ??= []; return logStorage.log; @@ -36,15 +61,24 @@ function pruneLog(maxEntries: number, maxAgeDays: number, maxPerChannel: number) 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); pruneLog(limits.maxEntries, limits.maxAgeDays, limits.maxPerChannel); + onMutate?.(); } export function removeLogEntry(loggedAt: number): void { logStorage.log = getLog().filter((e) => e.loggedAt !== loggedAt); + onMutate?.(); } export function clearLog(): void { 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); } diff --git a/plugins/message-logger/src/patches/fluxIntercept.ts b/plugins/message-logger/src/patches/fluxIntercept.ts index 9ba4133..8b92964 100644 --- a/plugins/message-logger/src/patches/fluxIntercept.ts +++ b/plugins/message-logger/src/patches/fluxIntercept.ts @@ -1,14 +1,12 @@ 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"; +import { addLogEntry, getLog, logLimitsFromStorage } from "../lib/store"; +import type { FilterOptions, LoggedMessage } from "../lib/types"; const TAG = "[MessageLogger]"; -const CLEANUP_FLAG = "__msgLoggerCleanup"; // 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 @@ -16,16 +14,37 @@ const CLEANUP_FLAG = "__msgLoggerCleanup"; const MAX_EDIT_HISTORY_PER_MESSAGE = 10; const MAX_TRACKED_MESSAGES = 500; -const ChannelMessages = findByProps("_channelMessages"); -const UserStore = findByStoreName("UserStore"); +// Reinserting a message is a synchronous store write that forces the channel's row list to +// 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 -// channel - tracked so onUnload can dispatch a real delete for each on unload. +// Ids currently kept "alive" inline instead of a real delete, mapped to their channel - tracked so +// onUnload can remove each for real on unload, and so rowStyling.ts knows which rows to style. export const fakedMessages = new Map(); // Past content versions per message, oldest first, current content not included. export const editHistory = new Map(); +// 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(); + +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(map: Map, max: number): void { while (map.size > max) { const oldestKey = map.keys().next().value; @@ -38,19 +57,6 @@ 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 { @@ -92,92 +98,93 @@ function hasLoggableContent(message: any): boolean { ); } -// Any action type other than MESSAGE_DELETE stops the real removal - live-verified that -// MESSAGE_EDIT_FAILED_AUTOMOD (the same technique meqativ/dumsane's NoDelete uses) is a genuine -// no-op for a message not already in a pending-edit state: dispatching it left the cached -// MessageRecord byte-for-byte identical. No need to reconstruct/spread the message into a fake -// 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 rememberMessage(message: any): void { + if (!message?.id) return; + shadowMessages.set(message.id, message); + evictOldest(shadowMessages, MAX_TRACKED_MESSAGES); } -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; - 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; + if (!original?.author?.id) return false; + if ((original.flags & EPHEMERAL) === EPHEMERAL) return false; + if (!hasLoggableContent(original)) return false; + if (!storage.options?.logDeleted) return false; + if (ignoredFor(original, false)) return false; + return true; +} - const keepInline = !!storage.options?.keepDeletedInline; - addLogEntry(snapshotMessage(original, "deleted", undefined, keepInline), limitsFromStorage()); +// Logs the deletion and, if eligible to be kept inline, returns the original message so the +// 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); - return suppressionEventFor(original); } -function handleSingleDelete(event: any): any { - if (event[CLEANUP_FLAG]) return undefined; // our own revert dispatch on unload - pass through untouched - +function subscribeSingleDelete(event: any): void { 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; + const original = processDelete(event.id, true); + if (original) keepInline(channelId, [original]); } -function handleBulkDelete(event: any): any { +function subscribeBulkDelete(event: any): void { 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[] = []; - + const allowInline = ids.length <= MAX_INLINE_BULK_KEEP; + const toKeep: any[] = []; 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); + const original = processDelete(id, allowInline); + if (original) toKeep.push(original); } - - 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 }]; + keepInline(channelId, toKeep); } -function handleUpdate(event: any): void { +function subscribeCreate(event: any): void { + rememberMessage(event?.message); +} + +function subscribeUpdate(event: any): void { const updated = event?.message; if (!updated?.id) return; - const channelId = updated.channel_id ?? updated.channelId; - const original = ChannelMessages?.get?.(channelId)?.get?.(updated.id); + const original = shadowMessages.get(updated.id); + rememberMessage(updated); if (!original) return; const newContent = updated.content ?? original.content; @@ -191,48 +198,78 @@ function handleUpdate(event: any): void { editHistory.set(updated.id, history); 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 { - if (!ChannelMessages) { - console.warn(TAG, "ChannelMessages module not found, skipping capture"); - return false; +// Rebuilds editHistory from every "edited" log entry, oldest first per message - the in-memory +// map only ever reflects edits witnessed live, so it's empty again after every restart. +function rebuildEditHistory(): void { + const byId = new Map(); + 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( - before("dispatch", FluxDispatcher, (args: any[]) => { - const event = args[0]; - if (!event?.type) return; + for (const [id, entries] of byId) { + entries.sort((a, b) => a.loggedAt - b.loggedAt); + const history = entries.map((e) => e.content ?? "").slice(-MAX_EDIT_HISTORY_PER_MESSAGE); + editHistory.set(id, history); + } + evictOldest(editHistory, MAX_TRACKED_MESSAGES); +} - 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); - } - }), - ); +// Kept-inline deletions are NOT rehydrated here - reinserting a synthetic message only becomes +// visible today by riding along a native row update that's already happening for some other +// reason (confirmed live: writing to ChannelMessages/MessageStore directly, forcing a change +// emit, and dispatching a synthetic MESSAGE_UPDATE for it all failed to surface a test message +// with no real event behind it). Needs its own investigation into injecting a row directly via +// the same before("updateRows", ...) hook rowStyling.ts already uses successfully, rather than +// trying to get Discord's own store to organically pick it up. +export function rehydrateFromLog(): void { + try { + rebuildEditHistory(); + } catch (e: any) { + 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; } -/** 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 { for (const [id, channelId] of fakedMessages) { + const record = ChannelMessages?.get?.(channelId); + if (!record) continue; try { - FluxDispatcher.dispatch({ type: "MESSAGE_DELETE", channelId, id, [CLEANUP_FLAG]: true }); + ChannelMessages.commit(record.remove(id)); } catch (e: any) { console.error(TAG, "Failed to revert faked message", id, e?.message ?? e); } } fakedMessages.clear(); editHistory.clear(); + shadowMessages.clear(); } diff --git a/plugins/message-logger/src/patches/rowStyling.ts b/plugins/message-logger/src/patches/rowStyling.ts index 223b73d..454cb88 100644 --- a/plugins/message-logger/src/patches/rowStyling.ts +++ b/plugins/message-logger/src/patches/rowStyling.ts @@ -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 // 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. +// +// 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) { 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 nodes: any[] = []; diff --git a/plugins/message-logger/src/ui/Settings.tsx b/plugins/message-logger/src/ui/Settings.tsx index fe33f1e..bfab195 100644 --- a/plugins/message-logger/src/ui/Settings.tsx +++ b/plugins/message-logger/src/ui/Settings.tsx @@ -7,11 +7,34 @@ 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 { + cloudSyncStorage, + deleteRemoteLog, + disconnect, + isConnected, + openOauth2Modal, + syncNow, +} from "../lib/cloudSync"; 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() { useProxy(storage); const o = storage.options; + const cloud = cloudSyncStorage(); + useProxy(cloud); return ( @@ -30,7 +53,7 @@ export default function Settings() { /> { o.keepDeletedInline = v; }} /> @@ -111,6 +134,62 @@ export default function Settings() { /> + + + 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. + + { cloud.enabled = v; }} + /> + {cloud.enabled && !isConnected() && ( + openOauth2Modal()} /> + )} + {cloud.enabled && isConnected() && ( + <> + { + showToast("Syncing…", undefined); + showToast(await syncNow() ? "Synced" : "Sync failed", undefined); + }} + /> + { + disconnect(); + showToast("Disconnected", undefined); + }} + /> + { + showToast(await deleteRemoteLog() ? "Cloud data deleted" : "Failed to delete cloud data", undefined); + }} + /> + + )} + { cloud.customHost = v; }} + /> + { cloud.customClientId = v; }} + /> + + openLogViewer()} />