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)));
}
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 { 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<string, unknown>;
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<unknown>][] = [];
/**
* 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<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.
*/
export default interface Persistent<T> {
export default interface Persistent<T> 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;

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 Persistent from "../interfaces/Persistent";
import Store from "../interfaces/Store";
interface Data {
sessions: Record<string, Session>;
@ -13,7 +14,7 @@ interface Data {
* Handles account authentication, managing multiple
* 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 current: Nullable<string>;
@ -26,10 +27,14 @@ export default class Auth implements Persistent<Data> {
makeAutoObservable(this);
}
get id() {
return "auth";
}
toJSON() {
return {
sessions: this.sessions,
current: this.current,
sessions: [...this.sessions],
current: this.current ?? undefined,
};
}

View file

@ -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<string, string>;
@ -9,7 +12,7 @@ interface Data {
/**
* 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>;
/**
@ -20,9 +23,13 @@ export default class Draft implements Persistent<Data> {
makeAutoObservable(this);
}
get id() {
return "draft";
}
toJSON() {
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 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<Data> {
export default class Experiments implements Store, Persistent<Data> {
private enabled: ObservableSet<Experiment>;
/**
@ -43,12 +50,17 @@ export default class Experiments implements Persistent<Data> {
*/
constructor() {
this.enabled = new ObservableSet();
makeAutoObservable(this);
}
get id() {
return "experiments";
}
toJSON() {
return {
enabled: this.enabled,
enabled: [...this.enabled],
};
}

View file

@ -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<Data> {
export default class Layout implements Store, Persistent<Data> {
/**
* 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<Data> {
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),
};
}

View file

@ -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<Data> {
export default class LocaleOptions implements Store, Persistent<Data> {
private lang: Language;
/**
@ -63,6 +64,10 @@ export default class LocaleOptions implements Persistent<Data> {
makeAutoObservable(this);
}
get id() {
return "locale";
}
toJSON() {
return {
lang: this.lang,

View file

@ -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<Data> {
export default class NotificationOptions implements Store, Persistent<Data> {
private server: ObservableMap<string, string>;
private channel: ObservableMap<string, string>;
@ -44,10 +47,14 @@ export default class NotificationOptions implements Persistent<Data> {
makeAutoObservable(this);
}
get id() {
return "notifications";
}
toJSON() {
return {
server: this.server,
channel: this.channel,
server: mapToRecord(this.server),
channel: mapToRecord(this.channel),
};
}

View file

@ -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 (
<Provider store={store}>
<StateContextProvider value={state}>