diff --git a/.vscode/launch.json b/.vscode/launch.json index e80b447e..62e9edab 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -10,8 +10,7 @@ "name": "Launch Chrome against localhost", "url": "http://local.revolt.chat:3000", "webRoot": "${workspaceFolder}", - "runtimeExecutable": "/usr/bin/chromium", - "userDataDir": "${workspaceFolder}/.vscode/vscode-chrome-debug-userdatadir" + "runtimeExecutable": "/usr/bin/chromium" } ] } diff --git a/package.json b/package.json index 57e2a352..b43e7b45 100644 --- a/package.json +++ b/package.json @@ -117,6 +117,7 @@ "eslint-config-preact": "^1.1.4", "eventemitter3": "^4.0.7", "highlight.js": "^11.0.1", + "json-stringify-deterministic": "^1.0.2", "localforage": "^1.9.0", "lodash.defaultsdeep": "^4.6.1", "lodash.isequal": "^4.5.0", diff --git a/src/context/index.tsx b/src/context/index.tsx index 3f768da0..40238b4c 100644 --- a/src/context/index.tsx +++ b/src/context/index.tsx @@ -1,5 +1,11 @@ import { BrowserRouter as Router } from "react-router-dom"; +import { useEffect, useState } from "preact/hooks"; + +import { hydrateState } from "../mobx/State"; + +import Preloader from "../components/ui/Preloader"; + import { Children } from "../types/Preact"; import Locale from "./Locale"; import Theme from "./Theme"; @@ -11,6 +17,14 @@ import Client from "./revoltjs/RevoltClient"; * @param param0 Provided children */ export default function Context({ children }: { children: Children }) { + const [ready, setReady] = useState(false); + + useEffect(() => { + hydrateState().then(() => setReady(true)); + }, []); + + if (!ready) return ; + return ( diff --git a/src/context/revoltjs/SyncManager.tsx b/src/context/revoltjs/SyncManager.tsx index 4dd5b43f..cec9cd5c 100644 --- a/src/context/revoltjs/SyncManager.tsx +++ b/src/context/revoltjs/SyncManager.tsx @@ -1,138 +1,37 @@ /** * This file monitors changes to settings and syncs them to the server. */ +import { ClientboundNotification } from "revolt.js/dist/websocket/notifications"; -/*type Props = { - settings: Settings; - locale: Language; - sync: SyncOptions; - notifications: Notifications; -}; +import { useEffect } from "preact/hooks"; -const lastValues: { [key in SyncKeys]?: unknown } = {}; +import { useApplicationState } from "../../mobx/State"; -export function mapSync( - packet: UserSettings, - revision?: Record, -) { - const update: { [key in SyncKeys]?: [number, SyncData[key]] } = {}; - for (const key of Object.keys(packet)) { - const [timestamp, obj] = packet[key]; - if (timestamp < (revision ?? {})[key] ?? 0) { - continue; - } +import { useClient } from "./RevoltClient"; - let object; - if (obj[0] === "{") { - object = JSON.parse(obj); - } else { - object = obj; - } - - lastValues[key as SyncKeys] = object; - update[key as SyncKeys] = [timestamp, object]; - } - - return update; -} - -function SyncManager(props: Props) { - const client = useContext(AppContext); - const status = useContext(StatusContext); +export default function SyncManager() { + const client = useClient(); + const state = useApplicationState(); + // Sync settings from Revolt. useEffect(() => { - if (status === ClientStatus.ONLINE) { - client - .syncFetchSettings( - DEFAULT_ENABLED_SYNC.filter( - (x) => !props.sync?.disabled?.includes(x), - ), - ) - .then((data) => { - dispatch({ - type: "SYNC_UPDATE", - update: mapSync(data), - }); - }); + state.sync.pull(client); + }, [client]); - client - .syncFetchUnreads() - .then((unreads) => dispatch({ type: "UNREADS_SET", unreads })); - } - }, [client, props.sync?.disabled, status]); - - const syncChange = useCallback( - (key: SyncKeys, data: unknown) => { - const timestamp = +new Date(); - dispatch({ - type: "SYNC_SET_REVISION", - key, - timestamp, - }); - - client.syncSetSettings( - { - [key]: data as string, - }, - timestamp, - ); - }, - [client], - ); - - const disabled = useMemo( - () => props.sync.disabled ?? [], - [props.sync.disabled], - ); - for (const [key, object] of [ - ["appearance", props.settings.appearance], - ["theme", props.settings.theme], - ["locale", props.locale], - ["notifications", props.notifications], - ] as [SyncKeys, unknown][]) { - // eslint-disable-next-line react-hooks/rules-of-hooks - useEffect(() => { - if (disabled.indexOf(key) === -1) { - if (typeof lastValues[key] !== "undefined") { - if (!isEqual(lastValues[key], object)) { - syncChange(key, object); - } - } - } - - lastValues[key] = object; - }, [key, syncChange, disabled, object]); - } + // Keep data synced. + useEffect(() => state.registerListeners(client), [client]); + // Take data updates from Revolt. useEffect(() => { function onPacket(packet: ClientboundNotification) { if (packet.type === "UserSettingsUpdate") { - const update: { [key in SyncKeys]?: [number, SyncData[key]] } = - mapSync(packet.update, props.sync.revision); - - dispatch({ - type: "SYNC_UPDATE", - update, - }); + state.sync.apply(packet.update); } } client.addListener("packet", onPacket); return () => client.removeListener("packet", onPacket); - }, [client, disabled, props.sync]); + }, [client]); - return null; -} - -export default connectState(SyncManager, (state) => { - return { - settings: state.settings, - locale: state.locale, - sync: state.sync, - notifications: state.notifications, - }; -});*/ - -export default function SyncManager() { return <>; } diff --git a/src/context/revoltjs/events.ts b/src/context/revoltjs/events.ts index c39ed9fb..4a87dc65 100644 --- a/src/context/revoltjs/events.ts +++ b/src/context/revoltjs/events.ts @@ -1,6 +1,4 @@ import { Client } from "revolt.js/dist"; -import { Message } from "revolt.js/dist/maps/Messages"; -import { ClientboundNotification } from "revolt.js/dist/websocket/notifications"; import { StateUpdater } from "preact/hooks"; diff --git a/src/mobx/State.ts b/src/mobx/State.ts index bd69020f..19ec3ba3 100644 --- a/src/mobx/State.ts +++ b/src/mobx/State.ts @@ -1,10 +1,11 @@ +// @ts-expect-error No typings. +import stringify from "json-stringify-deterministic"; import localforage from "localforage"; -import { autorun, makeAutoObservable, reaction } from "mobx"; - -import { createContext } from "preact"; -import { useContext } from "preact/hooks"; +import { makeAutoObservable, reaction } from "mobx"; +import { Client } from "revolt.js"; import Persistent from "./interfaces/Persistent"; +import Syncable from "./interfaces/Syncable"; import Auth from "./stores/Auth"; import Draft from "./stores/Draft"; import Experiments from "./stores/Experiments"; @@ -14,7 +15,11 @@ import MessageQueue from "./stores/MessageQueue"; import NotificationOptions from "./stores/NotificationOptions"; import ServerConfig from "./stores/ServerConfig"; import Settings from "./stores/Settings"; -import Sync from "./stores/Sync"; +import Sync, { Data as DataSync, SyncKeys } from "./stores/Sync"; + +export const MIGRATIONS = { + REDUX: 1640305719826, +}; /** * Handles global application state. @@ -32,6 +37,7 @@ export default class State { sync: Sync; private persistent: [string, Persistent][] = []; + private disabled: Set = new Set(); /** * Construct new State. @@ -46,11 +52,11 @@ export default class State { this.notifications = new NotificationOptions(); this.queue = new MessageQueue(); this.settings = new Settings(); - this.sync = new Sync(); + this.sync = new Sync(this); makeAutoObservable(this); - this.registerListeners = this.registerListeners.bind(this); this.register(); + this.setDisabled = this.setDisabled.bind(this); } /** @@ -83,17 +89,78 @@ export default class State { } } + setDisabled(key: string) { + this.disabled.add(key); + } + /** * Register reaction listeners for persistent data stores. * @returns Function to dispose of listeners */ - registerListeners() { + registerListeners(client: Client) { const listeners = this.persistent.map(([id, store]) => { return reaction( - () => store.toJSON(), + () => stringify(store.toJSON()), async (value) => { try { - await localforage.setItem(id, value); + await localforage.setItem(id, JSON.parse(value)); + if (id === "sync") return; + + const revision = +new Date(); + switch (id) { + case "settings": { + const { appearance, theme } = + this.settings.toSyncable(); + + const obj: Record = {}; + if (this.sync.isEnabled("appearance")) { + if (this.disabled.has("appearance")) { + this.disabled.delete("appearance"); + } else { + obj["appearance"] = appearance; + this.sync.setRevision( + "appearance", + revision, + ); + } + } + + if (this.sync.isEnabled("theme")) { + if (this.disabled.has("theme")) { + this.disabled.delete("theme"); + } else { + obj["theme"] = theme; + this.sync.setRevision( + "theme", + revision, + ); + } + } + + if (Object.keys(obj).length > 0) { + client.syncSetSettings( + obj as any, + revision, + ); + } + break; + } + default: { + if (this.sync.isEnabled(id as SyncKeys)) { + if (this.disabled.has(id)) { + this.disabled.delete(id); + } + + this.sync.setRevision(id, revision); + client.syncSetSettings( + ( + store as unknown as Syncable + ).toSyncable(), + revision, + ); + } + } + } } catch (err) { console.error("Failed to serialise!"); console.error(err); @@ -110,10 +177,15 @@ export default class State { * Load data stores from local storage. */ async hydrate() { - for (const [id, store] of this.persistent) { - const data = await localforage.getItem(id); - if (typeof data === "object" && data !== null) { - store.hydrate(data); + const sync = (await localforage.getItem("sync")) as DataSync; + if (sync) { + const { revision } = sync; + for (const [id, store] of this.persistent) { + if (id === "sync") continue; + const data = await localforage.getItem(id); + if (typeof data === "object" && data !== null) { + store.hydrate(data, revision[id]); + } } } } @@ -121,12 +193,16 @@ export default class State { var state: State; +export async function hydrateState() { + state = new State(); + await state.hydrate(); +} + /** * Get the application state * @returns Application state */ export function useApplicationState() { - if (!state) state = new State(); return state; } diff --git a/src/mobx/interfaces/Migrate.ts b/src/mobx/interfaces/Migrate.ts deleted file mode 100644 index eec9a163..00000000 --- a/src/mobx/interfaces/Migrate.ts +++ /dev/null @@ -1,11 +0,0 @@ -import Store from "./Store"; - -/** - * A data store which is migrated forwards. - */ -export default interface Migrate extends Store { - /** - * Migrate this data store. - */ - migrate(key: K, data: Record, rev: number): void; -} diff --git a/src/mobx/interfaces/Persistent.ts b/src/mobx/interfaces/Persistent.ts index 83061097..ee0c4b2e 100644 --- a/src/mobx/interfaces/Persistent.ts +++ b/src/mobx/interfaces/Persistent.ts @@ -13,5 +13,5 @@ export default interface Persistent extends Store { * Hydrate this data store using given data. * @param data Given data */ - hydrate(data: T): void; + hydrate(data: T, revision: number): void; } diff --git a/src/mobx/interfaces/Syncable.ts b/src/mobx/interfaces/Syncable.ts new file mode 100644 index 00000000..e19737d5 --- /dev/null +++ b/src/mobx/interfaces/Syncable.ts @@ -0,0 +1,9 @@ +import Store from "./Store"; + +/** + * A data store which syncs data to Revolt. + */ +export default interface Syncable extends Store { + apply(key: string, data: unknown, revision: number): void; + toSyncable(): { [key: string]: object }; +} diff --git a/src/mobx/legacy/redux.ts b/src/mobx/legacy/redux.ts index e9edfa5f..63727668 100644 --- a/src/mobx/legacy/redux.ts +++ b/src/mobx/legacy/redux.ts @@ -62,20 +62,22 @@ export interface LegacyAuthState { active?: string; } -function legacyMigrateAuth(auth: LegacyAuthState): DataAuth { +export function legacyMigrateAuth(auth: LegacyAuthState): DataAuth { return { current: auth.active, sessions: auth.accounts, }; } -function legacyMigrateLocale(lang: Language): DataLocaleOptions { +export function legacyMigrateLocale(lang: Language): DataLocaleOptions { return { lang, }; } -function legacyMigrateTheme(theme: LegacyThemeOptions): Partial { +export function legacyMigrateTheme( + theme: LegacyThemeOptions, +): Partial { const { light, font, css, monospaceFont, ...variables } = theme.custom ?? {}; @@ -90,7 +92,7 @@ function legacyMigrateTheme(theme: LegacyThemeOptions): Partial { }; } -function legacyMigrateAppearance( +export function legacyMigrateAppearance( appearance: LegacyAppearanceOptions, ): Partial { return { @@ -98,7 +100,7 @@ function legacyMigrateAppearance( }; } -function legacyMigrateNotification( +export function legacyMigrateNotification( channel: LegacyNotifications, ): DataNotificationOptions { return { @@ -106,7 +108,7 @@ function legacyMigrateNotification( }; } -function legacyMigrateSync(sync: LegacySyncOptions): DataSync { +export function legacyMigrateSync(sync: LegacySyncOptions): DataSync { return { disabled: sync.disabled ?? [], revision: { diff --git a/src/mobx/stores/Auth.ts b/src/mobx/stores/Auth.ts index b2ab3914..baee76b8 100644 --- a/src/mobx/stores/Auth.ts +++ b/src/mobx/stores/Auth.ts @@ -30,6 +30,20 @@ export default class Auth implements Store, Persistent { constructor() { this.sessions = new ObservableMap(); this.current = null; + + // Inject session token if it is provided. + if (import.meta.env.VITE_SESSION_TOKEN) { + this.sessions.set("0", { + session: { + name: "0", + user_id: "0", + token: import.meta.env.VITE_SESSION_TOKEN as string, + }, + }); + + this.current = "0"; + } + makeAutoObservable(this); } diff --git a/src/mobx/stores/NotificationOptions.ts b/src/mobx/stores/NotificationOptions.ts index 51c45511..18836afb 100644 --- a/src/mobx/stores/NotificationOptions.ts +++ b/src/mobx/stores/NotificationOptions.ts @@ -6,8 +6,15 @@ import { Server } from "revolt.js/dist/maps/Servers"; import { mapToRecord } from "../../lib/conversion"; +import { + legacyMigrateNotification, + LegacyNotifications, +} from "../legacy/redux"; + +import { MIGRATIONS } from "../State"; import Persistent from "../interfaces/Persistent"; import Store from "../interfaces/Store"; +import Syncable from "../interfaces/Syncable"; /** * Possible notification states. @@ -42,7 +49,9 @@ export interface Data { /** * Manages the user's notification preferences. */ -export default class NotificationOptions implements Store, Persistent { +export default class NotificationOptions + implements Store, Persistent, Syncable +{ private server: ObservableMap; private channel: ObservableMap; @@ -208,4 +217,18 @@ export default class NotificationOptions implements Store, Persistent { return false; } + + @action apply(_key: "notifications", data: unknown, revision: number) { + if (revision < MIGRATIONS.REDUX) { + data = legacyMigrateNotification(data as LegacyNotifications); + } + + this.hydrate(data as Data); + } + + @computed toSyncable() { + return { + notifications: this.toJSON(), + }; + } } diff --git a/src/mobx/stores/Settings.ts b/src/mobx/stores/Settings.ts index b64e4b9f..8deeb5ef 100644 --- a/src/mobx/stores/Settings.ts +++ b/src/mobx/stores/Settings.ts @@ -2,12 +2,22 @@ import { action, computed, makeAutoObservable, ObservableMap } from "mobx"; import { mapToRecord } from "../../lib/conversion"; +import { + LegacyAppearanceOptions, + legacyMigrateAppearance, + legacyMigrateTheme, + LegacyTheme, + LegacyThemeOptions, +} from "../legacy/redux"; + import { Fonts, MonospaceFonts, Overrides } from "../../context/Theme"; import { EmojiPack } from "../../components/common/Emoji"; +import { MIGRATIONS } from "../State"; import Persistent from "../interfaces/Persistent"; import Store from "../interfaces/Store"; +import Syncable from "../interfaces/Syncable"; import SAudio, { SoundOptions } from "./helpers/SAudio"; import SSecurity from "./helpers/SSecurity"; import STheme from "./helpers/STheme"; @@ -32,7 +42,9 @@ export interface ISettings { /** * Manages user settings. */ -export default class Settings implements Store, Persistent { +export default class Settings + implements Store, Persistent, Syncable +{ private data: ObservableMap; theme: STheme; @@ -109,4 +121,60 @@ export default class Settings implements Store, Persistent { @computed getUnchecked(key: string) { return this.data.get(key); } + + @action apply( + key: "appearance" | "theme", + data: unknown, + revision: number, + ) { + if (revision < MIGRATIONS.REDUX) { + if (key === "appearance") { + data = legacyMigrateAppearance(data as LegacyAppearanceOptions); + } else { + data = legacyMigrateTheme(data as LegacyThemeOptions); + } + } + + if (key === "appearance") { + this.remove("appearance:emoji"); + } else { + this.remove("appearance:ligatures"); + this.remove("appearance:theme:base"); + this.remove("appearance:theme:css"); + this.remove("appearance:theme:font"); + this.remove("appearance:theme:light"); + this.remove("appearance:theme:monoFont"); + this.remove("appearance:theme:overrides"); + } + + this.hydrate(data as ISettings); + } + + @computed private pullKeys(keys: (keyof ISettings)[]) { + const obj: Partial = {}; + keys.forEach((key) => { + let value = this.get(key); + if (!value) return; + (obj as any)[key] = value; + }); + + return obj; + } + + @computed toSyncable() { + const data: Record<"appearance" | "theme", Partial> = { + appearance: this.pullKeys(["appearance:emoji"]), + theme: this.pullKeys([ + "appearance:ligatures", + "appearance:theme:base", + "appearance:theme:css", + "appearance:theme:font", + "appearance:theme:light", + "appearance:theme:monoFont", + "appearance:theme:overrides", + ]), + }; + + return data; + } } diff --git a/src/mobx/stores/Sync.ts b/src/mobx/stores/Sync.ts index b0b47065..4a2bc10a 100644 --- a/src/mobx/stores/Sync.ts +++ b/src/mobx/stores/Sync.ts @@ -4,11 +4,14 @@ import { makeAutoObservable, ObservableMap, ObservableSet, + runInAction, } from "mobx"; import { Client } from "revolt.js"; +import { UserSettings } from "revolt.js/node_modules/revolt-api/types/Sync"; import { mapToRecord } from "../../lib/conversion"; +import State from "../State"; import Persistent from "../interfaces/Persistent"; import Store from "../interfaces/Store"; @@ -32,13 +35,15 @@ export interface Data { * Handles syncing settings data. */ export default class Sync implements Store, Persistent { + private state: State; private disabled: ObservableSet; - private revision: ObservableMap; + private revision: ObservableMap; /** * Construct new Sync store. */ - constructor() { + constructor(state: State) { + this.state = state; this.disabled = new ObservableSet(); this.revision = new ObservableMap(); makeAutoObservable(this); @@ -62,6 +67,12 @@ export default class Sync implements Store, Persistent { this.disabled.add(key as SyncKeys); } } + + if (data.revision) { + for (const key of Object.keys(data.revision)) { + this.setRevision(key, data.revision[key]); + } + } } @action enable(key: SyncKeys) { @@ -81,12 +92,74 @@ export default class Sync implements Store, Persistent { } @computed isEnabled(key: SyncKeys) { - return !this.disabled.has(key); + return !this.disabled.has(key) && SYNC_KEYS.includes(key); + } + + @action setRevision(key: string, revision: number) { + if (revision < (this.getRevision(key) ?? 0)) return; + this.revision.set(key, revision); + } + + @computed getRevision(key: string) { + return this.revision.get(key); + } + + @action apply(data: UserSettings) { + const tryRead = (key: string) => { + if (key in data) { + const revision = data[key][0]; + if (revision <= (this.getRevision(key) ?? 0)) { + return; + } + + let parsed; + try { + parsed = JSON.parse(data[key][1]); + } catch (err) { + parsed = data[key][1]; + } + + return [revision, parsed]; + } + }; + + runInAction(() => { + const appearance = tryRead("appearance"); + if (appearance) { + this.state.setDisabled("appearance"); + this.state.settings.apply( + "appearance", + appearance[1], + appearance[0], + ); + this.setRevision("appearance", appearance[0]); + } + + const theme = tryRead("theme"); + if (theme) { + this.state.setDisabled("theme"); + this.state.settings.apply("theme", theme[1], theme[0]); + this.setRevision("theme", theme[0]); + } + + const notifications = tryRead("notifications"); + if (notifications) { + this.state.setDisabled("notifications"); + this.state.notifications.apply( + "notifications", + notifications[1], + notifications[0], + ); + this.setRevision("notifications", notifications[0]); + } + }); } async pull(client: Client) { const data = await client.syncFetchSettings( SYNC_KEYS.filter(this.isEnabled), ); + + this.apply(data); } } diff --git a/src/mobx/stores/helpers/SSecurity.ts b/src/mobx/stores/helpers/SSecurity.ts index 41d0ed01..a57d8d1f 100644 --- a/src/mobx/stores/helpers/SSecurity.ts +++ b/src/mobx/stores/helpers/SSecurity.ts @@ -27,7 +27,6 @@ export default class SSecurity { } @computed isTrustedOrigin(origin: string) { - console.log(this.settings.get("security:trustedOrigins"), origin); return this.settings.get("security:trustedOrigins")?.includes(origin); } } diff --git a/src/pages/developer/Developer.tsx b/src/pages/developer/Developer.tsx index 5a7ccb2a..782662a4 100644 --- a/src/pages/developer/Developer.tsx +++ b/src/pages/developer/Developer.tsx @@ -1,6 +1,6 @@ import { Wrench } from "@styled-icons/boxicons-solid"; -import { useContext, useState } from "preact/hooks"; +import { useContext, useEffect, useState } from "preact/hooks"; import PaintCounter from "../../lib/PaintCounter"; import { TextReact } from "../../lib/i18n"; @@ -16,10 +16,14 @@ export default function Developer() { const userPermission = client.user!.permission; const [ping, setPing] = useState(client.websocket.ping); - setInterval( - () => setPing(client.websocket.ping), - client.options.heartbeat * 1e3, - ); + useEffect(() => { + const timer = setInterval( + () => setPing(client.websocket.ping), + client.options.heartbeat * 1e3, + ); + + return () => clearInterval(timer); + }, []); return (
diff --git a/src/pages/settings/panes/Notifications.tsx b/src/pages/settings/panes/Notifications.tsx index 6643dd37..6c692c44 100644 --- a/src/pages/settings/panes/Notifications.tsx +++ b/src/pages/settings/panes/Notifications.tsx @@ -1,3 +1,5 @@ +import { observer } from "mobx-react-lite"; + import styles from "./Panes.module.scss"; import { Text } from "preact-i18n"; import { useContext, useEffect, useState } from "preact/hooks"; @@ -11,7 +13,7 @@ import { AppContext } from "../../../context/revoltjs/RevoltClient"; import Checkbox from "../../../components/ui/Checkbox"; -export function Notifications() { +export const Notifications = observer(() => { const client = useContext(AppContext); const { openScreen } = useIntermediate(); const settings = useApplicationState().settings; @@ -118,4 +120,4 @@ export function Notifications() { ))}
); -} +}); diff --git a/yarn.lock b/yarn.lock index ce022ce1..f29f871a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2974,6 +2974,11 @@ json-stable-stringify-without-jsonify@^1.0.1: resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" integrity sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE= +json-stringify-deterministic@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/json-stringify-deterministic/-/json-stringify-deterministic-1.0.2.tgz#fe72b5f49cc39c5f7c5fcb17d2fb06fca092e727" + integrity sha512-u6lgTmpDXjVJChV2pOcW7bQOOXrGZAHKzfOfJXQ4ktFrpdotKgaf1crqktYrVOe/Ul0Qrm/CMjWNGS+ErpngCg== + json5@^2.1.2: version "2.2.0" resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.0.tgz#2dfefe720c6ba525d9ebd909950f0515316c89a3"