From 9706dd75f336d987405f5ec367586a9f27ddab60 Mon Sep 17 00:00:00 2001
From: Paul <paulmakles@gmail.com>
Date: Sat, 19 Jun 2021 18:46:05 +0100
Subject: [PATCH] Port modal / popover context.

---
 package.json                                  |  15 +
 src/components/common/ChannelIcon.tsx         |   2 +-
 src/components/common/ServerIcon.tsx          |   2 +-
 src/components/common/Tooltip.tsx             |  26 ++
 src/components/common/UserCheckbox.tsx        |  14 +
 src/components/common/UserIcon.tsx            |   4 +-
 src/components/markdown/Emoji.tsx             |  41 +++
 src/components/markdown/Markdown.module.scss  | 202 +++++++++++
 src/components/markdown/Markdown.tsx          |  17 +
 src/components/markdown/Renderer.tsx          | 170 +++++++++
 src/components/navigation/SidebarBase.tsx     |   1 +
 .../navigation/items/ConnectionStatus.tsx     |   4 +-
 .../navigation/left/HomeSidebar.tsx           |   2 +-
 src/components/ui/Checkbox.tsx                |   4 +-
 src/components/ui/Modal.tsx                   | 138 +++++++
 src/context/index.tsx                         |  21 +-
 src/context/intermediate/Intermediate.tsx     | 133 +++++++
 src/context/intermediate/Modals.tsx           |  41 +++
 src/context/intermediate/Popovers.tsx         |  27 ++
 src/context/intermediate/modals/Clipboard.tsx |  32 ++
 src/context/intermediate/modals/Error.tsx     |  30 ++
 src/context/intermediate/modals/Input.tsx     | 149 ++++++++
 .../intermediate/modals/ModifyAccount.tsx     | 120 ++++++
 .../modals/Onboarding.module.scss             |  40 ++
 .../intermediate/modals/Onboarding.tsx        |  66 ++++
 .../intermediate/modals/Prompt.module.scss    |  18 +
 src/context/intermediate/modals/Prompt.tsx    | 234 ++++++++++++
 src/context/intermediate/modals/SignedOut.tsx |  23 ++
 .../popovers/ChannelInfo.module.scss          |  16 +
 .../intermediate/popovers/ChannelInfo.tsx     |  38 ++
 .../popovers/ImageViewer.module.scss          |   6 +
 .../intermediate/popovers/ImageViewer.tsx     |  46 +++
 .../popovers/UserPicker.module.scss           |  21 ++
 .../intermediate/popovers/UserPicker.tsx      |  64 ++++
 .../popovers/UserProfile.module.scss          | 165 +++++++++
 .../intermediate/popovers/UserProfile.tsx     | 341 ++++++++++++++++++
 src/context/revoltjs/CheckAuth.tsx            |   4 +-
 src/context/revoltjs/RevoltClient.tsx         |  38 +-
 src/context/revoltjs/error.ts                 |  18 -
 src/context/revoltjs/events.ts                |   4 +-
 src/context/revoltjs/hooks.ts                 |   2 +-
 src/context/revoltjs/messages.ts              |  10 -
 src/context/revoltjs/util.tsx                 |  49 +++
 src/lib/PaintCounter.tsx                      |   4 +-
 src/pages/App.tsx                             |   2 +
 src/pages/home/Home.tsx                       |  52 +--
 src/pages/login/Login.tsx                     |   2 +-
 src/pages/login/forms/CaptchaBlock.tsx        |   2 +-
 src/pages/login/forms/Form.tsx                |   4 +-
 src/pages/login/forms/FormCreate.tsx          |   2 +-
 src/pages/login/forms/FormLogin.tsx           |   6 +-
 src/pages/login/forms/FormResend.tsx          |   2 +-
 src/pages/login/forms/FormReset.tsx           |   4 +-
 src/redux/reducers/queue.ts                   |   2 +-
 src/styles/_fonts.scss                        |   3 -
 src/types/Preact.ts                           |   3 +-
 yarn.lock                                     | 216 +++++++++++
 57 files changed, 2562 insertions(+), 140 deletions(-)
 create mode 100644 src/components/common/Tooltip.tsx
 create mode 100644 src/components/common/UserCheckbox.tsx
 create mode 100644 src/components/markdown/Emoji.tsx
 create mode 100644 src/components/markdown/Markdown.module.scss
 create mode 100644 src/components/markdown/Markdown.tsx
 create mode 100644 src/components/markdown/Renderer.tsx
 create mode 100644 src/components/ui/Modal.tsx
 create mode 100644 src/context/intermediate/Intermediate.tsx
 create mode 100644 src/context/intermediate/Modals.tsx
 create mode 100644 src/context/intermediate/Popovers.tsx
 create mode 100644 src/context/intermediate/modals/Clipboard.tsx
 create mode 100644 src/context/intermediate/modals/Error.tsx
 create mode 100644 src/context/intermediate/modals/Input.tsx
 create mode 100644 src/context/intermediate/modals/ModifyAccount.tsx
 create mode 100644 src/context/intermediate/modals/Onboarding.module.scss
 create mode 100644 src/context/intermediate/modals/Onboarding.tsx
 create mode 100644 src/context/intermediate/modals/Prompt.module.scss
 create mode 100644 src/context/intermediate/modals/Prompt.tsx
 create mode 100644 src/context/intermediate/modals/SignedOut.tsx
 create mode 100644 src/context/intermediate/popovers/ChannelInfo.module.scss
 create mode 100644 src/context/intermediate/popovers/ChannelInfo.tsx
 create mode 100644 src/context/intermediate/popovers/ImageViewer.module.scss
 create mode 100644 src/context/intermediate/popovers/ImageViewer.tsx
 create mode 100644 src/context/intermediate/popovers/UserPicker.module.scss
 create mode 100644 src/context/intermediate/popovers/UserPicker.tsx
 create mode 100644 src/context/intermediate/popovers/UserProfile.module.scss
 create mode 100644 src/context/intermediate/popovers/UserProfile.tsx
 delete mode 100644 src/context/revoltjs/error.ts
 delete mode 100644 src/context/revoltjs/messages.ts
 create mode 100644 src/context/revoltjs/util.tsx

diff --git a/package.json b/package.json
index 1048e99..e2c051e 100644
--- a/package.json
+++ b/package.json
@@ -24,16 +24,22 @@
     "preact": "^10.5.13"
   },
   "devDependencies": {
+    "@fontsource/fira-mono": "^4.4.5",
     "@fontsource/open-sans": "^4.4.5",
     "@hcaptcha/react-hcaptcha": "^0.3.6",
     "@preact/preset-vite": "^2.0.0",
     "@styled-icons/bootstrap": "^10.34.0",
     "@styled-icons/feather": "^10.34.0",
+    "@traptitech/markdown-it-katex": "^3.4.3",
+    "@traptitech/markdown-it-spoiler": "^1.1.6",
+    "@types/markdown-it": "^12.0.2",
     "@types/node": "^15.12.4",
     "@types/preact-i18n": "^2.3.0",
+    "@types/prismjs": "^1.16.5",
     "@types/react-helmet": "^6.1.1",
     "@types/react-router-dom": "^5.1.7",
     "@types/styled-components": "^5.1.10",
+    "@types/twemoji": "^12.1.1",
     "@typescript-eslint/eslint-plugin": "^4.27.0",
     "@typescript-eslint/parser": "^4.27.0",
     "classnames": "^2.3.1",
@@ -41,22 +47,31 @@
     "detect-browser": "^5.2.0",
     "eslint": "^7.28.0",
     "eslint-config-preact": "^1.1.4",
+    "highlight.js": "^11.0.1",
     "idb": "^6.1.2",
     "localforage": "^1.9.0",
+    "markdown-it": "^12.0.6",
+    "markdown-it-emoji": "^2.0.0",
+    "markdown-it-sub": "^1.0.0",
+    "markdown-it-sup": "^1.0.0",
     "preact-i18n": "^2.4.0-preactx",
     "prettier": "^2.3.1",
+    "prismjs": "^1.23.0",
     "react-device-detect": "^1.17.0",
     "react-helmet": "^6.1.0",
     "react-hook-form": "6.3.0",
     "react-overlapping-panels": "1.2.1",
     "react-redux": "^7.2.4",
     "react-router-dom": "^5.2.0",
+    "react-tippy": "^1.4.0",
     "redux": "^4.1.0",
     "revolt.js": "4.3.0",
     "rimraf": "^3.0.2",
     "sass": "^1.35.1",
     "styled-components": "^5.3.0",
+    "twemoji": "^13.1.0",
     "typescript": "^4.3.2",
+    "ulid": "^2.3.0",
     "vite": "^2.3.7",
     "vite-plugin-pwa": "^0.8.1"
   }
diff --git a/src/components/common/ChannelIcon.tsx b/src/components/common/ChannelIcon.tsx
index af245c3..2611edb 100644
--- a/src/components/common/ChannelIcon.tsx
+++ b/src/components/common/ChannelIcon.tsx
@@ -10,7 +10,7 @@ interface Props extends IconBaseProps<Channels.GroupChannel | Channels.TextChann
 
 const fallback = '/assets/group.png';
 export default function ChannelIcon(props: Props & Omit<JSX.HTMLAttributes<HTMLImageElement>, keyof Props>) {
-    const { client } = useContext(AppContext);
+    const client = useContext(AppContext);
 
     const { size, target, attachment, isServerChannel: server, animate, children, as, ...imgProps } = props;
     const iconURL = client.generateFileURL(target?.icon ?? attachment, { max_side: 256 }, animate);
diff --git a/src/components/common/ServerIcon.tsx b/src/components/common/ServerIcon.tsx
index b9ea811..46befcb 100644
--- a/src/components/common/ServerIcon.tsx
+++ b/src/components/common/ServerIcon.tsx
@@ -20,7 +20,7 @@ const ServerText = styled.div`
 
 const fallback = '/assets/group.png';
 export default function ServerIcon(props: Props & Omit<JSX.HTMLAttributes<HTMLImageElement>, keyof Props>) {
-    const { client } = useContext(AppContext);
+    const client = useContext(AppContext);
 
     const { target, attachment, size, animate, server_name, children, as, ...imgProps } = props;
     const iconURL = client.generateFileURL(target?.icon ?? attachment, { max_side: 256 }, animate);
diff --git a/src/components/common/Tooltip.tsx b/src/components/common/Tooltip.tsx
new file mode 100644
index 0000000..db4a6c7
--- /dev/null
+++ b/src/components/common/Tooltip.tsx
@@ -0,0 +1,26 @@
+import styled from "styled-components";
+import { Children } from "../../types/Preact";
+import { Position, Tooltip as TooltipCore, TooltipProps } from "react-tippy";
+
+type Props = Omit<TooltipProps, 'html'> & {
+    position?: Position;
+    children: Children;
+    content: Children;
+}
+
+const TooltipBase = styled.div`
+    padding: 8px;
+    font-size: 12px;
+    border-radius: 4px;
+    color: var(--foreground);
+    background: var(--secondary-background);
+`;
+
+export default function Tooltip(props: Props) {
+    return (
+        <TooltipCore
+            {...props}
+            // @ts-expect-error
+            html={<TooltipBase>{props.content}</TooltipBase>} />
+    );
+}
diff --git a/src/components/common/UserCheckbox.tsx b/src/components/common/UserCheckbox.tsx
new file mode 100644
index 0000000..35577eb
--- /dev/null
+++ b/src/components/common/UserCheckbox.tsx
@@ -0,0 +1,14 @@
+import { User } from "revolt.js";
+import UserIcon from "./UserIcon";
+import Checkbox, { CheckboxProps } from "../ui/Checkbox";
+
+type UserProps = Omit<CheckboxProps, "children"> & { user: User };
+
+export default function UserCheckbox({ user, ...props }: UserProps) {
+    return (
+        <Checkbox {...props}>
+            <UserIcon target={user} size={32} />
+            {user.username}
+        </Checkbox>
+    );
+}
diff --git a/src/components/common/UserIcon.tsx b/src/components/common/UserIcon.tsx
index 124d757..7aa5e37 100644
--- a/src/components/common/UserIcon.tsx
+++ b/src/components/common/UserIcon.tsx
@@ -49,11 +49,11 @@ const VoiceIndicator = styled.div<{ status: VoiceStatus }>`
 
 const fallback = '/assets/user.png';
 export default function UserIcon(props: Props & Omit<JSX.SVGAttributes<SVGSVGElement>, keyof Props>) {
-    const { client } = useContext(AppContext);
+    const client = useContext(AppContext);
 
     const { target, attachment, size, voice, status, animate, children, as, ...svgProps } = props;
     const iconURL = client.generateFileURL(target?.avatar ?? attachment, { max_side: 256 }, animate)
-        ?? client.users.getDefaultAvatarURL(target!._id);
+        ?? (target && client.users.getDefaultAvatarURL(target._id));
 
     return (
         <IconBase {...svgProps}
diff --git a/src/components/markdown/Emoji.tsx b/src/components/markdown/Emoji.tsx
new file mode 100644
index 0000000..b7043e7
--- /dev/null
+++ b/src/components/markdown/Emoji.tsx
@@ -0,0 +1,41 @@
+import twemoji from 'twemoji';
+
+var EMOJI_PACK = 'mutant';
+const REVISION = 3;
+
+/*export function setEmojiPack(pack: EmojiPacks) {
+    EMOJI_PACK = pack;
+}*/
+
+// Taken from Twemoji source code.
+// scripts/build.js#344
+// grabTheRightIcon(rawText);
+const UFE0Fg = /\uFE0F/g;
+const U200D = String.fromCharCode(0x200D);
+function toCodePoint(emoji: string) {
+    return twemoji.convert.toCodePoint(emoji.indexOf(U200D) < 0 ?
+        emoji.replace(UFE0Fg, '') :
+        emoji
+    );
+}
+
+function parseEmoji(emoji: string) {
+    let codepoint = toCodePoint(emoji);
+    return `https://static.revolt.chat/emoji/${EMOJI_PACK}/${codepoint}.svg?rev=${REVISION}`;
+}
+
+export function Emoji({ emoji, size }: { emoji: string, size?: number }) {
+    return (
+        <img
+            alt={emoji}
+            className="emoji"
+            draggable={false}
+            src={parseEmoji(emoji)}
+            style={size ? { width: `${size}px`, height: `${size}px` } : undefined}
+        />
+    )
+}
+
+export function generateEmoji(emoji: string) {
+    return `<img class="emoji" draggable="false" alt="${emoji}" src="${parseEmoji(emoji)}" />`;
+}
diff --git a/src/components/markdown/Markdown.module.scss b/src/components/markdown/Markdown.module.scss
new file mode 100644
index 0000000..2f13352
--- /dev/null
+++ b/src/components/markdown/Markdown.module.scss
@@ -0,0 +1,202 @@
+@import "@fontsource/fira-mono/400.css";
+
+.markdown {
+    :global(.emoji) {
+        height: 1.25em;
+        width: 1.25em;
+        margin: 0 0.05em 0 0.1em;
+        vertical-align: -0.2em;
+    }
+
+    &[data-large-emojis="true"] :global(.emoji) {
+        width: 3rem;
+        height: 3rem;
+        margin-bottom: 0;
+        margin-top: 1px;
+        margin-right: 2px;
+        vertical-align: -.3em;
+    }
+
+    p,
+    pre {
+        margin: 0;
+    }
+
+    a {
+        text-decoration: none;
+
+        &[data-type="mention"] {
+            padding: 0 6px;
+            font-weight: 600;
+            border-radius: 12px;
+            display: inline-block;
+            background: var(--secondary-background);
+
+            &:hover {
+                text-decoration: none;
+            }
+        }
+
+        &:hover {
+            text-decoration: underline;
+        }
+    }
+
+    h1,
+    h2,
+    h3,
+    h4,
+    h5,
+    h6,
+    ul,
+    ol,
+    blockquote {
+        margin: 0;
+    }
+
+    ul,
+    ol {
+        list-style-position: inside;
+        padding-left: 10px;
+    }
+
+    blockquote {
+        margin: 2px 0;
+        padding: 2px 0;
+        border-radius: 4px;
+        background: var(--hover);
+        border-inline-start: 4px solid var(--tertiary-background);
+
+        > * {
+            margin: 0 8px;
+        }
+    }
+
+    pre {
+        padding: 1em;
+        border-radius: 4px;
+        overflow-x: scroll;
+        border-radius: 3px;
+        background: var(--block) !important;
+    }
+
+    p > code {
+        padding: 1px 4px;
+    }
+
+    code {
+        color: white;
+        font-size: 90%;
+        border-radius: 4px;
+        background: var(--block);
+        font-family: "Fira Mono", monospace;
+    }
+
+    input[type="checkbox"] {
+        margin-right: 4px;
+        pointer-events: none;
+    }
+
+    table {
+        border-collapse: collapse;
+
+        th,
+        td {
+            padding: 6px;
+            border: 1px solid var(--tertiary-foreground);
+        }
+    }
+
+    :global(.katex-block) {
+        overflow-x: auto;
+    }
+
+    :global(.spoiler) {
+        padding: 0 2px;
+        cursor: pointer;
+        user-select: none;
+        color: transparent;
+        border-radius: 4px;
+        background: #151515;
+
+        &:global(.shown) {
+            cursor: auto;
+            user-select: all;
+            color: var(--foreground);
+            background: var(--secondary-background);
+        }
+    }
+
+    :global(.code) {
+        font-family: "Fira Mono", monospace;
+
+        :global(.lang) {
+            // height: 8px;
+            // position: relative;
+
+            div {
+                // margin-left: -5px;
+                // margin-top: -16px;
+                // position: absolute;
+
+                color: #111;
+                cursor: pointer;
+                padding: 2px 6px;
+                font-weight: 600;
+                user-select: none;
+                display: inline-block;
+                background: var(--accent);
+
+                font-size: 10px;
+                border-radius: 2px;
+                text-transform: uppercase;
+                box-shadow: 0 2px #787676;
+
+                &:active {
+                    transform: translateY(1px);
+                    box-shadow: 0 1px #787676;
+                }
+            }
+
+            // ! FIXME: had to change this temporarily due to overflow
+            width: fit-content;
+            padding-bottom: 8px;
+        }
+    }
+
+    input[type="checkbox"] {
+        width: 0;
+        opacity: 0;
+        pointer-events: none;
+    }
+
+    label {
+        pointer-events: none;
+    }
+
+    input[type="checkbox"] + label:before {
+        width: 12px;
+        height: 12px;
+        content: 'a';
+        font-size: 10px;
+        margin-right: 6px;
+        line-height: 12px;
+        position: relative;
+        border-radius: 4px;
+        background: white;
+        display: inline-block;
+    }
+
+    input[type="checkbox"][checked="true"] + label:before {
+        content: '✓';
+        align-items: center;
+        display: inline-flex;
+        justify-content: center;
+        background: var(--accent);
+    }
+
+    input[type="checkbox"] + label {
+        line-height: 12px;
+        position: relative;
+    }
+}
diff --git a/src/components/markdown/Markdown.tsx b/src/components/markdown/Markdown.tsx
new file mode 100644
index 0000000..b98e828
--- /dev/null
+++ b/src/components/markdown/Markdown.tsx
@@ -0,0 +1,17 @@
+import { Suspense, lazy } from "preact/compat";
+
+const Renderer = lazy(() => import('./Renderer'));
+
+export interface MarkdownProps {
+    content?: string;
+    disallowBigEmoji?: boolean;
+}
+
+export default function Markdown(props: MarkdownProps) {
+    return (
+        // @ts-expect-error
+        <Suspense fallback="Getting ready to render Markdown...">
+            <Renderer {...props} />
+        </Suspense>
+    )
+}
diff --git a/src/components/markdown/Renderer.tsx b/src/components/markdown/Renderer.tsx
new file mode 100644
index 0000000..35dbef8
--- /dev/null
+++ b/src/components/markdown/Renderer.tsx
@@ -0,0 +1,170 @@
+import MarkdownIt from "markdown-it";
+import { RE_MENTIONS } from "revolt.js";
+import { generateEmoji } from "./Emoji";
+import { useContext } from "preact/hooks";
+import { MarkdownProps } from "./Markdown";
+import styles from "./Markdown.module.scss";
+import { AppContext } from "../../context/revoltjs/RevoltClient";
+
+import Prism from "prismjs";
+import "katex/dist/katex.min.css";
+import "prismjs/themes/prism-tomorrow.css";
+
+import MarkdownKatex from "@traptitech/markdown-it-katex";
+import MarkdownSpoilers from "@traptitech/markdown-it-spoiler";
+
+// @ts-ignore
+import MarkdownEmoji from "markdown-it-emoji/dist/markdown-it-emoji-bare";
+// @ts-ignore
+import MarkdownSup from "markdown-it-sup";
+// @ts-ignore
+import MarkdownSub from "markdown-it-sub";
+
+// Handler for code block copy.
+if (typeof window !== "undefined") {
+    (window as any).copycode = function(element: HTMLDivElement) {
+        try {
+            let code = element.parentElement?.parentElement?.children[1];
+            if (code) {
+                navigator.clipboard.writeText((code as any).innerText.trim());
+            }
+        } catch (e) {}
+    };
+}
+
+export const md: MarkdownIt = MarkdownIt({
+    breaks: true,
+    linkify: true,
+    highlight: (str, lang) => {
+        let v = Prism.languages[lang];
+        if (v) {
+            let out = Prism.highlight(str, v, lang);
+            return `<pre class="code"><div class="lang"><div onclick="copycode(this)">${lang}</div></div><code class="language-${lang}">${out}</code></pre>`;
+        }
+        
+        return `<pre class="code"><code>${md.utils.escapeHtml(str)}</code></pre>`;
+    }
+})
+.disable("image")
+.use(MarkdownEmoji/*, { defs: emojiDictionary }*/)
+.use(MarkdownSpoilers)
+.use(MarkdownSup)
+.use(MarkdownSub)
+.use(MarkdownKatex, {
+    throwOnError: false,
+    maxExpand: 0
+});
+
+// ? Force links to open _blank.
+// From: https://github.com/markdown-it/markdown-it/blob/master/docs/architecture.md#renderer
+const defaultRender =
+    md.renderer.rules.link_open ||
+    function(tokens, idx, options, _env, self) {
+        return self.renderToken(tokens, idx, options);
+    };
+
+// Handler for internal links, pushes events to React using magic.
+if (typeof window !== "undefined") {
+    (window as any).internalHandleURL = function(element: HTMLAnchorElement) {
+        const url = new URL(element.href, location as any);
+        const pathname = url.pathname;
+
+        if (pathname.startsWith("/@")) {
+            //InternalEventEmitter.emit("openProfile", pathname.substr(2));
+        } else {
+            //InternalEventEmitter.emit("navigate", pathname);
+        }
+    };
+}
+
+md.renderer.rules.link_open = function(tokens, idx, options, env, self) {
+    let internal;
+    const hIndex = tokens[idx].attrIndex("href");
+    if (hIndex >= 0) {
+        try {
+            // For internal links, we should use our own handler to use react-router history.
+            // @ts-ignore
+            const href = tokens[idx].attrs[hIndex][1];
+            const url = new URL(href, location as any);
+
+            if (url.hostname === location.hostname) {
+                internal = true;
+                // I'm sorry.
+                tokens[idx].attrPush([
+                    "onclick",
+                    "internalHandleURL(this); return false"
+                ]);
+
+                if (url.pathname.startsWith("/@")) {
+                    tokens[idx].attrPush(["data-type", "mention"]);
+                }
+            }
+        } catch (err) {
+            // Ignore the error, treat as normal link.
+        }
+    }
+
+    if (!internal) {
+        // Add target=_blank for external links.
+        const aIndex = tokens[idx].attrIndex("target");
+
+        if (aIndex < 0) {
+            tokens[idx].attrPush(["target", "_blank"]);
+        } else {
+            try {
+                // @ts-ignore
+                tokens[idx].attrs[aIndex][1] = "_blank";
+            } catch (_) {}
+        }
+    }
+
+    return defaultRender(tokens, idx, options, env, self);
+};
+
+md.renderer.rules.emoji = function(token, idx) {
+    return generateEmoji(token[idx].content);
+};
+
+const RE_TWEMOJI = /:(\w+):/g;
+
+export default function Renderer({ content, disallowBigEmoji }: MarkdownProps) {
+    const client = useContext(AppContext);
+    if (typeof content === "undefined") return null;
+    if (content.length === 0) return null;
+
+    // We replace the message with the mention at the time of render.
+    // We don't care if the mention changes.
+    let newContent = content.replace(
+        RE_MENTIONS,
+        (sub: string, ...args: any[]) => {
+            const id = args[0],
+                user = client.users.get(id);
+
+            if (user) {
+                return `[@${user.username}](/@${id})`;
+            }
+
+            return sub;
+        }
+    );
+
+    const useLargeEmojis = disallowBigEmoji ? false : content.replace(RE_TWEMOJI, '').trim().length === 0;
+
+    return (
+        <span
+            className={styles.markdown}
+            dangerouslySetInnerHTML={{
+                __html: md.render(newContent)
+            }}
+            data-large-emojis={useLargeEmojis}
+            onClick={ev => {
+                if (ev.target) {
+                    let element: Element = ev.target as any;
+                    if (element.classList.contains("spoiler")) {
+                        element.classList.add("shown");
+                    }
+                }
+            }}
+        />
+    );
+}
diff --git a/src/components/navigation/SidebarBase.tsx b/src/components/navigation/SidebarBase.tsx
index 677c914..cfd4ee2 100644
--- a/src/components/navigation/SidebarBase.tsx
+++ b/src/components/navigation/SidebarBase.tsx
@@ -5,4 +5,5 @@ export default styled.div`
     display: flex;
     user-select: none;
     flex-direction: row;
+    align-items: stretch;
 `;
diff --git a/src/components/navigation/items/ConnectionStatus.tsx b/src/components/navigation/items/ConnectionStatus.tsx
index 88a886c..7b7a9b0 100644
--- a/src/components/navigation/items/ConnectionStatus.tsx
+++ b/src/components/navigation/items/ConnectionStatus.tsx
@@ -1,10 +1,10 @@
 import { Text } from "preact-i18n";
 import Banner from "../../ui/Banner";
 import { useContext } from "preact/hooks";
-import { AppContext, ClientStatus } from "../../../context/revoltjs/RevoltClient";
+import { ClientStatus, StatusContext } from "../../../context/revoltjs/RevoltClient";
 
 export default function ConnectionStatus() {
-    const { status } = useContext(AppContext);
+    const status = useContext(StatusContext);
 
     if (status === ClientStatus.OFFLINE) {
         return (
diff --git a/src/components/navigation/left/HomeSidebar.tsx b/src/components/navigation/left/HomeSidebar.tsx
index 4e767e3..cc4d6dc 100644
--- a/src/components/navigation/left/HomeSidebar.tsx
+++ b/src/components/navigation/left/HomeSidebar.tsx
@@ -48,7 +48,7 @@ const HomeList = styled.div`
 
 function HomeSidebar(props: Props) {
     const { pathname } = useLocation();
-    const { client } = useContext(AppContext);
+    const client = useContext(AppContext);
     const { channel } = useParams<{ channel: string }>();
     // const { openScreen, writeClipboard } = useContext(IntermediateContext);
 
diff --git a/src/components/ui/Checkbox.tsx b/src/components/ui/Checkbox.tsx
index 432b1f6..fb9adf3 100644
--- a/src/components/ui/Checkbox.tsx
+++ b/src/components/ui/Checkbox.tsx
@@ -60,7 +60,7 @@ const Checkmark = styled.div<{ checked: boolean }>`
         `}
 `;
 
-interface Props {
+export interface CheckboxProps {
     checked: boolean;
     disabled?: boolean;
     className?: string;
@@ -69,7 +69,7 @@ interface Props {
     onChange: (state: boolean) => void;
 }
 
-export default function Checkbox(props: Props) {
+export default function Checkbox(props: CheckboxProps) {
     return (
         <CheckboxBase disabled={props.disabled}>
             <CheckboxContent>
diff --git a/src/components/ui/Modal.tsx b/src/components/ui/Modal.tsx
new file mode 100644
index 0000000..65a9396
--- /dev/null
+++ b/src/components/ui/Modal.tsx
@@ -0,0 +1,138 @@
+import Button from "./Button";
+import classNames from "classnames";
+import { Children } from "../../types/Preact";
+import { createPortal, useEffect } from "preact/compat";
+import styled, { keyframes } from "styled-components";
+
+const open = keyframes`
+    0% {opacity: 0;}
+    70% {opacity: 0;}
+    100% {opacity: 1;}
+`;
+
+const zoomIn = keyframes`
+    0% {transform: scale(0.5);}
+    98% {transform: scale(1.01);}
+    100% {transform: scale(1);}
+`;
+
+const ModalBase = styled.div`
+    top: 0;
+    left: 0;
+    width: 100%;
+    height: 100%;
+    z-index: 9999;
+    position: fixed;
+    max-height: 100%;
+    user-select: none;
+
+    animation-name: ${open};
+    animation-duration: 0.2s;
+
+    display: grid;
+    overflow-y: auto;
+    place-items: center;
+
+    color: var(--foreground);
+    background: rgba(0, 0, 0, 0.8);
+`;
+
+const ModalContainer = styled.div`
+    overflow: hidden;
+    border-radius: 8px;
+    max-width: calc(100vw - 20px);
+
+    animation-name: ${zoomIn};
+    animation-duration: 0.25s;
+    animation-timing-function: cubic-bezier(.3,.3,.18,1.1);
+`;
+
+const ModalContent = styled.div<{ [key in 'attachment' | 'noBackground' | 'border']?: boolean }>`
+`;
+
+const ModalActions = styled.div`
+    gap: 8px;
+    display: flex;
+    flex-direction: row-reverse;
+
+    padding: 1em 1.5em;
+    border-radius: 0 0 8px 8px;
+    background: var(--secondary-background);
+`;
+
+export interface Action {
+    text: Children;
+    onClick: () => void;
+    confirmation?: boolean;
+    style?: 'default' | 'contrast' | 'error' | 'contrast-error';
+}
+
+interface Props {
+    children?: Children;
+    title?: Children;
+
+    disallowClosing?: boolean;
+    noBackground?: boolean;
+    dontModal?: boolean;
+
+    onClose: () => void;
+    actions?: Action[];
+    disabled?: boolean;
+    border?: boolean;
+    visible: boolean;
+}
+
+export default function Modal(props: Props) {
+    if (!props.visible) return null;
+
+    let content = (
+        <ModalContent
+            attachment={!!props.actions}
+            noBackground={props.noBackground}
+            border={props.border}>
+            {props.title && <h3>{props.title}</h3>}
+            {props.children}
+        </ModalContent>
+    );
+
+    if (props.dontModal) {
+        return content;
+    }
+
+    let confirmationAction = props.actions?.find(action => action.confirmation);
+    useEffect(() => {
+        if (!confirmationAction) return;
+
+        // ! FIXME: this may be done better if we
+        // ! can focus the button although that
+        // ! doesn't seem to work...
+        function keyDown(e: KeyboardEvent) {
+            if (e.key === "Enter") {
+                confirmationAction!.onClick();
+            }
+        }
+
+        document.body.addEventListener("keydown", keyDown);
+        return () => document.body.removeEventListener("keydown", keyDown);
+    }, [ confirmationAction ]);
+
+    return createPortal(
+        <ModalBase onClick={(!props.disallowClosing && props.onClose) || undefined}>
+            <ModalContainer onClick={e => (e.cancelBubble = true)}>
+                {content}
+                {props.actions && (
+                    <ModalActions>
+                        {props.actions.map(x => (
+                            <Button style={x.style ?? "contrast"}
+                                onClick={x.onClick}
+                                disabled={props.disabled}>
+                                {x.text}
+                            </Button>
+                        ))}
+                    </ModalActions>
+                )}
+            </ModalContainer>
+        </ModalBase>,
+        document.body
+    );
+}
diff --git a/src/context/index.tsx b/src/context/index.tsx
index cf02569..fde2bfa 100644
--- a/src/context/index.tsx
+++ b/src/context/index.tsx
@@ -2,20 +2,23 @@ import State from "../redux/State";
 import { Children } from "../types/Preact";
 import { BrowserRouter } from "react-router-dom";
 
+import Intermediate from './intermediate/Intermediate';
 import ClientContext from './revoltjs/RevoltClient';
 import Locale from "./Locale";
 import Theme from "./Theme";
 
 export default function Context({ children }: { children: Children }) {
     return (
-        <BrowserRouter>
-            <State>
-                <ClientContext>
-                    <Locale>
-                        <Theme>{children}</Theme>
-                    </Locale>
-                </ClientContext>
-            </State>
-        </BrowserRouter>
+        <State>
+            <Locale>
+                <Intermediate>
+                    <BrowserRouter>
+                        <ClientContext>
+                            <Theme>{children}</Theme>
+                        </ClientContext>
+                    </BrowserRouter>
+                </Intermediate>
+            </Locale>
+        </State>
     );
 }
diff --git a/src/context/intermediate/Intermediate.tsx b/src/context/intermediate/Intermediate.tsx
new file mode 100644
index 0000000..a623d9f
--- /dev/null
+++ b/src/context/intermediate/Intermediate.tsx
@@ -0,0 +1,133 @@
+import { Attachment, Channels, EmbedImage, Servers } from "revolt.js/dist/api/objects";
+import { useContext, useEffect, useMemo, useState } from "preact/hooks";
+import { Action } from "../../components/ui/Modal";
+import { useHistory } from "react-router-dom";
+import { Children } from "../../types/Preact";
+import { createContext } from "preact";
+import Modals from './Modals';
+
+export type Screen =
+| { id: "none" }
+
+// Modals
+| { id: "signed_out" }
+| { id: "error"; error: string }
+| { id: "clipboard"; text: string }
+| { id: "modify_account"; field: "username" | "email" | "password" }
+| { id: "_prompt"; question: Children; content?: Children; actions: Action[] }
+| ({ id: "special_prompt" } & (
+    { type: "leave_group", target: Channels.GroupChannel } |
+    { type: "close_dm", target: Channels.DirectMessageChannel } |
+    { type: "leave_server", target: Servers.Server } |
+    { type: "delete_server", target: Servers.Server } |
+    { type: "delete_channel", target: Channels.TextChannel } |
+    { type: "delete_message", target: Channels.Message } |
+    { type: "create_invite", target: Channels.TextChannel | Channels.GroupChannel } |
+    { type: "kick_member", target: Servers.Server, user: string } |
+    { type: "ban_member", target: Servers.Server, user: string }
+)) |
+({ id: "special_input" } & (
+    { type: "create_group" | "create_server" | "set_custom_status" } |
+    { type: "create_channel", server: string }
+))
+| {
+      id: "_input";
+      question: Children;
+      field: Children;
+      defaultValue?: string;
+      callback: (value: string) => Promise<void>;
+  }
+| {
+      id: "onboarding";
+      callback: (
+          username: string,
+          loginAfterSuccess?: true
+      ) => Promise<void>;
+  }
+
+// Pop-overs
+| { id: "image_viewer"; attachment?: Attachment; embed?: EmbedImage; }
+| { id: "profile"; user_id: string }
+| { id: "channel_info"; channel_id: string }
+| {
+      id: "user_picker";
+      omit?: string[];
+      callback: (users: string[]) => Promise<void>;
+  };
+
+export const IntermediateContext = createContext({
+    screen: { id: "none" } as Screen,
+    focusTaken: false
+});
+
+export const IntermediateActionsContext = createContext({
+    openScreen: (screen: Screen) => {},
+    writeClipboard: (text: string) => {}
+});
+
+interface Props {
+    children: Children;
+}
+
+export default function Intermediate(props: Props) {
+    const [screen, openScreen] = useState<Screen>({ id: "none" });
+    const history = useHistory();
+
+    const value = {
+        screen,
+        focusTaken: screen.id !== 'none'
+    };
+
+    const actions = useMemo(() => {
+        return {
+            openScreen: (screen: Screen) => openScreen(screen),
+            writeClipboard: (text: string) => {
+                if (navigator.clipboard) {
+                    navigator.clipboard.writeText(text);
+                } else {
+                    actions.openScreen({ id: "clipboard", text });
+                }
+            }
+        }
+    }, []);
+
+    useEffect(() => {
+//        const openProfile = (user_id: string) =>
+//            openScreen({ id: "profile", user_id });
+        // const navigate = (path: string) => history.push(path);
+
+        // InternalEventEmitter.addListener("openProfile", openProfile);
+        // InternalEventEmitter.addListener("navigate", navigate);
+
+        return () => {
+            // InternalEventEmitter.removeListener("openProfile", openProfile);
+            // InternalEventEmitter.removeListener("navigate", navigate);
+        };
+    }, []);
+
+    return (
+        <IntermediateContext.Provider value={value}>
+            <IntermediateActionsContext.Provider value={actions}>
+                {props.children}
+                <Modals
+                    {...value}
+                    {...actions}
+                    key={
+                        screen.id
+                    } /** By specifying a key, we reset state whenever switching screen. */
+                />
+                {/*<Prompt
+                    when={screen.id !== 'none'}
+                    message={() => {
+                        openScreen({ id: 'none' });
+                        setTimeout(() => history.push(history.location), 0);
+
+                        return false;
+                    }}
+                />*/}
+            </IntermediateActionsContext.Provider>
+        </IntermediateContext.Provider>
+    );
+}
+
+export const useIntermediate = () => useContext(IntermediateActionsContext);
diff --git a/src/context/intermediate/Modals.tsx b/src/context/intermediate/Modals.tsx
new file mode 100644
index 0000000..a70c137
--- /dev/null
+++ b/src/context/intermediate/Modals.tsx
@@ -0,0 +1,41 @@
+import { Screen } from "./Intermediate";
+
+import { ErrorModal } from "./modals/Error";
+import { SignedOutModal } from "./modals/SignedOut";
+import { ClipboardModal } from "./modals/Clipboard";
+import { OnboardingModal } from "./modals/Onboarding";
+import { ModifyAccountModal } from "./modals/ModifyAccount";
+import { InputModal, SpecialInputModal } from "./modals/Input";
+import { PromptModal, SpecialPromptModal } from "./modals/Prompt";
+
+export interface Props {
+    screen: Screen;
+    openScreen: (id: any) => void;
+}
+
+export default function Modals({ screen, openScreen }: Props) {
+    const onClose = () => openScreen({ id: "none" });
+
+    switch (screen.id) {
+        case "_prompt":
+            return <PromptModal onClose={onClose} {...screen} />;
+        case "special_prompt":
+            return <SpecialPromptModal onClose={onClose} {...screen} />;
+        case "_input":
+            return <InputModal onClose={onClose} {...screen} />;
+        case "special_input":
+            return <SpecialInputModal onClose={onClose} {...screen} />;
+        case "error":
+            return <ErrorModal onClose={onClose} {...screen} />;
+        case "signed_out":
+            return <SignedOutModal onClose={onClose} {...screen} />;
+        case "clipboard":
+            return <ClipboardModal onClose={onClose} {...screen} />;
+        case "modify_account":
+            return <ModifyAccountModal onClose={onClose} {...screen} />;
+        case "onboarding":
+            return <OnboardingModal onClose={onClose} {...screen} />;
+    }
+
+    return null;
+}
diff --git a/src/context/intermediate/Popovers.tsx b/src/context/intermediate/Popovers.tsx
new file mode 100644
index 0000000..565d762
--- /dev/null
+++ b/src/context/intermediate/Popovers.tsx
@@ -0,0 +1,27 @@
+import { IntermediateContext, useIntermediate } from "./Intermediate";
+import { useContext } from "preact/hooks";
+
+import { UserPicker } from "./popovers/UserPicker";
+import { UserProfile } from "./popovers/UserProfile";
+import { ImageViewer } from "./popovers/ImageViewer";
+import { ChannelInfo } from "./popovers/ChannelInfo";
+
+export default function Popovers() {
+    const { screen } = useContext(IntermediateContext);
+    const { openScreen } = useIntermediate();
+
+    const onClose = () => openScreen({ id: "none" });
+
+    switch (screen.id) {
+        case "profile":
+            return <UserProfile {...screen} onClose={onClose} />;
+        case "user_picker":
+            return <UserPicker {...screen} onClose={onClose} />;
+        case "image_viewer":
+            return <ImageViewer {...screen} onClose={onClose} />;
+        case "channel_info":
+            return <ChannelInfo {...screen} onClose={onClose} />;
+    }
+
+    return null;
+}
diff --git a/src/context/intermediate/modals/Clipboard.tsx b/src/context/intermediate/modals/Clipboard.tsx
new file mode 100644
index 0000000..f0b12ba
--- /dev/null
+++ b/src/context/intermediate/modals/Clipboard.tsx
@@ -0,0 +1,32 @@
+import { Text } from "preact-i18n";
+import Modal from "../../../components/ui/Modal";
+
+interface Props {
+    onClose: () => void;
+    text: string;
+}
+
+export function ClipboardModal({ onClose, text }: Props) {
+    return (
+        <Modal
+            visible={true}
+            onClose={onClose}
+            title={<Text id="app.special.modals.clipboard.unavailable" />}
+            actions={[
+                {
+                    onClick: onClose,
+                    confirmation: true,
+                    text: <Text id="app.special.modals.actions.close" />
+                }
+            ]}
+        >
+            {location.protocol !== "https:" && (
+                <p>
+                    <Text id="app.special.modals.clipboard.https" />
+                </p>
+            )}
+            <Text id="app.special.modals.clipboard.copy" />{" "}
+            <code style={{ userSelect: "all" }}>{text}</code>
+        </Modal>
+    );
+}
diff --git a/src/context/intermediate/modals/Error.tsx b/src/context/intermediate/modals/Error.tsx
new file mode 100644
index 0000000..5b3cc17
--- /dev/null
+++ b/src/context/intermediate/modals/Error.tsx
@@ -0,0 +1,30 @@
+import { Text } from "preact-i18n";
+import Modal from "../../../components/ui/Modal";
+
+interface Props {
+    onClose: () => void;
+    error: string;
+}
+
+export function ErrorModal({ onClose, error }: Props) {
+    return (
+        <Modal
+            visible={true}
+            onClose={() => false}
+            title={<Text id="app.special.modals.error" />}
+            actions={[
+                {
+                    onClick: onClose,
+                    confirmation: true,
+                    text: <Text id="app.special.modals.actions.ok" />
+                },
+                {
+                    onClick: () => location.reload(),
+                    text: <Text id="app.special.modals.actions.reload" />
+                }
+            ]}
+        >
+            <Text id={`error.${error}`}>{error}</Text>
+        </Modal>
+    );
+}
diff --git a/src/context/intermediate/modals/Input.tsx b/src/context/intermediate/modals/Input.tsx
new file mode 100644
index 0000000..4aa68f9
--- /dev/null
+++ b/src/context/intermediate/modals/Input.tsx
@@ -0,0 +1,149 @@
+import { ulid } from "ulid";
+import { Text } from "preact-i18n";
+import { useHistory } from "react-router";
+import Modal from "../../../components/ui/Modal";
+import { Children } from "../../../types/Preact";
+import { takeError } from "../../revoltjs/util";
+import { useContext, useState } from "preact/hooks";
+import Overline from '../../../components/ui/Overline';
+import InputBox from '../../../components/ui/InputBox';
+import { AppContext } from "../../revoltjs/RevoltClient";
+
+interface Props {
+    onClose: () => void;
+    question: Children;
+    field: Children;
+    defaultValue?: string;
+    callback: (value: string) => Promise<void>;
+}
+
+export function InputModal({
+    onClose,
+    question,
+    field,
+    defaultValue,
+    callback
+}: Props) {
+    const [processing, setProcessing] = useState(false);
+    const [value, setValue] = useState(defaultValue ?? "");
+    const [error, setError] = useState<undefined | string>(undefined);
+
+    return (
+        <Modal
+            visible={true}
+            title={question}
+            disabled={processing}
+            actions={[
+                {
+                    text: <Text id="app.special.modals.actions.ok" />,
+                    onClick: () => {
+                        setProcessing(true);
+                        callback(value)
+                            .then(onClose)
+                            .catch(err => {
+                                setError(takeError(err));
+                                setProcessing(false)
+                            })
+                    }
+                },
+                {
+                    text: <Text id="app.special.modals.actions.cancel" />,
+                    onClick: onClose
+                }
+            ]}
+            onClose={onClose}
+        >
+            <Overline error={error} block>
+                {field}
+            </Overline>
+            <InputBox
+                value={value}
+                onChange={e => setValue(e.currentTarget.value)}
+            />
+        </Modal>
+    );
+}
+
+type SpecialProps = { onClose: () => void } & (
+    { type: "create_group" | "create_server" | "set_custom_status" } |
+    { type: "create_channel", server: string }
+)
+
+export function SpecialInputModal(props: SpecialProps) {
+    const history = useHistory();
+    const client = useContext(AppContext);
+
+    const { onClose } = props;
+    switch (props.type) {
+        case "create_group": {
+            return <InputModal
+                onClose={onClose}
+                question={<Text id="app.main.groups.create" />}
+                field={<Text id="app.main.groups.name" />}
+                callback={async name => {
+                    const group = await client.channels.createGroup(
+                        {
+                            name,
+                            nonce: ulid(),
+                            users: []
+                        }
+                    );
+
+                    history.push(`/channel/${group._id}`);
+                }}
+            />;
+        }
+        case "create_server": {
+            return <InputModal
+                onClose={onClose}
+                question={<Text id="app.main.servers.create" />}
+                field={<Text id="app.main.servers.name" />}
+                callback={async name => {
+                    const server = await client.servers.createServer(
+                        {
+                            name,
+                            nonce: ulid()
+                        }
+                    );
+
+                    history.push(`/server/${server._id}`);
+                }}
+            />;
+        }
+        case "create_channel": {
+            return <InputModal
+                onClose={onClose}
+                question={<Text id="app.context_menu.create_channel" />}
+                field={<Text id="app.main.servers.channel_name" />}
+                callback={async name => {
+                    const channel = await client.servers.createChannel(
+                        props.server,
+                        {
+                            name,
+                            nonce: ulid()
+                        }
+                    );
+
+                    history.push(`/server/${props.server}/channel/${channel._id}`);
+                }}
+            />;
+        }
+        case "set_custom_status": {
+            return <InputModal
+                onClose={onClose}
+                question={<Text id="app.context_menu.set_custom_status" />}
+                field={<Text id="app.context_menu.custom_status" />}
+                defaultValue={client.user?.status?.text}
+                callback={text =>
+                    client.users.editUser({
+                        status: {
+                            ...client.user?.status,
+                            text
+                        }
+                    })
+                }
+            />;
+        }
+        default: return null;
+    }
+}
diff --git a/src/context/intermediate/modals/ModifyAccount.tsx b/src/context/intermediate/modals/ModifyAccount.tsx
new file mode 100644
index 0000000..2deb2b8
--- /dev/null
+++ b/src/context/intermediate/modals/ModifyAccount.tsx
@@ -0,0 +1,120 @@
+import { Text } from "preact-i18n";
+import { useForm } from "react-hook-form";
+import Modal from "../../../components/ui/Modal";
+import { takeError } from "../../revoltjs/util";
+import { useContext, useState } from "preact/hooks";
+import FormField from '../../../pages/login/FormField';
+import Overline from "../../../components/ui/Overline";
+import { AppContext } from "../../revoltjs/RevoltClient";
+
+interface Props {
+    onClose: () => void;
+    field: "username" | "email" | "password";
+}
+
+export function ModifyAccountModal({ onClose, field }: Props) {
+    const client = useContext(AppContext);
+    const { handleSubmit, register, errors } = useForm();
+    const [error, setError] = useState<string | undefined>(undefined);
+
+    async function onSubmit({
+        password,
+        new_username,
+        new_email,
+        new_password
+    }: {
+        password: string;
+        new_username: string;
+        new_email: string;
+        new_password: string;
+    }) {
+        try {
+            if (field === "email") {
+                await client.req("POST", "/auth/change/email", {
+                    password,
+                    new_email
+                });
+                onClose();
+            } else if (field === "password") {
+                await client.req("POST", "/auth/change/password", {
+                    password,
+                    new_password
+                });
+                onClose();
+            } else if (field === "username") {
+                await client.req("PATCH", "/users/id/username", {
+                    username: new_username,
+                    password
+                });
+                onClose();
+            }
+        } catch (err) {
+            setError(takeError(err));
+        }
+    }
+
+    return (
+        <Modal
+            visible={true}
+            onClose={onClose}
+            title={<Text id={`app.special.modals.account.change.${field}`} />}
+            actions={[
+                {
+                    confirmation: true,
+                    onClick: handleSubmit(onSubmit),
+                    text:
+                        field === "email" ? (
+                            <Text id="app.special.modals.actions.send_email" />
+                        ) : (
+                            <Text id="app.special.modals.actions.update" />
+                        )
+                },
+                {
+                    onClick: onClose,
+                    text: <Text id="app.special.modals.actions.close" />
+                }
+            ]}
+        >
+            <form onSubmit={handleSubmit(onSubmit) as any}>
+                {field === "email" && (
+                    <FormField
+                        type="email"
+                        name="new_email"
+                        register={register}
+                        showOverline
+                        error={errors.new_email?.message}
+                    />
+                )}
+                {field === "password" && (
+                    <FormField
+                        type="password"
+                        name="new_password"
+                        register={register}
+                        showOverline
+                        error={errors.new_password?.message}
+                    />
+                )}
+                {field === "username" && (
+                    <FormField
+                        type="username"
+                        name="new_username"
+                        register={register}
+                        showOverline
+                        error={errors.new_username?.message}
+                    />
+                )}
+                <FormField
+                    type="current_password"
+                    register={register}
+                    showOverline
+                    error={errors.current_password?.message}
+                />
+                {error && (
+                    <Overline type="error" error={error}>
+                        <Text id="app.special.modals.account.failed" />
+                    </Overline>
+                )}
+            </form>
+        </Modal>
+    );
+}
diff --git a/src/context/intermediate/modals/Onboarding.module.scss b/src/context/intermediate/modals/Onboarding.module.scss
new file mode 100644
index 0000000..f3ed2dd
--- /dev/null
+++ b/src/context/intermediate/modals/Onboarding.module.scss
@@ -0,0 +1,40 @@
+.onboarding {
+    display: flex;
+    align-items: center;
+    flex-direction: column;
+
+    div {
+        flex: 1;
+
+        &.header {
+            padding: 3em;
+            text-align: center;
+
+            h1 {
+                margin: 0;
+            }
+        }
+
+        &.form {
+            flex-grow: 1;
+            max-width: 420px;
+
+            img {
+                margin: auto;
+                display: block;
+                max-height: 420px;
+                border-radius: 8px;
+            }
+
+            input {
+                width: 100%;
+            }
+
+            button {
+                display: block;
+                margin: 24px 0;
+                margin-left: auto;
+            }
+        }
+    }
+}
diff --git a/src/context/intermediate/modals/Onboarding.tsx b/src/context/intermediate/modals/Onboarding.tsx
new file mode 100644
index 0000000..b1741e2
--- /dev/null
+++ b/src/context/intermediate/modals/Onboarding.tsx
@@ -0,0 +1,66 @@
+import { Text } from "preact-i18n";
+import { useState } from "preact/hooks";
+import { useForm } from "react-hook-form";
+import styles from "./Onboarding.module.scss";
+import { takeError } from "../../revoltjs/util";
+import Button from "../../../components/ui/Button";
+import FormField from "../../../pages/login/FormField";
+import Preloader from "../../../components/ui/Preloader";
+
+// import WideSvg from "../../../assets/wide.svg";
+
+interface Props {
+    onClose: () => void;
+    callback: (username: string, loginAfterSuccess?: true) => Promise<void>;
+}
+
+export function OnboardingModal({ onClose, callback }: Props) {
+    const { handleSubmit, register } = useForm();
+    const [loading, setLoading] = useState(false);
+    const [error, setError] = useState<string | undefined>(undefined);
+
+    async function onSubmit({ username }: { username: string }) {
+        setLoading(true);
+        callback(username, true)
+            .then(onClose)
+            .catch((err: any) => {
+                setError(takeError(err));
+                setLoading(false);
+            });
+    }
+
+    return (
+        <div className={styles.onboarding}>
+            <div className={styles.header}>
+                <h1>
+                    <Text id="app.special.modals.onboarding.welcome" />
+                </h1>
+            </div>
+            <div className={styles.form}>
+                {loading ? (
+                    <Preloader />
+                ) : (
+                    <>
+                        <p>
+                            <Text id="app.special.modals.onboarding.pick" />
+                        </p>
+                        <form onSubmit={handleSubmit(onSubmit) as any}>
+                            <div>
+                                <FormField
+                                    type="username"
+                                    register={register}
+                                    showOverline
+                                    error={error}
+                                />
+                            </div>
+                            <Button type="submit">
+                                <Text id="app.special.modals.actions.continue" />
+                            </Button>
+                        </form>
+                    </>
+                )}
+            </div>
+            <div />
+        </div>
+    );
+}
diff --git a/src/context/intermediate/modals/Prompt.module.scss b/src/context/intermediate/modals/Prompt.module.scss
new file mode 100644
index 0000000..a107fcf
--- /dev/null
+++ b/src/context/intermediate/modals/Prompt.module.scss
@@ -0,0 +1,18 @@
+.invite {
+    display: flex;
+    flex-direction: column;
+
+    code {
+        padding: 1em;
+        user-select: all;
+        font-size: 1.4em;
+        text-align: center;
+        font-family: "Fira Mono";
+    }
+}
+
+.column {
+    display: flex;
+    align-items: center;
+    flex-direction: column;
+}
diff --git a/src/context/intermediate/modals/Prompt.tsx b/src/context/intermediate/modals/Prompt.tsx
new file mode 100644
index 0000000..c5aedfa
--- /dev/null
+++ b/src/context/intermediate/modals/Prompt.tsx
@@ -0,0 +1,234 @@
+import { Text } from "preact-i18n";
+import styles from './Prompt.module.scss';
+import { Children } from "../../../types/Preact";
+import { IntermediateContext, useIntermediate } from "../Intermediate";
+import InputBox from "../../../components/ui/InputBox";
+import Overline from "../../../components/ui/Overline";
+import UserIcon from "../../../components/common/UserIcon";
+import Modal, { Action } from "../../../components/ui/Modal";
+import { Channels, Servers } from "revolt.js/dist/api/objects";
+import { useContext, useEffect, useState } from "preact/hooks";
+import { AppContext } from "../../revoltjs/RevoltClient";
+import { takeError } from "../../revoltjs/util";
+
+interface Props {
+    onClose: () => void;
+    question: Children;
+    content?: Children;
+    disabled?: boolean;
+    actions: Action[];
+    error?: string;
+}
+
+export function PromptModal({ onClose, question, content, actions, disabled, error }: Props) {
+    return (
+        <Modal
+            visible={true}
+            title={question}
+            actions={actions}
+            onClose={onClose}
+            disabled={disabled}>
+            { error && <Overline error={error} type="error" /> }
+            { content }
+        </Modal>
+    );
+}
+
+type SpecialProps = { onClose: () => void } & (
+    { type: "leave_group", target: Channels.GroupChannel } |
+    { type: "close_dm", target: Channels.DirectMessageChannel } |
+    { type: "leave_server", target: Servers.Server } |
+    { type: "delete_server", target: Servers.Server } |
+    { type: "delete_channel", target: Channels.TextChannel } |
+    { type: "delete_message", target: Channels.Message } |
+    { type: "create_invite", target: Channels.TextChannel | Channels.GroupChannel } |
+    { type: "kick_member", target: Servers.Server, user: string } |
+    { type: "ban_member", target: Servers.Server, user: string }
+)
+
+export function SpecialPromptModal(props: SpecialProps) {
+    const client = useContext(AppContext);
+    const [ processing, setProcessing ] = useState(false);
+    const [ error, setError ] = useState<undefined | string>(undefined);
+
+    const { onClose } = props;
+    switch (props.type) {
+        case 'leave_group':
+        case 'close_dm':
+        case 'leave_server':
+        case 'delete_server': 
+        case 'delete_message': 
+        case 'delete_channel': {
+            const EVENTS = {
+                'close_dm':       'confirm_close_dm',
+                'delete_server':  'confirm_delete',
+                'delete_channel': 'confirm_delete',
+                'delete_message': 'confirm_delete_message',
+                'leave_group':    'confirm_leave',
+                'leave_server':   'confirm_leave'
+            };
+
+            let event = EVENTS[props.type];
+            let name = props.type === 'close_dm' ? client.users.get(client.channels.getRecipient(props.target._id))?.username : 
+                 props.type === 'delete_message' ? undefined : props.target.name;
+
+            return (
+                <PromptModal
+                    onClose={onClose}
+                    question={<Text
+                        id={props.type === 'delete_message' ? 'app.context_menu.delete_message' : `app.special.modals.prompt.${event}`}
+                        fields={{ name }}
+                    />}
+                    actions={[
+                        {
+                            confirmation: true,
+                            style: 'contrast-error',
+                            text: <Text id="app.special.modals.actions.delete" />,
+                            onClick: async () => {
+                                setProcessing(true);
+
+                                try {
+                                    if (props.type === 'leave_group' || props.type === 'close_dm' || props.type === 'delete_channel') {
+                                        await client.channels.delete(props.target._id);
+                                    } else if (props.type === 'delete_message') {
+                                        await client.channels.deleteMessage(props.target.channel, props.target._id);
+                                    } else {
+                                        await client.servers.delete(props.target._id);
+                                    }
+
+                                    onClose();
+                                } catch (err) {
+                                    setError(takeError(err));
+                                    setProcessing(false);
+                                }
+                            }
+                        },
+                        { text: <Text id="app.special.modals.actions.cancel" />, onClick: onClose }
+                    ]}
+                    content={<Text id={`app.special.modals.prompt.${event}_long`} />}
+                    disabled={processing}
+                    error={error}
+                />
+            )
+        }
+        case "create_invite": {
+            const [ code, setCode ] = useState('abcdef');
+            const { writeClipboard } = useIntermediate();
+
+            useEffect(() => {
+                setProcessing(true);
+
+                client.channels.createInvite(props.target._id)
+                    .then(code => setCode(code))
+                    .catch(err => setError(takeError(err)))
+                    .finally(() => setProcessing(false));
+            }, []);
+
+            return (
+                <PromptModal
+                    onClose={onClose}
+                    question={<Text id={`app.context_menu.create_invite`} />}
+                    actions={[
+                        {
+                            text: <Text id="app.special.modals.actions.ok" />,
+                            confirmation: true,
+                            onClick: onClose
+                        },
+                        {
+                            text: <Text id="app.context_menu.copy_link" />,
+                            onClick: () => writeClipboard(`${window.location.protocol}//${window.location.host}/invite/${code}`)
+                        }
+                    ]}
+                    content={
+                        processing ?
+                            <Text id="app.special.modals.prompt.create_invite_generate" />
+                          : <div className={styles.invite}>
+                              <Text id="app.special.modals.prompt.create_invite_created" />
+                              <code>{code}</code>
+                            </div>
+                    }
+                    disabled={processing}
+                    error={error}
+                />
+            )
+        }
+        case "kick_member": {
+            const user = client.users.get(props.user);
+
+            return (
+                <PromptModal
+                    onClose={onClose}
+                    question={<Text id={`app.context_menu.kick_member`} />}
+                    actions={[
+                        {
+                            text: <Text id="app.special.modals.actions.kick" />,
+                            style: 'contrast-error',
+                            confirmation: true,
+                            onClick: async () => {
+                                setProcessing(true);
+                                
+                                try {
+                                    await client.servers.members.kickMember(props.target._id, props.user);
+                                    onClose();
+                                } catch (err) {
+                                    setError(takeError(err));
+                                    setProcessing(false);
+                                }
+                            }
+                        },
+                        { text: <Text id="app.special.modals.actions.cancel" />, onClick: onClose }
+                    ]}
+                    content={<div className={styles.column}>
+                        <UserIcon target={user} size={64} />
+                        <Text
+                            id="app.special.modals.prompt.confirm_kick"
+                            fields={{ name: user?.username }} />
+                    </div>}
+                    disabled={processing}
+                    error={error}
+                />
+            )
+        }
+        case "ban_member": {
+            const [ reason, setReason ] = useState<string | undefined>(undefined);
+            const user = client.users.get(props.user);
+
+            return (
+                <PromptModal
+                    onClose={onClose}
+                    question={<Text id={`app.context_menu.ban_member`} />}
+                    actions={[
+                        {
+                            text: <Text id="app.special.modals.actions.ban" />,
+                            style: 'contrast-error',
+                            confirmation: true,
+                            onClick: async () => {
+                                setProcessing(true);
+                                
+                                try {
+                                    await client.servers.banUser(props.target._id, props.user, { reason });
+                                    onClose();
+                                } catch (err) {
+                                    setError(takeError(err));
+                                    setProcessing(false);
+                                }
+                            }
+                        },
+                        { text: <Text id="app.special.modals.actions.cancel" />, onClick: onClose }
+                    ]}
+                    content={<div className={styles.column}>
+                        <UserIcon target={user} size={64} />
+                        <Text
+                            id="app.special.modals.prompt.confirm_ban"
+                            fields={{ name: user?.username }} />
+                        <Overline><Text id="app.special.modals.prompt.confirm_ban_reason" /></Overline>
+                        <InputBox value={reason ?? ''} onChange={e => setReason(e.currentTarget.value)} />
+                    </div>}
+                    disabled={processing}
+                    error={error}
+                />
+            )
+        }
+        default: return null;
+    }
+}
diff --git a/src/context/intermediate/modals/SignedOut.tsx b/src/context/intermediate/modals/SignedOut.tsx
new file mode 100644
index 0000000..b56f638
--- /dev/null
+++ b/src/context/intermediate/modals/SignedOut.tsx
@@ -0,0 +1,23 @@
+import { Text } from "preact-i18n";
+import Modal from "../../../components/ui/Modal";
+
+interface Props {
+    onClose: () => void;
+}
+
+export function SignedOutModal({ onClose }: Props) {
+    return (
+        <Modal
+            visible={true}
+            onClose={onClose}
+            title={<Text id="app.special.modals.signed_out" />}
+            actions={[
+                {
+                    onClick: onClose,
+                    confirmation: true,
+                    text: <Text id="app.special.modals.actions.ok" />
+                }
+            ]}
+        />
+    );
+}
diff --git a/src/context/intermediate/popovers/ChannelInfo.module.scss b/src/context/intermediate/popovers/ChannelInfo.module.scss
new file mode 100644
index 0000000..ff37d16
--- /dev/null
+++ b/src/context/intermediate/popovers/ChannelInfo.module.scss
@@ -0,0 +1,16 @@
+.info {
+    .header {
+        display: flex;
+        align-items: center;
+        flex-direction: row;
+
+        h1 {
+            margin: 0;
+            flex-grow: 1;
+        }
+
+        div {
+            cursor: pointer;
+        }
+    }
+}
diff --git a/src/context/intermediate/popovers/ChannelInfo.tsx b/src/context/intermediate/popovers/ChannelInfo.tsx
new file mode 100644
index 0000000..574943c
--- /dev/null
+++ b/src/context/intermediate/popovers/ChannelInfo.tsx
@@ -0,0 +1,38 @@
+import { X } from "@styled-icons/feather";
+import styles from "./ChannelInfo.module.scss";
+import Modal from "../../../components/ui/Modal";
+import { getChannelName } from "../../revoltjs/util";
+import Markdown from "../../../components/markdown/Markdown";
+import { useChannel, useForceUpdate } from "../../revoltjs/hooks";
+
+interface Props {
+    channel_id: string;
+    onClose: () => void;
+}
+
+export function ChannelInfo({ channel_id, onClose }: Props) {
+    const ctx = useForceUpdate();
+    const channel = useChannel(channel_id, ctx);
+    if (!channel) return null;
+
+    if (channel.channel_type === "DirectMessage" || channel.channel_type === 'SavedMessages') {
+        onClose();
+        return null;
+    }
+
+    return (
+        <Modal visible={true} onClose={onClose}>
+            <div className={styles.info}>
+                <div className={styles.header}>
+                    <h1>{ getChannelName(ctx.client, channel, [ ], true) }</h1>
+                    <div onClick={onClose}>
+                        <X size={36} />
+                    </div>
+                </div>
+                <p>
+                    <Markdown content={channel.description} />
+                </p>
+            </div>
+        </Modal>
+    );
+}
diff --git a/src/context/intermediate/popovers/ImageViewer.module.scss b/src/context/intermediate/popovers/ImageViewer.module.scss
new file mode 100644
index 0000000..0a9f360
--- /dev/null
+++ b/src/context/intermediate/popovers/ImageViewer.module.scss
@@ -0,0 +1,6 @@
+.viewer {
+    img {
+        max-width: 90vw;
+        max-height: 90vh;
+    }
+}
diff --git a/src/context/intermediate/popovers/ImageViewer.tsx b/src/context/intermediate/popovers/ImageViewer.tsx
new file mode 100644
index 0000000..ae6754e
--- /dev/null
+++ b/src/context/intermediate/popovers/ImageViewer.tsx
@@ -0,0 +1,46 @@
+import styles from "./ImageViewer.module.scss";
+import Modal from "../../../components/ui/Modal";
+import { useContext, useEffect } from "preact/hooks";
+import { AppContext } from "../../revoltjs/RevoltClient";
+import { Attachment, EmbedImage } from "revolt.js/dist/api/objects";
+
+interface Props {
+    onClose: () => void;
+    embed?: EmbedImage;
+    attachment?: Attachment;
+}
+
+export function ImageViewer({ attachment, embed, onClose }: Props) {
+    if (attachment && attachment.metadata.type !== "Image") return null;
+    const client = useContext(AppContext);
+
+    useEffect(() => {
+        function keyDown(e: KeyboardEvent) {
+            if (e.key === "Escape") {
+                onClose();
+            }
+        }
+
+        document.body.addEventListener("keydown", keyDown);
+        return () => document.body.removeEventListener("keydown", keyDown);
+    }, []);
+
+    return (
+        <Modal visible={true} onClose={onClose} noBackground>
+            <div className={styles.viewer}>
+                { attachment &&
+                    <>
+                        <img src={client.generateFileURL(attachment)} />
+                        {/*<AttachmentActions attachment={attachment} />*/}
+                    </>
+                }
+                { embed &&
+                    <>
+                        {/*<img src={proxyImage(embed.url)} />*/}
+                        {/*<EmbedMediaActions embed={embed} />*/}
+                    </>
+                }
+            </div>
+        </Modal>
+    );
+}
diff --git a/src/context/intermediate/popovers/UserPicker.module.scss b/src/context/intermediate/popovers/UserPicker.module.scss
new file mode 100644
index 0000000..56afcf3
--- /dev/null
+++ b/src/context/intermediate/popovers/UserPicker.module.scss
@@ -0,0 +1,21 @@
+.list {
+    width: 400px;
+    max-width: 100%;
+    max-height: 360px;
+    overflow-y: scroll;
+
+    // ! FIXME: very temporary code
+    > label {
+        > span {
+            align-items: flex-start !important;
+            > span {
+                display: flex;
+                padding: 4px;
+                flex-direction: row;
+                gap: 10px;
+                justify-content: flex-start;
+                align-items: center;
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/context/intermediate/popovers/UserPicker.tsx b/src/context/intermediate/popovers/UserPicker.tsx
new file mode 100644
index 0000000..9c2a8e6
--- /dev/null
+++ b/src/context/intermediate/popovers/UserPicker.tsx
@@ -0,0 +1,64 @@
+import { Text } from "preact-i18n";
+import { useState } from "preact/hooks";
+import styles from "./UserPicker.module.scss";
+import { useUsers } from "../../revoltjs/hooks";
+import Modal from "../../../components/ui/Modal";
+import { User, Users } from "revolt.js/dist/api/objects";
+import UserCheckbox from "../../../components/common/UserCheckbox";
+
+interface Props {
+    omit?: string[];
+    onClose: () => void;
+    callback: (users: string[]) => Promise<void>;
+}
+
+export function UserPicker(props: Props) {
+    const [selected, setSelected] = useState<string[]>([]);
+    const omit = [...(props.omit || []), "00000000000000000000000000"];
+
+    const users = useUsers();
+
+    return (
+        <Modal
+            visible={true}
+            title={<Text id="app.special.popovers.user_picker.select" />}
+            onClose={props.onClose}
+            actions={[
+                {
+                    text: <Text id="app.special.modals.actions.ok" />,
+                    onClick: () => props.callback(selected).then(props.onClose)
+                }
+            ]}
+        >
+            <div className={styles.list}>
+                {(users.filter(
+                    x =>
+                        x &&
+                        x.relationship === Users.Relationship.Friend &&
+                        !omit.includes(x._id)
+                ) as User[])
+                    .map(x => {
+                        return {
+                            ...x,
+                            selected: selected.includes(x._id)
+                        };
+                    })
+                    .map(x => (
+                        <UserCheckbox
+                            user={x}
+                            checked={x.selected}
+                            onChange={v => {
+                                if (v) {
+                                    setSelected([...selected, x._id]);
+                                } else {
+                                    setSelected(
+                                        selected.filter(y => y !== x._id)
+                                    );
+                                }
+                            }}
+                        />
+                    ))}
+            </div>
+        </Modal>
+    );
+}
diff --git a/src/context/intermediate/popovers/UserProfile.module.scss b/src/context/intermediate/popovers/UserProfile.module.scss
new file mode 100644
index 0000000..448595a
--- /dev/null
+++ b/src/context/intermediate/popovers/UserProfile.module.scss
@@ -0,0 +1,165 @@
+.modal {
+    height: 460px;
+    display: flex;
+    padding: 0 !important;
+    flex-direction: column;
+}
+
+.header {
+    background-size: cover;
+    border-radius: 8px 8px 0 0;
+    background-position: center;
+
+    &[data-force="light"] {
+        color: white;
+    }
+
+    &[data-force="dark"] {
+        color: black;
+    }
+}
+
+.profile {
+    gap: 16px;
+    width: 560px;
+    display: flex;
+    padding: 20px;
+    max-width: 100%;
+    align-items: center;
+    flex-direction: row;
+
+    .details {
+        flex-grow: 1;
+        min-width: 0;
+        display: flex;
+        flex-direction: column;
+
+        > * {
+            min-width: 0;
+            overflow: hidden;
+            white-space: nowrap;
+            text-overflow: ellipsis;
+        }
+
+        .username {
+            font-size: 22px;
+            font-weight: 600;
+        }
+
+        .status {
+            font-size: 13px;
+        }
+    }
+}
+
+.tabs {
+    gap: 8px;
+    display: flex;
+    padding: 0 1.5em;
+    font-size: .875rem;
+
+    > div {
+        padding: 8px;
+        cursor: pointer;
+        border-bottom: 2px solid transparent;
+        transition: border-bottom .3s;
+
+        &[data-active="true"] {
+            border-bottom: 2px solid var(--foreground);
+            cursor: default;
+        }
+
+        &:hover:not([data-active="true"]) {
+            border-bottom: 2px solid var(--tertiary-foreground);
+        }
+    }
+}
+
+.content {
+    gap: 8px;
+    height: 100%;
+    display: flex;
+    padding: 1em 1.5em;
+    max-width: 560px;
+    overflow-y: auto;
+    flex-direction: column;
+    background: var(--primary-background);
+    border-radius: 0 0 8px 8px;
+
+    .empty {
+        display: flex;
+        justify-content: center;
+        align-items: center;
+    }
+
+    .category {
+        font-size: 12px;
+        font-weight: 600;
+        text-transform: uppercase;
+        color: var(--tertiary-foreground);
+        margin-bottom: 8px;
+
+        &:not(:first-child) {
+            margin-top: 8px;
+        }
+    }
+
+    > div {
+        > span {
+            font-size: 15px;
+        }
+    }
+}
+
+.badges {
+    gap: 8px;
+    display: flex;
+    margin-top: 4px;
+    flex-direction: row;
+
+    img {
+        width: 32px;
+        height: 32px;
+        cursor: pointer;
+    }
+}
+
+.entries {
+    gap: 8px;
+    display: flex;
+    flex-direction: column;
+
+    a {
+        min-width: 0;
+    }
+
+    .entry {
+        gap: 8px;
+        min-width: 0;
+        padding: 12px;
+        display: flex;
+        cursor: pointer;
+        border-radius: 4px;
+        align-items: center;
+        color: var(--secondary-foreground);
+        background-color: var(--secondary-background);
+        transition: background-color .1s;
+
+        &:hover {
+            background-color: var(--primary-background);
+        }
+
+        img {
+            width: 32px;
+            height: 32px;
+            border-radius: 50%;
+        }
+
+        span {
+            min-width: 0;
+            overflow: hidden;
+            white-space: nowrap;
+            text-overflow: ellipsis;
+        }
+    }
+}
diff --git a/src/context/intermediate/popovers/UserProfile.tsx b/src/context/intermediate/popovers/UserProfile.tsx
new file mode 100644
index 0000000..0a11cfd
--- /dev/null
+++ b/src/context/intermediate/popovers/UserProfile.tsx
@@ -0,0 +1,341 @@
+import Modal from "../../../components/ui/Modal";
+import { Localizer, Text } from "preact-i18n";
+import styles from "./UserProfile.module.scss";
+import Preloader from "../../../components/ui/Preloader";
+import { Route } from "revolt.js/dist/api/routes";
+import { Users } from "revolt.js/dist/api/objects";
+import { IntermediateContext, useIntermediate } from "../Intermediate";
+import { Globe, Mail, Edit, UserPlus, Shield } from "@styled-icons/feather";
+import { Link, useHistory } from "react-router-dom";
+import { useContext, useEffect, useLayoutEffect, useState } from "preact/hooks";
+import { decodeTime } from "ulid";
+import { CashStack } from "@styled-icons/bootstrap";
+import { AppContext, ClientStatus, StatusContext } from "../../revoltjs/RevoltClient";
+import { useChannels, useForceUpdate, useUser, useUsers } from "../../revoltjs/hooks";
+import UserIcon from '../../../components/common/UserIcon';
+import UserStatus from '../../../components/common/UserStatus';
+import Tooltip from '../../../components/common/Tooltip';
+import ChannelIcon from '../../../components/common/ChannelIcon';
+import Markdown from '../../../components/markdown/Markdown';
+
+interface Props {
+    user_id: string;
+    dummy?: boolean;
+    onClose: () => void;
+    dummyProfile?: Users.Profile;
+}
+
+enum Badges {
+    Developer = 1,
+    Translator = 2,
+    Supporter = 4,
+    ResponsibleDisclosure = 8,
+    EarlyAdopter = 256
+}
+
+export function UserProfile({ user_id, onClose, dummy, dummyProfile }: Props) {
+    const { writeClipboard } = useIntermediate();
+
+    const [profile, setProfile] = useState<undefined | null | Users.Profile>(
+        undefined
+    );
+    const [mutual, setMutual] = useState<
+        undefined | null | Route<"GET", "/users/id/mutual">["response"]
+    >(undefined);
+
+    const client = useContext(AppContext);
+    const status = useContext(StatusContext);
+    const [tab, setTab] = useState("profile");
+    const history = useHistory();
+
+    const ctx = useForceUpdate();
+    const all_users = useUsers(undefined, ctx);
+    const channels = useChannels(undefined, ctx);
+
+    const user = all_users.find(x => x!._id === user_id);
+    const users = mutual?.users ? all_users.filter(x => mutual.users.includes(x!._id)) : undefined;
+
+    if (!user) {
+        useEffect(onClose, []);
+        return null;
+    }
+
+    useLayoutEffect(() => {
+        if (!user_id) return;
+        if (typeof profile !== 'undefined') setProfile(undefined);
+        if (typeof mutual  !== 'undefined') setMutual(undefined);
+    }, [user_id]);
+
+    if (dummy) {
+        useLayoutEffect(() => {
+            setProfile(dummyProfile);
+        }, [dummyProfile]);
+    }
+
+    useEffect(() => {
+        if (dummy) return;
+        if (
+            status === ClientStatus.ONLINE &&
+            typeof mutual === "undefined"
+        ) {
+            setMutual(null);
+            client.users
+                .fetchMutual(user_id)
+                .then(data => setMutual(data));
+        }
+    }, [mutual, status]);
+
+    useEffect(() => {
+        if (dummy) return;
+        if (
+            status === ClientStatus.ONLINE &&
+            typeof profile === "undefined"
+        ) {
+            setProfile(null);
+
+            // ! FIXME: in the future, also check if mutual guilds
+            // ! maybe just allow mutual group to allow profile viewing
+            /*if (
+                user.relationship === Users.Relationship.Friend ||
+                user.relationship === Users.Relationship.User
+            ) {*/
+                client.users
+                    .fetchProfile(user_id)
+                    .then(data => setProfile(data))
+                    .catch(() => {});
+            //}
+        }
+    }, [profile, status]);
+
+    const mutualGroups = channels.filter(
+        channel =>
+            channel?.channel_type === "Group" &&
+            channel.recipients.includes(user_id)
+    );
+
+    const backgroundURL = profile && client.users.getBackgroundURL(profile, { width: 1000 }, true);
+    const badges = (user.badges ?? 0) | (decodeTime(user._id) < 1623751765790 ? Badges.EarlyAdopter : 0);
+
+    return (
+        <Modal
+            visible
+            border={dummy}
+            onClose={onClose}
+            dontModal={dummy}
+        >
+            <div
+                className={styles.header}
+                data-force={
+                    profile?.background
+                        ? "light"
+                        : undefined
+                }
+                style={{
+                    backgroundImage: backgroundURL && `linear-gradient( rgba(0, 0, 0, 0.7), rgba(0, 0, 0, 0.7) ), url('${backgroundURL}')`
+                }}
+            >
+                <div className={styles.profile}>
+                    <UserIcon size={80} target={user} status />
+                    <div className={styles.details}>
+                        <Localizer>
+                            <span
+                                className={styles.username}
+                                onClick={() => writeClipboard(user.username)}>
+                                @{user.username}
+                            </span>
+                        </Localizer>
+                        {user.status?.text && (
+                            <span className={styles.status}>
+                                <UserStatus user={user} />
+                            </span>
+                        )}
+                    </div>
+                    {user.relationship === Users.Relationship.Friend && (
+                        <Localizer>
+                            <Tooltip
+                                content={
+                                    <Text id="app.context_menu.message_user" />
+                                }
+                            >
+                                {/*<IconButton
+                                    onClick={() => {
+                                        onClose();
+                                        history.push(`/open/${user_id}`);
+                                    }}
+                                >*/}
+                                    <Mail size={30} strokeWidth={1.5} />
+                                {/*</IconButton>*/}
+                            </Tooltip>
+                        </Localizer>
+                    )}
+                    {user.relationship === Users.Relationship.User && (
+                        /*<IconButton
+                            onClick={() => {
+                                onClose();
+                                if (dummy) return;
+                                history.push(`/settings/profile`);
+                            }}
+                        >*/
+                            <Edit size={28} strokeWidth={1.5} />
+                        /*</IconButton>*/
+                    )}
+                    {(user.relationship === Users.Relationship.Incoming ||
+                        user.relationship === Users.Relationship.None) && (
+                        /*<IconButton
+                            onClick={() => client.users.addFriend(user.username)}
+                        >*/
+                            <UserPlus size={28} strokeWidth={1.5} />
+                        /*</IconButton>*/
+                    )}
+                </div>
+                <div className={styles.tabs}>
+                    <div
+                        data-active={tab === "profile"}
+                        onClick={() => setTab("profile")}
+                    >
+                        <Text id="app.special.popovers.user_profile.profile" />
+                    </div>
+                    { user.relationship !== Users.Relationship.User &&
+                        <>
+                            <div
+                                data-active={tab === "friends"}
+                                onClick={() => setTab("friends")}
+                            >
+                                <Text id="app.special.popovers.user_profile.mutual_friends" />
+                            </div>
+                            <div
+                                data-active={tab === "groups"}
+                                onClick={() => setTab("groups")}
+                            >
+                                <Text id="app.special.popovers.user_profile.mutual_groups" />
+                            </div>
+                        </>
+                    }
+                </div>
+            </div>
+            <div className={styles.content}>
+                {tab === "profile" &&
+                <div>
+                    { !(profile?.content || (badges > 0)) &&
+                        <div className={styles.empty}><Text id="app.special.popovers.user_profile.empty" /></div> }
+                    { (badges > 0) && <div className={styles.category}><Text id="app.special.popovers.user_profile.sub.badges" /></div> }
+                    { (badges > 0) && (
+                        <div className={styles.badges}>
+                            <Localizer>
+                                {badges & Badges.Developer ? (
+                                    <Tooltip
+                                        content={
+                                            <Text id="app.navigation.tabs.dev" />
+                                        }
+                                    >
+                                        <img src="/assets/badges/developer.svg" />
+                                    </Tooltip>
+                                ) : (
+                                    <></>
+                                )}
+                                {badges & Badges.Translator ? (
+                                    <Tooltip
+                                        content={
+                                            <Text id="app.special.popovers.user_profile.badges.translator" />
+                                        }
+                                    >
+                                        <img src="/assets/badges/translator.svg" />
+                                    </Tooltip>
+                                ) : (
+                                    <></>
+                                )}
+                                {badges & Badges.EarlyAdopter ? (
+                                    <Tooltip
+                                        content={
+                                            <Text id="app.special.popovers.user_profile.badges.early_adopter" />
+                                        }
+                                    >
+                                        <img src="/assets/badges/early_adopter.svg" />
+                                    </Tooltip>
+                                ) : (
+                                    <></>
+                                )}
+                                {badges & Badges.Supporter ? (
+                                    <Tooltip
+                                        content={
+                                            <Text id="app.special.popovers.user_profile.badges.supporter" />
+                                        }
+                                    >
+                                        <CashStack size={32} color="#efab44" />
+                                    </Tooltip>
+                                ) : (
+                                    <></>
+                                )}
+                                {badges & Badges.ResponsibleDisclosure ? (
+                                    <Tooltip
+                                        content={
+                                            <Text id="app.special.popovers.user_profile.badges.responsible_disclosure" />
+                                        }
+                                    >
+                                        <Shield size={32} color="gray" />
+                                    </Tooltip>
+                                ) : (
+                                    <></>
+                                )}
+                            </Localizer>
+                        </div>
+                    )}
+                    { profile?.content && <div className={styles.category}><Text id="app.special.popovers.user_profile.sub.information" /></div> }
+                    <Markdown content={profile?.content} />
+                    {/*<div className={styles.category}><Text id="app.special.popovers.user_profile.sub.connections" /></div>*/}
+                </div>}
+                {tab === "friends" &&
+                    (users ? (
+                        <div className={styles.entries}>
+                            {users.length === 0 ? (
+                                <div className={styles.empty}>
+                                    <Text id="app.special.popovers.user_profile.no_users" />
+                                </div>
+                            ) : (
+                                users.map(
+                                    x =>
+                                        x && (
+                                            //<LinkProfile user_id={x._id}>
+                                                <div
+                                                    className={styles.entry}
+                                                    key={x._id}
+                                                >
+                                                    <UserIcon size={32} target={x} />
+                                                    <span>{x.username}</span>
+                                                </div>
+                                            //</LinkProfile>
+                                        )
+                                )
+                            )}
+                        </div>
+                    ) : (
+                        <Preloader />
+                    ))}
+                {tab === "groups" && (
+                    <div className={styles.entries}>
+                        {mutualGroups.length === 0 ? (
+                            <div className={styles.empty}>
+                                <Text id="app.special.popovers.user_profile.no_groups" />
+                            </div>
+                        ) : (
+                            mutualGroups.map(
+                                x =>
+                                    x?.channel_type === "Group" && (
+                                        <Link to={`/channel/${x._id}`}>
+                                            <div
+                                                className={styles.entry}
+                                                key={x._id}
+                                            >
+                                                <ChannelIcon target={x} size={32} />
+                                                <span>{x.name}</span>
+                                            </div>
+                                        </Link>
+                                    )
+                            )
+                        )}
+                    </div>
+                )}
+            </div>
+        </Modal>
+    );
+}
diff --git a/src/context/revoltjs/CheckAuth.tsx b/src/context/revoltjs/CheckAuth.tsx
index 3933e91..f084e93 100644
--- a/src/context/revoltjs/CheckAuth.tsx
+++ b/src/context/revoltjs/CheckAuth.tsx
@@ -2,7 +2,7 @@ import { ReactNode } from "react";
 import { useContext } from "preact/hooks";
 import { Redirect } from "react-router-dom";
 
-import { AppContext } from "./RevoltClient";
+import { OperationsContext } from "./RevoltClient";
 
 interface Props {
     auth?: boolean;
@@ -10,7 +10,7 @@ interface Props {
 }
 
 export const CheckAuth = (props: Props) => {
-    const { operations } = useContext(AppContext);
+    const operations = useContext(OperationsContext);
 
     if (props.auth && !operations.ready()) {
         return <Redirect to="/login" />;
diff --git a/src/context/revoltjs/RevoltClient.tsx b/src/context/revoltjs/RevoltClient.tsx
index c93b95a..d489dee 100644
--- a/src/context/revoltjs/RevoltClient.tsx
+++ b/src/context/revoltjs/RevoltClient.tsx
@@ -1,10 +1,10 @@
 import { openDB } from 'idb';
 import { Client } from "revolt.js";
-import { takeError } from "./error";
+import { takeError } from "./util";
 import { createContext } from "preact";
 import { Children } from "../../types/Preact";
 import { Route } from "revolt.js/dist/api/routes";
-import { useEffect, useState } from "preact/hooks";
+import { useEffect, useMemo, useState } from "preact/hooks";
 import { connectState } from "../../redux/connector";
 import Preloader from "../../components/ui/Preloader";
 import { WithDispatcher } from "../../redux/reducers";
@@ -30,13 +30,9 @@ export interface ClientOperations {
     ready: () => boolean;
 }
 
-export interface AppState {
-    client: Client;
-    status: ClientStatus;
-    operations: ClientOperations;
-}
-
-export const AppContext = createContext<AppState>(undefined as any);
+export const AppContext = createContext<Client>(undefined as any);
+export const StatusContext = createContext<ClientStatus>(undefined as any);
+export const OperationsContext = createContext<ClientOperations>(undefined as any);
 
 type Props = WithDispatcher & {
     auth: AuthState;
@@ -78,10 +74,8 @@ function Context({ auth, sync, children, dispatcher }: Props) {
 
     if (status === ClientStatus.INIT) return null;
 
-    const value: AppState = {
-        client,
-        status,
-        operations: {
+    const operations: ClientOperations = useMemo(() => {
+        return {
             login: async data => {
                 setReconnectDisallowed(true);
 
@@ -131,14 +125,14 @@ function Context({ auth, sync, children, dispatcher }: Props) {
             },
             loggedIn: () => typeof auth.active !== "undefined",
             ready: () => (
-                value.operations.loggedIn() &&
+                operations.loggedIn() &&
                 typeof client.user !== "undefined"
             )
         }
-    };
+    }, [ client, auth.active ]);
 
     useEffect(
-        () => registerEvents({ ...value, dispatcher }, setStatus, client),
+        () => registerEvents({ operations, dispatcher }, setStatus, client),
         [ client ]
     );
 
@@ -155,7 +149,7 @@ function Context({ auth, sync, children, dispatcher }: Props) {
                     return setStatus(ClientStatus.OFFLINE);
                 }
 
-                if (value.operations.ready())
+                if (operations.ready())
                     setStatus(ClientStatus.CONNECTING);
                 
                 if (navigator.onLine) {
@@ -194,7 +188,7 @@ function Context({ auth, sync, children, dispatcher }: Props) {
                     setStatus(ClientStatus.DISCONNECTED);
                     const error = takeError(err);
                     if (error === "Forbidden") {
-                        value.operations.logout(true);
+                        operations.logout(true);
                         // openScreen({ id: "signed_out" });
                     } else {
                         // openScreen({ id: "error", error });
@@ -217,8 +211,12 @@ function Context({ auth, sync, children, dispatcher }: Props) {
     }
 
     return (
-        <AppContext.Provider value={value}>
-            { children }
+        <AppContext.Provider value={client}>
+            <StatusContext.Provider value={status}>
+                <OperationsContext.Provider value={operations}>
+                    { children }
+                </OperationsContext.Provider>
+            </StatusContext.Provider>
         </AppContext.Provider>
     );
 }
diff --git a/src/context/revoltjs/error.ts b/src/context/revoltjs/error.ts
deleted file mode 100644
index 0c44c77..0000000
--- a/src/context/revoltjs/error.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-export function takeError(
-    error: any
-): string {
-    const type = error?.response?.data?.type;
-    let id = type;
-    if (!type) {
-        if (error?.response?.status === 403) {
-            return "Unauthorized";
-        } else if (error && (!!error.isAxiosError && !error.response)) {
-            return "NetworkError";
-        }
-
-        console.error(error);
-        return "UnknownError";
-    }
-
-    return id;
-}
diff --git a/src/context/revoltjs/events.ts b/src/context/revoltjs/events.ts
index 0a10c53..fea22e7 100644
--- a/src/context/revoltjs/events.ts
+++ b/src/context/revoltjs/events.ts
@@ -2,7 +2,7 @@ import { ClientboundNotification } from "revolt.js/dist/websocket/notifications"
 import { WithDispatcher } from "../../redux/reducers";
 import { Client, Message } from "revolt.js/dist";
 import {
-    AppState,
+    ClientOperations,
     ClientStatus
 } from "./RevoltClient";
 import { StateUpdater } from "preact/hooks";
@@ -17,7 +17,7 @@ export function setReconnectDisallowed(allowed: boolean) {
 export function registerEvents({
     operations,
     dispatcher
-}: AppState & WithDispatcher, setStatus: StateUpdater<ClientStatus>, client: Client) {
+}: { operations: ClientOperations } & WithDispatcher, setStatus: StateUpdater<ClientStatus>, client: Client) {
     const listeners = {
         connecting: () =>
             operations.ready() && setStatus(ClientStatus.CONNECTING),
diff --git a/src/context/revoltjs/hooks.ts b/src/context/revoltjs/hooks.ts
index 00f8eb7..e8ccb64 100644
--- a/src/context/revoltjs/hooks.ts
+++ b/src/context/revoltjs/hooks.ts
@@ -9,7 +9,7 @@ export interface HookContext {
 }
 
 export function useForceUpdate(context?: HookContext): HookContext {
-    const { client } = useContext(AppContext);
+    const client = useContext(AppContext);
     if (context) return context;
     const [, updateState] = useState({});
     return { client, forceUpdate: useCallback(() => updateState({}), []) };
diff --git a/src/context/revoltjs/messages.ts b/src/context/revoltjs/messages.ts
deleted file mode 100644
index 44f52c4..0000000
--- a/src/context/revoltjs/messages.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import { Message } from "revolt.js/dist/api/objects";
-
-export type MessageObject = Omit<Message, "edited"> & { edited?: string };
-export function mapMessage(message: Partial<Message>) {
-    const { edited, ...msg } = message;
-    return {
-        ...msg,
-        edited: edited?.$date,
-    } as MessageObject;
-}
diff --git a/src/context/revoltjs/util.tsx b/src/context/revoltjs/util.tsx
new file mode 100644
index 0000000..71e760a
--- /dev/null
+++ b/src/context/revoltjs/util.tsx
@@ -0,0 +1,49 @@
+import { Channel, Message, User } from "revolt.js/dist/api/objects";
+import { Children } from "../../types/Preact";
+import { Text } from "preact-i18n";
+import { Client } from "revolt.js";
+
+export function takeError(
+    error: any
+): string {
+    const type = error?.response?.data?.type;
+    let id = type;
+    if (!type) {
+        if (error?.response?.status === 403) {
+            return "Unauthorized";
+        } else if (error && (!!error.isAxiosError && !error.response)) {
+            return "NetworkError";
+        }
+
+        console.error(error);
+        return "UnknownError";
+    }
+
+    return id;
+}
+
+export function getChannelName(client: Client, channel: Channel, users: User[], prefixType?: boolean): Children {
+    if (channel.channel_type === "SavedMessages")
+        return <Text id="app.navigation.tabs.saved" />;
+
+    if (channel.channel_type === "DirectMessage") {
+        let uid = client.channels.getRecipient(channel._id);
+
+        return <>{prefixType && "@"}{users.find(x => x._id === uid)?.username}</>;
+    }
+
+    if (channel.channel_type === "TextChannel" && prefixType) {
+        return <>#{channel.name}</>;
+    }
+
+    return <>{channel.name}</>;
+}
+
+export type MessageObject = Omit<Message, "edited"> & { edited?: string };
+export function mapMessage(message: Partial<Message>) {
+    const { edited, ...msg } = message;
+    return {
+        ...msg,
+        edited: edited?.$date,
+    } as MessageObject;
+}
diff --git a/src/lib/PaintCounter.tsx b/src/lib/PaintCounter.tsx
index e5ca308..668ce68 100644
--- a/src/lib/PaintCounter.tsx
+++ b/src/lib/PaintCounter.tsx
@@ -9,10 +9,10 @@ export default function PaintCounter({ small }: { small?: boolean }) {
     const count = counts[uniqueId] ?? 0;
     counts[uniqueId] = count + 1;
     return (
-        <span>
+        <div style={{ textAlign: 'center', fontSize: '0.8em' }}>
             { small ? <>P: { count + 1 }</> : <>
                 Painted {count + 1} time(s).
             </> }
-        </span>
+        </div>
     )
 }
diff --git a/src/pages/App.tsx b/src/pages/App.tsx
index 484a73d..ebe37fe 100644
--- a/src/pages/App.tsx
+++ b/src/pages/App.tsx
@@ -6,6 +6,7 @@ import LeftSidebar from "../components/navigation/LeftSidebar";
 import RightSidebar from "../components/navigation/RightSidebar";
 
 import Home from './home/Home';
+import Popovers from "../context/intermediate/Popovers";
 
 export default function App() {
     return (
@@ -20,6 +21,7 @@ export default function App() {
                     <Home />
                 </Route>
             </Switch>
+            <Popovers />
         </OverlappingPanels>
     );
 };
diff --git a/src/pages/home/Home.tsx b/src/pages/home/Home.tsx
index 4ae75db..9ccb04f 100644
--- a/src/pages/home/Home.tsx
+++ b/src/pages/home/Home.tsx
@@ -1,59 +1,9 @@
-import { useChannels, useForceUpdate, useServers, useUser } from "../../context/revoltjs/hooks";
-import ChannelIcon from "../../components/common/ChannelIcon";
-import ServerIcon from "../../components/common/ServerIcon";
-import UserIcon from "../../components/common/UserIcon";
 import PaintCounter from "../../lib/PaintCounter";
 
-export function Nested() {
-    const ctx = useForceUpdate();
-
-    let user = useUser('01EX2NCWQ0CHS3QJF0FEQS1GR4', ctx)!;
-    let user2 = useUser('01EX40TVKYNV114H8Q8VWEGBWQ', ctx)!;
-    let user3 = useUser('01F5GV44HTXP3MTCD2VPV42DPE', ctx)!;
-
-    let channels = useChannels(undefined, ctx);
-    let servers = useServers(undefined, ctx);
-
-    return (
-        <>
-            <h3>Nested component</h3>
-            <PaintCounter />
-            @{ user.username } is { user.online ? 'online' : 'offline' }<br/><br/>
-
-            <h3>UserIcon Tests</h3>
-            <UserIcon size={64} target={user} />
-            <UserIcon size={64} target={user} status />
-            <UserIcon size={64} target={user} voice='muted' />
-            <UserIcon size={64} attachment={user2.avatar} />
-            <UserIcon size={64} attachment={user3.avatar} />
-            <UserIcon size={64} attachment={user3.avatar} animate />
-
-            <h3>Channels</h3>
-            { channels.map(channel =>
-                channel &&
-                channel.channel_type !== 'SavedMessages' &&
-                channel.channel_type !== 'DirectMessage' &&
-                <ChannelIcon size={48} target={channel} />
-            ) }
-
-            <h3>Servers</h3>
-            { servers.map(server =>
-                server &&
-                <ServerIcon size={48} target={server} />
-            ) }
-
-            <br/><br/>
-            <p>{ 'test long paragraph'.repeat(2000) }</p>
-        </>
-    )
-}
-
 export default function Home() {
     return (
-        <div style={{ overflowY: 'scroll', height: '100vh' }}>
-            <h1>HOME</h1>
+        <div>
             <PaintCounter />
-            <Nested />
         </div>
     );
 }
diff --git a/src/pages/login/Login.tsx b/src/pages/login/Login.tsx
index 2334a17..3323593 100644
--- a/src/pages/login/Login.tsx
+++ b/src/pages/login/Login.tsx
@@ -17,7 +17,7 @@ import { FormReset, FormSendReset } from "./forms/FormReset";
 
 export default function Login() {
     const theme = useContext(ThemeContext);
-    const { client } = useContext(AppContext);
+    const client = useContext(AppContext);
 
     return (
         <div className={styles.login}>
diff --git a/src/pages/login/forms/CaptchaBlock.tsx b/src/pages/login/forms/CaptchaBlock.tsx
index 0ba486a..e55b098 100644
--- a/src/pages/login/forms/CaptchaBlock.tsx
+++ b/src/pages/login/forms/CaptchaBlock.tsx
@@ -11,7 +11,7 @@ export interface CaptchaProps {
 }
 
 export function CaptchaBlock(props: CaptchaProps) {
-    const { client } = useContext(AppContext);
+    const client = useContext(AppContext);
 
     useEffect(() => {
         if (!client.configuration?.features.captcha.enabled) {
diff --git a/src/pages/login/forms/Form.tsx b/src/pages/login/forms/Form.tsx
index 56cdfad..3239e39 100644
--- a/src/pages/login/forms/Form.tsx
+++ b/src/pages/login/forms/Form.tsx
@@ -7,7 +7,7 @@ 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 { takeError } from "../../../context/revoltjs/util";
 import { AppContext } from "../../../context/revoltjs/RevoltClient";
 
 import FormField from "../FormField";
@@ -34,7 +34,7 @@ function getInviteCode() {
 }
 
 export function Form({ page, callback }: Props) {
-    const { client } = useContext(AppContext);
+    const client = useContext(AppContext);
 
     const [loading, setLoading] = useState(false);
     const [success, setSuccess] = useState<string | undefined>(undefined);
diff --git a/src/pages/login/forms/FormCreate.tsx b/src/pages/login/forms/FormCreate.tsx
index ca00a0c..4336acd 100644
--- a/src/pages/login/forms/FormCreate.tsx
+++ b/src/pages/login/forms/FormCreate.tsx
@@ -3,7 +3,7 @@ import { useContext } from "preact/hooks";
 import { Form } from "./Form";
 
 export function FormCreate() {
-    const { client } = useContext(AppContext);
+    const client = useContext(AppContext);
 
     return (
         <Form
diff --git a/src/pages/login/forms/FormLogin.tsx b/src/pages/login/forms/FormLogin.tsx
index 04e6f38..6588a52 100644
--- a/src/pages/login/forms/FormLogin.tsx
+++ b/src/pages/login/forms/FormLogin.tsx
@@ -2,10 +2,10 @@ import { Form } from "./Form";
 import { useContext } from "preact/hooks";
 import { useHistory } from "react-router-dom";
 import { deviceDetect } from "react-device-detect";
-import { AppContext } from "../../../context/revoltjs/RevoltClient";
+import { OperationsContext } from "../../../context/revoltjs/RevoltClient";
 
 export function FormLogin() {
-    const { operations } = useContext(AppContext);
+    const { login } = useContext(OperationsContext);
     const history = useHistory();
 
     return (
@@ -21,7 +21,7 @@ export function FormLogin() {
                     device_name = "Unknown Device";
                 }
 
-                await operations.login({ ...data, device_name });
+                await login({ ...data, device_name });
                 history.push("/");
             }}
         />
diff --git a/src/pages/login/forms/FormResend.tsx b/src/pages/login/forms/FormResend.tsx
index 9d52b20..643767d 100644
--- a/src/pages/login/forms/FormResend.tsx
+++ b/src/pages/login/forms/FormResend.tsx
@@ -3,7 +3,7 @@ import { useContext } from "preact/hooks";
 import { Form } from "./Form";
 
 export function FormResend() {
-    const { client } = useContext(AppContext);
+    const client = useContext(AppContext);
 
     return (
         <Form
diff --git a/src/pages/login/forms/FormReset.tsx b/src/pages/login/forms/FormReset.tsx
index 7d8ecdb..a8a7886 100644
--- a/src/pages/login/forms/FormReset.tsx
+++ b/src/pages/login/forms/FormReset.tsx
@@ -4,7 +4,7 @@ import { useHistory, useParams } from "react-router-dom";
 import { AppContext } from "../../../context/revoltjs/RevoltClient";
 
 export function FormSendReset() {
-    const { client } = useContext(AppContext);
+    const client = useContext(AppContext);
 
     return (
         <Form
@@ -18,7 +18,7 @@ export function FormSendReset() {
 
 export function FormReset() {
     const { token } = useParams<{ token: string }>();
-    const { client } = useContext(AppContext);
+    const client = useContext(AppContext);
     const history = useHistory();
 
     return (
diff --git a/src/redux/reducers/queue.ts b/src/redux/reducers/queue.ts
index 9ed4cfd..abf78f9 100644
--- a/src/redux/reducers/queue.ts
+++ b/src/redux/reducers/queue.ts
@@ -1,4 +1,4 @@
-import { MessageObject } from "../../context/revoltjs/messages";
+import { MessageObject } from "../../context/revoltjs/util";
 
 export enum QueueStatus {
     SENDING = "sending",
diff --git a/src/styles/_fonts.scss b/src/styles/_fonts.scss
index c290de2..9552f22 100644
--- a/src/styles/_fonts.scss
+++ b/src/styles/_fonts.scss
@@ -3,7 +3,4 @@
 @import "@fontsource/open-sans/600.css";
 @import "@fontsource/open-sans/700.css";
 
-@import "@fontsource/open-sans/300-italic.css";
 @import "@fontsource/open-sans/400-italic.css";
-@import "@fontsource/open-sans/600-italic.css";
-@import "@fontsource/open-sans/700-italic.css";
diff --git a/src/types/Preact.ts b/src/types/Preact.ts
index b7b4779..80ac489 100644
--- a/src/types/Preact.ts
+++ b/src/types/Preact.ts
@@ -1,3 +1,4 @@
 import { VNode } from "preact";
 
-export type Children = VNode | (VNode | string)[] | string;
+export type Child = VNode | string | false | undefined;
+export type Children = Child | Child[] | Children[];
diff --git a/yarn.lock b/yarn.lock
index 3906148..16f513a 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -912,6 +912,11 @@
     minimatch "^3.0.4"
     strip-json-comments "^3.1.1"
 
+"@fontsource/fira-mono@^4.4.5":
+  version "4.4.5"
+  resolved "https://registry.yarnpkg.com/@fontsource/fira-mono/-/fira-mono-4.4.5.tgz#ceac70967cd3c4262195603aba567cd4582493f8"
+  integrity sha512-LWbsPhTr1JRV3zUgvMrOxQDn1BG9F4R0FPeBkqWP8/oqPxvVYAhEepg1DN9M1k6L9sRN2I2HWHBpt4QVbDGXpw==
+
 "@fontsource/open-sans@^4.4.5":
   version "4.4.5"
   resolved "https://registry.yarnpkg.com/@fontsource/open-sans/-/open-sans-4.4.5.tgz#07b31617e62ed753c94cabcf552ebaed4de497ce"
@@ -1109,11 +1114,28 @@
     ejs "^2.6.1"
     magic-string "^0.25.0"
 
+"@traptitech/markdown-it-katex@^3.4.3":
+  version "3.4.3"
+  resolved "https://registry.yarnpkg.com/@traptitech/markdown-it-katex/-/markdown-it-katex-3.4.3.tgz#23dacbd276ac748409a189550e0ecd764cfde8cf"
+  integrity sha512-ZUG8iapT1xL035NWKYvG8/2AczS40G6JkCf+7Ju5G1aKnCbBIwyuoM+AnwJ+j9WdSGzPRYUG2sNels8a8//uPg==
+  dependencies:
+    katex "^0.13.9"
+
+"@traptitech/markdown-it-spoiler@^1.1.6":
+  version "1.1.6"
+  resolved "https://registry.yarnpkg.com/@traptitech/markdown-it-spoiler/-/markdown-it-spoiler-1.1.6.tgz#973e92045699551e2c9fb39bbd673ee48bc90b83"
+  integrity sha512-tH/Fk1WMsnSuLpuRsXw8iHtdivoCEI5V08hQ7doVm6WmzAnBf/cUzyH9+GbOldPq9Hwv9v9tuy5t/MxmdNAGXg==
+
 "@types/estree@0.0.39":
   version "0.0.39"
   resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.39.tgz#e177e699ee1b8c22d23174caaa7422644389509f"
   integrity sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==
 
+"@types/highlight.js@^9.7.0":
+  version "9.12.4"
+  resolved "https://registry.yarnpkg.com/@types/highlight.js/-/highlight.js-9.12.4.tgz#8c3496bd1b50cc04aeefd691140aa571d4dbfa34"
+  integrity sha512-t2szdkwmg2JJyuCM20e8kR2X59WCE5Zkl4bzm1u1Oukjm79zpbiAv+QjnwLnuuV0WHEcX2NgUItu0pAMKuOPww==
+
 "@types/history@*":
   version "4.7.8"
   resolved "https://registry.yarnpkg.com/@types/history/-/history-4.7.8.tgz#49348387983075705fe8f4e02fb67f7daaec4934"
@@ -1132,6 +1154,25 @@
   resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.7.tgz#98a993516c859eb0d5c4c8f098317a9ea68db9ad"
   integrity sha512-cxWFQVseBm6O9Gbw1IWb8r6OS4OhSt3hPZLkFApLjM8TEXROBuQGLAH2i2gZpcXdLBIrpXuTDhH7Vbm1iXmNGA==
 
+"@types/linkify-it@*":
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/@types/linkify-it/-/linkify-it-3.0.1.tgz#4d26a9efe3aa2caf829234ec5a39580fc88b6001"
+  integrity sha512-pQv3Sygwxxh6jYQzXaiyWDAHevJqWtqDUv6t11Sa9CPGiXny66II7Pl6PR8QO5OVysD6HYOkHMeBgIjLnk9SkQ==
+
+"@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"
+  integrity sha512-p4DIfLMmGN0iLSbMxknDXeSm8W2ZRqQeN/1EAwVxVqJietzgp3WeP1UQjCKWDXWBcEbUa1ECx8YAfdpQdDQmZQ==
+  dependencies:
+    "@types/highlight.js" "^9.7.0"
+    "@types/linkify-it" "*"
+    "@types/mdurl" "*"
+
+"@types/mdurl@*":
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/@types/mdurl/-/mdurl-1.0.2.tgz#e2ce9d83a613bacf284c7be7d491945e39e1f8e9"
+  integrity sha512-eC4U9MlIcu2q0KQmXszyn5Akca/0jrQmwDRgpAMJai7qBWq4amIQhZyNau4VYGtCeALvW1/NtjzJJ567aZxfKA==
+
 "@types/node@*":
   version "15.12.3"
   resolved "https://registry.yarnpkg.com/@types/node/-/node-15.12.3.tgz#2817bf5f25bc82f56579018c53f7d41b1830b1af"
@@ -1149,6 +1190,11 @@
   dependencies:
     preact "^10.0.0"
 
+"@types/prismjs@^1.16.5":
+  version "1.16.5"
+  resolved "https://registry.yarnpkg.com/@types/prismjs/-/prismjs-1.16.5.tgz#378f491ff02304ce50924b05283111d4a286ecba"
+  integrity sha512-nSU7U6FQDJJCraFNwaHmH5YDsd/VA9rTnJ7B7AGFdn+m+VSt3FjLWN7+AbqxZ67dbFazqtrDFUto3HK4ljrHIg==
+
 "@types/prop-types@*":
   version "15.7.3"
   resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.3.tgz#2ab0d5da2e5815f94b0b9d4b95d1e5f243ab2ca7"
@@ -1218,6 +1264,11 @@
     "@types/react" "*"
     csstype "^3.0.2"
 
+"@types/twemoji@^12.1.1":
+  version "12.1.1"
+  resolved "https://registry.yarnpkg.com/@types/twemoji/-/twemoji-12.1.1.tgz#34c5dcecff438b5be173889a6ee8ad51ba90445f"
+  integrity sha512-dW1B1WHTfrWmEzXb/tp8xsZqQHAyMB9JwLwbBqkIQVzmNUI02R7lJqxUpKFM114ygNZHKA1r74oPugCAiYHt1A==
+
 "@typescript-eslint/eslint-plugin@^4.27.0":
   version "4.27.0"
   resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.27.0.tgz#0b7fc974e8bc9b2b5eb98ed51427b0be529b4ad0"
@@ -1380,6 +1431,11 @@ argparse@^1.0.7:
   dependencies:
     sprintf-js "~1.0.2"
 
+argparse@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38"
+  integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==
+
 array-includes@^3.1.2, array-includes@^3.1.3:
   version "3.1.3"
   resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.1.3.tgz#c7f619b382ad2afaf5326cddfdc0afc61af7690a"
@@ -1602,6 +1658,15 @@ classnames@^2.3.1:
   resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.3.1.tgz#dfcfa3891e306ec1dad105d0e88f4417b8535e8e"
   integrity sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA==
 
+clipboard@^2.0.0:
+  version "2.0.8"
+  resolved "https://registry.yarnpkg.com/clipboard/-/clipboard-2.0.8.tgz#ffc6c103dd2967a83005f3f61976aa4655a4cdba"
+  integrity sha512-Y6WO0unAIQp5bLmk1zdThRhgJt/x3ks6f30s3oE3H1mgIEU33XyQjEf8gsf6DxC7NPX8Y1SsNWjUjL/ywLnnbQ==
+  dependencies:
+    good-listener "^1.2.2"
+    select "^1.1.2"
+    tiny-emitter "^2.0.0"
+
 color-convert@^1.9.0:
   version "1.9.3"
   resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8"
@@ -1636,6 +1701,11 @@ commander@^2.20.0:
   resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
   integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==
 
+commander@^6.0.0:
+  version "6.2.1"
+  resolved "https://registry.yarnpkg.com/commander/-/commander-6.2.1.tgz#0792eb682dfbc325999bb2b84fddddba110ac73c"
+  integrity sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==
+
 common-tags@^1.8.0:
   version "1.8.0"
   resolved "https://registry.yarnpkg.com/common-tags/-/common-tags-1.8.0.tgz#8e3153e542d4a39e9b10554434afaaf98956a937"
@@ -1742,6 +1812,11 @@ define-properties@^1.1.3:
   dependencies:
     object-keys "^1.0.12"
 
+delegate@^3.1.2:
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/delegate/-/delegate-3.2.0.tgz#b66b71c3158522e8ab5744f720d8ca0c2af59166"
+  integrity sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw==
+
 detect-browser@^5.2.0:
   version "5.2.0"
   resolved "https://registry.yarnpkg.com/detect-browser/-/detect-browser-5.2.0.tgz#c9cd5afa96a6a19fda0bbe9e9be48a6b6e1e9c97"
@@ -1800,6 +1875,11 @@ enquirer@^2.3.5:
   dependencies:
     ansi-colors "^4.1.1"
 
+entities@~2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/entities/-/entities-2.1.0.tgz#992d3129cf7df6870b96c57858c249a120f8b8b5"
+  integrity sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w==
+
 es-abstract@^1.18.0-next.1, es-abstract@^1.18.0-next.2, es-abstract@^1.18.2:
   version "1.18.3"
   resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.18.3.tgz#25c4c3380a27aa203c44b2b685bba94da31b63e0"
@@ -2145,6 +2225,15 @@ from@~0:
   resolved "https://registry.yarnpkg.com/from/-/from-0.1.7.tgz#83c60afc58b9c56997007ed1a768b3ab303a44fe"
   integrity sha1-g8YK/Fi5xWmXAH7Rp2izqzA6RP4=
 
+fs-extra@^8.0.1:
+  version "8.1.0"
+  resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-8.1.0.tgz#49d43c45a88cd9677668cb7be1b46efdb8d2e1c0"
+  integrity sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==
+  dependencies:
+    graceful-fs "^4.2.0"
+    jsonfile "^4.0.0"
+    universalify "^0.1.0"
+
 fs-extra@^9.0.1:
   version "9.1.0"
   resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-9.1.0.tgz#5954460c764a8da2094ba3554bf839e6b9a7c86d"
@@ -2237,6 +2326,13 @@ globby@^11.0.3:
     merge2 "^1.3.0"
     slash "^3.0.0"
 
+good-listener@^1.2.2:
+  version "1.2.2"
+  resolved "https://registry.yarnpkg.com/good-listener/-/good-listener-1.2.2.tgz#d53b30cdf9313dffb7dc9a0d477096aa6d145c50"
+  integrity sha1-1TswzfkxPf+33JoNR3CWqm0UXFA=
+  dependencies:
+    delegate "^3.1.2"
+
 graceful-fs@^4.1.6, graceful-fs@^4.2.0:
   version "4.2.6"
   resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.6.tgz#ff040b2b0853b23c3d31027523706f1885d76bee"
@@ -2269,6 +2365,11 @@ has@^1.0.3:
   dependencies:
     function-bind "^1.1.1"
 
+highlight.js@^11.0.1:
+  version "11.0.1"
+  resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-11.0.1.tgz#a78bafccd9aa297978799fe5eed9beb7ee1ef887"
+  integrity sha512-EqYpWyTF2s8nMfttfBA2yLKPNoZCO33pLS4MnbXQ4hECf1TKujCt1Kq7QAdrio7roL4+CqsfjqwYj4tYgq0pJQ==
+
 history@^4.9.0:
   version "4.10.1"
   resolved "https://registry.yarnpkg.com/history/-/history-4.10.1.tgz#33371a65e3a83b267434e2b3f3b1b4c58aad4cf3"
@@ -2520,6 +2621,22 @@ json5@^2.1.2:
   dependencies:
     minimist "^1.2.5"
 
+jsonfile@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-4.0.0.tgz#8771aae0799b64076b76640fca058f9c10e33ecb"
+  integrity sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=
+  optionalDependencies:
+    graceful-fs "^4.1.6"
+
+jsonfile@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-5.0.0.tgz#e6b718f73da420d612823996fdf14a03f6ff6922"
+  integrity sha512-NQRZ5CRo74MhMMC3/3r5g2k4fjodJ/wh8MxjFbCViWKFjxrnudWSY5vomh+23ZaXzAS7J3fBZIR2dV6WbmfM0w==
+  dependencies:
+    universalify "^0.1.2"
+  optionalDependencies:
+    graceful-fs "^4.1.6"
+
 jsonfile@^6.0.1:
   version "6.1.0"
   resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-6.1.0.tgz#bc55b2634793c679ec6403094eb13698a6ec0aae"
@@ -2537,6 +2654,13 @@ jsonfile@^6.0.1:
     array-includes "^3.1.2"
     object.assign "^4.1.2"
 
+katex@^0.13.9:
+  version "0.13.11"
+  resolved "https://registry.yarnpkg.com/katex/-/katex-0.13.11.tgz#66138ebf173f25ef130cd3a3ea3ea1d12a3f1362"
+  integrity sha512-yJBHVIgwlAaapzlbvTpVF/ZOs8UkTj/sd46Fl8+qAf2/UiituPYVeapVD8ADZtqyRg/qNWUKt7gJoyYVWLrcXw==
+  dependencies:
+    commander "^6.0.0"
+
 kolorist@^1.2.10:
   version "1.4.1"
   resolved "https://registry.yarnpkg.com/kolorist/-/kolorist-1.4.1.tgz#5ce60d5fefa23ca55a7e3203e16f7b9ed5b0556a"
@@ -2557,6 +2681,13 @@ lie@3.1.1:
   dependencies:
     immediate "~3.0.5"
 
+linkify-it@^3.0.1:
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/linkify-it/-/linkify-it-3.0.2.tgz#f55eeb8bc1d3ae754049e124ab3bb56d97797fb8"
+  integrity sha512-gDBO4aHNZS6coiZCKVhSNh43F9ioIL4JwRjLZPkoLIY4yZFwg264Y5lu2x6rb1Js42Gh6Yqm2f6L2AJcnkzinQ==
+  dependencies:
+    uc.micro "^1.0.1"
+
 localforage@^1.9.0:
   version "1.9.0"
   resolved "https://registry.yarnpkg.com/localforage/-/localforage-1.9.0.tgz#f3e4d32a8300b362b4634cc4e066d9d00d2f09d1"
@@ -2642,6 +2773,37 @@ map-stream@~0.1.0:
   resolved "https://registry.yarnpkg.com/map-stream/-/map-stream-0.1.0.tgz#e56aa94c4c8055a16404a0674b78f215f7c8e194"
   integrity sha1-5WqpTEyAVaFkBKBnS3jyFffI4ZQ=
 
+markdown-it-emoji@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/markdown-it-emoji/-/markdown-it-emoji-2.0.0.tgz#3164ad4c009efd946e98274f7562ad611089a231"
+  integrity sha512-39j7/9vP/CPCKbEI44oV8yoPJTpvfeReTn/COgRhSpNrjWF3PfP/JUxxB0hxV6ynOY8KH8Y8aX9NMDdo6z+6YQ==
+
+markdown-it-sub@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/markdown-it-sub/-/markdown-it-sub-1.0.0.tgz#375fd6026eae7ddcb012497f6411195ea1e3afe8"
+  integrity sha1-N1/WAm6ufdywEkl/ZBEZXqHjr+g=
+
+markdown-it-sup@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/markdown-it-sup/-/markdown-it-sup-1.0.0.tgz#cb9c9ff91a5255ac08f3fd3d63286e15df0a1fc3"
+  integrity sha1-y5yf+RpSVawI8/09YyhuFd8KH8M=
+
+markdown-it@^12.0.6:
+  version "12.0.6"
+  resolved "https://registry.yarnpkg.com/markdown-it/-/markdown-it-12.0.6.tgz#adcc8e5fe020af292ccbdf161fe84f1961516138"
+  integrity sha512-qv3sVLl4lMT96LLtR7xeRJX11OUFjsaD5oVat2/SNBIb21bJXwal2+SklcRbTwGwqWpWH/HRtYavOoJE+seL8w==
+  dependencies:
+    argparse "^2.0.1"
+    entities "~2.1.0"
+    linkify-it "^3.0.1"
+    mdurl "^1.0.1"
+    uc.micro "^1.0.5"
+
+mdurl@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/mdurl/-/mdurl-1.0.1.tgz#fe85b2ec75a59037f2adfec100fd6c601761152e"
+  integrity sha1-/oWy7HWlkDfyrf7BAP1sYBdhFS4=
+
 merge-stream@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60"
@@ -2857,6 +3019,11 @@ picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.2, picomatch@^2.2.3:
   resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.0.tgz#f1f061de8f6a4bf022892e2d128234fb98302972"
   integrity sha512-lY1Q/PiJGC2zOv/z391WOTD+Z02bCgsFfvxoXXf6h7kv9o+WmsmzYqrAwY63sNgOxE4xEdq0WyUnXfKeBrSvYw==
 
+popper.js@^1.11.1:
+  version "1.16.1"
+  resolved "https://registry.yarnpkg.com/popper.js/-/popper.js-1.16.1.tgz#2a223cb3dc7b6213d740e40372be40de43e65b1b"
+  integrity sha512-Wb4p1J4zyFTbM+u6WuO4XstYx4Ky9Cewe4DWrel7B0w6VVICvPwdOpotjzcf6eD8TsckVnIMNONQyPIUFOUbCQ==
+
 postcss-value-parser@^4.0.2:
   version "4.1.0"
   resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.1.0.tgz#443f6a20ced6481a2bda4fa8532a6e55d789a2cb"
@@ -2904,6 +3071,13 @@ pretty-bytes@^5.3.0, pretty-bytes@^5.6.0:
   resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-5.6.0.tgz#356256f643804773c82f64723fe78c92c62beaeb"
   integrity sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==
 
+prismjs@^1.23.0:
+  version "1.23.0"
+  resolved "https://registry.yarnpkg.com/prismjs/-/prismjs-1.23.0.tgz#d3b3967f7d72440690497652a9d40ff046067f33"
+  integrity sha512-c29LVsqOaLbBHuIbsTxaKENh1N2EQBOHaWv7gkHN4dgRbxSREqDnDbtFJYdpPauS4YCplMSNCABQ6Eeor69bAA==
+  optionalDependencies:
+    clipboard "^2.0.0"
+
 progress@^2.0.0:
   version "2.0.3"
   resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8"
@@ -3025,6 +3199,13 @@ react-side-effect@^2.1.0:
   resolved "https://registry.yarnpkg.com/react-side-effect/-/react-side-effect-2.1.1.tgz#66c5701c3e7560ab4822a4ee2742dee215d72eb3"
   integrity sha512-2FoTQzRNTncBVtnzxFOk2mCpcfxQpenBMbk5kSVBg5UcPqV9fRbgY2zhb7GTWWOlpFmAxhClBDlIq8Rsubz1yQ==
 
+react-tippy@^1.4.0:
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/react-tippy/-/react-tippy-1.4.0.tgz#e8a8b4085ec985e5c94fe128918b733b588a1465"
+  integrity sha512-r/hM5XK9Ztr2ZY7IWKuRmISTlUPS/R6ddz6PO2EuxCgW+4JBcGZRPU06XcVPRDCOIiio8ryBQFrXMhFMhsuaHA==
+  dependencies:
+    popper.js "^1.11.1"
+
 readdirp@~3.6.0:
   version "3.6.0"
   resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7"
@@ -3199,6 +3380,11 @@ sass@^1.35.1:
   dependencies:
     chokidar ">=3.0.0 <4.0.0"
 
+select@^1.1.2:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/select/-/select-1.1.2.tgz#0e7350acdec80b1108528786ec1d4418d11b396d"
+  integrity sha1-DnNQrN7ICxEIUoeG7B1EGNEbOW0=
+
 semver@7.0.0:
   version "7.0.0"
   resolved "https://registry.yarnpkg.com/semver/-/semver-7.0.0.tgz#5f3ca35761e47e05b206c6daff2cf814f0316b8e"
@@ -3478,6 +3664,11 @@ 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=
 
+tiny-emitter@^2.0.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/tiny-emitter/-/tiny-emitter-2.1.0.tgz#1d1a56edfc51c43e863cbb5382a72330e3555423"
+  integrity sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==
+
 tiny-invariant@^1.0.2:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.1.0.tgz#634c5f8efdc27714b7f386c35e6760991d230875"
@@ -3530,6 +3721,21 @@ tsutils@^3.17.1, tsutils@^3.21.0:
   dependencies:
     tslib "^1.8.1"
 
+twemoji-parser@13.1.0:
+  version "13.1.0"
+  resolved "https://registry.yarnpkg.com/twemoji-parser/-/twemoji-parser-13.1.0.tgz#65e7e449c59258791b22ac0b37077349127e3ea4"
+  integrity sha512-AQOzLJpYlpWMy8n+0ATyKKZzWlZBJN+G0C+5lhX7Ftc2PeEVdUU/7ns2Pn2vVje26AIZ/OHwFoUbdv6YYD/wGg==
+
+twemoji@^13.1.0:
+  version "13.1.0"
+  resolved "https://registry.yarnpkg.com/twemoji/-/twemoji-13.1.0.tgz#65bb71e966dae56f0d42c30176f04cbdae109913"
+  integrity sha512-e3fZRl2S9UQQdBFLYXtTBT6o4vidJMnpWUAhJA+yLGR+kaUTZAt3PixC0cGvvxWSuq2MSz/o0rJraOXrWw/4Ew==
+  dependencies:
+    fs-extra "^8.0.1"
+    jsonfile "^5.0.0"
+    twemoji-parser "13.1.0"
+    universalify "^0.1.2"
+
 type-check@^0.4.0, type-check@~0.4.0:
   version "0.4.0"
   resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1"
@@ -3557,6 +3763,11 @@ ua-parser-js@^0.7.24:
   resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.28.tgz#8ba04e653f35ce210239c64661685bf9121dec31"
   integrity sha512-6Gurc1n//gjp9eQNXjD9O3M/sMwVtN5S8Lv9bvOYBfKfDNiIIhqiyi01vMBO45u4zkDE420w/e0se7Vs+sIg+g==
 
+uc.micro@^1.0.1, uc.micro@^1.0.5:
+  version "1.0.6"
+  resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-1.0.6.tgz#9c411a802a409a91fc6cf74081baba34b24499ac"
+  integrity sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==
+
 ulid@^2.3.0:
   version "2.3.0"
   resolved "https://registry.yarnpkg.com/ulid/-/ulid-2.3.0.tgz#93063522771a9774121a84d126ecd3eb9804071f"
@@ -3602,6 +3813,11 @@ unique-string@^2.0.0:
   dependencies:
     crypto-random-string "^2.0.0"
 
+universalify@^0.1.0, universalify@^0.1.2:
+  version "0.1.2"
+  resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66"
+  integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==
+
 universalify@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.0.tgz#75a4984efedc4b08975c5aeb73f530d02df25717"
-- 
GitLab