feat(mobx): add persistence

This commit is contained in:
Paul 2021-12-11 16:24:23 +00:00
parent 2b55770ecc
commit bc799931a8
12 changed files with 136 additions and 45 deletions

View file

@ -7,3 +7,11 @@ export function urlBase64ToUint8Array(base64String: string) {
return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0))); return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0)));
} }
export function mapToRecord<K extends symbol | string | number, V>(
map: Map<K, V>,
) {
let record = {} as Record<K, V>;
map.forEach((v, k) => (record[k] = v));
return record;
}

View file

@ -1,22 +1,16 @@
import { makeAutoObservable } from "mobx"; import localforage from "localforage";
import { autorun, makeAutoObservable, reaction } from "mobx";
import { createContext } from "preact"; import { createContext } from "preact";
import { useContext } from "preact/hooks"; import { useContext } from "preact/hooks";
import Persistent from "./interfaces/Persistent";
import Auth from "./stores/Auth"; import Auth from "./stores/Auth";
import Draft from "./stores/Draft"; import Draft from "./stores/Draft";
import Experiments from "./stores/Experiments"; import Experiments from "./stores/Experiments";
import Layout from "./stores/Layout"; import Layout from "./stores/Layout";
import LocaleOptions from "./stores/LocaleOptions"; import LocaleOptions from "./stores/LocaleOptions";
interface StoreDefinition {
id: string;
instance: Record<string, unknown>;
persistent: boolean;
synced: boolean;
global: boolean;
}
/** /**
* Handles global application state. * Handles global application state.
*/ */
@ -27,6 +21,8 @@ export default class State {
experiments: Experiments; experiments: Experiments;
layout: Layout; layout: Layout;
private persistent: [string, Persistent<unknown>][] = [];
/** /**
* Construct new State. * Construct new State.
*/ */
@ -38,6 +34,57 @@ export default class State {
this.layout = new Layout(); this.layout = new Layout();
makeAutoObservable(this); 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<string, Record<string, unknown>>
)[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<T>
if (
typeof obj.hydrate === "function" &&
typeof obj.toJSON === "function"
) {
this.persistent.push([
id,
obj as unknown as Persistent<unknown>,
]);
}
}
}
}
}
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);
}
}
} }
} }

View file

@ -1,14 +0,0 @@
auth
drafts
experiments
last opened
locale
notifications
queue
section toggle
serevr config
settings
sync
themes
trusted links
unreads

View file

@ -1,10 +1,11 @@
import Store from "./Store";
/** /**
* A data store which is persistent and should cache its data locally. * A data store which is persistent and should cache its data locally.
*/ */
export default interface Persistent<T> { export default interface Persistent<T> extends Store {
/** /**
* Override toJSON to serialise this data store. * Serialise this data store.
* This will also force all subclasses to implement this method.
*/ */
toJSON(): unknown; toJSON(): unknown;

View file

@ -0,0 +1,3 @@
export default interface Store {
get id(): string;
}

View file

@ -3,6 +3,7 @@ import { Session } from "revolt-api/types/Auth";
import { Nullable } from "revolt.js/dist/util/null"; import { Nullable } from "revolt.js/dist/util/null";
import Persistent from "../interfaces/Persistent"; import Persistent from "../interfaces/Persistent";
import Store from "../interfaces/Store";
interface Data { interface Data {
sessions: Record<string, Session>; sessions: Record<string, Session>;
@ -13,7 +14,7 @@ interface Data {
* Handles account authentication, managing multiple * Handles account authentication, managing multiple
* accounts and their sessions. * accounts and their sessions.
*/ */
export default class Auth implements Persistent<Data> { export default class Auth implements Store, Persistent<Data> {
private sessions: ObservableMap<string, Session>; private sessions: ObservableMap<string, Session>;
private current: Nullable<string>; private current: Nullable<string>;
@ -26,10 +27,14 @@ export default class Auth implements Persistent<Data> {
makeAutoObservable(this); makeAutoObservable(this);
} }
get id() {
return "auth";
}
toJSON() { toJSON() {
return { return {
sessions: this.sessions, sessions: [...this.sessions],
current: this.current, current: this.current ?? undefined,
}; };
} }

View file

@ -1,6 +1,9 @@
import { action, computed, makeAutoObservable, ObservableMap } from "mobx"; import { action, computed, makeAutoObservable, ObservableMap } from "mobx";
import { mapToRecord } from "../../lib/conversion";
import Persistent from "../interfaces/Persistent"; import Persistent from "../interfaces/Persistent";
import Store from "../interfaces/Store";
interface Data { interface Data {
drafts: Record<string, string>; drafts: Record<string, string>;
@ -9,7 +12,7 @@ interface Data {
/** /**
* Handles storing draft (currently being written) messages. * Handles storing draft (currently being written) messages.
*/ */
export default class Draft implements Persistent<Data> { export default class Draft implements Store, Persistent<Data> {
private drafts: ObservableMap<string, string>; private drafts: ObservableMap<string, string>;
/** /**
@ -20,9 +23,13 @@ export default class Draft implements Persistent<Data> {
makeAutoObservable(this); makeAutoObservable(this);
} }
get id() {
return "draft";
}
toJSON() { toJSON() {
return { return {
drafts: this.drafts, drafts: mapToRecord(this.drafts),
}; };
} }

View file

@ -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 Persistent from "../interfaces/Persistent";
import Store from "../interfaces/Store";
/** /**
* Union type of available experiments. * Union type of available experiments.
@ -35,7 +42,7 @@ interface Data {
/** /**
* Handles enabling and disabling client experiments. * Handles enabling and disabling client experiments.
*/ */
export default class Experiments implements Persistent<Data> { export default class Experiments implements Store, Persistent<Data> {
private enabled: ObservableSet<Experiment>; private enabled: ObservableSet<Experiment>;
/** /**
@ -43,12 +50,17 @@ export default class Experiments implements Persistent<Data> {
*/ */
constructor() { constructor() {
this.enabled = new ObservableSet(); this.enabled = new ObservableSet();
makeAutoObservable(this); makeAutoObservable(this);
} }
get id() {
return "experiments";
}
toJSON() { toJSON() {
return { return {
enabled: this.enabled, enabled: [...this.enabled],
}; };
} }

View file

@ -1,6 +1,9 @@
import { action, computed, makeAutoObservable, ObservableMap } from "mobx"; import { action, computed, makeAutoObservable, ObservableMap } from "mobx";
import { mapToRecord } from "../../lib/conversion";
import Persistent from "../interfaces/Persistent"; import Persistent from "../interfaces/Persistent";
import Store from "../interfaces/Store";
interface Data { interface Data {
lastSection?: "home" | "server"; lastSection?: "home" | "server";
@ -14,7 +17,7 @@ interface Data {
* Handles providing good UX experience on navigating * Handles providing good UX experience on navigating
* back and forth between different parts of the app. * back and forth between different parts of the app.
*/ */
export default class Layout implements Persistent<Data> { export default class Layout implements Store, Persistent<Data> {
/** /**
* The last 'major section' that the user had open. * The last 'major section' that the user had open.
* This is either the home tab or a channel ID (for a server channel). * This is either the home tab or a channel ID (for a server channel).
@ -47,12 +50,16 @@ export default class Layout implements Persistent<Data> {
makeAutoObservable(this); makeAutoObservable(this);
} }
get id() {
return "layout";
}
toJSON() { toJSON() {
return { return {
lastSection: this.lastSection, lastSection: this.lastSection,
lastHomePath: this.lastHomePath, lastHomePath: this.lastHomePath,
lastOpened: this.lastOpened, lastOpened: mapToRecord(this.lastOpened),
openSections: this.openSections, openSections: mapToRecord(this.openSections),
}; };
} }

View file

@ -3,6 +3,7 @@ import { action, computed, makeAutoObservable } from "mobx";
import { Language, Languages } from "../../context/Locale"; import { Language, Languages } from "../../context/Locale";
import Persistent from "../interfaces/Persistent"; import Persistent from "../interfaces/Persistent";
import Store from "../interfaces/Store";
interface Data { interface Data {
lang: Language; lang: Language;
@ -52,7 +53,7 @@ export function findLanguage(lang?: string): Language {
* Handles providing good UX experience on navigating * Handles providing good UX experience on navigating
* back and forth between different parts of the app. * back and forth between different parts of the app.
*/ */
export default class LocaleOptions implements Persistent<Data> { export default class LocaleOptions implements Store, Persistent<Data> {
private lang: Language; private lang: Language;
/** /**
@ -63,6 +64,10 @@ export default class LocaleOptions implements Persistent<Data> {
makeAutoObservable(this); makeAutoObservable(this);
} }
get id() {
return "locale";
}
toJSON() { toJSON() {
return { return {
lang: this.lang, lang: this.lang,

View file

@ -1,7 +1,10 @@
import { action, computed, makeAutoObservable, ObservableMap } from "mobx"; import { action, computed, makeAutoObservable, ObservableMap } from "mobx";
import { Channel } from "revolt-api/types/Channels"; import { Channel } from "revolt-api/types/Channels";
import { mapToRecord } from "../../lib/conversion";
import Persistent from "../interfaces/Persistent"; import Persistent from "../interfaces/Persistent";
import Store from "../interfaces/Store";
/** /**
* Possible notification states. * Possible notification states.
@ -31,7 +34,7 @@ interface Data {
/** /**
* Manages the user's notification preferences. * Manages the user's notification preferences.
*/ */
export default class NotificationOptions implements Persistent<Data> { export default class NotificationOptions implements Store, Persistent<Data> {
private server: ObservableMap<string, string>; private server: ObservableMap<string, string>;
private channel: ObservableMap<string, string>; private channel: ObservableMap<string, string>;
@ -44,10 +47,14 @@ export default class NotificationOptions implements Persistent<Data> {
makeAutoObservable(this); makeAutoObservable(this);
} }
get id() {
return "notifications";
}
toJSON() { toJSON() {
return { return {
server: this.server, server: mapToRecord(this.server),
channel: this.channel, channel: mapToRecord(this.channel),
}; };
} }

View file

@ -26,16 +26,19 @@ export default function StateLoader(props: Props) {
}, [state]); }, [state]);
useEffect(() => { useEffect(() => {
localForage.getItem("state").then((state) => { localForage.getItem("state").then((s) => {
if (state !== null) { if (s !== null) {
dispatch({ type: "__INIT", state: state as State }); dispatch({ type: "__INIT", state: s as State });
} }
setLoaded(true); state.hydrate().then(() => setLoaded(true));
}); });
}, []); }, []);
if (!loaded) return null; if (!loaded) return null;
useEffect(state.registerListeners);
return ( return (
<Provider store={store}> <Provider store={store}>
<StateContextProvider value={state}> <StateContextProvider value={state}>