From cf3930b0941773269957e4c2b844cbf86fdc544b Mon Sep 17 00:00:00 2001
From: Paul <paulmakles@gmail.com>
Date: Thu, 29 Jul 2021 12:41:28 +0100
Subject: [PATCH] Add MobX store, create observable User.

---
 package.json                          |  2 +
 src/context/index.tsx                 | 25 +++----
 src/context/revoltjs/RevoltClient.tsx |  6 +-
 src/context/revoltjs/events.ts        |  4 ++
 src/context/revoltjs/hooks.ts         |  6 +-
 src/mobx/State.tsx                    | 26 ++++++++
 src/mobx/index.ts                     | 95 +++++++++++++++++++++++++++
 src/pages/developer/Developer.tsx     | 85 ++++++++++++++++++------
 tsconfig.json                         | 43 ++++++------
 yarn.lock                             | 10 +++
 10 files changed, 244 insertions(+), 58 deletions(-)
 create mode 100644 src/mobx/State.tsx
 create mode 100644 src/mobx/index.ts

diff --git a/package.json b/package.json
index 25a4562..83f056b 100644
--- a/package.json
+++ b/package.json
@@ -82,6 +82,8 @@
     "markdown-it-sub": "^1.0.0",
     "markdown-it-sup": "^1.0.0",
     "mediasoup-client": "npm:@insertish/mediasoup-client@3.6.36-esnext",
+    "mobx": "^6.3.2",
+    "mobx-react-lite": "^3.2.0",
     "preact-context-menu": "^0.1.5",
     "preact-i18n": "^2.4.0-preactx",
     "prettier": "^2.3.1",
diff --git a/src/context/index.tsx b/src/context/index.tsx
index f7a3ccb..e3c757e 100644
--- a/src/context/index.tsx
+++ b/src/context/index.tsx
@@ -2,6 +2,7 @@ import { BrowserRouter as Router } from "react-router-dom";
 
 import State from "../redux/State";
 
+import MobXState from "../mobx/State";
 import { Children } from "../types/Preact";
 import Locale from "./Locale";
 import Settings from "./Settings";
@@ -14,17 +15,19 @@ export default function Context({ children }: { children: Children }) {
     return (
         <Router>
             <State>
-                <Theme>
-                    <Settings>
-                        <Locale>
-                            <Intermediate>
-                                <Client>
-                                    <Voice>{children}</Voice>
-                                </Client>
-                            </Intermediate>
-                        </Locale>
-                    </Settings>
-                </Theme>
+                <MobXState>
+                    <Theme>
+                        <Settings>
+                            <Locale>
+                                <Intermediate>
+                                    <Client>
+                                        <Voice>{children}</Voice>
+                                    </Client>
+                                </Intermediate>
+                            </Locale>
+                        </Settings>
+                    </Theme>
+                </MobXState>
             </State>
         </Router>
     );
diff --git a/src/context/revoltjs/RevoltClient.tsx b/src/context/revoltjs/RevoltClient.tsx
index 6c8b3ac..27ed319 100644
--- a/src/context/revoltjs/RevoltClient.tsx
+++ b/src/context/revoltjs/RevoltClient.tsx
@@ -14,6 +14,7 @@ import { AuthState } from "../../redux/reducers/auth";
 
 import Preloader from "../../components/ui/Preloader";
 
+import { useData } from "../../mobx/State";
 import { Children } from "../../types/Preact";
 import { useIntermediate } from "../intermediate/Intermediate";
 import { registerEvents, setReconnectDisallowed } from "./events";
@@ -157,9 +158,10 @@ function Context({ auth, children }: Props) {
         };
     }, [client, auth.active]);
 
+    const store = useData();
     useEffect(
-        () => registerEvents({ operations }, setStatus, client),
-        [client],
+        () => registerEvents({ operations }, setStatus, client, store),
+        [client, store],
     );
 
     useEffect(() => {
diff --git a/src/context/revoltjs/events.ts b/src/context/revoltjs/events.ts
index 67f6267..f660733 100644
--- a/src/context/revoltjs/events.ts
+++ b/src/context/revoltjs/events.ts
@@ -5,6 +5,8 @@ import { StateUpdater } from "preact/hooks";
 
 import { dispatch } from "../../redux";
 
+import { DataStore } from "../../mobx";
+import { useData } from "../../mobx/State";
 import { ClientOperations, ClientStatus } from "./RevoltClient";
 
 export var preventReconnect = false;
@@ -18,6 +20,7 @@ export function registerEvents(
     { operations }: { operations: ClientOperations },
     setStatus: StateUpdater<ClientStatus>,
     client: Client,
+    store: DataStore,
 ) {
     function attemptReconnect() {
         if (preventReconnect) return;
@@ -45,6 +48,7 @@ export function registerEvents(
         },
 
         packet: (packet: ClientboundNotification) => {
+            store.packet(packet);
             switch (packet.type) {
                 case "ChannelStartTyping": {
                     if (packet.user === client.user?._id) return;
diff --git a/src/context/revoltjs/hooks.ts b/src/context/revoltjs/hooks.ts
index 7a4ba41..22a297c 100644
--- a/src/context/revoltjs/hooks.ts
+++ b/src/context/revoltjs/hooks.ts
@@ -5,7 +5,7 @@ import Collection from "revolt.js/dist/maps/Collection";
 
 import { useContext, useEffect, useState } from "preact/hooks";
 
-//#region Hooks v1
+//#region Hooks v1 (deprecated)
 import { AppContext } from "./RevoltClient";
 
 export interface HookContext {
@@ -238,7 +238,7 @@ export function useServerPermission(id: string, context?: HookContext) {
 }
 //#endregion
 
-//#region Hooks v2
+//#region Hooks v2 (deprecated)
 type CollectionKeys = Exclude<
     keyof PickProperties<Client, Collection<any>>,
     undefined
@@ -249,7 +249,7 @@ interface Depedency {
     id?: string;
 }
 
-export function useData<T>(
+export function useDataDeprecated<T>(
     cb: (client: Client) => T,
     dependencies: Depedency[],
 ): T {
diff --git a/src/mobx/State.tsx b/src/mobx/State.tsx
new file mode 100644
index 0000000..cd3b149
--- /dev/null
+++ b/src/mobx/State.tsx
@@ -0,0 +1,26 @@
+import { createContext } from "preact";
+import { useContext } from "preact/hooks";
+
+import { DataStore } from ".";
+import { Children } from "../types/Preact";
+
+interface Props {
+    children: Children;
+}
+
+export const DataContext = createContext<DataStore>(null!);
+
+// ! later we can do seamless account switching, by hooking this into Redux
+// ! and monitoring changes to active account and hence swapping stores.
+// although this may need more work since we need a Client per account too.
+const store = new DataStore();
+
+export default function StateLoader(props: Props) {
+    return (
+        <DataContext.Provider value={store}>
+            {props.children}
+        </DataContext.Provider>
+    );
+}
+
+export const useData = () => useContext(DataContext);
diff --git a/src/mobx/index.ts b/src/mobx/index.ts
new file mode 100644
index 0000000..b41a413
--- /dev/null
+++ b/src/mobx/index.ts
@@ -0,0 +1,95 @@
+import isEqual from "lodash.isequal";
+import {
+    makeAutoObservable,
+    observable,
+    autorun,
+    runInAction,
+    reaction,
+    makeObservable,
+    action,
+    extendObservable,
+} from "mobx";
+import { Attachment, Users } from "revolt.js/dist/api/objects";
+import { RemoveUserField } from "revolt.js/dist/api/routes";
+import { ClientboundNotification } from "revolt.js/dist/websocket/notifications";
+
+type Nullable<T> = T | null;
+function toNullable<T>(data?: T) {
+    return typeof data === "undefined" ? null : data;
+}
+
+class User {
+    _id: string;
+    username: string;
+
+    avatar: Nullable<Attachment>;
+    badges: Nullable<number>;
+    status: Nullable<Users.Status>;
+    relationship: Nullable<Users.Relationship>;
+    online: Nullable<boolean>;
+
+    constructor(data: Users.User) {
+        this._id = data._id;
+        this.username = data.username;
+
+        this.avatar = toNullable(data.avatar);
+        this.badges = toNullable(data.badges);
+        this.status = toNullable(data.status);
+        this.relationship = toNullable(data.relationship);
+        this.online = toNullable(data.online);
+
+        makeAutoObservable(this);
+    }
+
+    @action update(data: Partial<Users.User>, clear?: RemoveUserField) {
+        const apply = (key: keyof Users.User) => {
+            // This code has been tested.
+            // @ts-expect-error
+            if (data[key] && !isEqual(this[key], data[key])) {
+                // @ts-expect-error
+                this[key] = data[key];
+            }
+        };
+
+        switch (clear) {
+            case "Avatar":
+                this.avatar = null;
+                break;
+            case "StatusText": {
+                if (this.status) {
+                    this.status.text = undefined;
+                }
+            }
+        }
+
+        apply("avatar");
+        apply("badges");
+        apply("status");
+        apply("relationship");
+        apply("online");
+    }
+}
+
+export class DataStore {
+    @observable users = new Map<string, User>();
+
+    constructor() {
+        makeAutoObservable(this);
+    }
+
+    @action
+    packet(packet: ClientboundNotification) {
+        switch (packet.type) {
+            case "Ready": {
+                for (let user of packet.users) {
+                    this.users.set(user._id, new User(user));
+                }
+                break;
+            }
+            case "UserUpdate": {
+                this.users.get(packet.id)?.update(packet.data, packet.clear);
+                break;
+            }
+        }
+    }
+}
diff --git a/src/pages/developer/Developer.tsx b/src/pages/developer/Developer.tsx
index 4c0f3cc..562a41a 100644
--- a/src/pages/developer/Developer.tsx
+++ b/src/pages/developer/Developer.tsx
@@ -1,4 +1,6 @@
 import { Wrench } from "@styled-icons/boxicons-solid";
+import { isObservable, isObservableProp } from "mobx";
+import { observer } from "mobx-react-lite";
 import { Channels } from "revolt.js/dist/api/objects";
 
 import { useContext } from "preact/hooks";
@@ -7,10 +9,13 @@ import PaintCounter from "../../lib/PaintCounter";
 import { TextReact } from "../../lib/i18n";
 
 import { AppContext } from "../../context/revoltjs/RevoltClient";
-import { useData, useUserPermission } from "../../context/revoltjs/hooks";
+import { useUserPermission } from "../../context/revoltjs/hooks";
 
+import UserIcon from "../../components/common/user/UserIcon";
 import Header from "../../components/ui/Header";
 
+import { useData } from "../../mobx/State";
+
 export default function Developer() {
     // const voice = useContext(VoiceContext);
     const client = useContext(AppContext);
@@ -35,7 +40,10 @@ export default function Developer() {
                     fields={{ provider: <b>GAMING!</b> }}
                 />
             </div>
-            <DataTest />
+            <ObserverTest />
+            <ObserverTest2 />
+            <ObserverTest3 />
+            <ObserverTest4 />
             <div style={{ padding: "16px" }}>
                 {/*<span>
                     <b>Voice Status:</b> {VoiceStatus[voice.status]}
@@ -55,29 +63,66 @@ export default function Developer() {
     );
 }
 
-function DataTest() {
-    const channel_id = (
-        useContext(AppContext)
-            .channels.toArray()
-            .find((x) => x.channel_type === "Group") as Channels.GroupChannel
-    )._id;
+const ObserverTest = observer(() => {
+    const client = useContext(AppContext);
+    const store = useData();
+    return (
+        <div style={{ padding: "16px" }}>
+            <p>
+                username:{" "}
+                {store.users.get(client.user!._id)?.username ?? "no user!"}
+                <PaintCounter small />
+            </p>
+        </div>
+    );
+});
 
-    const data = useData(
-        (client) => {
-            return {
-                name: (client.channels.get(channel_id) as Channels.GroupChannel)
-                    .name,
-            };
-        },
-        [{ key: "channels", id: channel_id }],
+const ObserverTest2 = observer(() => {
+    const client = useContext(AppContext);
+    const store = useData();
+    return (
+        <div style={{ padding: "16px" }}>
+            <p>
+                status:{" "}
+                {JSON.stringify(store.users.get(client.user!._id)?.status) ??
+                    "none"}
+                <PaintCounter small />
+            </p>
+        </div>
     );
+});
 
+const ObserverTest3 = observer(() => {
+    const client = useContext(AppContext);
+    const store = useData();
     return (
         <div style={{ padding: "16px" }}>
-            Channel name: {data.name}
-            <div style={{ width: "24px" }}>
+            <p>
+                avatar{" "}
+                <UserIcon
+                    size={64}
+                    attachment={
+                        store.users.get(client.user!._id)?.avatar ?? undefined
+                    }
+                />
                 <PaintCounter small />
-            </div>
+            </p>
         </div>
     );
-}
+});
+
+const ObserverTest4 = observer(() => {
+    const client = useContext(AppContext);
+    const store = useData();
+    return (
+        <div style={{ padding: "16px" }}>
+            <p>
+                status text:{" "}
+                {JSON.stringify(
+                    store.users.get(client.user!._id)?.status?.text,
+                ) ?? "none"}
+                <PaintCounter small />
+            </p>
+        </div>
+    );
+});
diff --git a/tsconfig.json b/tsconfig.json
index 8f10876..722675d 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,24 +1,23 @@
 {
-  "compilerOptions": {
-    "target": "ESNext",
-    "lib": ["DOM", "DOM.Iterable", "ESNext", "WebWorker"],
-    "allowJs": true,
-    "skipLibCheck": true,
-    "esModuleInterop": true,
-    "allowSyntheticDefaultImports": true,
-    "strict": true,
-    "forceConsistentCasingInFileNames": true,
-    "module": "ESNext",
-    "moduleResolution": "Node",
-    "resolveJsonModule": true,
-    "isolatedModules": true,
-    "noEmit": true,
-    "jsx": "preserve",
-    "jsxFactory": "h",
-    "jsxFragmentFactory": "Fragment",
-    "types": [
-      "vite-plugin-pwa/client"
-    ]
-  },
-  "include": ["src", "ui/ui.tsx"]
+    "compilerOptions": {
+        "target": "ESNext",
+        "lib": ["DOM", "DOM.Iterable", "ESNext", "WebWorker"],
+        "allowJs": true,
+        "skipLibCheck": true,
+        "esModuleInterop": true,
+        "allowSyntheticDefaultImports": true,
+        "strict": true,
+        "forceConsistentCasingInFileNames": true,
+        "module": "ESNext",
+        "moduleResolution": "Node",
+        "resolveJsonModule": true,
+        "isolatedModules": true,
+        "noEmit": true,
+        "jsx": "preserve",
+        "jsxFactory": "h",
+        "jsxFragmentFactory": "Fragment",
+        "types": ["vite-plugin-pwa/client"],
+        "experimentalDecorators": true
+    },
+    "include": ["src", "ui/ui.tsx"]
 }
diff --git a/yarn.lock b/yarn.lock
index e14cb3b..f943b7e 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3077,6 +3077,16 @@ minimist@^1.2.5:
   resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602"
   integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==
 
+mobx-react-lite@^3.2.0:
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/mobx-react-lite/-/mobx-react-lite-3.2.0.tgz#331d7365a6b053378dfe9c087315b4e41c5df69f"
+  integrity sha512-q5+UHIqYCOpBoFm/PElDuOhbcatvTllgRp3M1s+Hp5j0Z6XNgDbgqxawJ0ZAUEyKM8X1zs70PCuhAIzX1f4Q/g==
+
+mobx@^6.3.2:
+  version "6.3.2"
+  resolved "https://registry.yarnpkg.com/mobx/-/mobx-6.3.2.tgz#125590961f702a572c139ab69392bea416d2e51b"
+  integrity sha512-xGPM9dIE1qkK9Nrhevp0gzpsmELKU4MFUJRORW/jqxVFIHHWIoQrjDjL8vkwoJYY3C2CeVJqgvl38hgKTalTWg==
+
 ms@2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
-- 
GitLab