From 0115ace3fac2050278bff39aa25f35bc179f7665 Mon Sep 17 00:00:00 2001
From: Paul <paulmakles@gmail.com>
Date: Mon, 21 Jun 2021 13:28:26 +0100
Subject: [PATCH] Port sync, queue management and notifs.

---
 package.json                                  |   2 +
 src/assets/sounds/Audio.ts                    |  21 ++
 {public => src}/assets/sounds/call_join.mp3   | Bin
 {public => src}/assets/sounds/call_join.ogg   | Bin
 {public => src}/assets/sounds/call_leave.mp3  | Bin
 {public => src}/assets/sounds/call_leave.ogg  | Bin
 {public => src}/assets/sounds/inbound.mp3     | Bin
 {public => src}/assets/sounds/inbound.ogg     | Bin
 {public => src}/assets/sounds/message.mp3     | Bin
 {public => src}/assets/sounds/message.ogg     | Bin
 {public => src}/assets/sounds/outbound.mp3    | Bin
 {public => src}/assets/sounds/outbound.ogg    | Bin
 src/components/common/messaging/Message.tsx   |   2 +-
 .../common/messaging/MessageBox.tsx           |  18 +-
 src/context/revoltjs/Notifications.tsx        | 256 ++++++++++++++++++
 src/context/revoltjs/RevoltClient.tsx         |  35 +--
 src/context/revoltjs/StateMonitor.tsx         |  78 ++++++
 src/context/revoltjs/SyncManager.tsx          | 124 +++++++++
 src/pages/RevoltApp.tsx                       |   8 +-
 yarn.lock                                     |  12 +
 20 files changed, 521 insertions(+), 35 deletions(-)
 create mode 100644 src/assets/sounds/Audio.ts
 rename {public => src}/assets/sounds/call_join.mp3 (100%)
 rename {public => src}/assets/sounds/call_join.ogg (100%)
 rename {public => src}/assets/sounds/call_leave.mp3 (100%)
 rename {public => src}/assets/sounds/call_leave.ogg (100%)
 rename {public => src}/assets/sounds/inbound.mp3 (100%)
 rename {public => src}/assets/sounds/inbound.ogg (100%)
 rename {public => src}/assets/sounds/message.mp3 (100%)
 rename {public => src}/assets/sounds/message.ogg (100%)
 rename {public => src}/assets/sounds/outbound.mp3 (100%)
 rename {public => src}/assets/sounds/outbound.ogg (100%)
 create mode 100644 src/context/revoltjs/Notifications.tsx
 create mode 100644 src/context/revoltjs/StateMonitor.tsx
 create mode 100644 src/context/revoltjs/SyncManager.tsx

diff --git a/package.json b/package.json
index 102e828..226fa5d 100644
--- a/package.json
+++ b/package.json
@@ -34,6 +34,7 @@
     "@styled-icons/simple-icons": "^10.33.0",
     "@traptitech/markdown-it-katex": "^3.4.3",
     "@traptitech/markdown-it-spoiler": "^1.1.6",
+    "@types/lodash.isequal": "^4.5.5",
     "@types/markdown-it": "^12.0.2",
     "@types/node": "^15.12.4",
     "@types/preact-i18n": "^2.3.0",
@@ -53,6 +54,7 @@
     "highlight.js": "^11.0.1",
     "idb": "^6.1.2",
     "localforage": "^1.9.0",
+    "lodash.isequal": "^4.5.0",
     "markdown-it": "^12.0.6",
     "markdown-it-emoji": "^2.0.0",
     "markdown-it-sub": "^1.0.0",
diff --git a/src/assets/sounds/Audio.ts b/src/assets/sounds/Audio.ts
new file mode 100644
index 0000000..37da121
--- /dev/null
+++ b/src/assets/sounds/Audio.ts
@@ -0,0 +1,21 @@
+import message from './message.mp3';
+import call_join from './call_join.mp3';
+import call_leave from './call_leave.mp3';
+
+const SoundMap: { [key in Sounds]: string } = {
+    message,
+    call_join,
+    call_leave
+}
+
+export type Sounds = 'message' | 'call_join' | 'call_leave';
+
+export function playSound(sound: Sounds) {
+    let file = SoundMap[sound];
+    let el = new Audio(file);
+    try {
+        el.play();
+    } catch (err) {
+        console.error('Failed to play audio file', file, err);
+    }
+}
diff --git a/public/assets/sounds/call_join.mp3 b/src/assets/sounds/call_join.mp3
similarity index 100%
rename from public/assets/sounds/call_join.mp3
rename to src/assets/sounds/call_join.mp3
diff --git a/public/assets/sounds/call_join.ogg b/src/assets/sounds/call_join.ogg
similarity index 100%
rename from public/assets/sounds/call_join.ogg
rename to src/assets/sounds/call_join.ogg
diff --git a/public/assets/sounds/call_leave.mp3 b/src/assets/sounds/call_leave.mp3
similarity index 100%
rename from public/assets/sounds/call_leave.mp3
rename to src/assets/sounds/call_leave.mp3
diff --git a/public/assets/sounds/call_leave.ogg b/src/assets/sounds/call_leave.ogg
similarity index 100%
rename from public/assets/sounds/call_leave.ogg
rename to src/assets/sounds/call_leave.ogg
diff --git a/public/assets/sounds/inbound.mp3 b/src/assets/sounds/inbound.mp3
similarity index 100%
rename from public/assets/sounds/inbound.mp3
rename to src/assets/sounds/inbound.mp3
diff --git a/public/assets/sounds/inbound.ogg b/src/assets/sounds/inbound.ogg
similarity index 100%
rename from public/assets/sounds/inbound.ogg
rename to src/assets/sounds/inbound.ogg
diff --git a/public/assets/sounds/message.mp3 b/src/assets/sounds/message.mp3
similarity index 100%
rename from public/assets/sounds/message.mp3
rename to src/assets/sounds/message.mp3
diff --git a/public/assets/sounds/message.ogg b/src/assets/sounds/message.ogg
similarity index 100%
rename from public/assets/sounds/message.ogg
rename to src/assets/sounds/message.ogg
diff --git a/public/assets/sounds/outbound.mp3 b/src/assets/sounds/outbound.mp3
similarity index 100%
rename from public/assets/sounds/outbound.mp3
rename to src/assets/sounds/outbound.mp3
diff --git a/public/assets/sounds/outbound.ogg b/src/assets/sounds/outbound.ogg
similarity index 100%
rename from public/assets/sounds/outbound.ogg
rename to src/assets/sounds/outbound.ogg
diff --git a/src/components/common/messaging/Message.tsx b/src/components/common/messaging/Message.tsx
index 82cd15e..4de938e 100644
--- a/src/components/common/messaging/Message.tsx
+++ b/src/components/common/messaging/Message.tsx
@@ -33,7 +33,7 @@ export default function Message({ attachContext, message, contrast, content: rep
             </MessageInfo>
             <MessageContent>
                 { head && <Username user={user} /> }
-                { content ?? <Markdown content={content} /> }
+                { replacement ?? <Markdown content={content} /> }
                 { message.attachments?.map((attachment, index) =>
                     <Attachment key={index} attachment={attachment} hasContent={ index > 0 || content.length > 0 } />) }
                 { message.embeds?.map((embed, index) =>
diff --git a/src/components/common/messaging/MessageBox.tsx b/src/components/common/messaging/MessageBox.tsx
index 26825ca..32b4e34 100644
--- a/src/components/common/messaging/MessageBox.tsx
+++ b/src/components/common/messaging/MessageBox.tsx
@@ -1,16 +1,16 @@
-import { useContext } from "preact/hooks";
-import { Channel } from "revolt.js";
 import { ulid } from "ulid";
-import { AppContext } from "../../../context/revoltjs/RevoltClient";
-import { takeError } from "../../../context/revoltjs/util";
+import { Channel } from "revolt.js";
+import TextArea from "../../ui/TextArea";
+import { useContext } from "preact/hooks";
 import { defer } from "../../../lib/defer";
-import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
-import { SingletonMessageRenderer, SMOOTH_SCROLL_ON_RECEIVE } from "../../../lib/renderer/Singleton";
-import { connectState } from "../../../redux/connector";
-import { WithDispatcher } from "../../../redux/reducers";
 import IconButton from "../../ui/IconButton";
-import TextArea from "../../ui/TextArea";
 import { Send } from '@styled-icons/feather';
+import { connectState } from "../../../redux/connector";
+import { WithDispatcher } from "../../../redux/reducers";
+import { takeError } from "../../../context/revoltjs/util";
+import { AppContext } from "../../../context/revoltjs/RevoltClient";
+import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
+import { SingletonMessageRenderer, SMOOTH_SCROLL_ON_RECEIVE } from "../../../lib/renderer/Singleton";
 
 type Props = WithDispatcher & {
     channel: Channel;
diff --git a/src/context/revoltjs/Notifications.tsx b/src/context/revoltjs/Notifications.tsx
new file mode 100644
index 0000000..09b8eaf
--- /dev/null
+++ b/src/context/revoltjs/Notifications.tsx
@@ -0,0 +1,256 @@
+import { decodeTime } from "ulid";
+import { AppContext } from "./RevoltClient";
+import { Users } from "revolt.js/dist/api/objects";
+import { useContext, useEffect } from "preact/hooks";
+import { IntlContext, translate } from "preact-i18n";
+import { connectState } from "../../redux/connector";
+import { playSound } from "../../assets/sounds/Audio";
+import { Message, SYSTEM_USER_ID, User } from "revolt.js";
+import { NotificationOptions } from "../../redux/reducers/settings";
+import { Route, Switch, useHistory, useParams } from "react-router-dom";
+
+interface Props {
+    options?: NotificationOptions;
+}
+
+const notifications: { [key: string]: Notification } = {};
+
+async function createNotification(title: string, options: globalThis.NotificationOptions) {
+    try {
+        return new Notification(title, options);
+    } catch (err) {
+        let sw = await navigator.serviceWorker.getRegistration();
+        sw?.showNotification(title, options);
+    }
+}
+
+function Notifier(props: Props) {
+    const { intl } = useContext(IntlContext) as any;
+    const showNotification = props.options?.desktopEnabled ?? false;
+    // const playIncoming = props.options?.soundEnabled ?? true;
+    // const playOutgoing = props.options?.outgoingSoundEnabled ?? true;
+
+    const client = useContext(AppContext);
+    const { guild: guild_id, channel: channel_id } = useParams<{
+        guild: string;
+        channel: string;
+    }>();
+    const history = useHistory();
+
+    async function message(msg: Message) {
+        if (msg.author === client.user!._id) return;
+        if (msg.channel === channel_id && document.hasFocus()) return;
+        if (client.user?.status?.presence === Users.Presence.Busy) return;
+
+        // Sounds.playInbound();
+        playSound('message');
+        if (!showNotification) return;
+
+        const channel = client.channels.get(msg.channel);
+        const author = client.users.get(msg.author);
+        if (author?.relationship === Users.Relationship.Blocked) return;
+
+        let title;
+        switch (channel?.channel_type) {
+            case "SavedMessages":
+                return;
+            case "DirectMessage":
+                title = `@${author?.username}`;
+                break;
+            case "Group":
+                if (author?._id === SYSTEM_USER_ID) {
+                    title = channel.name;
+                } else {
+                    title = `@${author?.username} - ${channel.name}`;
+                }
+                break;
+            case "TextChannel":
+                const server = client.servers.get(channel.server);
+                title = `@${author?.username} (#${channel.name}, ${server?.name})`;
+                break;
+            default:
+                title = msg.channel;
+                break;
+        }
+
+        let image;
+        if (msg.attachments) {
+            let imageAttachment = msg.attachments.find(x => x.metadata.type === 'Image');
+            if (imageAttachment) {
+                image = client.generateFileURL(imageAttachment, { max_side: 720 });
+            }
+        }
+
+        let body, icon;
+        if (typeof msg.content === "string") {
+            body = client.markdownToText(msg.content);
+            icon = client.users.getAvatarURL(msg.author, { max_side: 256 });
+        } else {
+            let users = client.users;
+            switch (msg.content.type) {
+                // ! FIXME: update to support new replacements
+                case "user_added":
+                    body = `${users.get(msg.content.id)?.username} ${translate(
+                        "app.main.channel.system.user_joined",
+                        "",
+                        intl.dictionary
+                    )} (${translate(
+                        "app.main.channel.system.added_by",
+                        "",
+                        intl.dictionary
+                    )} ${users.get(msg.content.by)?.username})`;
+                    icon = client.users.getAvatarURL(msg.content.id, { max_side: 256 });
+                    break;
+                case "user_remove":
+                    body = `${users.get(msg.content.id)?.username} ${translate(
+                        "app.main.channel.system.user_left",
+                        "",
+                        intl.dictionary
+                    )} (${translate(
+                        "app.main.channel.system.added_by",
+                        "",
+                        intl.dictionary
+                    )} ${users.get(msg.content.by)?.username})`;
+                    icon = client.users.getAvatarURL(msg.content.id, { max_side: 256 });
+                    break;
+                case "user_left":
+                    body = `${users.get(msg.content.id)?.username} ${translate(
+                        "app.main.channel.system.user_left",
+                        "",
+                        intl.dictionary
+                    )}`;
+                    icon = client.users.getAvatarURL(msg.content.id, { max_side: 256 });
+                    break;
+                case "channel_renamed":
+                    body = `${users.get(msg.content.by)?.username} ${translate(
+                        "app.main.channel.system.channel_renamed",
+                        "",
+                        intl.dictionary
+                    )} ${msg.content.name}`;
+                    icon = client.users.getAvatarURL(msg.content.by, { max_side: 256 });
+                    break;
+            }
+        }
+
+        let notif = await createNotification(title, {
+            icon,
+            image,
+            body,
+            timestamp: decodeTime(msg._id),
+            tag: msg.channel,
+            badge: '/assets/icons/android-chrome-512x512.png',
+            silent: true
+        });
+
+        if (notif) {
+            notif.addEventListener("click", () => {
+                const id = msg.channel;
+                if (id !== channel_id) {
+                    let channel = client.channels.get(id);
+                    if (channel) {
+                        if (channel.channel_type === 'TextChannel') {
+                            history.push(`/server/${channel.server}/channel/${id}`);
+                        } else {
+                            history.push(`/channel/${id}`);
+                        }
+                    }
+                }
+            });
+
+            notifications[msg.channel] = notif;
+            notif.addEventListener(
+                "close",
+                () => delete notifications[msg.channel]
+            );
+        }
+    }
+
+    async function relationship(user: User, property: string) {
+        if (client.user?.status?.presence === Users.Presence.Busy) return;
+        if (property !== "relationship") return;
+        if (!showNotification) return;
+
+        let event;
+        switch (user.relationship) {
+            case Users.Relationship.Incoming:
+                event = translate(
+                    "notifications.sent_request",
+                    "",
+                    intl.dictionary,
+                    { person: user.username }
+                );
+                break;
+            case Users.Relationship.Friend:
+                event = translate(
+                    "notifications.now_friends",
+                    "",
+                    intl.dictionary,
+                    { person: user.username }
+                );
+                break;
+            default:
+                return;
+        }
+
+        let notif = await createNotification(event, {
+            icon: client.users.getAvatarURL(user._id, { max_side: 256 }),
+            badge: '/assets/icons/android-chrome-512x512.png',
+            timestamp: +new Date()
+        });
+
+        notif?.addEventListener("click", () => {
+            history.push(`/friends`);
+        });
+    }
+
+    useEffect(() => {
+        client.addListener("message", message);
+        client.users.addListener("mutation", relationship);
+
+        return () => {
+            client.removeListener("message", message);
+            client.users.removeListener("mutation", relationship);
+        };
+    }, [client, guild_id, channel_id, showNotification]);
+
+    useEffect(() => {
+        function visChange() {
+            if (document.visibilityState === "visible") {
+                if (notifications[channel_id]) {
+                    notifications[channel_id].close();
+                }
+            }
+        }
+
+        visChange();
+
+        document.addEventListener("visibilitychange", visChange);
+        return () =>
+            document.removeEventListener("visibilitychange", visChange);
+    }, [guild_id, channel_id]);
+
+    return <></>;
+}
+
+const NotifierComponent = connectState(
+    Notifier,
+    state => {
+        return {
+            options: state.settings.notification
+        };
+    },
+    true
+);
+
+export default function Notifications() {
+    return (
+        <Switch>
+            <Route path="/channel/:channel">
+                <NotifierComponent />
+            </Route>
+            <Route path="/">
+                <NotifierComponent />
+            </Route>
+        </Switch>
+    );
+}
diff --git a/src/context/revoltjs/RevoltClient.tsx b/src/context/revoltjs/RevoltClient.tsx
index 5c2ee71..da9c157 100644
--- a/src/context/revoltjs/RevoltClient.tsx
+++ b/src/context/revoltjs/RevoltClient.tsx
@@ -10,6 +10,7 @@ import { WithDispatcher } from "../../redux/reducers";
 import { AuthState } from "../../redux/reducers/auth";
 import { SyncOptions } from "../../redux/reducers/sync";
 import { useEffect, useMemo, useState } from "preact/hooks";
+import { useIntermediate } from '../intermediate/Intermediate';
 import { registerEvents, setReconnectDisallowed } from "./events";
 import { SingletonMessageRenderer } from '../../lib/renderer/Singleton';
 
@@ -42,6 +43,7 @@ type Props = WithDispatcher & {
 };
 
 function Context({ auth, sync, children, dispatcher }: Props) {
+    const { openScreen } = useIntermediate();
     const [status, setStatus] = useState(ClientStatus.INIT);
     const [client, setClient] = useState<Client>(undefined as unknown as Client);
 
@@ -92,13 +94,13 @@ function Context({ auth, sync, children, dispatcher }: Props) {
                         });
 
                     if (onboarding) {
-                        /*openScreen({
+                        openScreen({
                             id: "onboarding",
                             callback: async (username: string) => {
                                 await (onboarding as any)(username, true);
                                 login();
                             }
-                        });*/
+                        });
                     } else {
                         login();
                     }
@@ -113,7 +115,7 @@ function Context({ auth, sync, children, dispatcher }: Props) {
                 delete client.user;
                 dispatcher({ type: "RESET" });
 
-                // openScreen({ id: "none" });
+                openScreen({ id: "none" });
                 setStatus(ClientStatus.READY);
 
                 client.websocket.disconnect();
@@ -168,32 +170,17 @@ function Context({ auth, sync, children, dispatcher }: Props) {
                         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() });*/
-                    //}
+                    if (callback) {
+                        openScreen({ id: "onboarding", callback });
+                    }
                 } catch (err) {
                     setStatus(ClientStatus.DISCONNECTED);
                     const error = takeError(err);
-                    if (error === "Forbidden") {
+                    if (error === "Forbidden" || error === "Unauthorized") {
                         operations.logout(true);
-                        // openScreen({ id: "signed_out" });
+                        openScreen({ id: "signed_out" });
                     } else {
-                        // openScreen({ id: "error", error });
+                        openScreen({ id: "error", error });
                     }
                 }
             } else {
diff --git a/src/context/revoltjs/StateMonitor.tsx b/src/context/revoltjs/StateMonitor.tsx
new file mode 100644
index 0000000..1269acc
--- /dev/null
+++ b/src/context/revoltjs/StateMonitor.tsx
@@ -0,0 +1,78 @@
+/**
+ * This file monitors the message cache to delete any queued messages that have already sent.
+ */
+
+import { Message } from "revolt.js";
+import { AppContext } from "./RevoltClient";
+import { Typing } from "../../redux/reducers/typing";
+import { useContext, useEffect } from "preact/hooks";
+import { connectState } from "../../redux/connector";
+import { WithDispatcher } from "../../redux/reducers";
+import { QueuedMessage } from "../../redux/reducers/queue";
+
+type Props = WithDispatcher & {
+    messages: QueuedMessage[];
+    typing: Typing
+};
+
+function StateMonitor(props: Props) {
+    const client = useContext(AppContext);
+
+    useEffect(() => {
+        props.dispatcher({
+            type: 'QUEUE_DROP_ALL'
+        });
+    }, [ ]);
+
+    useEffect(() => {
+        function add(msg: Message) {
+            if (!msg.nonce) return;
+            if (!props.messages.find(x => x.id === msg.nonce)) return;
+
+            props.dispatcher({
+                type: 'QUEUE_REMOVE',
+                nonce: msg.nonce
+            });
+        }
+
+        client.addListener('message', add);
+        return () => client.removeListener('message', add);
+    }, [ props.messages ]);
+
+    useEffect(() => {
+        function removeOld() {
+            if (!props.typing) return;
+            for (let channel of Object.keys(props.typing)) {
+                let users = props.typing[channel];
+
+                for (let user of users) {
+                    if (+ new Date() > user.started + 5000) {
+                        props.dispatcher({
+                            type: 'TYPING_STOP',
+                            channel,
+                            user: user.id
+                        });
+                    }
+                }
+            }
+        }
+
+        removeOld();
+
+        let interval = setInterval(removeOld, 1000);
+        return () => clearInterval(interval);
+    }, [ props.typing ]);
+
+    return <></>;
+}
+
+export default connectState(
+    StateMonitor,
+    state => {
+        return {
+            messages: [...state.queue],
+            typing: state.typing
+        };
+    },
+    true
+);
diff --git a/src/context/revoltjs/SyncManager.tsx b/src/context/revoltjs/SyncManager.tsx
new file mode 100644
index 0000000..95361cc
--- /dev/null
+++ b/src/context/revoltjs/SyncManager.tsx
@@ -0,0 +1,124 @@
+/**
+ * This file monitors changes to settings and syncs them to the server.
+ */
+
+import isEqual from "lodash.isequal";
+import { Language } from "../Locale";
+import { Sync } from "revolt.js/dist/api/objects";
+import { useContext, useEffect } from "preact/hooks";
+import { connectState } from "../../redux/connector";
+import { WithDispatcher } from "../../redux/reducers";
+import { Settings } from "../../redux/reducers/settings";
+import { AppContext, ClientStatus, StatusContext } from "./RevoltClient";
+import { ClientboundNotification } from "revolt.js/dist/websocket/notifications";
+import { DEFAULT_ENABLED_SYNC, SyncData, SyncKeys, SyncOptions } from "../../redux/reducers/sync";
+
+type Props = WithDispatcher & {
+    settings: Settings,
+    locale: Language,
+    sync: SyncOptions
+};
+
+var lastValues: { [key in SyncKeys]?: any } = { };
+
+export function mapSync(packet: Sync.UserSettings, revision?: { [key: string]: number }) {
+    let update: { [key in SyncKeys]?: [ number, SyncData[key] ] } = {};
+    for (let key of Object.keys(packet)) {
+        let [ timestamp, obj ] = packet[key];
+        if (timestamp < (revision ?? {} as any)[key] ?? 0) {
+            continue;
+        }
+
+        let object;
+        if (obj[0] === '{') {
+            object = JSON.parse(obj)
+        } else {
+            object = obj;
+        }
+
+        lastValues[key as SyncKeys] = object;
+        update[key as SyncKeys] = [ timestamp, object ];
+    }
+
+    return update;
+}
+
+function SyncManager(props: Props) {
+    const client = useContext(AppContext);
+    const status = useContext(StatusContext);
+
+    useEffect(() => {
+        if (status === ClientStatus.ONLINE) {
+            client
+                .syncFetchSettings(DEFAULT_ENABLED_SYNC.filter(x => !props.sync?.disabled?.includes(x)))
+                .then(data => {
+                    props.dispatcher({
+                        type: 'SYNC_UPDATE',
+                        update: mapSync(data)
+                    });
+                });
+
+            client
+                .syncFetchUnreads()
+                .then(unreads => props.dispatcher({ type: 'UNREADS_SET', unreads }));
+        }
+    }, [ status ]);
+
+    function syncChange(key: SyncKeys, data: any) {
+        let timestamp = + new Date();
+        props.dispatcher({
+            type: 'SYNC_SET_REVISION',
+            key,
+            timestamp
+        });
+
+        client.syncSetSettings({
+            [key]: data
+        }, timestamp);
+    }
+
+    let disabled = props.sync.disabled ?? [];
+    for (let [key, object] of [ ['appearance', props.settings.appearance], ['theme', props.settings.theme], ['locale', props.locale] ] as [SyncKeys, any][]) {
+        useEffect(() => {
+            if (disabled.indexOf(key) === -1) {
+                if (typeof lastValues[key] !== 'undefined') {
+                    if (!isEqual(lastValues[key], object)) {
+                        syncChange(key, object);
+                    }
+                }
+            }
+
+            lastValues[key] = object;
+        }, [ disabled, object ]);
+    }
+
+    useEffect(() => {
+        function onPacket(packet: ClientboundNotification) {
+            if (packet.type === 'UserSettingsUpdate') {
+                let update: { [key in SyncKeys]?: [ number, SyncData[key] ] } = mapSync(packet.update, props.sync.revision);
+
+                props.dispatcher({
+                    type: 'SYNC_UPDATE',
+                    update
+                });
+            }
+        }
+
+        client.addListener('packet', onPacket);
+        return () => client.removeListener('packet', onPacket);
+    }, [ disabled, props.sync ]);
+
+    return <></>;
+}
+
+export default connectState(
+    SyncManager,
+    state => {
+        return {
+            settings: state.settings,
+            locale: state.locale,
+            sync: state.sync
+        };
+    },
+    true
+);
diff --git a/src/pages/RevoltApp.tsx b/src/pages/RevoltApp.tsx
index ab74d14..5b89edd 100644
--- a/src/pages/RevoltApp.tsx
+++ b/src/pages/RevoltApp.tsx
@@ -3,8 +3,11 @@ import { isTouchscreenDevice } from "../lib/isTouchscreenDevice";
 import { Switch, Route } from "react-router-dom";
 import styled from "styled-components";
 
-import Popovers from "../context/intermediate/Popovers";
 import ContextMenus from "../lib/ContextMenus";
+import Popovers from "../context/intermediate/Popovers";
+import SyncManager from "../context/revoltjs/SyncManager";
+import StateMonitor from "../context/revoltjs/StateMonitor";
+import Notifications from "../context/revoltjs/Notifications";
 
 import LeftSidebar from "../components/navigation/LeftSidebar";
 import RightSidebar from "../components/navigation/RightSidebar";
@@ -57,6 +60,9 @@ export default function App() {
             </Routes>
             <ContextMenus />
             <Popovers />
+            <Notifications />
+            <StateMonitor />
+            <SyncManager />
         </OverlappingPanels>
     );
 };
diff --git a/yarn.lock b/yarn.lock
index ce8812d..a08e2b0 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1167,6 +1167,18 @@
   resolved "https://registry.yarnpkg.com/@types/linkify-it/-/linkify-it-3.0.1.tgz#4d26a9efe3aa2caf829234ec5a39580fc88b6001"
   integrity sha512-pQv3Sygwxxh6jYQzXaiyWDAHevJqWtqDUv6t11Sa9CPGiXny66II7Pl6PR8QO5OVysD6HYOkHMeBgIjLnk9SkQ==
 
+"@types/lodash.isequal@^4.5.5":
+  version "4.5.5"
+  resolved "https://registry.yarnpkg.com/@types/lodash.isequal/-/lodash.isequal-4.5.5.tgz#4fed1b1b00bef79e305de0352d797e9bb816c8ff"
+  integrity sha512-4IKbinG7MGP131wRfceK6W4E/Qt3qssEFLF30LnJbjYiSfHGGRU/Io8YxXrZX109ir+iDETC8hw8QsDijukUVg==
+  dependencies:
+    "@types/lodash" "*"
+
+"@types/lodash@*":
+  version "4.14.170"
+  resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.170.tgz#0d67711d4bf7f4ca5147e9091b847479b87925d6"
+  integrity sha512-bpcvu/MKHHeYX+qeEN8GE7DIravODWdACVA1ctevD8CN24RhPZIKMn9ntfAsrvLfSX3cR5RrBKAbYm9bGs0A+Q==
+
 "@types/markdown-it@^12.0.2":
   version "12.0.2"
   resolved "https://registry.yarnpkg.com/@types/markdown-it/-/markdown-it-12.0.2.tgz#153e5477970ed2a47b2f619ed4ab66f870de8a04"
-- 
GitLab