From bc799931a86943708ce3659ba60bd63647e9e48d Mon Sep 17 00:00:00 2001 From: Paul Date: Sat, 11 Dec 2021 16:24:23 +0000 Subject: [PATCH] feat(mobx): add persistence --- src/lib/conversion.ts | 8 ++++ src/mobx/State.ts | 65 ++++++++++++++++++++++---- src/mobx/TODO | 14 ------ src/mobx/interfaces/Persistent.ts | 7 +-- src/mobx/interfaces/Store.ts | 3 ++ src/mobx/stores/Auth.ts | 11 +++-- src/mobx/stores/Draft.ts | 11 ++++- src/mobx/stores/Experiments.ts | 18 +++++-- src/mobx/stores/Layout.ts | 13 ++++-- src/mobx/stores/LocaleOptions.ts | 7 ++- src/mobx/stores/NotificationOptions.ts | 13 ++++-- src/redux/State.tsx | 11 +++-- 12 files changed, 136 insertions(+), 45 deletions(-) delete mode 100644 src/mobx/TODO create mode 100644 src/mobx/interfaces/Store.ts diff --git a/src/lib/conversion.ts b/src/lib/conversion.ts index 48a6aaf9..f0840bb0 100644 --- a/src/lib/conversion.ts +++ b/src/lib/conversion.ts @@ -7,3 +7,11 @@ export function urlBase64ToUint8Array(base64String: string) { return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0))); } + +export function mapToRecord( + map: Map, +) { + let record = {} as Record; + map.forEach((v, k) => (record[k] = v)); + return record; +} diff --git a/src/mobx/State.ts b/src/mobx/State.ts index 8cd5dea0..006cae11 100644 --- a/src/mobx/State.ts +++ b/src/mobx/State.ts @@ -1,22 +1,16 @@ -import { makeAutoObservable } from "mobx"; +import localforage from "localforage"; +import { autorun, makeAutoObservable, reaction } from "mobx"; import { createContext } from "preact"; import { useContext } from "preact/hooks"; +import Persistent from "./interfaces/Persistent"; import Auth from "./stores/Auth"; import Draft from "./stores/Draft"; import Experiments from "./stores/Experiments"; import Layout from "./stores/Layout"; import LocaleOptions from "./stores/LocaleOptions"; -interface StoreDefinition { - id: string; - instance: Record; - persistent: boolean; - synced: boolean; - global: boolean; -} - /** * Handles global application state. */ @@ -27,6 +21,8 @@ export default class State { experiments: Experiments; layout: Layout; + private persistent: [string, Persistent][] = []; + /** * Construct new State. */ @@ -38,6 +34,57 @@ export default class State { this.layout = new Layout(); makeAutoObservable(this); + this.registerListeners = this.registerListeners.bind(this); + this.register(); + } + + private register() { + for (const key of Object.keys(this)) { + const obj = ( + this as unknown as Record> + )[key]; + + // Check if this is an object. + if (typeof obj === "object") { + // Check if this is a Store. + if (typeof obj.id === "string") { + const id = obj.id; + + // Check if this is a Persistent + if ( + typeof obj.hydrate === "function" && + typeof obj.toJSON === "function" + ) { + this.persistent.push([ + id, + obj as unknown as Persistent, + ]); + } + } + } + } + } + + registerListeners() { + const listeners = this.persistent.map(([id, store]) => { + return reaction( + () => store.toJSON(), + (value) => { + localforage.setItem(id, value); + }, + ); + }); + + return () => listeners.forEach((x) => x()); + } + + async hydrate() { + for (const [id, store] of this.persistent) { + const data = await localforage.getItem(id); + if (typeof data === "object" && data !== null) { + store.hydrate(data); + } + } } } diff --git a/src/mobx/TODO b/src/mobx/TODO deleted file mode 100644 index 63d63932..00000000 --- a/src/mobx/TODO +++ /dev/null @@ -1,14 +0,0 @@ -auth -drafts -experiments -last opened -locale -notifications -queue -section toggle -serevr config -settings -sync -themes -trusted links -unreads diff --git a/src/mobx/interfaces/Persistent.ts b/src/mobx/interfaces/Persistent.ts index 576e6133..83061097 100644 --- a/src/mobx/interfaces/Persistent.ts +++ b/src/mobx/interfaces/Persistent.ts @@ -1,10 +1,11 @@ +import Store from "./Store"; + /** * A data store which is persistent and should cache its data locally. */ -export default interface Persistent { +export default interface Persistent extends Store { /** - * Override toJSON to serialise this data store. - * This will also force all subclasses to implement this method. + * Serialise this data store. */ toJSON(): unknown; diff --git a/src/mobx/interfaces/Store.ts b/src/mobx/interfaces/Store.ts new file mode 100644 index 00000000..af78fb4d --- /dev/null +++ b/src/mobx/interfaces/Store.ts @@ -0,0 +1,3 @@ +export default interface Store { + get id(): string; +} diff --git a/src/mobx/stores/Auth.ts b/src/mobx/stores/Auth.ts index e76d9f18..9cea243e 100644 --- a/src/mobx/stores/Auth.ts +++ b/src/mobx/stores/Auth.ts @@ -3,6 +3,7 @@ import { Session } from "revolt-api/types/Auth"; import { Nullable } from "revolt.js/dist/util/null"; import Persistent from "../interfaces/Persistent"; +import Store from "../interfaces/Store"; interface Data { sessions: Record; @@ -13,7 +14,7 @@ interface Data { * Handles account authentication, managing multiple * accounts and their sessions. */ -export default class Auth implements Persistent { +export default class Auth implements Store, Persistent { private sessions: ObservableMap; private current: Nullable; @@ -26,10 +27,14 @@ export default class Auth implements Persistent { makeAutoObservable(this); } + get id() { + return "auth"; + } + toJSON() { return { - sessions: this.sessions, - current: this.current, + sessions: [...this.sessions], + current: this.current ?? undefined, }; } diff --git a/src/mobx/stores/Draft.ts b/src/mobx/stores/Draft.ts index 225353b6..e245ee15 100644 --- a/src/mobx/stores/Draft.ts +++ b/src/mobx/stores/Draft.ts @@ -1,6 +1,9 @@ import { action, computed, makeAutoObservable, ObservableMap } from "mobx"; +import { mapToRecord } from "../../lib/conversion"; + import Persistent from "../interfaces/Persistent"; +import Store from "../interfaces/Store"; interface Data { drafts: Record; @@ -9,7 +12,7 @@ interface Data { /** * Handles storing draft (currently being written) messages. */ -export default class Draft implements Persistent { +export default class Draft implements Store, Persistent { private drafts: ObservableMap; /** @@ -20,9 +23,13 @@ export default class Draft implements Persistent { makeAutoObservable(this); } + get id() { + return "draft"; + } + toJSON() { return { - drafts: this.drafts, + drafts: mapToRecord(this.drafts), }; } diff --git a/src/mobx/stores/Experiments.ts b/src/mobx/stores/Experiments.ts index 0e62d4bc..8e564cc0 100644 --- a/src/mobx/stores/Experiments.ts +++ b/src/mobx/stores/Experiments.ts @@ -1,6 +1,13 @@ -import { action, computed, makeAutoObservable, ObservableSet } from "mobx"; +import { + action, + autorun, + computed, + makeAutoObservable, + ObservableSet, +} from "mobx"; import Persistent from "../interfaces/Persistent"; +import Store from "../interfaces/Store"; /** * Union type of available experiments. @@ -35,7 +42,7 @@ interface Data { /** * Handles enabling and disabling client experiments. */ -export default class Experiments implements Persistent { +export default class Experiments implements Store, Persistent { private enabled: ObservableSet; /** @@ -43,12 +50,17 @@ export default class Experiments implements Persistent { */ constructor() { this.enabled = new ObservableSet(); + makeAutoObservable(this); } + get id() { + return "experiments"; + } + toJSON() { return { - enabled: this.enabled, + enabled: [...this.enabled], }; } diff --git a/src/mobx/stores/Layout.ts b/src/mobx/stores/Layout.ts index b1cf2793..6f8ce3e7 100644 --- a/src/mobx/stores/Layout.ts +++ b/src/mobx/stores/Layout.ts @@ -1,6 +1,9 @@ import { action, computed, makeAutoObservable, ObservableMap } from "mobx"; +import { mapToRecord } from "../../lib/conversion"; + import Persistent from "../interfaces/Persistent"; +import Store from "../interfaces/Store"; interface Data { lastSection?: "home" | "server"; @@ -14,7 +17,7 @@ interface Data { * Handles providing good UX experience on navigating * back and forth between different parts of the app. */ -export default class Layout implements Persistent { +export default class Layout implements Store, Persistent { /** * The last 'major section' that the user had open. * This is either the home tab or a channel ID (for a server channel). @@ -47,12 +50,16 @@ export default class Layout implements Persistent { makeAutoObservable(this); } + get id() { + return "layout"; + } + toJSON() { return { lastSection: this.lastSection, lastHomePath: this.lastHomePath, - lastOpened: this.lastOpened, - openSections: this.openSections, + lastOpened: mapToRecord(this.lastOpened), + openSections: mapToRecord(this.openSections), }; } diff --git a/src/mobx/stores/LocaleOptions.ts b/src/mobx/stores/LocaleOptions.ts index f1d4c3ec..a4bd249f 100644 --- a/src/mobx/stores/LocaleOptions.ts +++ b/src/mobx/stores/LocaleOptions.ts @@ -3,6 +3,7 @@ import { action, computed, makeAutoObservable } from "mobx"; import { Language, Languages } from "../../context/Locale"; import Persistent from "../interfaces/Persistent"; +import Store from "../interfaces/Store"; interface Data { lang: Language; @@ -52,7 +53,7 @@ export function findLanguage(lang?: string): Language { * Handles providing good UX experience on navigating * back and forth between different parts of the app. */ -export default class LocaleOptions implements Persistent { +export default class LocaleOptions implements Store, Persistent { private lang: Language; /** @@ -63,6 +64,10 @@ export default class LocaleOptions implements Persistent { makeAutoObservable(this); } + get id() { + return "locale"; + } + toJSON() { return { lang: this.lang, diff --git a/src/mobx/stores/NotificationOptions.ts b/src/mobx/stores/NotificationOptions.ts index eda1140b..0d8bc6ea 100644 --- a/src/mobx/stores/NotificationOptions.ts +++ b/src/mobx/stores/NotificationOptions.ts @@ -1,7 +1,10 @@ import { action, computed, makeAutoObservable, ObservableMap } from "mobx"; import { Channel } from "revolt-api/types/Channels"; +import { mapToRecord } from "../../lib/conversion"; + import Persistent from "../interfaces/Persistent"; +import Store from "../interfaces/Store"; /** * Possible notification states. @@ -31,7 +34,7 @@ interface Data { /** * Manages the user's notification preferences. */ -export default class NotificationOptions implements Persistent { +export default class NotificationOptions implements Store, Persistent { private server: ObservableMap; private channel: ObservableMap; @@ -44,10 +47,14 @@ export default class NotificationOptions implements Persistent { makeAutoObservable(this); } + get id() { + return "notifications"; + } + toJSON() { return { - server: this.server, - channel: this.channel, + server: mapToRecord(this.server), + channel: mapToRecord(this.channel), }; } diff --git a/src/redux/State.tsx b/src/redux/State.tsx index fddb34b2..7edfd4b7 100644 --- a/src/redux/State.tsx +++ b/src/redux/State.tsx @@ -26,16 +26,19 @@ export default function StateLoader(props: Props) { }, [state]); useEffect(() => { - localForage.getItem("state").then((state) => { - if (state !== null) { - dispatch({ type: "__INIT", state: state as State }); + localForage.getItem("state").then((s) => { + if (s !== null) { + dispatch({ type: "__INIT", state: s as State }); } - setLoaded(true); + state.hydrate().then(() => setLoaded(true)); }); }, []); if (!loaded) return null; + + useEffect(state.registerListeners); + return (