From ec97dbebd0cc4a9fec78a6401050531e14ba283c Mon Sep 17 00:00:00 2001
From: Paul <paulmakles@gmail.com>
Date: Fri, 18 Jun 2021 22:47:25 +0100
Subject: [PATCH] Hide client behind context. Use idb for saving data. Allow
 logins.

---
 package.json                           |   5 +-
 src/app.tsx                            |  18 ++-
 src/context/revoltjs/RevoltClient.tsx  | 183 +++++++++++++++++++++++--
 src/context/revoltjs/events.ts         | 121 ++++++++++++++++
 src/context/revoltjs/hooks.ts          | 108 +++++++++++++++
 src/pages/login/Login.tsx              |   5 +-
 src/pages/login/forms/CaptchaBlock.tsx |  12 +-
 src/pages/login/forms/Form.tsx         |  12 +-
 src/pages/login/forms/FormCreate.tsx   |   7 +-
 src/pages/login/forms/FormResend.tsx   |   7 +-
 src/pages/login/forms/FormReset.tsx    |  10 +-
 src/redux/index.ts                     |   2 +-
 yarn.lock                              | 171 +++--------------------
 13 files changed, 474 insertions(+), 187 deletions(-)
 create mode 100644 src/context/revoltjs/events.ts
 create mode 100644 src/context/revoltjs/hooks.ts

diff --git a/package.json b/package.json
index 26f8587..0b20ee7 100644
--- a/package.json
+++ b/package.json
@@ -29,7 +29,7 @@
     "@preact/preset-vite": "^2.0.0",
     "@styled-icons/bootstrap": "^10.34.0",
     "@styled-icons/feather": "^10.34.0",
-    "@types/node": "^15.12.3",
+    "@types/node": "^15.12.4",
     "@types/preact-i18n": "^2.3.0",
     "@types/react-helmet": "^6.1.1",
     "@types/react-router-dom": "^5.1.7",
@@ -40,6 +40,7 @@
     "detect-browser": "^5.2.0",
     "eslint": "^7.28.0",
     "eslint-config-preact": "^1.1.4",
+    "idb": "^6.1.2",
     "localforage": "^1.9.0",
     "preact-i18n": "^2.4.0-preactx",
     "prettier": "^2.3.1",
@@ -50,7 +51,7 @@
     "react-redux": "^7.2.4",
     "react-router-dom": "^5.2.0",
     "redux": "^4.1.0",
-    "revolt.js": "4.2.0-alpha.3-patch.0",
+    "revolt.js": "4.3.0",
     "rimraf": "^3.0.2",
     "sass": "^1.35.1",
     "styled-components": "^5.3.0",
diff --git a/src/app.tsx b/src/app.tsx
index 605c43e..28e68f5 100644
--- a/src/app.tsx
+++ b/src/app.tsx
@@ -4,6 +4,22 @@ import Context from "./context";
 
 import { Login } from "./pages/login/Login";
 
+import { useForceUpdate, useSelf, useUser } from "./context/revoltjs/hooks";
+
+function Test() {
+    const ctx = useForceUpdate();
+
+    let self = useSelf(ctx);
+    let bree = useUser('01EZZJ98RM1YVB1FW9FG221CAN', ctx);
+
+    return (
+        <div>
+            <h1>logged in as { self?.username }</h1>
+            <h4>bree: { JSON.stringify(bree) }</h4>
+        </div>
+    )
+}
+
 export function App() {
     return (
         <Context>
@@ -15,7 +31,7 @@ export function App() {
                 </Route>
                 <Route path="/">
                     <CheckAuth auth>
-                        <h1>revolt app</h1>
+                        <Test />
                     </CheckAuth>
                 </Route>
             </Switch>
diff --git a/src/context/revoltjs/RevoltClient.tsx b/src/context/revoltjs/RevoltClient.tsx
index a606730..dc919f2 100644
--- a/src/context/revoltjs/RevoltClient.tsx
+++ b/src/context/revoltjs/RevoltClient.tsx
@@ -1,14 +1,19 @@
+import { openDB } from 'idb';
 import { Client } from "revolt.js";
+import { takeError } from "./error";
 import { createContext } from "preact";
-import { useState } from "preact/hooks";
 import { Children } from "../../types/Preact";
 import { Route } from "revolt.js/dist/api/routes";
+import { useEffect, useState } from "preact/hooks";
 import { connectState } from "../../redux/connector";
+import Preloader from "../../components/ui/Preloader";
 import { WithDispatcher } from "../../redux/reducers";
 import { AuthState } from "../../redux/reducers/auth";
 import { SyncOptions } from "../../redux/reducers/sync";
+import { registerEvents, setReconnectDisallowed } from "./events";
 
 export enum ClientStatus {
+    INIT,
     LOADING,
     READY,
     OFFLINE,
@@ -26,19 +31,13 @@ export interface ClientOperations {
 }
 
 export interface AppState {
+    client: Client;
     status: ClientStatus;
     operations: ClientOperations;
 }
 
 export const AppContext = createContext<AppState>(undefined as any);
 
-export const RevoltClient = new Client({
-    autoReconnect: false,
-    apiURL: import.meta.env.VITE_API_URL,
-    debug: process.env.NODE_ENV === "development",
-    // db: new Db("state", 3, ["channels", "servers", "users", "members"])
-});
-
 type Props = WithDispatcher & {
     auth: AuthState;
     sync: SyncOptions;
@@ -46,18 +45,176 @@ type Props = WithDispatcher & {
 };
 
 function Context({ auth, sync, children, dispatcher }: Props) {
-    const [status, setStatus] = useState(ClientStatus.LOADING);
+    const [status, setStatus] = useState(ClientStatus.INIT);
+    const [client, setClient] = useState<Client>(undefined as unknown as Client);
+
+    useEffect(() => {
+        (async () => {
+            let db;
+            try {
+                db = await openDB('state', 3, {
+                    upgrade(db) {
+                        for (let store of [ "channels", "servers", "users", "members" ]) {
+                            db.createObjectStore(store, {
+                                keyPath: '_id'
+                            });
+                        }
+                    },
+                });
+            } catch (err) {
+                console.error('Failed to open IndexedDB store, continuing without.');
+            }
+
+            setClient(new Client({
+                autoReconnect: false,
+                apiURL: import.meta.env.VITE_API_URL,
+                debug: import.meta.env.DEV,
+                db
+            }));
+
+            setStatus(ClientStatus.LOADING);
+        })();
+    }, [ ]);
+
+    if (status === ClientStatus.INIT) return null;
 
     const value: AppState = {
+        client,
         status,
         operations: {
-            login: async data => {},
-            logout: async shouldRequest => {},
-            loggedIn: () => false,
-            ready: () => false
+            login: async data => {
+                setReconnectDisallowed(true);
+
+                try {
+                    const onboarding = await client.login(data);
+                    setReconnectDisallowed(false);
+                    const login = () =>
+                        dispatcher({
+                            type: "LOGIN",
+                            session: client.session as any
+                        });
+
+                    if (onboarding) {
+                        /*openScreen({
+                            id: "onboarding",
+                            callback: async (username: string) => {
+                                await (onboarding as any)(username, true);
+                                login();
+                            }
+                        });*/
+                    } else {
+                        login();
+                    }
+                } catch (err) {
+                    setReconnectDisallowed(false);
+                    throw err;
+                }
+            },
+            logout: async shouldRequest => {
+                dispatcher({ type: "LOGOUT" });
+
+                delete client.user;
+                dispatcher({ type: "RESET" });
+
+                // openScreen({ id: "none" });
+                setStatus(ClientStatus.READY);
+
+                client.websocket.disconnect();
+
+                if (shouldRequest) {
+                    try {
+                        await client.logout();
+                    } catch (err) {
+                        console.error(err);
+                    }
+                }
+            },
+            loggedIn: () => typeof auth.active !== "undefined",
+            ready: () => (
+                value.operations.loggedIn() &&
+                typeof client.user !== "undefined"
+            )
         }
     };
 
+    useEffect(
+        () => registerEvents({ ...value, dispatcher }, setStatus, client),
+        [ client ]
+    );
+
+    useEffect(() => {
+        (async () => {
+            await client.restore();
+
+            if (auth.active) {
+                dispatcher({ type: "QUEUE_FAIL_ALL" });
+
+                const active = auth.accounts[auth.active];
+                client.user = client.users.get(active.session.user_id);
+                if (!navigator.onLine) {
+                    return setStatus(ClientStatus.OFFLINE);
+                }
+
+                if (value.operations.ready())
+                    setStatus(ClientStatus.CONNECTING);
+                
+                if (navigator.onLine) {
+                    await client
+                        .fetchConfiguration()
+                        .catch(() =>
+                            console.error("Failed to connect to API server.")
+                        );
+                }
+
+                try {
+                    await client.fetchConfiguration();
+                    const callback = await client.useExistingSession(
+                        active.session
+                    );
+
+                    //if (callback) {
+                        /*openScreen({ id: "onboarding", callback });*/
+                    //} else {
+                        /*
+                        // ! FIXME: all this code needs to be re-written
+                        (async () => {
+                            // ! FIXME: should be included in Ready payload
+                            props.dispatcher({
+                                type: 'SYNC_UPDATE',
+                                // ! FIXME: write a procedure to resolve merge conflicts
+                                update: mapSync(
+                                    await client.syncFetchSettings(DEFAULT_ENABLED_SYNC.filter(x => !props.sync?.disabled?.includes(x)))
+                                )
+                            });
+                        })()
+
+                        props.dispatcher({ type: 'UNREADS_SET', unreads: await client.syncFetchUnreads() });*/
+                    //}
+                } catch (err) {
+                    setStatus(ClientStatus.DISCONNECTED);
+                    const error = takeError(err);
+                    if (error === "Forbidden") {
+                        value.operations.logout(true);
+                        // openScreen({ id: "signed_out" });
+                    } else {
+                        // openScreen({ id: "error", error });
+                    }
+                }
+            } else {
+                await client
+                    .fetchConfiguration()
+                    .catch(() =>
+                        console.error("Failed to connect to API server.")
+                    );
+                setStatus(ClientStatus.READY);
+            }
+        })();
+    }, []);
+
+    if (status === ClientStatus.LOADING) {
+        return <Preloader />;
+    }
+
     return (
         <AppContext.Provider value={value}>
             { children }
diff --git a/src/context/revoltjs/events.ts b/src/context/revoltjs/events.ts
new file mode 100644
index 0000000..0a10c53
--- /dev/null
+++ b/src/context/revoltjs/events.ts
@@ -0,0 +1,121 @@
+import { ClientboundNotification } from "revolt.js/dist/websocket/notifications";
+import { WithDispatcher } from "../../redux/reducers";
+import { Client, Message } from "revolt.js/dist";
+import {
+    AppState,
+    ClientStatus
+} from "./RevoltClient";
+import { StateUpdater } from "preact/hooks";
+
+export var preventReconnect = false;
+let preventUntil = 0;
+
+export function setReconnectDisallowed(allowed: boolean) {
+    preventReconnect = allowed;
+}
+
+export function registerEvents({
+    operations,
+    dispatcher
+}: AppState & WithDispatcher, setStatus: StateUpdater<ClientStatus>, client: Client) {
+    const listeners = {
+        connecting: () =>
+            operations.ready() && setStatus(ClientStatus.CONNECTING),
+
+        dropped: () => {
+            operations.ready() && setStatus(ClientStatus.DISCONNECTED);
+
+            if (preventReconnect) return;
+            function reconnect() {
+                preventUntil = +new Date() + 2000;
+                client.websocket.connect().catch(err => console.error(err));
+            }
+
+            if (+new Date() > preventUntil) {
+                setTimeout(reconnect, 2000);
+            } else {
+                reconnect();
+            }
+        },
+
+        packet: (packet: ClientboundNotification) => {
+            switch (packet.type) {
+                case "ChannelStartTyping": {
+                    if (packet.user === client.user?._id) return;
+                    dispatcher({
+                        type: "TYPING_START",
+                        channel: packet.id,
+                        user: packet.user
+                    });
+                    break;
+                }
+                case "ChannelStopTyping": {
+                    if (packet.user === client.user?._id) return;
+                    dispatcher({
+                        type: "TYPING_STOP",
+                        channel: packet.id,
+                        user: packet.user
+                    });
+                    break;
+                }
+                case "ChannelAck": {
+                    dispatcher({
+                        type: "UNREADS_MARK_READ",
+                        channel: packet.id,
+                        message: packet.message_id,
+                        request: false
+                    });
+                    break;
+                }
+            }
+        },
+
+        message: (message: Message) => {
+            if (message.mentions?.includes(client.user!._id)) {
+                dispatcher({
+                    type: "UNREADS_MENTION",
+                    channel: message.channel,
+                    message: message._id
+                });
+            }
+        },
+
+        ready: () => {
+            setStatus(ClientStatus.ONLINE);
+        }
+    };
+
+    let listenerFunc: { [key: string]: Function };
+    if (import.meta.env.DEV) {
+        listenerFunc = {};
+        for (const listener of Object.keys(listeners)) {
+            listenerFunc[listener] = (...args: any[]) => {
+                console.debug(`Calling ${listener} with`, args);
+                (listeners as any)[listener](...args);
+            };
+        }
+    } else {
+        listenerFunc = listeners;
+    }
+
+    for (const listener of Object.keys(listenerFunc)) {
+        client.addListener(listener, (listenerFunc as any)[listener]);
+    }
+
+    /*const online = () =>
+        operations.ready() && setStatus(ClientStatus.RECONNECTING);
+    const offline = () =>
+        operations.ready() && setStatus(ClientStatus.OFFLINE);
+
+    window.addEventListener("online", online);
+    window.addEventListener("offline", offline);
+
+    return () => {
+        for (const listener of Object.keys(listenerFunc)) {
+            RevoltClient.removeListener(listener, (listenerFunc as any)[listener]);
+        }
+
+        window.removeEventListener("online", online);
+        window.removeEventListener("offline", offline);
+    };*/
+}
diff --git a/src/context/revoltjs/hooks.ts b/src/context/revoltjs/hooks.ts
new file mode 100644
index 0000000..3f100b0
--- /dev/null
+++ b/src/context/revoltjs/hooks.ts
@@ -0,0 +1,108 @@
+import { useCallback, useContext, useEffect, useState } from "preact/hooks";
+import { Channels, Servers, Users } from "revolt.js/dist/api/objects";
+import { Client, PermissionCalculator } from 'revolt.js';
+import { AppContext } from "./RevoltClient";
+
+interface HookContext {
+    client: Client,
+    forceUpdate: () => void
+}
+
+export function useForceUpdate(context?: HookContext): HookContext {
+    const { client } = useContext(AppContext);
+    if (context) return context;
+    const [, updateState] = useState({});
+    return { client, forceUpdate: useCallback(() => updateState({}), []) };
+}
+
+function useObject(type: string, id?: string | string[], context?: HookContext) {
+    const ctx = useForceUpdate(context);
+
+    function mutation(target: string) {
+        if (typeof id === 'string' ? target === id :
+            Array.isArray(id) ? id.includes(target) : true) {
+            ctx.forceUpdate();
+        }
+    }
+
+    const map = (ctx.client as any)[type];
+    useEffect(() => {
+        map.addListener("update", mutation);
+        return () => map.removeListener("update", mutation);
+    }, [id]);
+
+    return typeof id === 'string' ? map.get(id)
+        : Array.isArray(id) ? id.map(x => map.get(x))
+        : map.toArray();
+}
+
+export function useUser(id?: string, context?: HookContext) {
+    if (typeof id === "undefined") return;
+    return useObject('users', id, context) as Readonly<Users.User> | undefined;
+}
+
+export function useSelf(context?: HookContext) {
+    const ctx = useForceUpdate(context);
+    return useUser(ctx.client.user!._id, ctx);
+}
+
+export function useUsers(ids?: string[], context?: HookContext) {
+    return useObject('users', ids, context) as (Readonly<Users.User> | undefined)[];
+}
+
+export function useChannel(id?: string, context?: HookContext) {
+    if (typeof id === "undefined") return;
+    return useObject('channels', id, context) as Readonly<Channels.Channel> | undefined;
+}
+
+export function useChannels(ids?: string[], context?: HookContext) {
+    return useObject('channels', ids, context) as (Readonly<Channels.Channel> | undefined)[];
+}
+
+export function useServer(id?: string, context?: HookContext) {
+    if (typeof id === "undefined") return;
+    return useObject('servers', id, context) as Readonly<Servers.Server> | undefined;
+}
+
+export function useServers(ids?: string[], context?: HookContext) {
+    return useObject('servers', ids, context) as (Readonly<Servers.Server> | undefined)[];
+}
+
+export function useUserPermission(id: string, context?: HookContext) {
+    const ctx = useForceUpdate(context);
+
+    const mutation = (target: string) => (target === id) && ctx.forceUpdate();
+    useEffect(() => {
+        ctx.client.users.addListener("update", mutation);
+        return () => ctx.client.users.removeListener("update", mutation);
+    }, [id]);
+    
+    let calculator = new PermissionCalculator(ctx.client);
+    return calculator.forUser(id);
+}
+
+export function useChannelPermission(id: string, context?: HookContext) {
+    const ctx = useForceUpdate(context);
+
+    const mutation = (target: string) => (target === id) && ctx.forceUpdate();
+    useEffect(() => {
+        ctx.client.channels.addListener("update", mutation);
+        return () => ctx.client.channels.removeListener("update", mutation);
+    }, [id]);
+    
+    let calculator = new PermissionCalculator(ctx.client);
+    return calculator.forChannel(id);
+}
+
+export function useServerPermission(id: string, context?: HookContext) {
+    const ctx = useForceUpdate(context);
+
+    const mutation = (target: string) => (target === id) && ctx.forceUpdate();
+    useEffect(() => {
+        ctx.client.servers.addListener("update", mutation);
+        return () => ctx.client.servers.removeListener("update", mutation);
+    }, [id]);
+    
+    let calculator = new PermissionCalculator(ctx.client);
+    return calculator.forServer(id);
+}
diff --git a/src/pages/login/Login.tsx b/src/pages/login/Login.tsx
index 6e463dd..3eda13c 100644
--- a/src/pages/login/Login.tsx
+++ b/src/pages/login/Login.tsx
@@ -6,7 +6,7 @@ import { APP_VERSION } from "../../version";
 import { LIBRARY_VERSION } from "revolt.js";
 import { Route, Switch } from "react-router-dom";
 import { ThemeContext } from "../../context/Theme";
-import { RevoltClient } from "../../context/revoltjs/RevoltClient";
+import { AppContext } from "../../context/revoltjs/RevoltClient";
 
 import background from "./background.jpg";
 
@@ -17,6 +17,7 @@ import { FormReset, FormSendReset } from "./forms/FormReset";
 
 export const Login = () => {
     const theme = useContext(ThemeContext);
+    const { client } = useContext(AppContext);
 
     return (
         <div className={styles.login}>
@@ -27,7 +28,7 @@ export const Login = () => {
                 <div className={styles.attribution}>
                     <span>
                         API:{" "}
-                        <code>{RevoltClient.configuration?.revolt ?? "???"}</code>{" "}
+                        <code>{client.configuration?.revolt ?? "???"}</code>{" "}
                         &middot; revolt.js: <code>{LIBRARY_VERSION}</code>{" "}
                         &middot; App: <code>{APP_VERSION}</code>
                     </span>
diff --git a/src/pages/login/forms/CaptchaBlock.tsx b/src/pages/login/forms/CaptchaBlock.tsx
index 7030dbd..0ba486a 100644
--- a/src/pages/login/forms/CaptchaBlock.tsx
+++ b/src/pages/login/forms/CaptchaBlock.tsx
@@ -1,9 +1,9 @@
 import { Text } from "preact-i18n";
-import { useEffect } from "preact/hooks";
 import styles from "../Login.module.scss";
 import HCaptcha from "@hcaptcha/react-hcaptcha";
+import { useContext, useEffect } from "preact/hooks";
 import Preloader from "../../../components/ui/Preloader";
-import { RevoltClient } from "../../../context/revoltjs/RevoltClient";
+import { AppContext } from "../../../context/revoltjs/RevoltClient";
 
 export interface CaptchaProps {
     onSuccess: (token?: string) => void;
@@ -11,19 +11,21 @@ export interface CaptchaProps {
 }
 
 export function CaptchaBlock(props: CaptchaProps) {
+    const { client } = useContext(AppContext);
+
     useEffect(() => {
-        if (!RevoltClient.configuration?.features.captcha.enabled) {
+        if (!client.configuration?.features.captcha.enabled) {
             props.onSuccess();
         }
     }, []);
 
-    if (!RevoltClient.configuration?.features.captcha.enabled)
+    if (!client.configuration?.features.captcha.enabled)
         return <Preloader />;
 
     return (
         <div>
             <HCaptcha
-                sitekey={RevoltClient.configuration.features.captcha.key}
+                sitekey={client.configuration.features.captcha.key}
                 onVerify={token => props.onSuccess(token)}
             />
             <div className={styles.footer}>
diff --git a/src/pages/login/forms/Form.tsx b/src/pages/login/forms/Form.tsx
index ff28139..56cdfad 100644
--- a/src/pages/login/forms/Form.tsx
+++ b/src/pages/login/forms/Form.tsx
@@ -1,14 +1,14 @@
 import { Legal } from "./Legal";
 import { Text } from "preact-i18n";
 import { Link } from "react-router-dom";
-import { useState } from "preact/hooks";
 import styles from "../Login.module.scss";
 import { useForm } from "react-hook-form";
 import { MailProvider } from "./MailProvider";
+import { useContext, useState } from "preact/hooks";
 import { CheckCircle, Mail } from "@styled-icons/feather";
 import { CaptchaBlock, CaptchaProps } from "./CaptchaBlock";
 import { takeError } from "../../../context/revoltjs/error";
-import { RevoltClient } from "../../../context/revoltjs/RevoltClient";
+import { AppContext } from "../../../context/revoltjs/RevoltClient";
 
 import FormField from "../FormField";
 import Button from "../../../components/ui/Button";
@@ -34,6 +34,8 @@ function getInviteCode() {
 }
 
 export function Form({ page, callback }: Props) {
+    const { client } = useContext(AppContext);
+
     const [loading, setLoading] = useState(false);
     const [success, setSuccess] = useState<string | undefined>(undefined);
     const [error, setGlobalError] = useState<string | undefined>(undefined);
@@ -73,7 +75,7 @@ export function Form({ page, callback }: Props) {
 
         try {
             if (
-                RevoltClient.configuration?.features.captcha.enabled &&
+                client.configuration?.features.captcha.enabled &&
                 page !== "reset"
             ) {
                 setCaptcha({
@@ -103,7 +105,7 @@ export function Form({ page, callback }: Props) {
     if (typeof success !== "undefined") {
         return (
             <div className={styles.success}>
-                {RevoltClient.configuration?.features.email ? (
+                {client.configuration?.features.email ? (
                     <>
                         <Mail size={72} />
                         <h2>
@@ -157,7 +159,7 @@ export function Form({ page, callback }: Props) {
                         error={errors.password?.message}
                     />
                 )}
-                {RevoltClient.configuration?.features.invite_only &&
+                {client.configuration?.features.invite_only &&
                     page === "create" && (
                         <FormField
                             type="invite"
diff --git a/src/pages/login/forms/FormCreate.tsx b/src/pages/login/forms/FormCreate.tsx
index 0d586c5..ca00a0c 100644
--- a/src/pages/login/forms/FormCreate.tsx
+++ b/src/pages/login/forms/FormCreate.tsx
@@ -1,12 +1,15 @@
-import { RevoltClient } from "../../../context/revoltjs/RevoltClient";
+import { AppContext } from "../../../context/revoltjs/RevoltClient";
+import { useContext } from "preact/hooks";
 import { Form } from "./Form";
 
 export function FormCreate() {
+    const { client } = useContext(AppContext);
+
     return (
         <Form
             page="create"
             callback={async data => {
-                await RevoltClient.register(process.env.API_SERVER as string, data);
+                await client.register(import.meta.env.VITE_API_URL, data);
             }}
         />
     );
diff --git a/src/pages/login/forms/FormResend.tsx b/src/pages/login/forms/FormResend.tsx
index badbabf..9d52b20 100644
--- a/src/pages/login/forms/FormResend.tsx
+++ b/src/pages/login/forms/FormResend.tsx
@@ -1,12 +1,15 @@
-import { RevoltClient } from "../../../context/revoltjs/RevoltClient";
+import { AppContext } from "../../../context/revoltjs/RevoltClient";
+import { useContext } from "preact/hooks";
 import { Form } from "./Form";
 
 export function FormResend() {
+    const { client } = useContext(AppContext);
+
     return (
         <Form
             page="resend"
             callback={async data => {
-                await RevoltClient.req("POST", "/auth/resend", data);
+                await client.req("POST", "/auth/resend", data);
             }}
         />
     );
diff --git a/src/pages/login/forms/FormReset.tsx b/src/pages/login/forms/FormReset.tsx
index 01ddee7..7d8ecdb 100644
--- a/src/pages/login/forms/FormReset.tsx
+++ b/src/pages/login/forms/FormReset.tsx
@@ -1,13 +1,16 @@
 import { Form } from "./Form";
+import { useContext } from "preact/hooks";
 import { useHistory, useParams } from "react-router-dom";
-import { RevoltClient } from "../../../context/revoltjs/RevoltClient";
+import { AppContext } from "../../../context/revoltjs/RevoltClient";
 
 export function FormSendReset() {
+    const { client } = useContext(AppContext);
+
     return (
         <Form
             page="send_reset"
             callback={async data => {
-                await RevoltClient.req("POST", "/auth/send_reset", data);
+                await client.req("POST", "/auth/send_reset", data);
             }}
         />
     );
@@ -15,13 +18,14 @@ export function FormSendReset() {
 
 export function FormReset() {
     const { token } = useParams<{ token: string }>();
+    const { client } = useContext(AppContext);
     const history = useHistory();
 
     return (
         <Form
             page="reset"
             callback={async data => {
-                await RevoltClient.req("POST", "/auth/reset" as any, {
+                await client.req("POST", "/auth/reset" as any, {
                     token,
                     ...(data as any)
                 });
diff --git a/src/redux/index.ts b/src/redux/index.ts
index a8e2270..6099d9e 100644
--- a/src/redux/index.ts
+++ b/src/redux/index.ts
@@ -28,7 +28,7 @@ export type State = {
 
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
 export const store = createStore((state: any, action: any) => {
-    if (process.env.NODE_ENV === "development") {
+    if (import.meta.env.DEV) {
         console.debug("State Update:", action);
     }
 
diff --git a/yarn.lock b/yarn.lock
index 5f17267..dd31e17 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -960,26 +960,14 @@
   resolved "https://registry.yarnpkg.com/@hcaptcha/react-hcaptcha/-/react-hcaptcha-0.3.6.tgz#cbbb9abdaea451a4df408bc9d476e8b17f0b63f4"
   integrity sha512-DQ5nvGVbbhd2IednxRhCV9wiPcCmclEV7bH98yGynGCXzO5XftO/XC0a1M1kEf9Ee+CLO/u+1HM/uE/PSrC3vQ==
 
-"@insertish/mutable@1.0.6":
-  version "1.0.6"
-  resolved "https://registry.yarnpkg.com/@insertish/mutable/-/mutable-1.0.6.tgz#f42eaba8528ff68cc8065d51f9bbbd30a24f34de"
-  integrity sha512-FTaPbesmBwcr3iKfbA2udFto61/sL7rOiCM08vBbE2X0wC63nsvTos6gnkwa1Nwom1v15jjrc/4B0YqI3vbZ/Q==
+"@insertish/mutable@1.1.0":
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/@insertish/mutable/-/mutable-1.1.0.tgz#06f95f855691ccb69ee3c339887a80bcd1498116"
+  integrity sha512-NH7aCGFAKRE1gFprrW/HsJoWCWQy18TZBarxLdeLVWdLFvkb2lD6Z5B70oOoUHFNpykiTC8IcRonsd9Xn13n8Q==
   dependencies:
-    "@insertish/zangodb" "^1.0.12"
     eventemitter3 "^4.0.7"
     lodash.isequal "^4.5.0"
 
-"@insertish/zangodb@1.0.12", "@insertish/zangodb@^1.0.12":
-  version "1.0.12"
-  resolved "https://registry.yarnpkg.com/@insertish/zangodb/-/zangodb-1.0.12.tgz#25264ec065720fa43c7549ec7245e4f3839cb0ea"
-  integrity sha512-JlLI12Xqt1xvv/p2/AHs163ZYMZsB3sJyjB8yaAs6QcG0tyRBTIyxV5ISEAkAPo5kzlFza5z5oH82yQe/qw5RQ==
-  dependencies:
-    clone "^2.1.2"
-    deepmerge "^4.2.2"
-    memoizee "^0.4.15"
-    object-hash "^2.1.1"
-    q "^1.5.1"
-
 "@mdn/browser-compat-data@^2.0.7":
   version "2.0.7"
   resolved "https://registry.yarnpkg.com/@mdn/browser-compat-data/-/browser-compat-data-2.0.7.tgz#72ec37b9c1e00ce0b4e0309d753be18e2da12ee3"
@@ -1144,11 +1132,16 @@
   resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.7.tgz#98a993516c859eb0d5c4c8f098317a9ea68db9ad"
   integrity sha512-cxWFQVseBm6O9Gbw1IWb8r6OS4OhSt3hPZLkFApLjM8TEXROBuQGLAH2i2gZpcXdLBIrpXuTDhH7Vbm1iXmNGA==
 
-"@types/node@*", "@types/node@^15.12.3":
+"@types/node@*":
   version "15.12.3"
   resolved "https://registry.yarnpkg.com/@types/node/-/node-15.12.3.tgz#2817bf5f25bc82f56579018c53f7d41b1830b1af"
   integrity sha512-SNt65CPCXvGNDZ3bvk1TQ0Qxoe3y1RKH88+wZ2Uf05dduBCqqFQ76ADP9pbT+Cpvj60SkRppMCh2Zo8tDixqjQ==
 
+"@types/node@^15.12.4":
+  version "15.12.4"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-15.12.4.tgz#e1cf817d70a1e118e81922c4ff6683ce9d422e26"
+  integrity sha512-zrNj1+yqYF4WskCMOHwN+w9iuD12+dGm0rQ35HLl9/Ouuq52cEtd0CH9qMgrdNmi5ejC1/V7vKEXYubB+65DkA==
+
 "@types/preact-i18n@^2.3.0":
   version "2.3.0"
   resolved "https://registry.yarnpkg.com/@types/preact-i18n/-/preact-i18n-2.3.0.tgz#d99d4a9ad03b0b65e57ed4d874447de74384e32f"
@@ -1604,11 +1597,6 @@ chalk@^4.0.0:
   optionalDependencies:
     fsevents "~2.3.2"
 
-clone@^2.1.2:
-  version "2.1.2"
-  resolved "https://registry.yarnpkg.com/clone/-/clone-2.1.2.tgz#1b7f4b9f591f1e8f83670401600345a02887435f"
-  integrity sha1-G39Ln1kfHo+DZwQBYANFoCiHQ18=
-
 color-convert@^1.9.0:
   version "1.9.3"
   resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8"
@@ -1706,14 +1694,6 @@ csstype@^3.0.2:
   resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.0.8.tgz#d2266a792729fb227cd216fb572f43728e1ad340"
   integrity sha512-jXKhWqXPmlUeoQnF/EhTtTl4C9SnrxSH/jZUih3jmO6lBKr99rP3/+FmrMj4EFpOXzMtXHAZkd3x0E6h6Fgflw==
 
-d@1, d@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/d/-/d-1.0.1.tgz#8698095372d58dbee346ffd0c7093f99f8f9eb5a"
-  integrity sha512-m62ShEObQ39CfralilEQRjH6oAMtNCV1xJyEx5LpRYUVN+EviphDgUc/F3hnYbADmkiNs67Y+3ylmlG7Lnu+FA==
-  dependencies:
-    es5-ext "^0.10.50"
-    type "^1.0.1"
-
 dayjs@^1.10.5:
   version "1.10.5"
   resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.10.5.tgz#5600df4548fc2453b3f163ebb2abbe965ccfb986"
@@ -1846,42 +1826,6 @@ es-to-primitive@^1.2.1:
     is-date-object "^1.0.1"
     is-symbol "^1.0.2"
 
-es5-ext@^0.10.35, es5-ext@^0.10.46, es5-ext@^0.10.50, es5-ext@^0.10.53, es5-ext@~0.10.14, es5-ext@~0.10.2, es5-ext@~0.10.46:
-  version "0.10.53"
-  resolved "https://registry.yarnpkg.com/es5-ext/-/es5-ext-0.10.53.tgz#93c5a3acfdbef275220ad72644ad02ee18368de1"
-  integrity sha512-Xs2Stw6NiNHWypzRTY1MtaG/uJlwCk8kH81920ma8mvN8Xq1gsfhZvpkImLQArw8AHnv8MT2I45J3c0R8slE+Q==
-  dependencies:
-    es6-iterator "~2.0.3"
-    es6-symbol "~3.1.3"
-    next-tick "~1.0.0"
-
-es6-iterator@^2.0.3, es6-iterator@~2.0.3:
-  version "2.0.3"
-  resolved "https://registry.yarnpkg.com/es6-iterator/-/es6-iterator-2.0.3.tgz#a7de889141a05a94b0854403b2d0a0fbfa98f3b7"
-  integrity sha1-p96IkUGgWpSwhUQDstCg+/qY87c=
-  dependencies:
-    d "1"
-    es5-ext "^0.10.35"
-    es6-symbol "^3.1.1"
-
-es6-symbol@^3.1.1, es6-symbol@~3.1.3:
-  version "3.1.3"
-  resolved "https://registry.yarnpkg.com/es6-symbol/-/es6-symbol-3.1.3.tgz#bad5d3c1bcdac28269f4cb331e431c78ac705d18"
-  integrity sha512-NJ6Yn3FuDinBaBRWl/q5X/s4koRHBrgKAu+yGI6JCBeiu3qrcbJhwT2GeR/EXVfylRk8dpQVJoLEFhK+Mu31NA==
-  dependencies:
-    d "^1.0.1"
-    ext "^1.1.2"
-
-es6-weak-map@^2.0.3:
-  version "2.0.3"
-  resolved "https://registry.yarnpkg.com/es6-weak-map/-/es6-weak-map-2.0.3.tgz#b6da1f16cc2cc0d9be43e6bdbfc5e7dfcdf31d53"
-  integrity sha512-p5um32HOTO1kP+w7PRnB+5lQ43Z6muuMuIMffvDN8ZB4GcnjLBV6zGStpbASIMk4DCAvEaamhe2zhyCb/QXXsA==
-  dependencies:
-    d "1"
-    es5-ext "^0.10.46"
-    es6-iterator "^2.0.3"
-    es6-symbol "^3.1.1"
-
 esbuild@^0.12.5:
   version "0.12.9"
   resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.12.9.tgz#bed4e7087c286cd81d975631f77d47feb1660070"
@@ -2087,14 +2031,6 @@ esutils@^2.0.2:
   resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64"
   integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==
 
-event-emitter@^0.3.5:
-  version "0.3.5"
-  resolved "https://registry.yarnpkg.com/event-emitter/-/event-emitter-0.3.5.tgz#df8c69eef1647923c7157b9ce83840610b02cc39"
-  integrity sha1-34xp7vFkeSPHFXuc6DhAYQsCzDk=
-  dependencies:
-    d "1"
-    es5-ext "~0.10.14"
-
 event-stream@=3.3.4:
   version "3.3.4"
   resolved "https://registry.yarnpkg.com/event-stream/-/event-stream-3.3.4.tgz#4ab4c9a0f5a54db9338b4c34d86bfce8f4b35571"
@@ -2118,13 +2054,6 @@ exponential-backoff@^3.1.0:
   resolved "https://registry.yarnpkg.com/exponential-backoff/-/exponential-backoff-3.1.0.tgz#9409c7e579131f8bd4b32d7d8094a911040f2e68"
   integrity sha512-oBuz5SYz5zzyuHINoe9ooePwSu0xApKWgeNzok4hZ5YKXFh9zrQBEM15CXqoZkJJPuI2ArvqjPQd8UKJA753XA==
 
-ext@^1.1.2:
-  version "1.4.0"
-  resolved "https://registry.yarnpkg.com/ext/-/ext-1.4.0.tgz#89ae7a07158f79d35517882904324077e4379244"
-  integrity sha512-Key5NIsUxdqKg3vIsdw9dSuXpPCQ297y6wBjL30edxwPgt2E44WcWBZey/ZvUc6sERLTxKdyCu4gZFmUbk1Q7A==
-  dependencies:
-    type "^2.0.0"
-
 extend@3.0.2:
   version "3.0.2"
   resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa"
@@ -2354,6 +2283,11 @@ hoist-non-react-statics@^3.0.0, hoist-non-react-statics@^3.1.0, hoist-non-react-
   dependencies:
     react-is "^16.7.0"
 
+idb@^6.1.2:
+  version "6.1.2"
+  resolved "https://registry.yarnpkg.com/idb/-/idb-6.1.2.tgz#82ef5c951b8e1f47875d36ccafa4bedafc62f2f1"
+  integrity sha512-1DNDVu3yDhAZkFDlJf0t7r+GLZ248F5pTAtA7V0oVG3yjmV125qZOx3g0XpAEkGZVYQiFDAsSOnGet2bhugc3w==
+
 ignore@^4.0.6:
   version "4.0.6"
   resolved "https://registry.yarnpkg.com/ignore/-/ignore-4.0.6.tgz#750e3db5862087b4737ebac8207ffd1ef27b25fc"
@@ -2482,11 +2416,6 @@ is-obj@^1.0.1:
   resolved "https://registry.yarnpkg.com/is-obj/-/is-obj-1.0.1.tgz#3e4729ac1f5fde025cd7d83a896dab9f4f67db0f"
   integrity sha1-PkcprB9f3gJc19g6iW2rn09n2w8=
 
-is-promise@^2.2.2:
-  version "2.2.2"
-  resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-2.2.2.tgz#39ab959ccbf9a774cf079f7b40c7a26f763135f1"
-  integrity sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==
-
 is-regex@^1.1.3:
   version "1.1.3"
   resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.1.3.tgz#d029f9aff6448b93ebbe3f33dac71511fdcbef9f"
@@ -2696,13 +2625,6 @@ lru-cache@^6.0.0:
   dependencies:
     yallist "^4.0.0"
 
-lru-queue@^0.1.0:
-  version "0.1.0"
-  resolved "https://registry.yarnpkg.com/lru-queue/-/lru-queue-0.1.0.tgz#2738bd9f0d3cf4f84490c5736c48699ac632cda3"
-  integrity sha1-Jzi9nw089PhEkMVzbEhpmsYyzaM=
-  dependencies:
-    es5-ext "~0.10.2"
-
 magic-string@^0.25.0, magic-string@^0.25.7:
   version "0.25.7"
   resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.25.7.tgz#3f497d6fd34c669c6798dcb821f2ef31f5445051"
@@ -2715,20 +2637,6 @@ map-stream@~0.1.0:
   resolved "https://registry.yarnpkg.com/map-stream/-/map-stream-0.1.0.tgz#e56aa94c4c8055a16404a0674b78f215f7c8e194"
   integrity sha1-5WqpTEyAVaFkBKBnS3jyFffI4ZQ=
 
-memoizee@^0.4.15:
-  version "0.4.15"
-  resolved "https://registry.yarnpkg.com/memoizee/-/memoizee-0.4.15.tgz#e6f3d2da863f318d02225391829a6c5956555b72"
-  integrity sha512-UBWmJpLZd5STPm7PMUlOw/TSy972M+z8gcyQ5veOnSDRREz/0bmpyTfKt3/51DhEBqCZQn1udM/5flcSPYhkdQ==
-  dependencies:
-    d "^1.0.1"
-    es5-ext "^0.10.53"
-    es6-weak-map "^2.0.3"
-    event-emitter "^0.3.5"
-    is-promise "^2.2.2"
-    lru-queue "^0.1.0"
-    next-tick "^1.1.0"
-    timers-ext "^0.1.7"
-
 merge-stream@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60"
@@ -2787,16 +2695,6 @@ natural-compare@^1.4.0:
   resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
   integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=
 
-next-tick@1, next-tick@^1.1.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.1.0.tgz#1836ee30ad56d67ef281b22bd199f709449b35eb"
-  integrity sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==
-
-next-tick@~1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.0.0.tgz#ca86d1fe8828169b0120208e3dc8424b9db8342c"
-  integrity sha1-yobR/ogoFpsBICCOPchCS524NCw=
-
 node-cleanup@^2.1.2:
   version "2.1.2"
   resolved "https://registry.yarnpkg.com/node-cleanup/-/node-cleanup-2.1.2.tgz#7ac19abd297e09a7f72a71545d951b517e4dde2c"
@@ -2817,11 +2715,6 @@ object-assign@^4.1.1:
   resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
   integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=
 
-object-hash@^2.1.1:
-  version "2.2.0"
-  resolved "https://registry.yarnpkg.com/object-hash/-/object-hash-2.2.0.tgz#5ad518581eefc443bd763472b8ff2e9c2c0d54a5"
-  integrity sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==
-
 object-inspect@^1.10.3, object-inspect@^1.9.0:
   version "1.10.3"
   resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.10.3.tgz#c2aa7d2d09f50c99375704f7a0adf24c5782d369"
@@ -3032,11 +2925,6 @@ punycode@^2.1.0:
   resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec"
   integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==
 
-q@^1.5.1:
-  version "1.5.1"
-  resolved "https://registry.yarnpkg.com/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7"
-  integrity sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc=
-
 queue-microtask@^1.2.2:
   version "1.2.3"
   resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243"
@@ -3243,13 +3131,12 @@ reusify@^1.0.4:
   resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76"
   integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==
 
-revolt.js@4.2.0-alpha.3-patch.0:
-  version "4.2.0-alpha.3-patch.0"
-  resolved "https://registry.yarnpkg.com/revolt.js/-/revolt.js-4.2.0-alpha.3-patch.0.tgz#ca79731c2b2fa9a8dbfbc5c9f84bef6ee2759918"
-  integrity sha512-g4eXHDbQyjKEiDOjj+3BxbRwPuVfOCYsnVqOiOXoAib4k48c27N+ZU0apYV25/AzCvIoYGDtVfY3I33UkTl1Rw==
+revolt.js@4.3.0:
+  version "4.3.0"
+  resolved "https://registry.yarnpkg.com/revolt.js/-/revolt.js-4.3.0.tgz#dc396470da82dd58eac74150ed9e3d64f67c28db"
+  integrity sha512-QFD0KQMk6e6bOioZJSSSnzgtx76yJLFSp9LyM1fIIelP02vrMpU1wO7s89lE+7jljh7SVgJqyCfZmlshdyb7Ew==
   dependencies:
-    "@insertish/mutable" "1.0.6"
-    "@insertish/zangodb" "1.0.12"
+    "@insertish/mutable" "1.1.0"
     axios "^0.19.2"
     eventemitter3 "^4.0.7"
     exponential-backoff "^3.1.0"
@@ -3586,14 +3473,6 @@ through@2, through@~2.3, through@~2.3.1:
   resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5"
   integrity sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=
 
-timers-ext@^0.1.7:
-  version "0.1.7"
-  resolved "https://registry.yarnpkg.com/timers-ext/-/timers-ext-0.1.7.tgz#6f57ad8578e07a3fb9f91d9387d65647555e25c6"
-  integrity sha512-b85NUNzTSdodShTIbky6ZF02e8STtVVfD+fu4aXXShEELpozH+bCpJLYMPZbsABN2wDH7fJpqIoXxJpzbf0NqQ==
-  dependencies:
-    es5-ext "~0.10.46"
-    next-tick "1"
-
 tiny-invariant@^1.0.2:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.1.0.tgz#634c5f8efdc27714b7f386c35e6760991d230875"
@@ -3663,16 +3542,6 @@ type-fest@^0.20.2:
   resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4"
   integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==
 
-type@^1.0.1:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/type/-/type-1.2.0.tgz#848dd7698dafa3e54a6c479e759c4bc3f18847a0"
-  integrity sha512-+5nt5AAniqsCnu2cEQQdpzCAh33kVx8n0VoFidKpB1dVVLAN/F+bgVOqOJqOnEnrhp222clB5p3vUlD+1QAnfg==
-
-type@^2.0.0:
-  version "2.5.0"
-  resolved "https://registry.yarnpkg.com/type/-/type-2.5.0.tgz#0a2e78c2e77907b252abe5f298c1b01c63f0db3d"
-  integrity sha512-180WMDQaIMm3+7hGXWf12GtdniDEy7nYcyFMKJn/eZz/6tSLXrUN9V0wKSbMjej0I1WHWbpREDEKHtqPQa9NNw==
-
 typescript@^4.3.2:
   version "4.3.3"
   resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.3.3.tgz#5401db69bd3203daf1851a1a74d199cb3112c11a"
-- 
GitLab