From 31d8950ea1786914e952d0ade6bfa0e7db2c792d Mon Sep 17 00:00:00 2001
From: Paul <paulmakles@gmail.com>
Date: Sat, 19 Jun 2021 22:37:12 +0100
Subject: [PATCH] Port settings.

---
 package.json                                  |   2 +
 .../navigation/items/ButtonItem.tsx           |  38 +-
 .../navigation/left/HomeSidebar.tsx           |  10 +-
 .../navigation/left/ServerListSidebar.tsx     |   7 +-
 .../navigation/left/ServerSidebar.tsx         |  31 +-
 src/components/ui/Checkbox.tsx                |  15 +-
 src/components/ui/Modal.tsx                   |  10 +-
 src/components/ui/TextArea.module.scss        |  31 ++
 src/components/ui/TextArea.tsx                | 146 +++++++
 src/context/Theme.tsx                         |  19 +-
 .../intermediate/modals/Onboarding.tsx        |   3 +-
 .../popovers/UserProfile.module.scss          |   1 +
 src/context/revoltjs/FileUploads.module.scss  |  82 ++++
 src/context/revoltjs/FileUploads.tsx          | 148 +++++++
 src/context/revoltjs/RequiresOnline.tsx       |  44 +++
 src/lib/conversion.ts                         |   9 +
 src/lib/debounce.ts                           |  15 +
 src/lib/fileSize.ts                           |   9 +
 src/main.tsx                                  |   2 +-
 src/pages/{App.tsx => RevoltApp.tsx}          |  61 +--
 src/{ => pages}/app.tsx                       |  10 +-
 src/pages/home/Home.tsx                       |   3 +-
 src/pages/login/forms/FormLogin.tsx           |   4 +-
 src/pages/settings/ChannelSettings.tsx        |  46 +++
 src/pages/settings/GenericSettings.tsx        | 120 ++++++
 src/pages/settings/ServerSettings.tsx         |  65 +++
 src/pages/settings/Settings.module.scss       | 209 ++++++++++
 src/pages/settings/Settings.tsx               | 153 +++++++
 src/pages/settings/SettingsTextArea.tsx       |   6 +
 src/pages/settings/channel/Overview.tsx       |  89 +++++
 src/pages/settings/channel/Panes.module.scss  |  14 +
 src/pages/settings/panes/Account.tsx          |  95 +++++
 src/pages/settings/panes/Appearance.tsx       | 286 ++++++++++++++
 src/pages/settings/panes/Experiments.tsx      |  53 +++
 src/pages/settings/panes/Feedback.tsx         |  95 +++++
 src/pages/settings/panes/Languages.tsx        |  68 ++++
 src/pages/settings/panes/Notifications.tsx    | 142 +++++++
 src/pages/settings/panes/Panes.module.scss    | 374 ++++++++++++++++++
 src/pages/settings/panes/Profile.tsx          | 126 ++++++
 src/pages/settings/panes/Sessions.tsx         | 186 +++++++++
 src/pages/settings/panes/Sync.tsx             |  53 +++
 src/pages/settings/server/Bans.tsx            |  23 ++
 src/pages/settings/server/Invites.tsx         |  70 ++++
 src/pages/settings/server/Members.tsx         |  24 ++
 src/pages/settings/server/Overview.tsx        |  98 +++++
 src/pages/settings/server/Panes.module.scss   |  48 +++
 src/version.ts                                |   2 +-
 yarn.lock                                     |  17 +-
 48 files changed, 3056 insertions(+), 106 deletions(-)
 create mode 100644 src/components/ui/TextArea.module.scss
 create mode 100644 src/components/ui/TextArea.tsx
 create mode 100644 src/context/revoltjs/FileUploads.module.scss
 create mode 100644 src/context/revoltjs/FileUploads.tsx
 create mode 100644 src/context/revoltjs/RequiresOnline.tsx
 create mode 100644 src/lib/conversion.ts
 create mode 100644 src/lib/debounce.ts
 create mode 100644 src/lib/fileSize.ts
 rename src/pages/{App.tsx => RevoltApp.tsx} (50%)
 rename src/{ => pages}/app.tsx (74%)
 create mode 100644 src/pages/settings/ChannelSettings.tsx
 create mode 100644 src/pages/settings/GenericSettings.tsx
 create mode 100644 src/pages/settings/ServerSettings.tsx
 create mode 100644 src/pages/settings/Settings.module.scss
 create mode 100644 src/pages/settings/Settings.tsx
 create mode 100644 src/pages/settings/SettingsTextArea.tsx
 create mode 100644 src/pages/settings/channel/Overview.tsx
 create mode 100644 src/pages/settings/channel/Panes.module.scss
 create mode 100644 src/pages/settings/panes/Account.tsx
 create mode 100644 src/pages/settings/panes/Appearance.tsx
 create mode 100644 src/pages/settings/panes/Experiments.tsx
 create mode 100644 src/pages/settings/panes/Feedback.tsx
 create mode 100644 src/pages/settings/panes/Languages.tsx
 create mode 100644 src/pages/settings/panes/Notifications.tsx
 create mode 100644 src/pages/settings/panes/Panes.module.scss
 create mode 100644 src/pages/settings/panes/Profile.tsx
 create mode 100644 src/pages/settings/panes/Sessions.tsx
 create mode 100644 src/pages/settings/panes/Sync.tsx
 create mode 100644 src/pages/settings/server/Bans.tsx
 create mode 100644 src/pages/settings/server/Invites.tsx
 create mode 100644 src/pages/settings/server/Members.tsx
 create mode 100644 src/pages/settings/server/Overview.tsx
 create mode 100644 src/pages/settings/server/Panes.module.scss

diff --git a/package.json b/package.json
index 8862bde..c639394 100644
--- a/package.json
+++ b/package.json
@@ -31,6 +31,7 @@
     "@preact/preset-vite": "^2.0.0",
     "@styled-icons/bootstrap": "^10.34.0",
     "@styled-icons/feather": "^10.34.0",
+    "@styled-icons/simple-icons": "^10.33.0",
     "@traptitech/markdown-it-katex": "^3.4.3",
     "@traptitech/markdown-it-spoiler": "^1.1.6",
     "@types/markdown-it": "^12.0.2",
@@ -70,6 +71,7 @@
     "revolt.js": "4.3.0",
     "rimraf": "^3.0.2",
     "sass": "^1.35.1",
+    "shade-blend-color": "^1.0.0",
     "styled-components": "^5.3.0",
     "twemoji": "^13.1.0",
     "typescript": "^4.3.2",
diff --git a/src/components/navigation/items/ButtonItem.tsx b/src/components/navigation/items/ButtonItem.tsx
index 0acfbf0..bcbd59a 100644
--- a/src/components/navigation/items/ButtonItem.tsx
+++ b/src/components/navigation/items/ButtonItem.tsx
@@ -1,13 +1,17 @@
 import classNames from 'classnames';
 import styles from "./Item.module.scss";
+import Tooltip from '../../common/Tooltip';
+import IconButton from '../../ui/IconButton';
 import UserIcon from '../../common/UserIcon';
 import { Localizer, Text } from "preact-i18n";
 import { X, Zap } from "@styled-icons/feather";
 import UserStatus from '../../common/UserStatus';
 import { Children } from "../../../types/Preact";
 import ChannelIcon from '../../common/ChannelIcon';
+import { attachContextMenu } from 'preact-context-menu';
 import { Channels, Users } from "revolt.js/dist/api/objects";
 import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
+import { useIntermediate } from '../../../context/intermediate/Intermediate';
 
 interface CommonProps {
     active?: boolean
@@ -22,7 +26,7 @@ type UserProps = CommonProps & {
 }
 
 export function UserButton({ active, alert, alertCount, user, context, channel }: UserProps) {
-    // const { openScreen } = useContext(IntermediateContext);
+    const { openScreen } = useIntermediate();
 
     return (
         <div
@@ -30,13 +34,12 @@ export function UserButton({ active, alert, alertCount, user, context, channel }
             data-active={active}
             data-alert={typeof alert === 'string'}
             data-online={typeof channel !== 'undefined' || (user.online && user.status?.presence !== Users.Presence.Invisible)}
-            /*onContextMenu={attachContextMenu('Menu', {
+            onContextMenu={attachContextMenu('Menu', {
                 user: user._id,
                 channel: channel?._id,
                 unread: alert,
                 contextualChannel: context?._id
-            })}*/
-        >
+            })}>
             <div className={styles.avatar}>
                 <UserIcon target={user} size={32} status />
             </div>
@@ -56,24 +59,22 @@ export function UserButton({ active, alert, alertCount, user, context, channel }
                 { context?.channel_type === "Group" &&
                     context.owner === user._id && (
                         <Localizer>
-                            {/*<Tooltip
+                            <Tooltip
                                 content={
                                     <Text id="app.main.groups.owner" />
                                 }
-                            >*/}
+                            >
                                 <Zap size={20} />
-                            {/*</Tooltip>*/}
+                            </Tooltip>
                         </Localizer>
                 )}
                 {alert && <div className={styles.alert} data-style={alert}>{ alertCount }</div>}
                 { !isTouchscreenDevice && channel &&
-                    /*<IconButton
+                    <IconButton
                         className={styles.icon}
-                        style="default"
-                        onClick={() => openScreen({ id: 'special_prompt', type: 'close_dm', target: channel })}
-                    >*/
+                        onClick={() => openScreen({ id: 'special_prompt', type: 'close_dm', target: channel })}>
                         <X size={24} />
-                    /*</IconButton>*/
+                    </IconButton>
                 }
             </div>
         </div>
@@ -93,15 +94,14 @@ export function ChannelButton({ active, alert, alertCount, channel, user, compac
         return <UserButton {...{ active, alert, channel, user }} />
     }
 
-    //const { openScreen } = useContext(IntermediateContext);
+    const { openScreen } = useIntermediate();
 
     return (
         <div
             data-active={active}
             data-alert={typeof alert === 'string'}
             className={classNames(styles.item, { [styles.compact]: compact })}
-            //onContextMenu={attachContextMenu('Menu', { channel: channel._id })}>
-            >
+            onContextMenu={attachContextMenu('Menu', { channel: channel._id })}>
             <div className={styles.avatar}>
                 <ChannelIcon target={channel} size={compact ? 24 : 32} />
             </div>
@@ -124,13 +124,11 @@ export function ChannelButton({ active, alert, alertCount, channel, user, compac
             <div className={styles.button}>
                 {alert && <div className={styles.alert} data-style={alert}>{ alertCount }</div>}
                 {!isTouchscreenDevice && channel.channel_type === "Group" && (
-                    /*<IconButton
+                    <IconButton
                         className={styles.icon}
-                        style="default"
-                        onClick={() => openScreen({ id: 'special_prompt', type: 'leave_group', target: channel })}
-                    >*/
+                        onClick={() => openScreen({ id: 'special_prompt', type: 'leave_group', target: channel })}>
                         <X size={24} />
-                    /*</IconButton>*/
+                    </IconButton>
                 )}
             </div>
         </div>
diff --git a/src/components/navigation/left/HomeSidebar.tsx b/src/components/navigation/left/HomeSidebar.tsx
index bf1b99c..d014de1 100644
--- a/src/components/navigation/left/HomeSidebar.tsx
+++ b/src/components/navigation/left/HomeSidebar.tsx
@@ -1,24 +1,20 @@
 import { Localizer, Text } from "preact-i18n";
-import { useContext, useLayoutEffect } from "preact/hooks";
-import { Home, Users, Tool, Settings, Save } from "@styled-icons/feather";
+import { useContext } from "preact/hooks";
+import { Home, Users, Tool, Save } from "@styled-icons/feather";
 
-import { Link, Redirect, useHistory, useLocation, useParams } from "react-router-dom";
+import { Link, Redirect, useLocation, useParams } from "react-router-dom";
 import { WithDispatcher } from "../../../redux/reducers";
 import { Unreads } from "../../../redux/reducers/unreads";
 import { connectState } from "../../../redux/connector";
 import { AppContext } from "../../../context/revoltjs/RevoltClient";
 import { useChannels, useForceUpdate, useUsers } from "../../../context/revoltjs/hooks";
-import { User } from "revolt.js";
 import { Users as UsersNS } from 'revolt.js/dist/api/objects';
 import { mapChannelWithUnread, useUnreads } from "./common";
 import { Channels } from "revolt.js/dist/api/objects";
-import UserIcon from '../../common/UserIcon';
 import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
 import ConnectionStatus from '../items/ConnectionStatus';
-import UserStatus from '../../common/UserStatus';
 import ButtonItem, { ChannelButton } from '../items/ButtonItem';
 import styled from "styled-components";
-import Header from '../../ui/Header';
 import UserHeader from "../../common/UserHeader";
 import Category from '../../ui/Category';
 import PaintCounter from "../../../lib/PaintCounter";
diff --git a/src/components/navigation/left/ServerListSidebar.tsx b/src/components/navigation/left/ServerListSidebar.tsx
index 7ff3e74..f64e4fe 100644
--- a/src/components/navigation/left/ServerListSidebar.tsx
+++ b/src/components/navigation/left/ServerListSidebar.tsx
@@ -1,5 +1,3 @@
-import { useContext } from "preact/hooks";
-import { PlusCircle } from "@styled-icons/feather";
 import { Channel, Servers } from "revolt.js/dist/api/objects";
 import { Link, useLocation, useParams } from "react-router-dom";
 import { useChannels, useForceUpdate, useServers } from "../../../context/revoltjs/hooks";
@@ -11,6 +9,7 @@ import { Children } from "../../../types/Preact";
 import LineDivider from "../../ui/LineDivider";
 import ServerIcon from "../../common/ServerIcon";
 import PaintCounter from "../../../lib/PaintCounter";
+import { attachContextMenu } from 'preact-context-menu';
 
 function Icon({ children, unread, size }: { children: Children, unread?: 'mention' | 'unread', size: number }) {
     return (
@@ -157,8 +156,7 @@ export function ServerListSidebar({ unreads }: Props) {
                         <Link to={`/server/${entry!._id}`}>
                             <ServerEntry
                                 active={entry!._id === server?._id}
-                                //onContextMenu={attachContextMenu('Menu', { server: entry!._id })}>
-                                >
+                                onContextMenu={attachContextMenu('Menu', { server: entry!._id })}>
                                 <Icon size={36} unread={entry.unread}>
                                     <ServerIcon size={32} target={entry} />
                                 </Icon>
@@ -169,6 +167,7 @@ export function ServerListSidebar({ unreads }: Props) {
                 <PaintCounter small />
             </ServerList>
         </ServersBase>
+        // ! FIXME: add overlay back
         /*<div className={styles.servers}>
             <div className={styles.list}>
                 <Link to={`/`}>
diff --git a/src/components/navigation/left/ServerSidebar.tsx b/src/components/navigation/left/ServerSidebar.tsx
index e500855..f4cae5f 100644
--- a/src/components/navigation/left/ServerSidebar.tsx
+++ b/src/components/navigation/left/ServerSidebar.tsx
@@ -12,11 +12,32 @@ import Header from '../../ui/Header';
 import ConnectionStatus from '../items/ConnectionStatus';
 import { connectState } from "../../../redux/connector";
 import PaintCounter from "../../../lib/PaintCounter";
+import styled from "styled-components";
+import { attachContextMenu } from 'preact-context-menu';
 
 interface Props {
     unreads: Unreads;
 }
 
+const ServerBase = styled.div`
+    height: 100%;
+    width: 240px;
+    display: flex;
+    flex-shrink: 0;
+    flex-direction: column;
+    background: var(--secondary-background);
+`;
+
+const ServerList = styled.div`
+    padding: 6px;
+    flex-grow: 1;
+    overflow-y: scroll;
+
+    > svg {
+        width: 100%;
+    }
+`;
+
 function ServerSidebar(props: Props & WithDispatcher) {
     const { server: server_id, channel: channel_id } = useParams<{ server?: string, channel?: string }>();
     const ctx = useForceUpdate();
@@ -33,7 +54,7 @@ function ServerSidebar(props: Props & WithDispatcher) {
     if (channel) useUnreads({ ...props, channel }, ctx);
 
     return (
-        <div>
+        <ServerBase>
             <Header placement="secondary" background style={{ background: `url('${ctx.client.servers.getBannerURL(server._id, { width: 480 }, true)}')` }}>
                 <div>
                     { server.name }
@@ -45,9 +66,7 @@ function ServerSidebar(props: Props & WithDispatcher) {
                 </div> }
             </Header>
             <ConnectionStatus />
-            <div
-                //onContextMenu={attachContextMenu('Menu', { server_list: server._id })}>
-                >
+            <ServerList onContextMenu={attachContextMenu('Menu', { server_list: server._id })}>
                 {channels.map(entry => {
                     return (
                         <Link to={`/server/${server._id}/channel/${entry._id}`}>
@@ -61,9 +80,9 @@ function ServerSidebar(props: Props & WithDispatcher) {
                         </Link>
                     );
                 })}
-            </div>
+            </ServerList>
             <PaintCounter small />
-        </div>
+        </ServerBase>
     )
 };
 
diff --git a/src/components/ui/Checkbox.tsx b/src/components/ui/Checkbox.tsx
index fb9adf3..e7cd62b 100644
--- a/src/components/ui/Checkbox.tsx
+++ b/src/components/ui/Checkbox.tsx
@@ -23,6 +23,14 @@ const CheckboxBase = styled.label`
     input {
         display: none;
     }
+
+    &:hover {
+        background: var(--secondary-background);
+
+        .check {
+            background: var(--background);
+        }
+    }
 `;
 
 const CheckboxContent = styled.span`
@@ -46,6 +54,7 @@ const Checkmark = styled.div<{ checked: boolean }>`
     display: grid;
     border-radius: 4px;
     place-items: center;
+    transition: 0.2s ease all;
     background: var(--secondary-background);
 
     svg {
@@ -56,7 +65,7 @@ const Checkmark = styled.div<{ checked: boolean }>`
     ${(props) =>
         props.checked &&
         css`
-            background: var(--accent);
+            background: var(--accent) !important;
         `}
 `;
 
@@ -71,7 +80,7 @@ export interface CheckboxProps {
 
 export default function Checkbox(props: CheckboxProps) {
     return (
-        <CheckboxBase disabled={props.disabled}>
+        <CheckboxBase disabled={props.disabled} className={props.className}>
             <CheckboxContent>
                 <span>{props.children}</span>
                 {props.description && (
@@ -87,7 +96,7 @@ export default function Checkbox(props: CheckboxProps) {
                     !props.disabled && props.onChange(!props.checked)
                 }
             />
-            <Checkmark checked={props.checked}>
+            <Checkmark checked={props.checked} className="check">
                 <Check size={20} />
             </Checkmark>
         </CheckboxBase>
diff --git a/src/components/ui/Modal.tsx b/src/components/ui/Modal.tsx
index 7d80cb8..26197b2 100644
--- a/src/components/ui/Modal.tsx
+++ b/src/components/ui/Modal.tsx
@@ -47,7 +47,7 @@ const ModalContainer = styled.div`
     animation-timing-function: cubic-bezier(.3,.3,.18,1.1);
 `;
 
-const ModalContent = styled.div<{ [key in 'attachment' | 'noBackground' | 'border']?: boolean }>`
+const ModalContent = styled.div<{ [key in 'attachment' | 'noBackground' | 'border' | 'padding']?: boolean }>`
     border-radius: 8px;
     text-overflow: ellipsis;
 
@@ -56,10 +56,13 @@ const ModalContent = styled.div<{ [key in 'attachment' | 'noBackground' | 'borde
     }
 
     ${ props => !props.noBackground && css`
-        padding: 1.5em;
         background: var(--secondary-header);
     ` }
 
+    ${ props => props.padding && css`
+        padding: 1.5em;
+    ` }
+
     ${ props => props.attachment && css`
         border-radius: 8px 8px 0 0;
     ` }
@@ -110,7 +113,8 @@ export default function Modal(props: Props) {
         <ModalContent
             attachment={!!props.actions}
             noBackground={props.noBackground}
-            border={props.border}>
+            border={props.border}
+            padding={!props.dontModal}>
             {props.title && <h3>{props.title}</h3>}
             {props.children}
         </ModalContent>
diff --git a/src/components/ui/TextArea.module.scss b/src/components/ui/TextArea.module.scss
new file mode 100644
index 0000000..7e009fd
--- /dev/null
+++ b/src/components/ui/TextArea.module.scss
@@ -0,0 +1,31 @@
+.container {
+    font-size: 0.875rem;
+    line-height: 20px;
+    position: relative;
+}
+
+.textarea {
+    width: 100%;
+    white-space: pre-wrap;
+
+    textarea::placeholder {
+        overflow: hidden;
+        white-space: nowrap;
+        text-overflow: ellipsis;
+    }
+}
+
+.hide {
+    width: 100%;
+    overflow: hidden;
+    position: relative;
+}
+
+.ghost {
+    width: 100%;
+    white-space: pre-wrap;
+    
+    top: 0;
+    position: absolute;
+    visibility: hidden;
+}
diff --git a/src/components/ui/TextArea.tsx b/src/components/ui/TextArea.tsx
new file mode 100644
index 0000000..d88dcb7
--- /dev/null
+++ b/src/components/ui/TextArea.tsx
@@ -0,0 +1,146 @@
+// ! FIXME: temporarily here until re-written
+// ! DO NOT IMRPOVE, JUST RE-WRITE
+
+import classNames from "classnames";
+import { memo } from "preact/compat";
+import styles from "./TextArea.module.scss";
+import { useState, useEffect, useRef, useLayoutEffect } from "preact/hooks";
+
+export interface TextAreaProps {
+    id?: string;
+    value: string;
+    maxRows?: number;
+    padding?: number;
+    minHeight?: number;
+    disabled?: boolean;
+    maxLength?: number;
+    className?: string;
+    autoFocus?: boolean;
+    forceFocus?: boolean;
+    placeholder?: string;
+    onKeyDown?: (ev: KeyboardEvent) => void;
+    onKeyUp?: (ev: KeyboardEvent) => void;
+    onChange: (
+        value: string,
+        ev: JSX.TargetedEvent<HTMLTextAreaElement, Event>
+    ) => void;
+    onFocus?: (current: HTMLTextAreaElement) => void;
+    onBlur?: () => void;
+}
+
+const lineHeight = 20;
+
+export const TextArea = memo((props: TextAreaProps) => {
+    const padding = props.padding ? props.padding * 2 : 0;
+
+    const [height, setHeightState] = useState(
+        props.minHeight ?? lineHeight + padding
+    );
+    const ghost = useRef<HTMLDivElement>();
+    const ref = useRef<HTMLTextAreaElement>();
+
+    function setHeight(h: number = lineHeight) {
+        let newHeight = Math.min(
+            Math.max(
+                lineHeight,
+                props.maxRows ? Math.min(h, props.maxRows * lineHeight) : h
+            ),
+            props.minHeight ?? Infinity
+        );
+
+        if (props.padding) newHeight += padding;
+        if (height !== newHeight) {
+            setHeightState(newHeight);
+        }
+    }
+
+    function onChange(ev: JSX.TargetedEvent<HTMLTextAreaElement, Event>) {
+        props.onChange(ev.currentTarget.value, ev);
+    }
+
+    useLayoutEffect(() => {
+        setHeight(ghost.current.clientHeight);
+    }, [ghost, props.value]);
+
+    useEffect(() => {
+        if (props.autoFocus) ref.current.focus();
+    }, [props.value]);
+
+    const inputSelected = () =>
+        ["TEXTAREA", "INPUT"].includes(document.activeElement?.nodeName ?? "");
+
+    useEffect(() => {
+        if (props.forceFocus) {
+            ref.current.focus();
+        }
+
+        if (props.autoFocus && !inputSelected()) {
+            ref.current.focus();
+        }
+
+        // ? if you are wondering what this is
+        // ? it is a quick and dirty hack to fix
+        // ? value not setting correctly
+        // ? I have no clue what's going on
+        ref.current.value = props.value;
+
+        if (!props.autoFocus) return;
+        function keyDown(e: KeyboardEvent) {
+            if ((e.ctrlKey && e.key !== "v") || e.altKey || e.metaKey) return;
+            if (e.key.length !== 1) return;
+            if (ref && !inputSelected()) {
+                ref.current.focus();
+            }
+        }
+
+        document.body.addEventListener("keydown", keyDown);
+        return () => document.body.removeEventListener("keydown", keyDown);
+    }, [ref]);
+
+    useEffect(() => {
+        function focus(textarea_id: string) {
+            if (props.id === textarea_id) {
+                ref.current.focus();
+            }
+        }
+
+        // InternalEventEmitter.addListener("focus_textarea", focus);
+        // return () =>
+            // InternalEventEmitter.removeListener("focus_textarea", focus);
+    }, [ref]);
+
+    return (
+        <div className={classNames(styles.container, props.className)}>
+            <textarea
+                id={props.id}
+                name={props.id}
+                style={{ height }}
+                value={props.value}
+                onChange={onChange}
+                disabled={props.disabled}
+                maxLength={props.maxLength}
+                className={styles.textarea}
+                onKeyDown={props.onKeyDown}
+                placeholder={props.placeholder}
+                onContextMenu={e => e.stopPropagation()}
+                onKeyUp={ev => {
+                    setHeight(ghost.current.clientHeight);
+                    props.onKeyUp && props.onKeyUp(ev);
+                }}
+                ref={ref}
+                onFocus={() => props.onFocus && props.onFocus(ref.current)}
+                onBlur={props.onBlur}
+            />
+            <div className={styles.hide}>
+                <div className={styles.ghost} ref={ghost}>
+                    {props.value
+                        ? props.value
+                              .split("\n")
+                              .map(x => `‎${x}`)
+                              .join("\n")
+                        : undefined ?? "‎\n"}
+                </div>
+            </div>
+        </div>
+    );
+});
diff --git a/src/context/Theme.tsx b/src/context/Theme.tsx
index 42365cb..4c8a8dc 100644
--- a/src/context/Theme.tsx
+++ b/src/context/Theme.tsx
@@ -1,5 +1,6 @@
 import { isTouchscreenDevice } from "../lib/isTouchscreenDevice";
 import { createGlobalStyle } from "styled-components";
+import { connectState } from "../redux/connector";
 import { Children } from "../types/Preact";
 import { createContext } from "preact";
 import { Helmet } from "react-helmet";
@@ -116,10 +117,15 @@ export const ThemeContext = createContext<Theme>({} as any);
 
 interface Props {
     children: Children;
+    options?: ThemeOptions;
 }
 
-export default function Theme(props: Props) {
-    const theme = PRESETS.dark;
+function Theme(props: Props) {
+    const theme: Theme = {
+        ...PRESETS["dark"],
+        ...(PRESETS as any)[props.options?.preset as any],
+        ...props.options?.custom
+    };
 
     return (
         <ThemeContext.Provider value={theme}>
@@ -134,7 +140,16 @@ export default function Theme(props: Props) {
                 />
             </Helmet>
             <GlobalTheme theme={theme} />
+            {theme.css && (
+                <style dangerouslySetInnerHTML={{ __html: theme.css }} />
+            )}
             {props.children}
         </ThemeContext.Provider>
     );
 }
+
+export default connectState(Theme, state => {
+    return {
+        options: state.settings.theme
+    };
+});
diff --git a/src/context/intermediate/modals/Onboarding.tsx b/src/context/intermediate/modals/Onboarding.tsx
index b1741e2..17fc0fd 100644
--- a/src/context/intermediate/modals/Onboarding.tsx
+++ b/src/context/intermediate/modals/Onboarding.tsx
@@ -7,8 +7,6 @@ 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>;
@@ -34,6 +32,7 @@ export function OnboardingModal({ onClose, callback }: Props) {
             <div className={styles.header}>
                 <h1>
                     <Text id="app.special.modals.onboarding.welcome" />
+                    <img src="/assets/wide.svg" />
                 </h1>
             </div>
             <div className={styles.form}>
diff --git a/src/context/intermediate/popovers/UserProfile.module.scss b/src/context/intermediate/popovers/UserProfile.module.scss
index 448595a..38094de 100644
--- a/src/context/intermediate/popovers/UserProfile.module.scss
+++ b/src/context/intermediate/popovers/UserProfile.module.scss
@@ -9,6 +9,7 @@
     background-size: cover;
     border-radius: 8px 8px 0 0;
     background-position: center;
+    background-color: var(--secondary-background);
 
     &[data-force="light"] {
         color: white;
diff --git a/src/context/revoltjs/FileUploads.module.scss b/src/context/revoltjs/FileUploads.module.scss
new file mode 100644
index 0000000..2d5a3b3
--- /dev/null
+++ b/src/context/revoltjs/FileUploads.module.scss
@@ -0,0 +1,82 @@
+.uploader {
+    display: flex;
+    flex-direction: column;
+
+    &.icon {
+        .image {
+            border-radius: 50%;
+        }
+    }
+
+    &.banner {
+        .image {
+            border-radius: 4px;
+        }
+
+        .modify {
+            gap: 4px;
+            flex-direction: row;
+        }
+    }
+
+    .image {
+        cursor: pointer;
+        overflow: hidden;
+        background-size: cover;
+        background-position: center;
+        background-color: var(--secondary-background);
+
+        .uploading {
+            width: 100%;
+            height: 100%;
+            display: grid;
+            place-items: center;
+            background: rgba(0, 0, 0, 0.5);
+        }
+
+        &:hover .edit {
+            opacity: 1;
+        }
+
+        &:active .edit {
+            filter: brightness(0.8);
+        }
+
+        .edit {
+            opacity: 0;
+            width: 100%;
+            height: 100%;
+            display: grid;
+            color: white;
+            place-items: center;
+            background: rgba(95, 95, 95, 0.5);
+            transition: .2s ease-in-out opacity;
+        }
+    }
+
+    .modify {
+        display: flex;
+        margin-top: 5px;
+        font-size: 12px;
+        align-items: center;
+        flex-direction: column;
+        justify-content: center;
+
+        :first-child {
+            cursor: pointer;
+        }
+
+        .small {
+            display: flex;
+            font-size: 10px;
+            flex-direction: column;
+            color: var(--tertiary-foreground);
+        }
+    }
+
+    &[data-uploading="true"] {
+        .image, .modify:first-child {
+            cursor: not-allowed !important;
+        }
+    }
+}
diff --git a/src/context/revoltjs/FileUploads.tsx b/src/context/revoltjs/FileUploads.tsx
new file mode 100644
index 0000000..61df463
--- /dev/null
+++ b/src/context/revoltjs/FileUploads.tsx
@@ -0,0 +1,148 @@
+// ! FIXME: also TEMP CODE
+// ! RE-WRITE WITH STYLED-COMPONENTS
+
+import { Text } from "preact-i18n";
+import { takeError } from "./util";
+import classNames from "classnames";
+import styles from './FileUploads.module.scss';
+import Axios, { AxiosRequestConfig } from "axios";
+import { useContext, useState } from "preact/hooks";
+import { Edit, Plus, X } from "@styled-icons/feather";
+import Preloader from "../../components/ui/Preloader";
+import { determineFileSize } from "../../lib/fileSize";
+import IconButton from '../../components/ui/IconButton';
+import { useIntermediate } from "../intermediate/Intermediate";
+import { AppContext } from "./RevoltClient";
+
+type Props = {
+    maxFileSize: number
+    remove: () => Promise<void>
+    fileType: 'backgrounds' | 'icons' | 'avatars' | 'attachments' | 'banners'
+} & (
+    { behaviour: 'ask', onChange: (file: File) => void } |
+    { behaviour: 'upload', onUpload: (id: string) => Promise<void> }
+) & (
+    { style: 'icon' | 'banner', defaultPreview?: string, previewURL?: string, width?: number, height?: number } |
+    { style: 'attachment', attached: boolean, uploading: boolean, cancel: () => void, size?: number }
+)
+
+export async function uploadFile(autumnURL: string, tag: string, file: File, config?: AxiosRequestConfig) {
+    const formData = new FormData();
+    formData.append("file", file);
+    
+    const res = await Axios.post(autumnURL + "/" + tag, formData, {
+        headers: {
+            "Content-Type": "multipart/form-data"
+        },
+        ...config
+    });
+
+    return res.data.id;
+}
+
+export function FileUploader(props: Props) {
+    const { fileType, maxFileSize, remove } = props;
+    const { openScreen } = useIntermediate();
+    const client = useContext(AppContext);
+
+    const [ uploading, setUploading ] = useState(false);
+
+    function onClick() {
+        if (uploading) return;
+
+        const input = document.createElement("input");
+        input.type = "file";
+
+        input.onchange = async e => {
+            setUploading(true);
+
+            try {
+                const files = (e.target as any)?.files;
+                if (files && files[0]) {
+                    let file = files[0];
+
+                    if (file.size > maxFileSize) {
+                        return openScreen({ id: "error", error: "FileTooLarge" });
+                    }
+
+                    if (props.behaviour === 'ask') {
+                        await props.onChange(file);
+                    } else {
+                        await props.onUpload(await uploadFile(client.configuration!.features.autumn.url, fileType, file));
+                    }
+                }
+            } catch (err) {
+                return openScreen({ id: "error", error: takeError(err) });
+            } finally {
+                setUploading(false);
+            }
+        };
+
+        input.click();
+    }
+
+    function removeOrUpload() {
+        if (uploading) return;
+
+        if (props.style === 'attachment') {
+            if (props.attached) {
+                props.remove();
+            } else {
+                onClick();
+            }
+        } else {
+            if (props.previewURL) {
+                props.remove();
+            } else {
+                onClick();
+            }
+        }
+    }
+
+    if (props.style === 'icon' || props.style === 'banner') {
+        const { style, previewURL, defaultPreview, width, height } = props;
+        return (
+            <div className={classNames(styles.uploader,
+                { [styles.icon]: style === 'icon',
+                [styles.banner]: style === 'banner' })}
+                data-uploading={uploading}>
+                <div className={styles.image}
+                    style={{ backgroundImage:
+                        style === 'icon' ? `url('${previewURL ?? defaultPreview}')` :
+                        (previewURL ? `linear-gradient( rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0.5) ), url('${previewURL}')` : 'black'),
+                        width, height
+                    }}
+                    onClick={onClick}>
+                    { uploading ?
+                        <div className={styles.uploading}>
+                            <Preloader />
+                        </div> :
+                        <div className={styles.edit}>
+                            <Edit size={30} />
+                        </div> }
+                </div>
+                <div className={styles.modify}>
+                    <span onClick={removeOrUpload}>{ 
+                        uploading ? <Text id="app.main.channel.uploading_file" /> :
+                        props.previewURL ? <Text id="app.settings.actions.remove" /> :
+                        <Text id="app.settings.actions.upload" /> }</span>
+                    <span className={styles.small}><Text id="app.settings.actions.max_filesize" fields={{ filesize: determineFileSize(maxFileSize) }} /></span>
+                </div>
+            </div>
+        )
+    } else if (props.style === 'attachment') {
+        const { attached, uploading, cancel, size } = props;
+        return (
+            <IconButton
+                onClick={() => {
+                    if (uploading) return cancel();
+                    if (attached) return remove();
+                    onClick();
+                }}>
+                { attached ? <X size={size} /> : <Plus size={size} />}
+            </IconButton>
+        )
+    }
+
+    return null;
+}
diff --git a/src/context/revoltjs/RequiresOnline.tsx b/src/context/revoltjs/RequiresOnline.tsx
new file mode 100644
index 0000000..73b7f72
--- /dev/null
+++ b/src/context/revoltjs/RequiresOnline.tsx
@@ -0,0 +1,44 @@
+import { Text } from "preact-i18n";
+import styled from "styled-components";
+import { useContext } from "preact/hooks";
+import { Children } from "../../types/Preact";
+import { WifiOff } from "@styled-icons/feather";
+import Preloader from "../../components/ui/Preloader";
+import { ClientStatus, StatusContext } from "./RevoltClient";
+
+interface Props {
+    children: Children;
+}
+
+const Base = styled.div`
+    gap: 16px;
+    padding: 1em;
+    display: flex;
+    user-select: none;
+    align-items: center;
+    flex-direction: row;
+    justify-content: center;
+    color: var(--tertiary-foreground);
+    background: var(--secondary-header);
+
+    > div {
+        font-size: 18px;
+    }
+`;
+
+export default function RequiresOnline(props: Props) {
+    const status = useContext(StatusContext);
+
+    if (status === ClientStatus.CONNECTING) return <Preloader />;
+    if (status !== ClientStatus.ONLINE && status !== ClientStatus.READY)
+        return (
+            <Base>
+                <WifiOff size={16} />
+                <div>
+                    <Text id="app.special.requires_online" />
+                </div>
+            </Base>
+        );
+
+    return <>{ props.children }</>;
+}
diff --git a/src/lib/conversion.ts b/src/lib/conversion.ts
new file mode 100644
index 0000000..61dcad9
--- /dev/null
+++ b/src/lib/conversion.ts
@@ -0,0 +1,9 @@
+export function urlBase64ToUint8Array(base64String: string) {
+    const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
+    const base64 = (base64String + padding)
+        .replace(/\-/g, "+")
+        .replace(/_/g, "/");
+    const rawData = window.atob(base64);
+
+    return Uint8Array.from([...rawData].map(char => char.charCodeAt(0)));
+}
diff --git a/src/lib/debounce.ts b/src/lib/debounce.ts
new file mode 100644
index 0000000..c7ac383
--- /dev/null
+++ b/src/lib/debounce.ts
@@ -0,0 +1,15 @@
+export function debounce(cb: Function, duration: number) {
+    // Store the timer variable.
+    let timer: number;
+    // This function is given to React.
+    return (...args: any[]) => {
+        // Get rid of the old timer.
+        clearTimeout(timer);
+        // Set a new timer.
+        timer = setTimeout(() => {
+            // Instead calling the new function.
+            // (with the newer data)
+            cb(...args);
+        }, duration);
+    };
+}
diff --git a/src/lib/fileSize.ts b/src/lib/fileSize.ts
new file mode 100644
index 0000000..c6bba81
--- /dev/null
+++ b/src/lib/fileSize.ts
@@ -0,0 +1,9 @@
+export function determineFileSize(size: number) {
+    if (size > 1e6) {
+        return `${(size / 1e6).toFixed(2)} MB`;
+    } else if (size > 1e3) {
+        return `${(size / 1e3).toFixed(2)} KB`;
+    }
+
+    return `${size} B`;
+}
diff --git a/src/main.tsx b/src/main.tsx
index 70a4359..5415f66 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -1,6 +1,6 @@
 import { render } from "preact";
 import "./styles/index.scss";
-import { App } from "./app";
+import { App } from "./pages/app";
 
 import { registerSW } from 'virtual:pwa-register'
 
diff --git a/src/pages/App.tsx b/src/pages/RevoltApp.tsx
similarity index 50%
rename from src/pages/App.tsx
rename to src/pages/RevoltApp.tsx
index f0ff58d..88738fd 100644
--- a/src/pages/App.tsx
+++ b/src/pages/RevoltApp.tsx
@@ -11,7 +11,10 @@ import RightSidebar from "../components/navigation/RightSidebar";
 
 import Home from './home/Home';
 import Friends from "./friends/Friends";
+import Settings from './settings/Settings';
 import Developer from "./developer/Developer";
+import ServerSettings from "./settings/ServerSettings";
+import ChannelSettings from "./settings/ChannelSettings";
 
 const Routes = styled.div`
     min-width: 0;
@@ -31,17 +34,19 @@ export default function App() {
             docked={isTouchscreenDevice ? Docked.None : Docked.Left}>
             <Routes>
                 <Switch>
-                    <Route path="/dev">
-                        <Developer />
-                    </Route>
+                    <Route path="/server/:server/channel/:channel/settings/:page" component={ChannelSettings} />
+                    <Route path="/server/:server/channel/:channel/settings" component={ChannelSettings} />
+                    <Route path="/server/:server/settings/:page" component={ServerSettings} />
+                    <Route path="/server/:server/settings" component={ServerSettings} />
+                    <Route path="/channel/:channel/settings/:page" component={ChannelSettings} />
+                    <Route path="/channel/:channel/settings" component={ChannelSettings} />
+                    
+                    <Route path="/settings/:page" component={Settings} />
+                    <Route path="/settings" component={Settings} />
 
-                    <Route path="/friends">
-                        <Friends />
-                    </Route>
-
-                    <Route path="/">
-                        <Home />
-                    </Route>
+                    <Route path="/dev" component={Developer} />
+                    <Route path="/friends" component={Friends} />
+                    <Route path="/" component={Home} />
                 </Switch>
             </Routes>
             <ContextMenus />
@@ -55,31 +60,6 @@ export default function App() {
  * <Route path="/channel/:channel/message/:message">
                             <ChannelWrapper />
                         </Route> 
-                        <Route path="/server/:server/channel/:channel/settings/:page">
-                            <ChannelSettings key="channel_settings" />
-                        </Route>
-                        <Route path="/server/:server/channel/:channel/settings">
-                            <ChannelSettings key="channel_settings" />
-                        </Route>
-                        <Route path="/server/:server/settings/:page">
-                            <ServerSettings key="channel_settings" />
-                        </Route>
-                        <Route path="/server/:server/settings">
-                            <ServerSettings key="channel_settings" />
-                        </Route>
-                        <Route path="/channel/:channel/settings/:page">
-                            <ChannelSettings key="channel_settings" />
-                        </Route>
-                        <Route path="/channel/:channel/settings">
-                            <ChannelSettings key="channel_settings" />
-                        </Route>
-
-                        <Route path="/settings/:page">
-                            <Settings key="settings" />
-                        </Route>
-                        <Route path="/settings">
-                            <Settings key="settings" />
-                        </Route>
 
                         <Route path="/server/:server/channel/:channel">
                             <ChannelWrapper />
@@ -89,21 +69,10 @@ export default function App() {
                             <ChannelWrapper />
                         </Route>
                         
-                        <Route path="/friends">
-                            <Friends />
-                        </Route>
-                        <Route path="/dev">
-                            <Developer />
-                        </Route>
-                        
                         <Route path="/open/:id">
                             <Open />
                         </Route>
                         {/*<Route path="/invite/:code">
                             <OpenInvite />
                         </Route>
-
-                        <Route path="/">
-                            <Home />
-                        </Route>
  */
diff --git a/src/app.tsx b/src/pages/app.tsx
similarity index 74%
rename from src/app.tsx
rename to src/pages/app.tsx
index ee55979..c6af90a 100644
--- a/src/app.tsx
+++ b/src/pages/app.tsx
@@ -1,11 +1,11 @@
-import { CheckAuth } from "./context/revoltjs/CheckAuth";
-import Preloader from "./components/ui/Preloader";
+import { CheckAuth } from "../context/revoltjs/CheckAuth";
+import Preloader from "../components/ui/Preloader";
 import { Route, Switch } from "react-router-dom";
-import Context from "./context";
+import Context from "../context";
 
 import { lazy, Suspense } from "preact/compat";
-const Login = lazy(() => import('./pages/login/Login'));
-const RevoltApp = lazy(() => import('./pages/App'));
+const Login = lazy(() => import('./login/Login'));
+const RevoltApp = lazy(() => import('./RevoltApp'));
 
 export function App() {
     return (
diff --git a/src/pages/home/Home.tsx b/src/pages/home/Home.tsx
index 78e0348..01371fc 100644
--- a/src/pages/home/Home.tsx
+++ b/src/pages/home/Home.tsx
@@ -3,14 +3,13 @@ import { Link } from "react-router-dom";
 
 import { Text } from "preact-i18n";
 import Header from "../../components/ui/Header";
-// import WideLogo from "../../../../../assets/wide.svg";
 
 export default function Home() {
     return (
         <div className={styles.home}>
             <Header placement="primary"><Text id="app.navigation.tabs.home" /></Header>
             <h3>
-                <Text id="app.special.modals.onboarding.welcome" /> {/*<WideLogo />*/}
+                <Text id="app.special.modals.onboarding.welcome" /> <img src="/assets/wide.svg" />
             </h3>
             <ul>
                 <li>
diff --git a/src/pages/login/forms/FormLogin.tsx b/src/pages/login/forms/FormLogin.tsx
index 6588a52..d3af8a1 100644
--- a/src/pages/login/forms/FormLogin.tsx
+++ b/src/pages/login/forms/FormLogin.tsx
@@ -1,7 +1,7 @@
 import { Form } from "./Form";
+import { detect } from "detect-browser";
 import { useContext } from "preact/hooks";
 import { useHistory } from "react-router-dom";
-import { deviceDetect } from "react-device-detect";
 import { OperationsContext } from "../../../context/revoltjs/RevoltClient";
 
 export function FormLogin() {
@@ -12,7 +12,7 @@ export function FormLogin() {
         <Form
             page="login"
             callback={async data => {
-                const browser = deviceDetect();
+                const browser = detect();
                 let device_name;
                 if (browser) {
                     const { name, os } = browser;
diff --git a/src/pages/settings/ChannelSettings.tsx b/src/pages/settings/ChannelSettings.tsx
new file mode 100644
index 0000000..0f7cf15
--- /dev/null
+++ b/src/pages/settings/ChannelSettings.tsx
@@ -0,0 +1,46 @@
+import { Text } from "preact-i18n";
+import { List } from "@styled-icons/feather";
+import Category from "../../components/ui/Category";
+import { GenericSettings } from "./GenericSettings";
+import { getChannelName } from "../../context/revoltjs/util";
+import { Route, useHistory, useParams } from "react-router-dom";
+import { useChannel, useForceUpdate } from "../../context/revoltjs/hooks";
+
+import { Overview } from "./channel/Overview";
+
+export default function ChannelSettings() {
+    const { channel: cid } = useParams<{ channel: string; }>();
+    const ctx = useForceUpdate();
+    const channel = useChannel(cid, ctx);
+    if (!channel) return null;
+    if (channel.channel_type === 'SavedMessages' || channel.channel_type === 'DirectMessage') return null;
+
+    const history = useHistory();
+    function switchPage(to?: string) {
+        if (to) {
+            history.replace(`/channel/${cid}/settings/${to}`);
+        } else {
+            history.replace(`/channel/${cid}/settings`);
+        }
+    }
+
+    return (
+        <GenericSettings
+            pages={[
+                {
+                    category: <Category variant="uniform" text={getChannelName(ctx.client, channel, [], true)} />,
+                    id: 'overview',
+                    icon: <List size={20} strokeWidth={2} />,
+                    title: <Text id="app.settings.channel_pages.overview.title" />
+                }
+            ]}
+            children={[
+                <Route path="/"><Overview channel={channel} /></Route>
+            ]}
+            category="channel_pages"
+            switchPage={switchPage}
+            defaultPage="overview"
+            showExitButton
+        />
+    )
+}
diff --git a/src/pages/settings/GenericSettings.tsx b/src/pages/settings/GenericSettings.tsx
new file mode 100644
index 0000000..89579f4
--- /dev/null
+++ b/src/pages/settings/GenericSettings.tsx
@@ -0,0 +1,120 @@
+import { Text } from "preact-i18n";
+import { useEffect } from "preact/hooks";
+import styles from "./Settings.module.scss";
+import { Children } from "../../types/Preact";
+import Header from '../../components/ui/Header';
+import Category from '../../components/ui/Category';
+import IconButton from "../../components/ui/IconButton";
+import LineDivider from "../../components/ui/LineDivider";
+import { ArrowLeft, X, XCircle } from "@styled-icons/feather";
+import { Switch, useHistory, useParams } from "react-router-dom";
+import { isTouchscreenDevice } from "../../lib/isTouchscreenDevice";
+import ButtonItem from "../../components/navigation/items/ButtonItem";
+
+interface Props {
+    pages: {
+        category?: Children,
+        divider?: boolean,
+        id: string,
+        icon: Children
+        title: Children
+    }[]
+    custom?: Children
+    children: Children
+    defaultPage: string
+    showExitButton?: boolean
+    switchPage: (to?: string) => void
+    category: 'pages' | 'channel_pages' | 'server_pages'
+}
+
+export function GenericSettings({ pages, switchPage, category, custom, children, defaultPage, showExitButton }: Props) {
+    const history = useHistory();
+    const { page } = useParams<{ page: string; }>();
+
+    function exitSettings() {
+        if (history.length > 0) {
+            history.goBack();
+        } else {
+            history.push('/');
+        }
+    }
+
+    useEffect(() => {
+        function keyDown(e: KeyboardEvent) {
+            if (e.key === "Escape") {
+                exitSettings();
+            }
+        }
+
+        document.body.addEventListener("keydown", keyDown);
+        return () => document.body.removeEventListener("keydown", keyDown);
+    }, []);
+
+    return (
+        <div className={styles.settings} data-mobile={isTouchscreenDevice}>
+            {isTouchscreenDevice && (
+                <Header placement="primary">
+                    {typeof page === "undefined" ? (
+                        <>
+                            { showExitButton &&
+                                <IconButton onClick={exitSettings}>
+                                    <X size={24} />
+                                </IconButton> }
+                            <Text id="app.settings.title" />
+                        </>
+                    ) : (
+                        <>
+                            <IconButton onClick={() => switchPage()}>
+                                <ArrowLeft size={24} />
+                            </IconButton>
+                            <Text
+                                id={`app.settings.${category}.${page}.title`}
+                            />
+                        </>
+                    )}
+                </Header>
+            )}
+            {(!isTouchscreenDevice || typeof page === "undefined") && (
+                <div className={styles.sidebar}>
+                    <div className={styles.container}>
+                        {
+                            pages.map((entry, i) =>
+                                <>
+                                    { entry.category && <Category variant="uniform" text={entry.category} /> }
+                                    <ButtonItem
+                                        active={page === entry.id || (i === 0 && !isTouchscreenDevice && typeof page === "undefined")}
+                                        onClick={() => switchPage(entry.id)}
+                                        compact
+                                    >{entry.icon} {entry.title}</ButtonItem>
+                                    { entry.divider && <LineDivider /> }
+                                </>
+                            )
+                        }
+                        { custom }
+                    </div>
+                </div>
+            )}
+            {(!isTouchscreenDevice || typeof page === "string") && (
+                <div className={styles.content}>
+                    {!isTouchscreenDevice && (
+                        <h1>
+                            <Text
+                                id={`app.settings.${category}.${page ?? defaultPage}.title`}
+                            />
+                        </h1>
+                    )}
+                    <Switch>
+                        { children }
+                    </Switch>
+                </div>
+            )}
+            {!isTouchscreenDevice && (
+                <div className={styles.action}>
+                    <IconButton onClick={exitSettings}>
+                        <XCircle size={48} />
+                    </IconButton>
+                </div>
+            )}
+        </div>
+    );
+}
diff --git a/src/pages/settings/ServerSettings.tsx b/src/pages/settings/ServerSettings.tsx
new file mode 100644
index 0000000..c3946c7
--- /dev/null
+++ b/src/pages/settings/ServerSettings.tsx
@@ -0,0 +1,65 @@
+import { Text } from "preact-i18n";
+import Category from "../../components/ui/Category";
+import { GenericSettings } from "./GenericSettings";
+import { useServer } from "../../context/revoltjs/hooks";
+import { Route, useHistory, useParams } from "react-router-dom";
+import { List, Share, Users, XSquare } from "@styled-icons/feather";
+import RequiresOnline from "../../context/revoltjs/RequiresOnline";
+
+import { Overview } from "./server/Overview";
+import { Members } from "./server/Members";
+import { Invites } from "./server/Invites";
+import { Bans } from "./server/Bans";
+
+export default function ServerSettings() {
+    const { server: sid } = useParams<{ server: string; }>();
+    const server = useServer(sid);
+    if (!server) return null;
+
+    const history = useHistory();
+    function switchPage(to?: string) {
+        if (to) {
+            history.replace(`/server/${sid}/settings/${to}`);
+        } else {
+            history.replace(`/server/${sid}/settings`);
+        }
+    }
+
+    return (
+        <GenericSettings
+            pages={[
+                {
+                    category: <Category variant="uniform" text={server.name} />,
+                    id: 'overview',
+                    icon: <List size={20} strokeWidth={2} />,
+                    title: <Text id="app.settings.channel_pages.overview.title" />
+                },
+                {
+                    id: 'members',
+                    icon: <Users size={20} strokeWidth={2} />,
+                    title: "Members"
+                },
+                {
+                    id: 'invites',
+                    icon: <Share size={20} strokeWidth={2} />,
+                    title: "Invites"
+                },
+                {
+                    id: 'bans',
+                    icon: <XSquare size={20} strokeWidth={2} />,
+                    title: "Bans"
+                }
+            ]}
+            children={[
+                <Route path="/server/:server/settings/members"><RequiresOnline><Members server={server} /></RequiresOnline></Route>,
+                <Route path="/server/:server/settings/invites"><RequiresOnline><Invites server={server} /></RequiresOnline></Route>,
+                <Route path="/server/:server/settings/bans"><RequiresOnline><Bans server={server} /></RequiresOnline></Route>,
+                <Route path="/"><Overview server={server} /></Route>
+            ]}
+            category="server_pages"
+            switchPage={switchPage}
+            defaultPage="overview"
+            showExitButton
+        />
+    )
+}
diff --git a/src/pages/settings/Settings.module.scss b/src/pages/settings/Settings.module.scss
new file mode 100644
index 0000000..9497a95
--- /dev/null
+++ b/src/pages/settings/Settings.module.scss
@@ -0,0 +1,209 @@
+@keyframes open {
+    0% {transform: scale(1.2);};
+    100% {transform: scale(1);};
+}
+
+@keyframes opacity {
+    0% {opacity: 0;};
+    20% {opacity: .5;}
+    50% {opacity: 1;}
+}
+
+@keyframes close {
+    0% {transform: scale(1); opacity: 1;};
+    100% {transform: scale(1.2); opacity: 0;};
+}
+
+[data-touchscreen-device="true"] .settings {
+    flex-direction: column;
+    background: var(--primary-header);
+
+    .sidebar, .content {
+        background: var(--primary-background);
+    }
+
+    .sidebar {
+        justify-content: flex-start;
+
+        .container {
+            padding: 20px 8px;
+            min-width: 218px;
+        }
+
+        > div {
+            width: 100%;
+        }
+
+        .version {
+            place-items: center;
+
+        }
+    }
+
+    .content {
+        padding: 10px 12px 50px;
+    }
+}
+
+:global(.app):not([data-touchscreen-device="true"]) .settings {
+    top: 0;
+    left: 0;
+    z-index: 10;
+    width: 100%;
+    height: 100%;
+    position: fixed;
+    animation: open .18s ease-out,
+               opacity .18s;
+}
+
+.settings {
+    height: 100%;
+    display: flex;
+    user-select: none;
+    flex-direction: row;
+    justify-content: center;
+    background: var(--primary-background);
+
+    .sidebar {
+        flex-grow: 1;
+        display: flex;
+        flex-shrink: 0;
+        overflow-y: scroll;
+        justify-content: flex-end;
+        background: var(--secondary-background);
+
+        .container {
+            width: 218px;
+            padding: 60px 8px;
+        }
+
+        .divider {
+            height: 30px;
+        }
+
+        .donate {
+            color: goldenrod !important;
+        }
+
+        .logOut {
+            color: var(--error) !important;
+        }
+
+        .version {
+            margin: 1rem 12px 0;
+            font-size: 10px;
+            color: var(--secondary-foreground);
+            font-family: "Fira Mono", monospace;
+            user-select: text;
+
+            display: grid;
+            //place-items: center;
+
+            > div {
+                gap: 2px;
+                display: flex;
+                flex-direction: column;
+            }
+        }
+
+        scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
+    }
+
+    .content {
+        flex-grow: 2;
+        max-width: 740px;
+        padding: 60px 2em;
+        overflow-y: scroll;
+        overflow-x: hidden;
+
+        details {
+            margin: 14px 0;
+        }
+
+        h1 {
+            margin-top: 0;
+            line-height: 1em;
+            font-size: 1.2em;
+            font-weight: 600;
+        }
+
+        h3 {
+            font-size: 13px;
+            text-transform: uppercase;
+            color: var(--secondary-foreground);
+        }
+
+        h4 {
+            margin: 4px 2px;
+            font-size: 13px;
+
+            color: var(--tertiary-foreground);
+            text-transform: uppercase;
+        }
+
+        .footer {
+            border-top: 1px solid;
+            margin: 0;
+            padding-top: 5px;
+            font-size: 14px;
+            color: var(--secondary-foreground);
+        }
+    }
+
+    .action {
+        flex-grow: 1;
+        flex-shrink: 0;
+        padding: 60px 8px;
+        color: var(--tertiary-background);
+        
+        &:after {
+            content: "ESC";
+            display: flex;
+            text-align: center;
+            align-content: center;
+            justify-content: center;
+            position: relative;
+            color: var(--foreground);
+            width: 48px;
+            opacity: .5;
+            font-size: .75em;
+        }
+
+        > div {
+            display: inline;
+            > svg {
+                &:active {
+                    transform: translateY(2px);
+                }
+            }
+        }
+    }
+}
+
+.loader {
+    > div {
+        margin: auto;
+    }
+}
+
+.textarea {
+    margin-bottom: 1em;
+    border-radius: 4px;
+    font-family: 'Courier New', Courier, monospace;
+
+    textarea {
+        resize: none;
+        padding: 12px;
+        min-height: 180px;
+        border-radius: 4px;
+        color: var(--foreground);
+        border: 2px solid transparent;
+        background: var(--secondary-background);
+        transition: border-color .2s ease-in-out;
+
+        &:focus {
+            outline: none;
+            border: 2px solid var(--accent);
+        }
+    }
+}
diff --git a/src/pages/settings/Settings.tsx b/src/pages/settings/Settings.tsx
new file mode 100644
index 0000000..a4115c9
--- /dev/null
+++ b/src/pages/settings/Settings.tsx
@@ -0,0 +1,153 @@
+import { Text } from "preact-i18n";
+import { Sync } from "./panes/Sync";
+import { useContext } from "preact/hooks";
+import styles from "./Settings.module.scss";
+import { LIBRARY_VERSION } from "revolt.js";
+import { APP_VERSION } from "../../version";
+import { GenericSettings } from "./GenericSettings";
+import { Route, useHistory } from "react-router-dom";
+import {
+    Bell,
+    Box,
+    Coffee,
+    Gitlab,
+    Globe,
+    Image,
+    LogOut,
+    RefreshCw,
+    Shield,
+    ToggleRight,
+    User
+} from "@styled-icons/feather";
+import { Megaphone } from "@styled-icons/bootstrap";
+import LineDivider from "../../components/ui/LineDivider";
+import RequiresOnline from "../../context/revoltjs/RequiresOnline";
+import ButtonItem from "../../components/navigation/items/ButtonItem";
+import { AppContext, OperationsContext } from "../../context/revoltjs/RevoltClient";
+
+import { Account } from "./panes/Account";
+import { Profile } from "./panes/Profile";
+import { Sessions } from "./panes/Sessions";
+import { Feedback } from "./panes/Feedback";
+import { Languages } from "./panes/Languages";
+import { Appearance } from "./panes/Appearance";
+import { Notifications } from "./panes/Notifications";
+import { ExperimentsPage } from "./panes/Experiments";
+
+export default function Settings() {
+    const history = useHistory();
+    const client = useContext(AppContext);
+    const operations = useContext(OperationsContext);
+    
+    function switchPage(to?: string) {
+        if (to) {
+            history.replace(`/settings/${to}`);
+        } else {
+            history.replace(`/settings`);
+        }
+    }
+
+    return (
+        <GenericSettings
+            pages={[
+                {
+                    category: <Text id="app.settings.categories.user_settings" />,
+                    id: 'account',
+                    icon: <User size={20} strokeWidth={2} />,
+                    title: <Text id="app.settings.pages.account.title" />
+                },
+                {
+                    id: 'profile',
+                    icon: <Image size={20} strokeWidth={2} />,
+                    title: <Text id="app.settings.pages.profile.title" />
+                },
+                {
+                    id: 'sessions',
+                    icon: <Shield size={20} strokeWidth={2} />,
+                    title: <Text id="app.settings.pages.sessions.title" />
+                },
+                {
+                    category: <Text id="app.settings.categories.client_settings" />,
+                    id: 'appearance',
+                    icon: <Box size={20} strokeWidth={2} />,
+                    title: <Text id="app.settings.pages.appearance.title" />
+                },
+                {
+                    id: 'notifications',
+                    icon: <Bell size={20} strokeWidth={2} />,
+                    title: <Text id="app.settings.pages.notifications.title" />
+                },
+                {
+                    id: 'language',
+                    icon: <Globe size={20} strokeWidth={2} />,
+                    title: <Text id="app.settings.pages.language.title" />
+                },
+                {
+                    id: 'sync',
+                    icon: <RefreshCw size={20} strokeWidth={2} />,
+                    title: <Text id="app.settings.pages.sync.title" />
+                },
+                {
+                    divider: true,
+                    id: 'experiments',
+                    icon: <ToggleRight size={20} strokeWidth={2} />,
+                    title: <Text id="app.settings.pages.experiments.title" />
+                },
+                {
+                    id: 'feedback',
+                    icon: <Megaphone size={20} strokeWidth={0.3} />,
+                    title: <Text id="app.settings.pages.feedback.title" />
+                }
+            ]}
+            children={[
+                <Route path="/settings/profile"><Profile /></Route>,
+                <Route path="/settings/sessions">
+                    <RequiresOnline><Sessions /></RequiresOnline>
+                </Route>,
+                <Route path="/settings/appearance"><Appearance /></Route>,
+                <Route path="/settings/notifications"><Notifications /></Route>,
+                <Route path="/settings/language"><Languages /></Route>,
+                <Route path="/settings/sync"><Sync /></Route>,
+                <Route path="/settings/experiments"><ExperimentsPage /></Route>,
+                <Route path="/settings/feedback"><Feedback /></Route>,
+                <Route path="/"><Account /></Route>
+            ]}
+            defaultPage="account"
+            switchPage={switchPage}
+            category="pages"
+            custom={[
+                <a
+                    href="https://gitlab.insrt.uk/revolt"
+                    target="_blank"
+                >
+                    <ButtonItem compact>
+                        <Gitlab size={20} strokeWidth={2} />
+                        <Text id="app.settings.pages.source_code" />
+                    </ButtonItem>
+                </a>,
+                <a href="https://ko-fi.com/insertish" target="_blank">
+                    <ButtonItem className={styles.donate} compact>
+                        <Coffee size={20} strokeWidth={2} />
+                        <Text id="app.settings.pages.donate.title" />
+                    </ButtonItem>
+                </a>,
+                <LineDivider />,
+                <ButtonItem
+                    onClick={() => operations.logout()}
+                    className={styles.logOut}
+                    compact
+                >
+                    <LogOut size={20} strokeWidth={2} />
+                    <Text id="app.settings.pages.logOut" />
+                </ButtonItem>,
+                <div className={styles.version}>
+                    <div>
+                        <span>Stable {APP_VERSION}</span>
+                        <span>API: {client.configuration?.revolt ?? "N/A"}</span>
+                        <span>revolt.js: {LIBRARY_VERSION}</span>
+                    </div>
+                </div>
+            ]}
+        />
+    )
+}
diff --git a/src/pages/settings/SettingsTextArea.tsx b/src/pages/settings/SettingsTextArea.tsx
new file mode 100644
index 0000000..2cf4a64
--- /dev/null
+++ b/src/pages/settings/SettingsTextArea.tsx
@@ -0,0 +1,6 @@
+import styles from "./Settings.module.scss";
+import { TextArea, TextAreaProps } from "../../components/ui/TextArea";
+
+export function SettingsTextArea(props: TextAreaProps) {
+    return <TextArea {...props} className={styles.textarea} padding={16} />;
+}
diff --git a/src/pages/settings/channel/Overview.tsx b/src/pages/settings/channel/Overview.tsx
new file mode 100644
index 0000000..1efaf59
--- /dev/null
+++ b/src/pages/settings/channel/Overview.tsx
@@ -0,0 +1,89 @@
+import { Text } from "preact-i18n";
+import styles from "./Panes.module.scss";
+import Button from "../../../components/ui/Button";
+import { Channels } from "revolt.js/dist/api/objects";
+import InputBox from "../../../components/ui/InputBox";
+import { SettingsTextArea } from "../SettingsTextArea";
+import { useContext, useEffect, useState } from "preact/hooks";
+import { AppContext } from "../../../context/revoltjs/RevoltClient";
+import { FileUploader } from "../../../context/revoltjs/FileUploads";
+
+interface Props {
+    channel: Channels.GroupChannel | Channels.TextChannel;
+}
+
+export function Overview({ channel }: Props) {
+    const client = useContext(AppContext);
+
+    const [name, setName] = useState(channel.name);
+    const [description, setDescription] = useState(channel.description ?? '');
+
+    useEffect(() => setName(channel.name), [ channel.name ]);
+    useEffect(() => setDescription(channel.description ?? ''), [ channel.description ]);
+
+    const [ changed, setChanged ] = useState(false);
+    function save() {
+        let changes: any = {};
+        if (name !== channel.name) changes.name = name;
+        if (description !== channel.description)
+            changes.description = description;
+
+        client.channels.edit(channel._id, changes);
+        setChanged(false);
+    }
+
+    return (
+        <div className={styles.overview}>
+            <div className={styles.row}>
+                <FileUploader
+                    width={80}
+                    height={80}
+                    style="icon"
+                    fileType="icons"
+                    behaviour="upload"
+                    maxFileSize={2_500_000}
+                    onUpload={icon => client.channels.edit(channel._id, { icon })}
+                    previewURL={client.channels.getIconURL(channel._id, { max_side: 256 }, true)}
+                    remove={() => client.channels.edit(channel._id, { remove: 'Icon' })}
+                    defaultPreview={channel.channel_type === 'Group' ? "/assets/group.png" : undefined}
+                />
+                <div className={styles.name}>
+                    <h3>
+                        { channel.channel_type === 'Group' ?
+                            <Text id="app.main.groups.name" /> :
+                            <Text id="app.main.servers.channel_name" /> }
+                    </h3>
+                    <InputBox
+                        contrast
+                        value={name}
+                        maxLength={32}
+                        onChange={e => {
+                            setName(e.currentTarget.value)
+                            if (!changed) setChanged(true)
+                        }}
+                    />
+                </div>
+            </div>
+
+            <h3>
+                { channel.channel_type === 'Group' ?
+                    <Text id="app.main.groups.description" /> :
+                    <Text id="app.main.servers.channel_description" /> }
+            </h3>
+            <SettingsTextArea
+                maxRows={10}
+                minHeight={60}
+                maxLength={1024}
+                value={description}
+                placeholder={"Add a description..."}
+                onChange={content => {
+                    setDescription(content);
+                    if (!changed) setChanged(true)
+                }}
+            />
+            <Button onClick={save} style="contrast" disabled={!changed}>
+                <Text id="app.special.modals.actions.save" />
+            </Button>
+        </div>
+    );
+}
diff --git a/src/pages/settings/channel/Panes.module.scss b/src/pages/settings/channel/Panes.module.scss
new file mode 100644
index 0000000..281eab2
--- /dev/null
+++ b/src/pages/settings/channel/Panes.module.scss
@@ -0,0 +1,14 @@
+.overview {
+    .row {
+        gap: 20px;
+        display: flex;
+
+        .name {
+            flex-grow: 1;
+
+            input {
+                width: 100%;
+            }
+        }
+    }
+}
diff --git a/src/pages/settings/panes/Account.tsx b/src/pages/settings/panes/Account.tsx
new file mode 100644
index 0000000..6147c25
--- /dev/null
+++ b/src/pages/settings/panes/Account.tsx
@@ -0,0 +1,95 @@
+import { Text } from "preact-i18n";
+import styles from "./Panes.module.scss";
+import Tip from "../../../components/ui/Tip";
+import Button from "../../../components/ui/Button";
+import { Users } from "revolt.js/dist/api/objects";
+import { Link, useHistory } from "react-router-dom";
+import Overline from "../../../components/ui/Overline";
+import { AtSign, Key, Mail } from "@styled-icons/feather";
+import { useForceUpdate, useSelf } from "../../../context/revoltjs/hooks";
+import UserIcon from "../../../components/common/UserIcon";
+import { useContext, useEffect, useState } from "preact/hooks";
+import { ClientStatus, StatusContext } from "../../../context/revoltjs/RevoltClient";
+import { useIntermediate } from "../../../context/intermediate/Intermediate";
+
+export function Account() {
+    const { openScreen } = useIntermediate();
+    const status = useContext(StatusContext);
+
+    const ctx = useForceUpdate();
+    const user = useSelf(ctx);
+    if (!user) return null;
+
+    const [email, setEmail] = useState("...");
+    const [profile, setProfile] = useState<undefined | Users.Profile>(
+        undefined
+    );
+    const history = useHistory();
+
+    function switchPage(to: string) {
+        history.replace(`/settings/${to}`);
+    }
+
+    useEffect(() => {
+        if (email === "..." && status === ClientStatus.ONLINE) {
+            ctx.client
+                .req("GET", "/auth/user")
+                .then(account => setEmail(account.email));
+        }
+
+        if (profile === undefined && status === ClientStatus.ONLINE) {
+            ctx.client.users
+                .fetchProfile(user._id)
+                .then(profile => setProfile(profile ?? {}));
+        }
+    }, [status]);
+
+    return (
+        <div className={styles.user}>
+            <div className={styles.banner}>
+                <Link to="/settings/profile">
+                    <UserIcon target={user} size={72} />
+                </Link>
+                <div className={styles.username}>@{user.username}</div>
+            </div>
+            <div className={styles.details}>
+                {[
+                    ["username", user.username, <AtSign size={24} />],
+                    ["email", email, <Mail size={24} />],
+                    ["password", "*****", <Key size={24} />]
+                ].map(([field, value, icon]) => (
+                    <div>
+                        {icon}
+                        <div className={styles.detail}>
+                            <Overline>
+                                <Text id={`login.${field}`} />
+                            </Overline>
+                            <p>{value}</p>
+                        </div>
+                        <div>
+                            <Button
+                                onClick={() =>
+                                    openScreen({
+                                        id: "modify_account",
+                                        field: field as any
+                                    })
+                                }
+                                contrast
+                            >
+                                <Text id="app.settings.pages.account.change_field" />
+                            </Button>
+                        </div>
+                    </div>
+                ))}
+            </div>
+            <Tip>
+                <span>
+                    <Text id="app.settings.tips.account.a" />
+                </span>{" "}
+                <a onClick={() => switchPage("profile")}>
+                    <Text id="app.settings.tips.account.b" />
+                </a>
+            </Tip>
+        </div>
+    );
+}
diff --git a/src/pages/settings/panes/Appearance.tsx b/src/pages/settings/panes/Appearance.tsx
new file mode 100644
index 0000000..867d6f0
--- /dev/null
+++ b/src/pages/settings/panes/Appearance.tsx
@@ -0,0 +1,286 @@
+import { Text } from "preact-i18n";
+import styles from "./Panes.module.scss";
+import { debounce } from "../../../lib/debounce";
+import Button from "../../../components/ui/Button";
+import InputBox from "../../../components/ui/InputBox";
+import { SettingsTextArea } from "../SettingsTextArea";
+import { connectState } from "../../../redux/connector";
+import { WithDispatcher } from "../../../redux/reducers";
+import ColourSwatches from "../../../components/ui/ColourSwatches";
+import { EmojiPacks, Settings } from "../../../redux/reducers/settings";
+import { Theme, ThemeContext, ThemeOptions } from "../../../context/Theme";
+import { useCallback, useContext, useEffect, useState } from "preact/hooks";
+import { useIntermediate } from "../../../context/intermediate/Intermediate";
+
+// @ts-ignore
+import pSBC from 'shade-blend-color';
+
+interface Props {
+    settings: Settings;
+}
+
+// ! FIXME: code needs to be rewritten to fix jittering
+export function Component(props: Props & WithDispatcher) {
+    const theme = useContext(ThemeContext);
+    const { writeClipboard, openScreen } = useIntermediate();
+
+    function setTheme(theme: ThemeOptions) {
+        props.dispatcher({
+            type: "SETTINGS_SET_THEME",
+            theme
+        });
+    }
+
+    function pushOverride(custom: Partial<Theme>) {
+        props.dispatcher({
+            type: "SETTINGS_SET_THEME_OVERRIDE",
+            custom
+        });
+    }
+
+    function setAccent(accent: string) {
+        setOverride({
+            accent,
+            "sidebar-active": accent,
+            "scrollbar-thumb": pSBC(-0.2, accent)
+        });
+    }
+
+    const emojiPack = props.settings.appearance?.emojiPack ?? 'mutant';
+    function setEmojiPack(emojiPack: EmojiPacks) {
+        props.dispatcher({
+            type: 'SETTINGS_SET_APPEARANCE',
+            options: {
+                emojiPack
+            }
+        });
+    }
+
+    const setOverride = useCallback(debounce(pushOverride, 200), []) as (
+        custom: Partial<Theme>
+    ) => void;
+    const [ css, setCSS ] = useState(props.settings.theme?.custom?.css ?? '');
+
+    useEffect(() => setOverride({ css }), [ css ]);
+
+    const selected = props.settings.theme?.preset ?? "dark";
+    return (
+        <div className={styles.appearance}>
+            <h3>
+                <Text id="app.settings.pages.appearance.theme" />
+            </h3>
+            <div className={styles.themes}>
+                <div className={styles.theme}>
+                    <img
+                        src="/assets/images/light.svg"
+                        data-active={selected === "light"}
+                        onClick={() =>
+                            selected !== "light" &&
+                            setTheme({ preset: "light" })
+                        } />
+                    <h4>
+                        <Text id="app.settings.pages.appearance.color.light" />
+                    </h4>
+                </div>
+                <div className={styles.theme}>
+                    <img
+                        src="/assets/images/dark.svg"
+                        data-active={selected === "dark"}
+                        onClick={() =>
+                            selected !== "dark" && setTheme({ preset: "dark" })
+                        } />
+                    <h4>
+                        <Text id="app.settings.pages.appearance.color.dark" />
+                    </h4>
+                </div>
+            </div>
+
+            <h3>
+                <Text id="app.settings.pages.appearance.accent_selector" />
+            </h3>
+            <ColourSwatches value={theme.accent} onChange={setAccent} />
+
+            {/*<h3>
+                <Text id="app.settings.pages.appearance.message_display" />
+            </h3>
+            <div className={styles.display}>
+                <Radio
+                    description={
+                        <Text id="app.settings.pages.appearance.display.default_description" />
+                    }
+                    checked
+                >
+                    <Text id="app.settings.pages.appearance.display.default" />
+                </Radio>
+                <Radio
+                    description={
+                        <Text id="app.settings.pages.appearance.display.compact_description" />
+                    }
+                    disabled
+                >
+                    <Text id="app.settings.pages.appearance.display.compact" />
+                </Radio>
+            </div>*/}
+
+            <h3>
+                <Text id="app.settings.pages.appearance.emoji_pack" />
+            </h3>
+            <div className={styles.emojiPack}>
+                <div className={styles.row}>
+                    <div>
+                        <div className={styles.button}
+                            onClick={() => setEmojiPack('mutant')}
+                            data-active={emojiPack === 'mutant'}>
+                            <img src="/assets/images/mutant_emoji.svg" draggable={false} />
+                        </div>
+                        <h4>Mutant Remix <a href="https://mutant.revolt.chat" target="_blank">(by Revolt)</a></h4>
+                    </div>
+                    <div>
+                        <div className={styles.button}
+                            onClick={() => setEmojiPack('twemoji')}
+                            data-active={emojiPack === 'twemoji'}>
+                            <img src="/assets/images/twemoji_emoji.svg" draggable={false} />
+                        </div>
+                        <h4>Twemoji</h4>
+                    </div>
+                </div>
+                <div className={styles.row}>
+                    <div>
+                        <div className={styles.button}
+                            onClick={() => setEmojiPack('openmoji')}
+                            data-active={emojiPack === 'openmoji'}>
+                            <img src="/assets/images/openmoji_emoji.svg" draggable={false} />
+                        </div>
+                        <h4>Openmoji</h4>
+                    </div>
+                    <div>
+                        <div className={styles.button}
+                            onClick={() => setEmojiPack('noto')}
+                            data-active={emojiPack === 'noto'}>
+                            <img src="/assets/images/noto_emoji.svg" draggable={false} />
+                        </div>
+                        <h4>Noto Emoji</h4>
+                    </div>
+                </div>
+            </div>
+
+            <details>
+                <summary>
+                    <Text id="app.settings.pages.appearance.advanced" />
+                    <div className={styles.divider}></div>
+                </summary>
+                <h3>
+                    <Text id="app.settings.pages.appearance.overrides" />
+                </h3>
+                <div className={styles.actions}>
+                    <Button contrast
+                        onClick={() => setTheme({ custom: {} })}>
+                        <Text id="app.settings.pages.appearance.reset_overrides" />
+                    </Button>
+                    <Button contrast
+                        onClick={() => writeClipboard(JSON.stringify(theme))}>
+                        <Text id="app.settings.pages.appearance.export_clipboard" />
+                    </Button>
+                    <Button contrast
+                        onClick={async () => {
+                            const text = await navigator.clipboard.readText();
+                            setOverride(JSON.parse(text));
+                        }}>
+                        <Text id="app.settings.pages.appearance.import_clipboard" />
+                    </Button>
+                    <Button contrast
+                        onClick={async () => {
+                            openScreen({
+                                id: "_input",
+                                question: <Text id="app.settings.pages.appearance.import_theme" />,
+                                field: <Text id="app.settings.pages.appearance.theme_data" />,
+                                callback: async string => setOverride(JSON.parse(string))
+                            });
+                        }}>
+                        <Text id="app.settings.pages.appearance.import_manual" />
+                    </Button>
+                </div>
+                <div className={styles.overrides}>
+                    {[
+                        "accent",
+                        "background",
+                        "foreground",
+                        "primary-background",
+                        "primary-header",
+                        "secondary-background",
+                        "secondary-foreground",
+                        "secondary-header",
+                        "tertiary-background",
+                        "tertiary-foreground",
+                        "block",
+                        "message-box",
+                        "mention",
+                        "sidebar-active",
+                        "scrollbar-thumb",
+                        "scrollbar-track",
+                        "status-online",
+                        "status-away",
+                        "status-busy",
+                        "status-streaming",
+                        "status-invisible",
+                        "success",
+                        "warning",
+                        "error",
+                        "hover"
+                    ].map(x => (
+                        <div className={styles.entry} key={x}>
+                            <span>{x}</span>
+                            <div className={styles.override}>
+                                <div className={styles.picker}
+                                    style={{ backgroundColor: (theme as any)[x as any] }}>
+                                    <input
+                                        type="color"
+                                        value={(theme as any)[x as any]}
+                                        onChange={v =>
+                                            setOverride({
+                                                [x]: v.currentTarget.value
+                                            })
+                                        }
+                                    />
+                                </div>
+                                <InputBox
+                                    className={styles.text}
+                                    value={(theme as any)[x as any]}
+                                    onChange={y =>
+                                        setOverride({
+                                            [x]: y.currentTarget.value
+                                        })
+                                    }
+                                />
+                            </div>
+                        </div>
+                    ))}
+                </div>
+                <h3>
+                    <Text id="app.settings.pages.appearance.custom_css" />
+                </h3>
+                <SettingsTextArea
+                    maxRows={20}
+                    minHeight={480}
+                    value={css}
+                    onChange={css => setCSS(css)}
+                />
+            </details>
+
+            {/*<h3>
+                <Text id="app.settings.pages.appearance.sync" />
+            </h3>
+            <p>Coming soon!</p>*/}
+        </div>
+    );
+}
+
+export const Appearance = connectState(
+    Component,
+    state => {
+        return {
+            settings: state.settings
+        };
+    },
+    true
+);
diff --git a/src/pages/settings/panes/Experiments.tsx b/src/pages/settings/panes/Experiments.tsx
new file mode 100644
index 0000000..2922be8
--- /dev/null
+++ b/src/pages/settings/panes/Experiments.tsx
@@ -0,0 +1,53 @@
+import { Text } from "preact-i18n";
+import styles from "./Panes.module.scss";
+import Checkbox from "../../../components/ui/Checkbox";
+import { connectState } from "../../../redux/connector";
+import { WithDispatcher } from "../../../redux/reducers";
+import { AVAILABLE_EXPERIMENTS, ExperimentOptions } from "../../../redux/reducers/experiments";
+
+interface Props {
+    options?: ExperimentOptions;
+}
+
+export function Component(props: Props & WithDispatcher) {
+    return (
+        <div className={styles.notifications}>
+            <h3>
+                <Text id="app.settings.pages.experiments.features" />
+            </h3>
+            {
+                (AVAILABLE_EXPERIMENTS).map(
+                    key =>
+                        <Checkbox
+                            checked={(props.options?.enabled ?? []).indexOf(key) > -1}
+                            onChange={enabled => {
+                                props.dispatcher({
+                                    type: enabled ? 'EXPERIMENTS_ENABLE' : 'EXPERIMENTS_DISABLE',
+                                    key
+                                });
+                            }}
+                        >
+                            <Text id={`app.settings.pages.experiments.titles.${key}`} />
+                            <p>
+                                <Text id={`app.settings.pages.experiments.descriptions.${key}`} />
+                            </p>
+                        </Checkbox>
+                )
+            }
+            {
+                AVAILABLE_EXPERIMENTS.length === 0 &&
+                <Text id="app.settings.pages.experiments.not_available" />
+            }
+        </div>
+    );
+}
+
+export const ExperimentsPage = connectState(
+    Component,
+    state => {
+        return {
+            options: state.experiments
+        };
+    },
+    true
+);
diff --git a/src/pages/settings/panes/Feedback.tsx b/src/pages/settings/panes/Feedback.tsx
new file mode 100644
index 0000000..bda00cd
--- /dev/null
+++ b/src/pages/settings/panes/Feedback.tsx
@@ -0,0 +1,95 @@
+import { useState } from "preact/hooks";
+import styles from "./Panes.module.scss";
+import { Localizer, Text } from "preact-i18n";
+import Radio from "../../../components/ui/Radio";
+import Button from "../../../components/ui/Button";
+import InputBox from "../../../components/ui/InputBox";
+import { SettingsTextArea } from "../SettingsTextArea";
+import { useSelf } from "../../../context/revoltjs/hooks";
+
+export function Feedback() {
+    const user = useSelf();
+    const [other, setOther] = useState("");
+    const [description, setDescription] = useState("");
+    const [state, setState] = useState<"ready" | "sending" | "sent">("ready");
+    const [checked, setChecked] = useState<
+        "Bug" | "Feature Request" | "__other_option__"
+    >("Bug");
+
+    async function onSubmit(ev: JSX.TargetedEvent<HTMLFormElement, Event>) {
+        ev.preventDefault();
+        setState("sending");
+
+        await fetch(
+            `https://workers.revolt.chat/feedback`,
+            {
+                method: "POST",
+                body: JSON.stringify({
+                    checked,
+                    other,
+                    description,
+                    name: user?.username ?? "Unknown User"
+                }),
+                mode: 'no-cors'
+            }
+        );
+
+        setState("sent");
+        setChecked("Bug");
+        setDescription("");
+        setOther("");
+    }
+
+    return (
+        <form className={styles.feedback} onSubmit={onSubmit}>
+            <h3>
+                <Text id="app.settings.pages.feedback.report" />
+            </h3>
+            <div className={styles.options}>
+                <Radio
+                    checked={checked === "Bug"}
+                    disabled={state === "sending"}
+                    onSelect={() => setChecked("Bug")}>
+                    <Text id="app.settings.pages.feedback.bug" />
+                </Radio>
+                <Radio
+                    disabled={state === "sending"}
+                    checked={checked === "Feature Request"}
+                    onSelect={() => setChecked("Feature Request")}>
+                    <Text id="app.settings.pages.feedback.feature" />
+                </Radio>
+                <Radio
+                    disabled={state === "sending"}
+                    checked={checked === "__other_option__"}
+                    onSelect={() => setChecked("__other_option__")}>
+                    <Localizer>
+                        <InputBox
+                            value={other}
+                            disabled={state === "sending"}
+                            name="entry.1151440373.other_option_response"
+                            onChange={e => setOther(e.currentTarget.value)}
+                            placeholder={
+                                (
+                                    <Text id="app.settings.pages.feedback.other" />
+                                ) as any
+                            }
+                        />
+                    </Localizer>
+                </Radio>
+            </div>
+            <h3>
+                <Text id="app.settings.pages.feedback.describe" />
+            </h3>
+            <SettingsTextArea
+                maxRows={10}
+                value={description}
+                id="entry.685672624"
+                disabled={state === "sending"}
+                onChange={value => setDescription(value)}
+            />
+            <Button type="submit" contrast>
+                <Text id="app.settings.pages.feedback.send" />
+            </Button>
+        </form>
+    );
+}
diff --git a/src/pages/settings/panes/Languages.tsx b/src/pages/settings/panes/Languages.tsx
new file mode 100644
index 0000000..3c58b23
--- /dev/null
+++ b/src/pages/settings/panes/Languages.tsx
@@ -0,0 +1,68 @@
+import { Text } from "preact-i18n";
+import styles from "./Panes.module.scss";
+import Tip from "../../../components/ui/Tip";
+import Checkbox from "../../../components/ui/Checkbox";
+import { connectState } from "../../../redux/connector";
+import { WithDispatcher } from "../../../redux/reducers";
+import { Emoji } from "../../../components/markdown/Emoji";
+import { Language, LanguageEntry, Languages as Langs } from "../../../context/Locale";
+
+interface Props {
+    locale: Language;
+}
+
+export function Component({ locale, dispatcher }: Props & WithDispatcher) {
+    return (
+        <div className={styles.languages}>
+            <h3>
+                <Text id="app.settings.pages.language.select" />
+            </h3>
+            <div className={styles.list}>
+                {Object.keys(Langs).map(x => {
+                    const l = (Langs as any)[x] as LanguageEntry;
+                    return (
+                        <Checkbox
+                            key={x}
+                            className={styles.entry}
+                            checked={locale === x}
+                            onChange={v => {
+                                if (v) {
+                                    dispatcher({
+                                        type: "SET_LOCALE",
+                                        locale: x as Language
+                                    });
+                                }
+                            }}
+                        >
+                            <div className={styles.flag}><Emoji size={42} emoji={l.emoji} /></div>
+                            <span className={styles.description}>
+                                {l.display}
+                            </span>
+                        </Checkbox>
+                    );
+                })}
+            </div>
+            <Tip>
+                <span>
+                    <Text id="app.settings.tips.languages.a" />
+                </span>{" "}
+                <a
+                    href="https://weblate.insrt.uk/engage/revolt/?utm_source=widget"
+                    target="_blank"
+                >
+                    <Text id="app.settings.tips.languages.b" />
+                </a>
+            </Tip>
+        </div>
+    );
+}
+
+export const Languages = connectState(
+    Component,
+    state => {
+        return {
+            locale: state.locale
+        };
+    },
+    true
+);
diff --git a/src/pages/settings/panes/Notifications.tsx b/src/pages/settings/panes/Notifications.tsx
new file mode 100644
index 0000000..37529e4
--- /dev/null
+++ b/src/pages/settings/panes/Notifications.tsx
@@ -0,0 +1,142 @@
+import { Text } from "preact-i18n";
+import styles from "./Panes.module.scss";
+import Checkbox from "../../../components/ui/Checkbox";
+import { connectState } from "../../../redux/connector";
+import { WithDispatcher } from "../../../redux/reducers";
+import { useContext, useEffect, useState } from "preact/hooks";
+import { urlBase64ToUint8Array } from "../../../lib/conversion";
+import { AppContext } from "../../../context/revoltjs/RevoltClient";
+import { NotificationOptions } from "../../../redux/reducers/settings";
+import { useIntermediate } from "../../../context/intermediate/Intermediate";
+
+interface Props {
+    options?: NotificationOptions;
+}
+
+export function Component(props: Props & WithDispatcher) {
+    const client = useContext(AppContext);
+    const { openScreen } = useIntermediate();
+    const [pushEnabled, setPushEnabled] = useState<undefined | boolean>(
+        undefined
+    );
+
+    // Load current state of pushManager.
+    useEffect(() => {
+        navigator.serviceWorker?.getRegistration().then(async registration => {
+            const sub = await registration?.pushManager?.getSubscription();
+            setPushEnabled(sub !== null && sub !== undefined);
+        });
+    }, []);
+
+    return (
+        <div className={styles.notifications}>
+            <h3>
+                <Text id="app.settings.pages.notifications.push_notifications" />
+            </h3>
+            <Checkbox
+                disabled={!("Notification" in window)}
+                checked={props.options?.desktopEnabled ?? false}
+                onChange={async desktopEnabled => {
+                    if (desktopEnabled) {
+                        let permission = await Notification.requestPermission();
+                        if (permission !== "granted") {
+                            return openScreen({
+                                id: "error",
+                                error: "DeniedNotification"
+                            });
+                        }
+                    }
+
+                    props.dispatcher({
+                        type: "SETTINGS_SET_NOTIFICATION_OPTIONS",
+                        options: { desktopEnabled }
+                    });
+                }}
+            >
+                <Text id="app.settings.pages.notifications.enable_desktop" />
+                <p>
+                    <Text id="app.settings.pages.notifications.descriptions.enable_desktop" />
+                </p>
+            </Checkbox>
+            <Checkbox
+                disabled={typeof pushEnabled === "undefined"}
+                checked={pushEnabled ?? false}
+                onChange={async pushEnabled => {
+                    const reg = await navigator.serviceWorker?.getRegistration();
+                    if (reg) {
+                        if (pushEnabled) {
+                            const sub = await reg.pushManager.subscribe({
+                                userVisibleOnly: true,
+                                applicationServerKey: urlBase64ToUint8Array(
+                                    client.configuration!.vapid
+                                )
+                            });
+
+                            // tell the server we just subscribed
+                            const json = sub.toJSON();
+                            if (json.keys) {
+                                client.req("POST", "/push/subscribe", {
+                                    endpoint: sub.endpoint,
+                                    ...json.keys
+                                } as any);
+                                setPushEnabled(true);
+                            }
+                        } else {
+                            const sub = await reg.pushManager.getSubscription();
+                            sub?.unsubscribe();
+                            setPushEnabled(false);
+
+                            client.req("POST", "/push/unsubscribe");
+                        }
+                    }
+                }}
+            >
+                <Text id="app.settings.pages.notifications.enable_push" />
+                <p>
+                    <Text id="app.settings.pages.notifications.descriptions.enable_push" />
+                </p>
+            </Checkbox>
+            <h3>
+                <Text id="app.settings.pages.notifications.sounds" />
+            </h3>
+            <Checkbox
+                checked={props.options?.soundEnabled ?? true}
+                onChange={soundEnabled =>
+                    props.dispatcher({
+                        type: "SETTINGS_SET_NOTIFICATION_OPTIONS",
+                        options: { soundEnabled }
+                    })
+                }
+            >
+                <Text id="app.settings.pages.notifications.enable_sound" />
+                <p>
+                    <Text id="app.settings.pages.notifications.descriptions.enable_sound" />
+                </p>
+            </Checkbox>
+            <Checkbox
+                checked={props.options?.outgoingSoundEnabled ?? true}
+                onChange={outgoingSoundEnabled =>
+                    props.dispatcher({
+                        type: "SETTINGS_SET_NOTIFICATION_OPTIONS",
+                        options: { outgoingSoundEnabled }
+                    })
+                }
+            >
+                <Text id="app.settings.pages.notifications.enable_outgoing_sound" />
+                <p>
+                    <Text id="app.settings.pages.notifications.descriptions.enable_outgoing_sound" />
+                </p>
+            </Checkbox>
+        </div>
+    );
+}
+
+export const Notifications = connectState(
+    Component,
+    state => {
+        return {
+            options: state.settings.notification
+        };
+    },
+    true
+);
diff --git a/src/pages/settings/panes/Panes.module.scss b/src/pages/settings/panes/Panes.module.scss
new file mode 100644
index 0000000..56ef54f
--- /dev/null
+++ b/src/pages/settings/panes/Panes.module.scss
@@ -0,0 +1,374 @@
+.user {
+    .banner {
+        gap: 24px;
+        width: 100%;
+        padding: 1em;
+        display: flex;
+        border-radius: 6px;
+        align-items: center;
+        background: var(--secondary-header);
+
+        .username {
+            font-size: 24px;
+        }
+
+        a {
+            transition: 0.2s ease filter;
+        }
+
+        a:hover {
+            filter: brightness(80%);
+        }
+    }
+
+    .details {
+        display: flex;
+        margin-top: 1em;
+        flex-direction: column;
+
+        > div {
+            gap: 12px;
+            padding: 4px;
+            display: flex;
+            align-items: center;
+            flex-direction: row;
+        }
+
+        .detail {
+            flex-grow: 1;
+        }
+
+        p {
+            margin: 0;
+            color: var(--tertiary-foreground);
+        }
+    }
+
+    .preview {
+        width: 100%;
+        display: grid;
+        place-items: center;
+        grid-template-columns: minmax(auto, 100%);
+        
+        > div {
+            width: 100%;
+            max-width: 560px;
+        }
+    }
+
+    .row {
+        gap: 20px;
+        display: flex;
+
+        .pfp {
+            display: flex;
+            align-items: center;
+            flex-direction: column;
+        }
+
+        .background {
+            flex-grow: 1;
+        }
+    }
+}
+
+.appearance {
+    .theme {
+        display: flex;
+        flex-direction: column;
+        width: 100%;
+    }
+
+    .themes {
+        gap: 8px;
+        width: 100%;
+        display: flex;
+
+        img {
+            cursor: pointer;
+            border-radius: 8px;
+            transition: border 0.3s;
+            border: 3px solid transparent;
+
+            &[data-active="true"] {
+                cursor: default;
+                border: 3px solid var(--accent);
+                &:hover {
+                    border: 3px solid var(--accent);
+                }
+            }
+
+            &:hover {
+                border: 3px solid var(--tertiary-background);
+            }
+        }
+    }
+
+    details {
+        font-size: 14px;
+        font-weight: 700;
+        text-transform: uppercase;
+        color: var(--secondary-foreground);
+
+        summary {
+            cursor: pointer;
+        }
+
+        /*summary {
+            display: flex;
+            flex-grow: 1;
+            &::after {
+                display: flex;
+                align-items: flex-end;
+                content: "gh";
+            }
+        }*/
+
+        /*summary::-webkit-details-marker,
+        summary::marker {
+            content: "";
+        }*/        
+    }
+
+    .emojiPack {
+        gap: 12px;
+        display: flex;
+        flex-direction: column;
+
+        .row {
+            gap: 12px;
+            display: flex;
+
+            > div {
+                flex: 1;
+                display: flex;
+                flex-direction: column;
+            }
+        }
+
+        .button {
+            padding: 2rem 1.5rem;
+            display: grid;
+            place-items: center;
+
+            cursor: pointer;
+            border-radius: 8px;
+            transition: border 0.3s;
+            background: var(--hover);
+            border: 3px solid transparent;
+
+            img {
+                max-width: 100%;
+            }
+
+            &[data-active="true"] {
+                cursor: default;
+                background: var(--secondary-background);
+                border: 3px solid var(--accent);
+
+                &:hover {
+                    border: 3px solid var(--accent);
+                }
+            }
+
+            &:hover {
+                background: var(--secondary-background);
+                border: 3px solid var(--tertiary-background);
+            }
+        }
+
+        h4 {
+            text-transform: unset;
+
+            a {
+                opacity: 0.7;
+                color: var(--accent);
+                font-weight: 600;
+                &:hover {
+                    text-decoration: underline;
+                }
+            }
+        }
+    }
+
+    .display {
+        gap: 8px;
+        display: flex;
+        flex-direction: column;
+    }
+
+    .actions {
+        gap: 8px;
+        display: flex;
+        flex-wrap: wrap;
+        margin-bottom: 8px;
+    }
+
+    .overrides {
+        display: grid;
+        grid-template-columns: 1fr 1fr;
+
+        .entry {
+            gap: 8px;
+            padding: 2px;
+            margin-top: 8px;
+
+            .override {
+                display: flex;
+            }
+
+            span {
+                flex: 1;
+                display: block;
+                font-size: 14px;
+                font-weight: 600;
+                margin-bottom: 4px;
+                text-transform: capitalize;
+            }
+
+            .picker {
+                width: 30px;
+                height: 30px;
+                flex-shrink: 0;
+                border-radius: 4px;
+                overflow: hidden;
+                margin-right: 4px;
+
+                //TOFIX - Looks wonky on Chromium
+                border: 1px solid black;
+
+                input {
+                    opacity: 0;
+                    width: 30px;
+                    height: 30px;
+                    border: none;
+                    display: block;
+                    cursor: pointer;
+                }
+            }
+
+            .text {
+                border-radius: 4px;
+                padding: 0 4px 0;
+            }
+        }
+    }
+}
+
+.sessions {
+    .session {
+        display: flex;
+    }
+
+    .entry {
+        margin: 8px 0;
+        padding: 16px;
+        display: flex;
+        border-radius: 6px;
+        flex-direction: column;
+        background: var(--secondary-header);
+
+        &[data-active="true"] {
+            color: var(--primary-background);
+            background: var(--accent);
+            margin-bottom: 20px;
+        }
+
+        &[data-deleting="true"] {
+            opacity: 0.5;
+        }
+
+        .name {
+            font-weight: 600;
+        }
+
+        .icon {
+            gap: 8px;
+            display: flex;
+            padding-right: 12px;
+            align-items: center;
+
+            svg {
+                height: 42px;
+            }
+
+            div svg {
+                height: 24px;
+            }
+        }
+
+        .label {
+            margin: 0 0 6px 0;
+            color: var(--primary-text);
+            font-size: 12px;
+            font-weight: 600;
+        }
+
+        .info {
+            flex-grow: 1;
+            display: flex;
+            flex-direction: column;
+            justify-content: center;
+
+            .name {
+                text-transform: capitalize;
+            }
+
+            .time {
+                font-size: 12px;
+                color: var(--teriary-text);
+            }
+        }
+    }
+}
+
+.notifications {
+    label {
+        margin-top: 12px;
+    }
+
+    p {
+        margin-top: 0;
+        font-size: 0.9em;
+        color: var(--secondary-foreground);
+    }
+}
+
+.languages {
+    .list {
+        .entry {
+            padding: 2px 8px;
+            height: 50px;
+            border-radius: 4px;
+        }
+
+        .entry > span > span {
+            gap: 8px;
+            display: flex;
+            align-items: center;
+            flex-direction: row;
+
+            .flag {
+                display: flex;
+                font-size: 42px;
+                line-height: 48px;
+
+                > div {
+                    display: flex;
+                    align-items: center;
+                    justify-content: center;
+                }
+            }
+
+            .description {
+                color: var(--primary-text);
+            }
+        }
+    }
+}
+
+.feedback .options {
+    gap: 10px;
+    display: flex;
+    flex-direction: column;
+}
diff --git a/src/pages/settings/panes/Profile.tsx b/src/pages/settings/panes/Profile.tsx
new file mode 100644
index 0000000..c87287f
--- /dev/null
+++ b/src/pages/settings/panes/Profile.tsx
@@ -0,0 +1,126 @@
+import styles from "./Panes.module.scss";
+import Button from "../../../components/ui/Button";
+import { Users } from "revolt.js/dist/api/objects";
+import { SettingsTextArea } from "../SettingsTextArea";
+import { IntlContext, Text, translate } from "preact-i18n";
+import { useContext, useEffect, useState } from "preact/hooks";
+import { FileUploader } from "../../../context/revoltjs/FileUploads";
+import { useForceUpdate, useSelf } from "../../../context/revoltjs/hooks";
+import { UserProfile } from "../../../context/intermediate/popovers/UserProfile";
+import { ClientStatus, StatusContext } from "../../../context/revoltjs/RevoltClient";
+
+export function Profile() {
+    const { intl } = useContext(IntlContext) as any;
+    const status = useContext(StatusContext);
+
+    const ctx = useForceUpdate();
+    const user = useSelf();
+    if (!user) return null;
+
+    const [profile, setProfile] = useState<undefined | Users.Profile>(
+        undefined
+    );
+
+    // ! FIXME: temporary solution
+    // ! we should just announce profile changes through WS
+    function refreshProfile() {
+        ctx.client.users
+            .fetchProfile(user!._id)
+            .then(profile => setProfile(profile ?? {}));
+    }
+
+    useEffect(() => {
+        if (profile === undefined && status === ClientStatus.ONLINE) {
+            refreshProfile();
+        }
+    }, [status]);
+
+    const [ changed, setChanged ] = useState(false);
+
+    return (
+        <div className={styles.user}>
+            <h3>
+                <Text id="app.special.modals.actions.preview" />
+            </h3>
+            <div className={styles.preview}>
+                <UserProfile
+                    user_id={user._id}
+                    dummy={true}
+                    dummyProfile={profile}
+                    onClose={() => {}}
+                />
+            </div>
+            <div className={styles.row}>
+                <div className={styles.pfp}>
+                    <h3>
+                        <Text id="app.settings.pages.profile.profile_picture" />
+                    </h3>
+                    <FileUploader
+                        width={80}
+                        height={80}
+                        style="icon"
+                        fileType="avatars"
+                        behaviour="upload"
+                        maxFileSize={4_000_000}
+                        onUpload={avatar => ctx.client.users.editUser({ avatar })}
+                        remove={() => ctx.client.users.editUser({ remove: 'Avatar' })}
+                        defaultPreview={ctx.client.users.getAvatarURL(user._id, { max_side: 256 }, true)}
+                        previewURL={ctx.client.users.getAvatarURL(user._id, { max_side: 256 }, true, true)}
+                    />
+                </div>
+                <div className={styles.background}>
+                    <h3>
+                        <Text id="app.settings.pages.profile.custom_background" />
+                    </h3>
+                    <FileUploader
+                        height={92}
+                        style="banner"
+                        behaviour="upload"
+                        fileType="backgrounds"
+                        maxFileSize={6_000_000}
+                        onUpload={async background => {
+                            await ctx.client.users.editUser({ profile: { background } });
+                            refreshProfile();
+                        }}
+                        remove={async () => {
+                            await ctx.client.users.editUser({ remove: 'ProfileBackground' });
+                            setProfile({ ...profile, background: undefined });
+                        }}
+                        previewURL={profile?.background ? ctx.client.users.getBackgroundURL(profile, { width: 1000 }, true) : undefined}
+                    />
+                </div>
+            </div>
+            <h3>
+                <Text id="app.settings.pages.profile.info" />
+            </h3>
+            <SettingsTextArea
+                maxRows={10}
+                minHeight={200}
+                maxLength={2000}
+                value={profile?.content ?? ""}
+                disabled={typeof profile === "undefined"}
+                onChange={content => {
+                    setProfile({ ...profile, content })
+                    if (!changed) setChanged(true)
+                }}
+                placeholder={translate(
+                    `app.settings.pages.profile.${
+                        typeof profile === "undefined"
+                            ? "fetching"
+                            : "placeholder"
+                    }`,
+                    "",
+                    intl.dictionary
+                )}
+            />
+            <Button contrast
+                onClick={() => {
+                    setChanged(false);
+                    ctx.client.users.editUser({ profile: { content: profile?.content } })
+                }}
+                disabled={!changed}>
+                <Text id="app.special.modals.actions.save" />
+            </Button>
+        </div>
+    );
+}
diff --git a/src/pages/settings/panes/Sessions.tsx b/src/pages/settings/panes/Sessions.tsx
new file mode 100644
index 0000000..dff8026
--- /dev/null
+++ b/src/pages/settings/panes/Sessions.tsx
@@ -0,0 +1,186 @@
+import dayjs from "dayjs";
+import { decodeTime } from "ulid";
+import { Text } from "preact-i18n";
+import styles from "./Panes.module.scss";
+import Tip from "../../../components/ui/Tip";
+import { useHistory } from "react-router-dom";
+import Button from "../../../components/ui/Button";
+import Preloader from "../../../components/ui/Preloader";
+import { useContext, useEffect, useState } from "preact/hooks";
+import { AppContext } from "../../../context/revoltjs/RevoltClient";
+
+import { HelpCircle } from "@styled-icons/feather";
+import {
+    Android,
+    Firefoxbrowser,
+    Googlechrome,
+    Ios,
+    Linux,
+    Macos,
+    Microsoftedge,
+    Safari,
+    Windows
+} from "@styled-icons/simple-icons";
+
+import relativeTime from "dayjs/plugin/relativeTime";
+dayjs.extend(relativeTime);
+
+interface Session {
+    id: string;
+    friendly_name: string;
+}
+
+export function Sessions() {
+    const client = useContext(AppContext);
+    const deviceId = client.session?.id;
+
+    const [sessions, setSessions] = useState<Session[] | undefined>(undefined);
+    const [attemptingDelete, setDelete] = useState<string[]>([]);
+    const history = useHistory();
+
+    function switchPage(to: string) {
+        history.replace(`/settings/${to}`);
+    }
+
+    useEffect(() => {
+        client.req("GET", "/auth/sessions").then(data => {
+            data.sort(
+                (a, b) =>
+                    (b.id === deviceId ? 1 : 0) - (a.id === deviceId ? 1 : 0)
+            );
+            setSessions(data);
+        });
+    }, []);
+
+    if (typeof sessions === "undefined") {
+        return (
+            <div className={styles.loader}>
+                <Preloader />
+            </div>
+        );
+    }
+
+    function getIcon(session: Session) {
+        const name = session.friendly_name;
+        switch (true) {
+            case /firefox/i.test(name):
+                return <Firefoxbrowser />;
+            case /chrome/i.test(name):
+                return <Googlechrome />;
+            case /safari/i.test(name):
+                return <Safari />;
+            case /edge/i.test(name):
+                return <Microsoftedge />;
+            default:
+                return <HelpCircle />;
+        }
+    }
+
+    function getSystemIcon(session: Session) {
+        const name = session.friendly_name;
+        switch (true) {
+            case /linux/i.test(name):
+                return <Linux />;
+            case /android/i.test(name):
+                return <Android />;
+            case /mac.*os/i.test(name):
+                return <Macos />;
+            case /ios/i.test(name):
+                return <Ios />;
+            case /windows/i.test(name):
+                return <Windows />;
+            default:
+                return null;
+        }
+    }
+
+    const mapped = sessions.map(session => {
+        return {
+            ...session,
+            timestamp: decodeTime(session.id)
+        };
+    });
+
+    mapped.sort((a, b) => b.timestamp - a.timestamp);
+    let id = mapped.findIndex(x => x.id === deviceId);
+
+    const render = [
+        mapped[id],
+        ...mapped.slice(0, id),
+        ...mapped.slice(id + 1, mapped.length)
+    ];
+
+    return (
+        <div className={styles.sessions}>
+            <h3>
+                <Text id="app.settings.pages.sessions.active_sessions" />
+            </h3>
+            {render.map(session => (
+                <div
+                    className={styles.entry}
+                    data-active={session.id === deviceId}
+                    data-deleting={attemptingDelete.indexOf(session.id) > -1}
+                >
+                    {deviceId === session.id && (
+                        <span className={styles.label}>
+                            <Text id="app.settings.pages.sessions.this_device" />{" "}
+                        </span>
+                    )}
+                    <div className={styles.session}>
+                        <div className={styles.icon}>
+                            {getIcon(session)}
+                            <div>{getSystemIcon(session)}</div>
+                        </div>
+                        <div className={styles.info}>
+                            <span className={styles.name}>
+                                {session.friendly_name}
+                            </span>
+                            <span className={styles.time}>
+                                <Text
+                                    id="app.settings.pages.sessions.created"
+                                    fields={{
+                                        time_ago: dayjs(
+                                            session.timestamp
+                                        ).fromNow()
+                                    }}
+                                />
+                            </span>
+                        </div>
+                        {deviceId !== session.id && (
+                            <Button
+                                onClick={async () => {
+                                    setDelete([
+                                        ...attemptingDelete,
+                                        session.id
+                                    ]);
+                                    await client.req(
+                                        "DELETE",
+                                        `/auth/sessions/${session.id}` as any
+                                    );
+                                    setSessions(
+                                        sessions?.filter(
+                                            x => x.id !== session.id
+                                        )
+                                    );
+                                }}
+                                disabled={
+                                    attemptingDelete.indexOf(session.id) > -1
+                                }
+                            >
+                                <Text id="app.settings.pages.logOut" />
+                            </Button>
+                        )}
+                    </div>
+                </div>
+            ))}
+            <Tip>
+                <span>
+                    <Text id="app.settings.tips.sessions.a" />
+                </span>{" "}
+                <a onClick={() => switchPage("account")}>
+                    <Text id="app.settings.tips.sessions.b" />
+                </a>
+            </Tip>
+        </div>
+    );
+}
diff --git a/src/pages/settings/panes/Sync.tsx b/src/pages/settings/panes/Sync.tsx
new file mode 100644
index 0000000..e563128
--- /dev/null
+++ b/src/pages/settings/panes/Sync.tsx
@@ -0,0 +1,53 @@
+import { Text } from "preact-i18n";
+import styles from "./Panes.module.scss";
+import Checkbox from "../../../components/ui/Checkbox";
+import { connectState } from "../../../redux/connector";
+import { WithDispatcher } from "../../../redux/reducers";
+import { SyncKeys, SyncOptions } from "../../../redux/reducers/sync";
+
+interface Props {
+    options?: SyncOptions;
+}
+
+export function Component(props: Props & WithDispatcher) {
+    return (
+        <div className={styles.notifications}>
+            <h3>
+                <Text id="app.settings.pages.sync.categories" />
+            </h3>
+            {
+                ([
+                    ['appearance', 'appearance.title'],
+                    ['theme', 'appearance.theme'],
+                    ['locale', 'language.title']
+                ] as [ SyncKeys, string ][]).map(
+                    ([ key, title ]) =>
+                        <Checkbox
+                            checked={(props.options?.disabled ?? []).indexOf(key) === -1}
+                            onChange={enabled => {
+                                props.dispatcher({
+                                    type: enabled ? 'SYNC_ENABLE_KEY' : 'SYNC_DISABLE_KEY',
+                                    key
+                                });
+                            }}
+                        >
+                            <Text id={`app.settings.pages.${title}`} />
+                            <p>
+                                <Text id={`app.settings.pages.sync.descriptions.${key}`} />
+                            </p>
+                        </Checkbox>
+                )
+            }
+        </div>
+    );
+}
+
+export const Sync = connectState(
+    Component,
+    state => {
+        return {
+            options: state.sync
+        };
+    },
+    true
+);
diff --git a/src/pages/settings/server/Bans.tsx b/src/pages/settings/server/Bans.tsx
new file mode 100644
index 0000000..7d8a159
--- /dev/null
+++ b/src/pages/settings/server/Bans.tsx
@@ -0,0 +1,23 @@
+import { Servers } from "revolt.js/dist/api/objects";
+import { useContext, useEffect, useState } from "preact/hooks";
+import { AppContext } from "../../../context/revoltjs/RevoltClient";
+
+interface Props {
+    server: Servers.Server;
+}
+
+export function Bans({ server }: Props) {
+    const client = useContext(AppContext);
+    const [bans, setBans] = useState<Servers.Ban[] | undefined>(undefined);
+
+    useEffect(() => {
+        client.servers.fetchBans(server._id)
+            .then(bans => setBans(bans))
+    }, [ ]);
+
+    return (
+        <div>
+            { bans?.map(x => <div>{x._id.user}: {x.reason ?? 'no reason'} <button onClick={() => client.servers.unbanUser(server._id, x._id.user)}>unban</button></div>) }
+        </div>
+    );
+}
diff --git a/src/pages/settings/server/Invites.tsx b/src/pages/settings/server/Invites.tsx
new file mode 100644
index 0000000..115677c
--- /dev/null
+++ b/src/pages/settings/server/Invites.tsx
@@ -0,0 +1,70 @@
+import styles from './Panes.module.scss';
+import { XCircle } from "@styled-icons/feather";
+import { useEffect, useState } from "preact/hooks";
+import Preloader from "../../../components/ui/Preloader";
+import UserIcon from "../../../components/common/UserIcon";
+import IconButton from "../../../components/ui/IconButton";
+import { getChannelName } from "../../../context/revoltjs/util";
+import { Invites as InvitesNS, Servers } from "revolt.js/dist/api/objects";
+import { useChannels, useForceUpdate, useUsers } from "../../../context/revoltjs/hooks";
+
+interface Props {
+    server: Servers.Server;
+}
+
+export function Invites({ server }: Props) {
+    const [invites, setInvites] = useState<InvitesNS.ServerInvite[] | undefined>(undefined);
+
+    const ctx = useForceUpdate();
+    const [deleting, setDelete] = useState<string[]>([]);
+    const users = useUsers(invites?.map(x => x.creator) ?? [], ctx);
+    const channels = useChannels(invites?.map(x => x.channel) ?? [], ctx);
+
+    useEffect(() => {
+        ctx.client.servers.fetchInvites(server._id)
+            .then(invites => setInvites(invites))
+    }, [ ]);
+
+    return (
+        <div className={styles.invites}>
+            { typeof invites === 'undefined' && <Preloader /> }
+            {
+                invites?.map(
+                    invite => {
+                        let creator = users.find(x => x?._id === invite.creator);
+                        let channel = channels.find(x => x?._id === invite.channel);
+
+                        return (
+                            <div className={styles.invite}
+                                data-deleting={deleting.indexOf(invite._id) > -1}>
+                                <code>{ invite._id }</code>
+                                <span>
+                                    <UserIcon target={creator} size={24} /> {creator?.username ?? 'unknown'}
+                                </span>
+                                <span>{ (channel && creator) ? getChannelName(ctx.client, channel, [ creator ], true) : '#unknown' }</span>
+                                <IconButton
+                                    onClick={async () => {
+                                        setDelete([
+                                            ...deleting,
+                                            invite._id
+                                        ]);
+
+                                        await ctx.client.deleteInvite(invite._id);
+                                        
+                                        setInvites(
+                                            invites?.filter(
+                                                x => x._id !== invite._id
+                                            )
+                                        );
+                                    }}
+                                    disabled={deleting.indexOf(invite._id) > -1}>
+                                    <XCircle size={24} />
+                                </IconButton>
+                            </div>
+                        )
+                    }
+                )
+            }
+        </div>
+    );
+}
diff --git a/src/pages/settings/server/Members.tsx b/src/pages/settings/server/Members.tsx
new file mode 100644
index 0000000..d4fb3c8
--- /dev/null
+++ b/src/pages/settings/server/Members.tsx
@@ -0,0 +1,24 @@
+import { useEffect, useState } from "preact/hooks";
+import { Servers } from "revolt.js/dist/api/objects";
+import { useForceUpdate, useUsers } from "../../../context/revoltjs/hooks";
+
+interface Props {
+    server: Servers.Server;
+}
+
+export function Members({ server }: Props) {
+    const [members, setMembers] = useState<Servers.Member[] | undefined>(undefined);
+    const ctx = useForceUpdate();
+    const users = useUsers(members?.map(x => x._id.user) ?? [], ctx);
+
+    useEffect(() => {
+        ctx.client.servers.members.fetchMembers(server._id)
+            .then(members => setMembers(members))
+    }, [ ]);
+
+    return (
+        <div>
+            { members && members.length > 0 && users?.map(x => x && <div>@{x.username}</div>) }
+        </div>
+    );
+}
diff --git a/src/pages/settings/server/Overview.tsx b/src/pages/settings/server/Overview.tsx
new file mode 100644
index 0000000..a6d4234
--- /dev/null
+++ b/src/pages/settings/server/Overview.tsx
@@ -0,0 +1,98 @@
+import { Text } from "preact-i18n";
+import styles from './Panes.module.scss';
+import Button from "../../../components/ui/Button";
+import { Servers } from "revolt.js/dist/api/objects";
+import { SettingsTextArea } from "../SettingsTextArea";
+import InputBox from "../../../components/ui/InputBox";
+import { useContext, useEffect, useState } from "preact/hooks";
+import { AppContext } from "../../../context/revoltjs/RevoltClient";
+import { FileUploader } from "../../../context/revoltjs/FileUploads";
+
+interface Props {
+    server: Servers.Server;
+}
+
+export function Overview({ server }: Props) {
+    const client = useContext(AppContext);
+
+    const [name, setName] = useState(server.name);
+    const [description, setDescription] = useState(server.description ?? '');
+
+    useEffect(() => setName(server.name), [ server.name ]);
+    useEffect(() => setDescription(server.description ?? ''), [ server.description ]);
+
+    const [ changed, setChanged ] = useState(false);
+    function save() {
+        let changes: any = {};
+        if (name !== server.name) changes.name = name;
+        if (description !== server.description)
+            changes.description = description;
+        
+        client.servers.edit(server._id, changes);
+        setChanged(false);
+    }
+
+    return (
+        <div className={styles.overview}>
+            <div className={styles.row}>
+                <FileUploader
+                    width={80}
+                    height={80}
+                    style="icon"
+                    fileType="icons"
+                    behaviour="upload"
+                    maxFileSize={2_500_000}
+                    onUpload={icon => client.servers.edit(server._id, { icon })}
+                    previewURL={client.servers.getIconURL(server._id, { max_side: 256 }, true)}
+                    remove={() => client.servers.edit(server._id, { remove: 'Icon' })}
+                />
+                <div className={styles.name}>
+                    <h3>
+                        <Text id="app.main.servers.name" />
+                    </h3>
+                    <InputBox
+                        contrast
+                        value={name}
+                        maxLength={32}
+                        onChange={e => {
+                            setName(e.currentTarget.value)
+                            if (!changed) setChanged(true)
+                        }}
+                    />
+                </div>
+            </div>
+
+            <h3>
+                <Text id="app.main.servers.description" />
+            </h3>
+            <SettingsTextArea
+                maxRows={10}
+                minHeight={60}
+                maxLength={1024}
+                value={description}
+                placeholder={"Add a topic..."}
+                onChange={content => {
+                    setDescription(content);
+                    if (!changed) setChanged(true)
+                }}
+            />
+            <Button onClick={save} style="contrast" disabled={!changed}>
+                <Text id="app.special.modals.actions.save" />
+            </Button>
+
+            <h3>
+                <Text id="app.main.servers.custom_banner" />
+            </h3>
+            <FileUploader
+                height={160}
+                style="banner"
+                fileType="banners"
+                behaviour="upload"
+                maxFileSize={6_000_000}
+                onUpload={banner => client.servers.edit(server._id, { banner })}
+                previewURL={client.servers.getBannerURL(server._id, { width: 1000 }, true)}
+                remove={() => client.servers.edit(server._id, { remove: 'Banner' })}
+            />
+        </div>
+    );
+}
diff --git a/src/pages/settings/server/Panes.module.scss b/src/pages/settings/server/Panes.module.scss
new file mode 100644
index 0000000..7f518a6
--- /dev/null
+++ b/src/pages/settings/server/Panes.module.scss
@@ -0,0 +1,48 @@
+.overview {
+    .row {
+        gap: 20px;
+        display: flex;
+
+        .name {
+            flex-grow: 1;
+
+            input {
+                width: 100%;
+            }
+        }
+    }
+}
+
+.invites {
+    gap: 8px;
+    display: flex;
+    flex-direction: column;
+
+    .invite {
+        gap: 8px;
+        padding: 8px;
+        display: flex;
+        align-items: center;
+        flex-direction: row;
+        background: var(--secondary-background);
+
+        code, span {
+            flex: 1;
+        }
+
+        code {
+            font-size: 1.4em;
+            user-select: all;
+        }
+
+        span {
+            gap: 8px;
+            display: flex;
+            color: var(--secondary-foreground);
+        }
+
+        &[data-deleting="true"] {
+            opacity: 0.5;
+        }
+    }
+}
diff --git a/src/version.ts b/src/version.ts
index 0f4ec86..4c86db3 100644
--- a/src/version.ts
+++ b/src/version.ts
@@ -1 +1 @@
-export const APP_VERSION = "0.1.9-alpha.7";
+export const APP_VERSION = "1.0.0-vite";
diff --git a/yarn.lock b/yarn.lock
index effb671..0bf7315 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -836,7 +836,7 @@
     "@babel/types" "^7.4.4"
     esutils "^2.0.2"
 
-"@babel/runtime@^7.1.2", "@babel/runtime@^7.10.5", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.1", "@babel/runtime@^7.14.0", "@babel/runtime@^7.8.4", "@babel/runtime@^7.9.2":
+"@babel/runtime@^7.1.2", "@babel/runtime@^7.10.5", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.1", "@babel/runtime@^7.13.10", "@babel/runtime@^7.14.0", "@babel/runtime@^7.8.4", "@babel/runtime@^7.9.2":
   version "7.14.6"
   resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.14.6.tgz#535203bc0892efc7dec60bdc27b2ecf6e409062d"
   integrity sha512-/PCB2uJ7oM44tz8YhC4Z/6PeOKXp4K588f+5M3clr1M4zbqztlo0XEfJ2LEzj/FgwfgGcIdl8n7YYjTCI0BYwg==
@@ -1098,7 +1098,15 @@
     "@babel/runtime" "^7.14.0"
     "@styled-icons/styled-icon" "^10.6.3"
 
-"@styled-icons/styled-icon@^10.6.3":
+"@styled-icons/simple-icons@^10.33.0":
+  version "10.33.0"
+  resolved "https://registry.yarnpkg.com/@styled-icons/simple-icons/-/simple-icons-10.33.0.tgz#aad04f445083ab08332f90221545629f78a94bca"
+  integrity sha512-kNCBcbl3LTsknb7rMXj3DsK4WPQXRhXpYxRYu3Nw0PC+rnATZ+7J4VajFps1x6Eeq+J/c2ZLB+SAQra9QDOuFw==
+  dependencies:
+    "@babel/runtime" "^7.13.10"
+    "@styled-icons/styled-icon" "^10.6.0"
+
+"@styled-icons/styled-icon@^10.6.0", "@styled-icons/styled-icon@^10.6.3":
   version "10.6.3"
   resolved "https://registry.yarnpkg.com/@styled-icons/styled-icon/-/styled-icon-10.6.3.tgz#eae0e5e18fd601ac47e821bb9c2e099810e86403"
   integrity sha512-/A95L3peioLoWFiy+/eKRhoQ9r/oRrN/qzbSX4hXU1nGP2rUXcX3LWUhoBNAOp9Rw38ucc/4ralY427UUNtcGQ==
@@ -3421,6 +3429,11 @@ serialize-javascript@^4.0.0:
   dependencies:
     randombytes "^2.1.0"
 
+shade-blend-color@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/shade-blend-color/-/shade-blend-color-1.0.0.tgz#cfa10d3673a22ba31d552a0e793b708bc24be0bc"
+  integrity sha512-Tnp/ppF5h3YhPCpeHiZJ2DRnvmo4luu9qpMhuksCT+QInIXJ9alA3Vd9klfEi+RY8Oh7MaK5vzH/qcLo892L1g==
+
 shallowequal@^1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/shallowequal/-/shallowequal-1.1.0.tgz#188d521de95b9087404fd4dcb68b13df0ae4e7f8"
-- 
GitLab