diff --git a/.env b/.env
index d72dbacc6671a3424eda0ec96f295e662c7bc28a..f0c2383776e2a8f7436bc915a2f571f40279aa63 100644
--- a/.env
+++ b/.env
@@ -1,2 +1,2 @@
-VITE_API_URL=https://api.revolt.chat
+VITE_API_URL=http://local.revolt.chat:8000
 VITE_THEMES_URL=https://static.revolt.chat/themes
diff --git a/external/lang b/external/lang
index 9db39a2eecc5fbb7ed06d4598da60700e96e3274..210172de724fcd5adeacec221bd9da30350afc06 160000
--- a/external/lang
+++ b/external/lang
@@ -1 +1 @@
-Subproject commit 9db39a2eecc5fbb7ed06d4598da60700e96e3274
+Subproject commit 210172de724fcd5adeacec221bd9da30350afc06
diff --git a/package.json b/package.json
index 6cc365000bf7bf3b9dc6d846cbde0e4c2faafb8e..102e828264ea76eab1adadb066947f3e63105bd9 100644
--- a/package.json
+++ b/package.json
@@ -40,6 +40,7 @@
     "@types/prismjs": "^1.16.5",
     "@types/react-helmet": "^6.1.1",
     "@types/react-router-dom": "^5.1.7",
+    "@types/react-scroll": "^1.8.2",
     "@types/styled-components": "^5.1.10",
     "@types/twemoji": "^12.1.1",
     "@typescript-eslint/eslint-plugin": "^4.27.0",
@@ -66,6 +67,7 @@
     "react-overlapping-panels": "1.2.1",
     "react-redux": "^7.2.4",
     "react-router-dom": "^5.2.0",
+    "react-scroll": "^1.8.2",
     "react-tippy": "^1.4.0",
     "redux": "^4.1.0",
     "revolt.js": "4.3.0",
@@ -76,6 +78,7 @@
     "twemoji": "^13.1.0",
     "typescript": "^4.3.2",
     "ulid": "^2.3.0",
+    "use-resize-observer": "^7.0.0",
     "vite": "^2.3.7",
     "vite-plugin-pwa": "^0.8.1"
   }
diff --git a/src/components/common/ChannelIcon.tsx b/src/components/common/ChannelIcon.tsx
index db8ee8841cb851056838b2f65cc56982e413d29f..a79346249013b6ac1d035c8cc4974163f10e18c3 100644
--- a/src/components/common/ChannelIcon.tsx
+++ b/src/components/common/ChannelIcon.tsx
@@ -32,12 +32,6 @@ export default function ChannelIcon(props: Props & Omit<JSX.HTMLAttributes<HTMLI
             height={size}
             aria-hidden="true"
             square={isServerChannel}
-            src={iconURL ?? fallback}
-            onError={ e => {
-                let el = e.currentTarget;
-                if (el.src !== fallback) {
-                    el.src = fallback
-                }
-            }} />
+            src={iconURL ?? fallback} />
     );
 }
diff --git a/src/components/common/ServerIcon.tsx b/src/components/common/ServerIcon.tsx
index 46befcbb63cbbf8704f31e61491435c302e2278e..e3db726322a2054517af8653ab9b920dfa3c19ff 100644
--- a/src/components/common/ServerIcon.tsx
+++ b/src/components/common/ServerIcon.tsx
@@ -42,12 +42,6 @@ export default function ServerIcon(props: Props & Omit<JSX.HTMLAttributes<HTMLIm
             width={size}
             height={size}
             aria-hidden="true"
-            src={iconURL}
-            onError={ e => {
-                let el = e.currentTarget;
-                if (el.src !== fallback) {
-                    el.src = fallback
-                }
-            }} />
+            src={iconURL} />
     );
 }
diff --git a/src/components/common/UserIcon.tsx b/src/components/common/UserIcon.tsx
index a78416d8e094eed3066df430dbee965fb86d81c4..5e9243fdd66ec1acab4949cd67fa3ab5a5e58a03 100644
--- a/src/components/common/UserIcon.tsx
+++ b/src/components/common/UserIcon.tsx
@@ -54,7 +54,7 @@ export default function UserIcon(props: Props & Omit<JSX.SVGAttributes<SVGSVGEle
 
     const { target, attachment, size, voice, status, animate, children, as, ...svgProps } = props;
     const iconURL = client.generateFileURL(target?.avatar ?? attachment, { max_side: 256 }, animate)
-        ?? (target && client.users.getDefaultAvatarURL(target._id));
+        ?? (target ? client.users.getDefaultAvatarURL(target._id) : fallback);
 
     return (
         <IconBase {...svgProps}
@@ -65,13 +65,7 @@ export default function UserIcon(props: Props & Omit<JSX.SVGAttributes<SVGSVGEle
             <foreignObject x="0" y="0" width="32" height="32">
                 {
                     <img src={iconURL}
-                        draggable={false}
-                        onError={ e => {
-                            let el = e.currentTarget;
-                            if (el.src !== fallback) {
-                                el.src = fallback
-                            }
-                        }} />
+                        draggable={false} />
                 }
             </foreignObject>
             {props.status && (
diff --git a/src/components/common/UserShort.tsx b/src/components/common/UserShort.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..de16edf4dace6f8d2558713b96a359d2a330864b
--- /dev/null
+++ b/src/components/common/UserShort.tsx
@@ -0,0 +1,14 @@
+import { User } from "revolt.js";
+import UserIcon from "./UserIcon";
+import { Text } from "preact-i18n";
+
+export function Username({ user }: { user?: User }) {
+    return <b>{ user?.username ?? <Text id="app.main.channel.unknown_user" /> }</b>;
+}
+
+export default function UserShort({ user }: { user?: User }) {
+    return <>
+        <UserIcon size={24} target={user} />
+        <Username user={user} />
+    </>;
+}
diff --git a/src/components/common/messaging/Message.tsx b/src/components/common/messaging/Message.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..03c13596de618cc0c024b0480bd5b1d0b2ecbf13
--- /dev/null
+++ b/src/components/common/messaging/Message.tsx
@@ -0,0 +1,37 @@
+import UserIcon from "../UserIcon";
+import { Username } from "../UserShort";
+import Markdown from "../../markdown/Markdown";
+import { Children } from "../../../types/Preact";
+import { attachContextMenu } from "preact-context-menu";
+import { useUser } from "../../../context/revoltjs/hooks";
+import { MessageObject } from "../../../context/revoltjs/util";
+import MessageBase, { MessageContent, MessageDetail, MessageInfo } from "./MessageBase";
+
+interface Props {
+    attachContext?: boolean
+    message: MessageObject
+    contrast?: boolean
+    content?: Children
+    head?: boolean
+}
+
+export default function Message({ attachContext, message, contrast, content, head }: Props) {
+    // TODO: Can improve re-renders here by providing a list
+    // TODO: of dependencies. We only need to update on u/avatar.
+    let user = useUser(message.author);
+
+    return (
+        <MessageBase contrast={contrast}
+            onContextMenu={attachContext ? attachContextMenu('Menu', { message, contextualChannel: message.channel }) : undefined}>
+            <MessageInfo>
+                { head ?
+                    <UserIcon target={user} size={36} /> :
+                    <MessageDetail message={message} /> }
+            </MessageInfo>
+            <MessageContent>
+                { head && <Username user={user} /> }
+                { content ?? <Markdown content={message.content as string} /> }
+            </MessageContent>
+        </MessageBase>
+    )
+}
diff --git a/src/components/common/messaging/MessageBase.tsx b/src/components/common/messaging/MessageBase.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..209e6408beb80f259883b9202f087cd127776f30
--- /dev/null
+++ b/src/components/common/messaging/MessageBase.tsx
@@ -0,0 +1,111 @@
+import dayjs from "dayjs";
+import styled, { css } from "styled-components";
+import { decodeTime } from "ulid";
+import { MessageObject } from "../../../context/revoltjs/util";
+
+export interface BaseMessageProps {
+    head?: boolean,
+    status?: boolean,
+    mention?: boolean,
+    blocked?: boolean,
+    sending?: boolean,
+    contrast?: boolean
+}
+
+export default styled.div<BaseMessageProps>`
+    display: flex;
+    overflow-x: none;
+    padding: .125rem;
+    flex-direction: row;
+    padding-right: 16px;
+
+    ${ props => props.contrast && css`
+        padding: .3rem;
+        border-radius: 4px;
+        background: var(--hover);
+    ` }
+
+    ${ props => props.head && css`
+        margin-top: 12px;
+    ` }
+
+    ${ props => props.mention && css`
+        background: var(--mention);
+    ` }
+
+    ${ props => props.blocked && css`
+        filter: blur(4px);
+        transition: 0.2s ease filter;
+
+        &:hover {
+            filter: none;
+        }
+    ` }
+
+    ${ props => props.sending && css`
+        opacity: 0.8;
+        color: var(--tertiary-foreground);
+    ` }
+
+    ${ props => props.status && css`
+        color: var(--error);
+    ` }
+    
+    .copy {
+        width: 0;
+        opacity: 0;
+    }
+
+    &:hover {
+        background: var(--hover);
+
+        time {
+            opacity: 1;
+        }
+    }
+`;
+
+export const MessageInfo = styled.div`
+    width: 62px;
+    display: flex;
+    flex-shrink: 0;
+    padding-top: 2px;
+    flex-direction: row;
+    justify-content: center;
+
+    ::selection {
+        background-color: transparent;
+        color: var(--tertiary-foreground);
+    }
+
+    time {
+        opacity: 0;
+        cursor: default;
+        display: inline;
+        font-size: 10px;
+        padding-top: 1px;
+        color: var(--tertiary-foreground);
+    }
+`;
+
+export const MessageContent = styled.div`
+    min-width: 0;
+    flex-grow: 1;
+    display: flex;
+    overflow: hidden;
+    font-size: 0.875rem;
+    flex-direction: column;
+    justify-content: center;
+`;
+
+export function MessageDetail({ message }: { message: MessageObject }) {
+    return (
+        <>
+            <time>
+                <i className="copy">[</i>
+                {dayjs(decodeTime(message._id)).format("H:mm")}
+                <i className="copy">]</i>
+            </time>
+        </>
+    )
+}
diff --git a/src/components/common/messaging/SystemMessage.tsx b/src/components/common/messaging/SystemMessage.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..c867f915601fb21424cb697618537c7851906ab5
--- /dev/null
+++ b/src/components/common/messaging/SystemMessage.tsx
@@ -0,0 +1,152 @@
+import { User } from "revolt.js";
+import classNames from "classnames";
+import { attachContextMenu } from "preact-context-menu";
+import { MessageObject } from "../../../context/revoltjs/util";
+import { useForceUpdate, useUser } from "../../../context/revoltjs/hooks";
+import { TextReact } from "../../../lib/i18n";
+import UserIcon from "../UserIcon";
+import Username from "../UserShort";
+import UserShort from "../UserShort";
+import MessageBase, { MessageDetail, MessageInfo } from "./MessageBase";
+import styled from "styled-components";
+
+const SystemContent = styled.div`
+    gap: 4px;
+    display: flex;
+    padding: 2px 0;
+    flex-wrap: wrap;
+    align-items: center;
+    flex-direction: row;
+`;
+
+type SystemMessageParsed =
+    | { type: "text"; content: string }
+    | { type: "user_added"; user: User; by: User }
+    | { type: "user_remove"; user: User; by: User }
+    | { type: "user_joined"; user: User }
+    | { type: "user_left"; user: User }
+    | { type: "user_kicked"; user: User }
+    | { type: "user_banned"; user: User }
+    | { type: "channel_renamed"; name: string; by: User }
+    | { type: "channel_description_changed"; by: User }
+    | { type: "channel_icon_changed"; by: User };
+
+interface Props {
+    attachContext?: boolean;
+    message: MessageObject;
+}
+
+export function SystemMessage({ attachContext, message }: Props) {
+    const ctx = useForceUpdate();
+
+    let data: SystemMessageParsed;
+    let content = message.content;
+    if (typeof content === "object") {
+        switch (content.type) {
+            case "text":
+                data = content;
+                break;
+            case "user_added":
+            case "user_remove":
+                data = {
+                    type: content.type,
+                    user: useUser(content.id, ctx) as User,
+                    by: useUser(content.by, ctx) as User
+                };
+                break;
+            case "user_joined":
+            case "user_left":
+            case "user_kicked":
+            case "user_banned":
+                data = {
+                    type: content.type,
+                    user: useUser(content.id, ctx) as User
+                };
+                break;
+            case "channel_renamed":
+                data = {
+                    type: "channel_renamed",
+                    name: content.name,
+                    by: useUser(content.by, ctx) as User
+                };
+                break;
+            case "channel_description_changed":
+            case "channel_icon_changed":
+                data = {
+                    type: content.type,
+                    by: useUser(content.by, ctx) as User
+                };
+                break;
+            default:
+                data = { type: "text", content: JSON.stringify(content) };
+        }
+    } else {
+        data = { type: "text", content };
+    }
+
+    let children;
+    switch (data.type) {
+        case "text":
+            children = <span>{data.content}</span>;
+            break;
+        case "user_added":
+        case "user_remove":
+            children = (
+                <TextReact
+                    id={`app.main.channel.system.${data.type === 'user_added' ? "added_by" : "removed_by"}`}
+                    fields={{
+                        user: <UserShort user={data.user} />,
+                        other_user: <UserShort user={data.by} />
+                    }}
+                />
+            );
+            break;
+        case "user_joined":
+        case "user_left":
+        case "user_kicked":
+        case "user_banned":
+            children = (
+                <TextReact
+                    id={`app.main.channel.system.${data.type}`}
+                    fields={{
+                        user: <UserShort user={data.user} />
+                    }}
+                />
+            );
+            break;
+        case "channel_renamed":
+            children = (
+                <TextReact
+                    id={`app.main.channel.system.channel_renamed`}
+                    fields={{
+                        user: <UserShort user={data.by} />,
+                        name: <b>{data.name}</b>
+                    }}
+                />
+            );
+            break;
+        case "channel_description_changed":
+        case "channel_icon_changed":
+            children = (
+                <TextReact
+                    id={`app.main.channel.system.${data.type}`}
+                    fields={{
+                        user: <UserShort user={data.by} />
+                    }}
+                />
+            );
+            break;
+    }
+
+    return (
+        <MessageBase
+            onContextMenu={attachContext ? attachContextMenu('Menu',
+                { message, contextualChannel: message.channel }
+            ) : undefined}>
+            <MessageInfo>
+                <MessageDetail message={message} />
+            </MessageInfo>
+            <SystemContent>{children}</SystemContent>
+        </MessageBase>
+    );
+}
diff --git a/src/components/markdown/Markdown.tsx b/src/components/markdown/Markdown.tsx
index b98e8280becce0c70d09521989712c7eb69aed64..1dffb1c07b61cb3ecee05003482494b3e3a04054 100644
--- a/src/components/markdown/Markdown.tsx
+++ b/src/components/markdown/Markdown.tsx
@@ -10,7 +10,7 @@ export interface MarkdownProps {
 export default function Markdown(props: MarkdownProps) {
     return (
         // @ts-expect-error
-        <Suspense fallback="Getting ready to render Markdown...">
+        <Suspense fallback={props.content}>
             <Renderer {...props} />
         </Suspense>
     )
diff --git a/src/components/ui/DateDivider.tsx b/src/components/ui/DateDivider.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..f310753dec1639fb25faf30597bd76e9e162168e
--- /dev/null
+++ b/src/components/ui/DateDivider.tsx
@@ -0,0 +1,48 @@
+import dayjs from "dayjs";
+import styled, { css } from "styled-components";
+
+const Base = styled.div<{ unread?: boolean }>`
+    height: 0;
+    display: flex;
+    margin: 14px 10px;
+    user-select: none;
+    align-items: center;
+    border-top: thin solid var(--tertiary-foreground);
+
+    time {
+        margin-top: -2px;
+        font-size: .6875rem;
+        line-height: .6875rem;
+        padding: 2px 5px 2px 0;
+        color: var(--tertiary-foreground);
+        background: var(--primary-background);
+    }
+
+    ${ props => props.unread && css`
+        border-top: thin solid var(--accent);
+    ` }
+`;
+
+const Unread = styled.div`
+    background: var(--accent);
+    color: white;
+    padding: 5px 8px;
+    border-radius: 60px;
+    font-weight: 600;
+`;
+
+interface Props {
+    date: Date;
+    unread?: boolean;
+}
+
+export default function DateDivider(props: Props) {
+    return (
+        <Base unread={props.unread}>
+            { props.unread && <Unread>NEW</Unread> }
+            <time>
+                { dayjs(props.date).format("LL") }
+            </time>
+        </Base>
+    );
+}
diff --git a/src/context/Locale.tsx b/src/context/Locale.tsx
index 737eaff972b70cd6754b0ad91e6e437dbb0c1d28..df9b1eff74b282c4f85446e30a2ec4d710085485 100644
--- a/src/context/Locale.tsx
+++ b/src/context/Locale.tsx
@@ -1,7 +1,7 @@
 import { IntlProvider } from "preact-i18n";
 import { connectState } from "../redux/connector";
-import definition from "../../external/lang/en.json";
 import { useEffect, useState } from "preact/hooks";
+import definition from "../../external/lang/en.json";
 
 import dayjs from "dayjs";
 import calendar from "dayjs/plugin/calendar";
diff --git a/src/context/intermediate/modals/Prompt.tsx b/src/context/intermediate/modals/Prompt.tsx
index 645d395e30ca494cc5d9792932c765d5b50120f4..3a1dac547142e85598ddc51d80bec398311c0df0 100644
--- a/src/context/intermediate/modals/Prompt.tsx
+++ b/src/context/intermediate/modals/Prompt.tsx
@@ -9,7 +9,8 @@ import Modal, { Action } from "../../../components/ui/Modal";
 import { Channels, Servers } from "revolt.js/dist/api/objects";
 import { useContext, useEffect, useState } from "preact/hooks";
 import { AppContext } from "../../revoltjs/RevoltClient";
-import { takeError } from "../../revoltjs/util";
+import { mapMessage, takeError } from "../../revoltjs/util";
+import Message from "../../../components/common/messaging/Message";
 
 interface Props {
     onClose: () => void;
@@ -57,26 +58,23 @@ export function SpecialPromptModal(props: SpecialProps) {
         case 'close_dm':
         case 'leave_server':
         case 'delete_server': 
-        case 'delete_message': 
         case 'delete_channel': {
             const EVENTS = {
                 'close_dm':       'confirm_close_dm',
                 'delete_server':  'confirm_delete',
                 'delete_channel': 'confirm_delete',
-                'delete_message': 'confirm_delete_message',
                 'leave_group':    'confirm_leave',
                 'leave_server':   'confirm_leave'
             };
 
             let event = EVENTS[props.type];
-            let name = props.type === 'close_dm' ? client.users.get(client.channels.getRecipient(props.target._id))?.username : 
-                 props.type === 'delete_message' ? undefined : props.target.name;
+            let name = props.type === 'close_dm' ? client.users.get(client.channels.getRecipient(props.target._id))?.username : props.target.name;
 
             return (
                 <PromptModal
                     onClose={onClose}
                     question={<Text
-                        id={props.type === 'delete_message' ? 'app.context_menu.delete_message' : `app.special.modals.prompt.${event}`}
+                        id={`app.special.modals.prompt.${event}`}
                         fields={{ name }}
                     />}
                     actions={[
@@ -91,8 +89,6 @@ export function SpecialPromptModal(props: SpecialProps) {
                                 try {
                                     if (props.type === 'leave_group' || props.type === 'close_dm' || props.type === 'delete_channel') {
                                         await client.channels.delete(props.target._id);
-                                    } else if (props.type === 'delete_message') {
-                                        await client.channels.deleteMessage(props.target.channel, props.target._id);
                                     } else {
                                         await client.servers.delete(props.target._id);
                                     }
@@ -112,6 +108,41 @@ export function SpecialPromptModal(props: SpecialProps) {
                 />
             )
         }
+        case 'delete_message': {
+            return (
+                <PromptModal
+                    onClose={onClose}
+                    question={<Text id={'app.context_menu.delete_message'} />}
+                    actions={[
+                        {
+                            confirmation: true,
+                            contrast: true,
+                            error: true,
+                            text: <Text id="app.special.modals.actions.delete" />,
+                            onClick: async () => {
+                                setProcessing(true);
+
+                                try {
+                                    await client.channels.deleteMessage(props.target.channel, props.target._id);
+
+                                    onClose();
+                                } catch (err) {
+                                    setError(takeError(err));
+                                    setProcessing(false);
+                                }
+                            }
+                        },
+                        { text: <Text id="app.special.modals.actions.cancel" />, onClick: onClose }
+                    ]}
+                    content={<>
+                        <Text id={`app.special.modals.prompt.confirm_delete_message_long`} />
+                        <Message message={mapMessage(props.target)} head={true} contrast />
+                    </>}
+                    disabled={processing}
+                    error={error}
+                />
+            )
+        }
         case "create_invite": {
             const [ code, setCode ] = useState('abcdef');
             const { writeClipboard } = useIntermediate();
diff --git a/src/context/revoltjs/RevoltClient.tsx b/src/context/revoltjs/RevoltClient.tsx
index 8f86c82439dd115a732df63ba59a4287aaf73d68..5c2ee715c4eb2189355852353c916767250097de 100644
--- a/src/context/revoltjs/RevoltClient.tsx
+++ b/src/context/revoltjs/RevoltClient.tsx
@@ -4,13 +4,14 @@ import { takeError } from "./util";
 import { createContext } from "preact";
 import { Children } from "../../types/Preact";
 import { Route } from "revolt.js/dist/api/routes";
-import { useEffect, useMemo, useState } from "preact/hooks";
 import { connectState } from "../../redux/connector";
 import Preloader from "../../components/ui/Preloader";
 import { WithDispatcher } from "../../redux/reducers";
 import { AuthState } from "../../redux/reducers/auth";
 import { SyncOptions } from "../../redux/reducers/sync";
+import { useEffect, useMemo, useState } from "preact/hooks";
 import { registerEvents, setReconnectDisallowed } from "./events";
+import { SingletonMessageRenderer } from '../../lib/renderer/Singleton';
 
 export enum ClientStatus {
     INIT,
@@ -61,13 +62,15 @@ function Context({ auth, sync, children, dispatcher }: Props) {
                 console.error('Failed to open IndexedDB store, continuing without.');
             }
 
-            setClient(new Client({
+            const client = new Client({
                 autoReconnect: false,
                 apiURL: import.meta.env.VITE_API_URL,
                 debug: import.meta.env.DEV,
                 db
-            }));
+            });
 
+            setClient(client);
+            SingletonMessageRenderer.subscribe(client);
             setStatus(ClientStatus.LOADING);
         })();
     }, [ ]);
@@ -131,10 +134,7 @@ function Context({ auth, sync, children, dispatcher }: Props) {
         }
     }, [ client, auth.active ]);
 
-    useEffect(
-        () => registerEvents({ operations, dispatcher }, setStatus, client),
-        [ client ]
-    );
+    useEffect(() => registerEvents({ operations, dispatcher }, setStatus, client), [ client ]);
 
     useEffect(() => {
         (async () => {
diff --git a/src/context/revoltjs/util.tsx b/src/context/revoltjs/util.tsx
index 71e760a68c74b6144bd487048932ca77d829a106..a611d3e1eac8d3c80cbc80e8cfbb2e249fb05605 100644
--- a/src/context/revoltjs/util.tsx
+++ b/src/context/revoltjs/util.tsx
@@ -22,14 +22,13 @@ export function takeError(
     return id;
 }
 
-export function getChannelName(client: Client, channel: Channel, users: User[], prefixType?: boolean): Children {
+export function getChannelName(client: Client, channel: Channel, prefixType?: boolean): Children {
     if (channel.channel_type === "SavedMessages")
         return <Text id="app.navigation.tabs.saved" />;
 
     if (channel.channel_type === "DirectMessage") {
         let uid = client.channels.getRecipient(channel._id);
-
-        return <>{prefixType && "@"}{users.find(x => x._id === uid)?.username}</>;
+        return <>{prefixType && "@"}{client.users.get(uid)?.username}</>;
     }
 
     if (channel.channel_type === "TextChannel" && prefixType) {
diff --git a/src/lib/i18n.tsx b/src/lib/i18n.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..5f270b2eac5c7524f0dfd58c0f7d6a781f3c0d17
--- /dev/null
+++ b/src/lib/i18n.tsx
@@ -0,0 +1,54 @@
+import { IntlContext } from "preact-i18n";
+import { useContext } from "preact/hooks";
+import { Children } from "../types/Preact";
+
+interface Fields {
+    [key: string]: Children
+}
+
+interface Props {
+    id: string;
+    fields: Fields
+}
+
+export interface IntlType {
+    intl: {
+        dictionary: {
+            [key: string]: Object | string
+        }
+    }
+}
+
+// This will exhibit O(2^n) behaviour.
+function recursiveReplaceFields(input: string, fields: Fields) {
+    const key = Object.keys(fields)[0];
+    if (key) {
+        const { [key]: field, ...restOfFields } = fields;
+        if (typeof field === 'undefined') return [ input ];
+
+        const values: (Children | string[])[] = input.split(`{{${key}}}`)
+            .map(v => recursiveReplaceFields(v, restOfFields));
+
+        for (let i=values.length - 1;i>0;i-=2) {
+            values.splice(i, 0, field);
+        }
+        
+        return values.flat();
+    } else {
+        // base case
+        return [ input ];
+    }
+}
+
+export function TextReact({ id, fields }: Props) {
+    const { intl } = useContext(IntlContext) as unknown as IntlType;
+
+    const path = id.split('.');
+    let entry = intl.dictionary[path.shift()!];
+    for (let key of path) {
+        // @ts-expect-error
+        entry = entry[key];
+    }
+
+    return <>{ recursiveReplaceFields(entry as string, fields) }</>;
+}
diff --git a/src/lib/renderer/Singleton.ts b/src/lib/renderer/Singleton.ts
new file mode 100644
index 0000000000000000000000000000000000000000..da4acadbe0f3694499d8fd2a21ca1c3b6c0cb78a
--- /dev/null
+++ b/src/lib/renderer/Singleton.ts
@@ -0,0 +1,192 @@
+import { RendererRoutines, RenderState, ScrollState } from "./types";
+import { SimpleRenderer } from "./simple/SimpleRenderer";
+import { useEffect, useState } from "preact/hooks";
+import EventEmitter3 from 'eventemitter3';
+import { Client, Message } from "revolt.js";
+
+export const SMOOTH_SCROLL_ON_RECEIVE = false;
+
+export class SingletonRenderer extends EventEmitter3 {
+    client?: Client;
+    channel?: string;
+    state: RenderState;
+    currentRenderer: RendererRoutines;
+
+    stale = false;
+    fetchingTop = false;
+    fetchingBottom = false;
+
+    constructor() {
+        super();
+
+        this.receive = this.receive.bind(this);
+        this.edit = this.edit.bind(this);
+        this.delete = this.delete.bind(this);
+
+        this.state = { type: 'LOADING' };
+        this.currentRenderer = SimpleRenderer;
+    }
+
+    private receive(message: Message) {
+        this.currentRenderer.receive(this, message);
+    }
+
+    private edit(id: string, patch: Partial<Message>) {
+        this.currentRenderer.edit(this, id, patch);
+    }
+
+    private delete(id: string) {
+        this.currentRenderer.delete(this, id);
+    }
+
+    subscribe(client: Client) {
+        if (this.client) {
+            this.client.removeListener('message', this.receive);
+            this.client.removeListener('message/update', this.edit);
+            this.client.removeListener('message/delete', this.delete);
+        }
+
+        this.client = client;
+        client.addListener('message', this.receive);
+        client.addListener('message/update', this.edit);
+        client.addListener('message/delete', this.delete);
+    }
+
+    private setStateUnguarded(state: RenderState, scroll?: ScrollState) {
+        this.state = state;
+        this.emit('state', state);
+
+        if (scroll) {
+            this.emit('scroll', scroll);
+        }
+    }
+
+    setState(id: string, state: RenderState, scroll?: ScrollState) {
+        if (id !== this.channel) return;
+        this.setStateUnguarded(state, scroll);
+    }
+
+    markStale() {
+        this.stale = true;   
+    }
+
+    async init(id: string) {
+        this.channel = id;
+        this.stale = false;
+        this.setStateUnguarded({ type: 'LOADING' });
+        await this.currentRenderer.init(this, id);
+    }
+
+    async reloadStale(id: string) {
+        if (this.stale) {
+            this.stale = false;
+            await this.init(id);
+        }
+    }
+
+    async loadTop(ref?: HTMLDivElement) {
+        if (this.fetchingTop) return;
+        this.fetchingTop = true;
+
+        function generateScroll(end: string): ScrollState {
+            if (ref) {
+                let heightRemoved = 0;
+                let messageContainer = ref.children[0];
+                if (messageContainer) {
+                    for (let child of Array.from(messageContainer.children)) {
+                        // If this child has a ulid.
+                        if (child.id?.length === 26) {
+                            // Check whether it was removed.
+                            if (child.id.localeCompare(end) === 1) {
+                                heightRemoved += child.clientHeight +
+                                    // We also need to take into account the top margin of the container.
+                                    parseInt(window.getComputedStyle(child).marginTop.slice(0, -2));
+                            }
+                        }
+                    }
+                }
+
+                return {
+                    type: 'OffsetTop',
+                    previousHeight: ref.scrollHeight - heightRemoved
+                }
+            } else {
+                return {
+                    type: 'OffsetTop',
+                    previousHeight: 0
+                }
+            }
+        }
+
+        await this.currentRenderer.loadTop(this, generateScroll);
+
+        // Allow state updates to propagate.
+        setTimeout(() => this.fetchingTop = false, 0);
+    }
+
+    async loadBottom(ref?: HTMLDivElement) {
+        if (this.fetchingBottom) return;
+        this.fetchingBottom = true;
+
+        function generateScroll(start: string): ScrollState {
+            if (ref) {
+                let heightRemoved = 0;
+                let messageContainer = ref.children[0];
+                if (messageContainer) {
+                    for (let child of Array.from(messageContainer.children)) {
+                        // If this child has a ulid.
+                        if (child.id?.length === 26) {
+                            // Check whether it was removed.
+                            if (child.id.localeCompare(start) === -1) {
+                                heightRemoved += child.clientHeight +
+                                    // We also need to take into account the top margin of the container.
+                                    parseInt(window.getComputedStyle(child).marginTop.slice(0, -2));
+                            }
+                        }
+                    }
+                }
+
+                return {
+                    type: 'ScrollTop',
+                    y: ref.scrollTop - heightRemoved
+                }
+            } else {
+                return {
+                    type: 'ScrollToBottom'
+                }
+            }
+        }
+
+        await this.currentRenderer.loadBottom(this, generateScroll);
+
+        // Allow state updates to propagate.
+        setTimeout(() => this.fetchingBottom = false, 0);
+    }
+
+    async jumpToBottom(id: string, smooth: boolean) {
+        if (id !== this.channel) return;
+        if (this.state.type === 'RENDER' && this.state.atBottom) {
+            this.emit('scroll', { type: 'ScrollToBottom', smooth });
+        } else {
+            await this.currentRenderer.init(this, id, true);
+        }
+    }
+}
+
+export const SingletonMessageRenderer = new SingletonRenderer();
+
+export function useRenderState(id: string) {
+    const [state, setState] = useState<Readonly<RenderState>>(SingletonMessageRenderer.state);
+    if (typeof id === "undefined") return;
+
+    function render(state: RenderState) {
+        setState(state);
+    }
+
+    useEffect(() => {
+        SingletonMessageRenderer.addListener("state", render);
+        return () => SingletonMessageRenderer.removeListener("state", render);
+    }, [id]);
+
+    return state;
+}
diff --git a/src/lib/renderer/simple/SimpleRenderer.ts b/src/lib/renderer/simple/SimpleRenderer.ts
new file mode 100644
index 0000000000000000000000000000000000000000..afd808886d2c48a878f9d465a861ed32e8be9c4f
--- /dev/null
+++ b/src/lib/renderer/simple/SimpleRenderer.ts
@@ -0,0 +1,178 @@
+import { mapMessage } from "../../../context/revoltjs/util";
+import { SMOOTH_SCROLL_ON_RECEIVE } from "../Singleton";
+import { RendererRoutines } from "../types";
+
+export const SimpleRenderer: RendererRoutines = {
+    init: async (renderer, id, smooth) => {
+        if (renderer.client!.websocket.connected) {
+            renderer.client!.channels
+                .fetchMessagesWithUsers(id, { }, true)
+                .then(({ messages: data }) => {
+                    data.reverse();
+                    let messages = data.map(x => mapMessage(x));
+                    renderer.setState(
+                        id,
+                        {
+                            type: 'RENDER',
+                            messages,
+                            atTop: data.length < 50,
+                            atBottom: true
+                        },
+                        { type: 'ScrollToBottom', smooth }
+                    );
+                });
+        } else {
+            renderer.setState(id, { type: 'WAITING_FOR_NETWORK' });
+        }
+    },
+    receive: async (renderer, message) => {
+        if (message.channel !== renderer.channel) return;
+        if (renderer.state.type !== 'RENDER') return;
+        if (renderer.state.messages.find(x => x._id === message._id)) return;
+        if (!renderer.state.atBottom) return;
+
+        let messages = [ ...renderer.state.messages, mapMessage(message) ];
+        let atTop = renderer.state.atTop;
+        if (messages.length > 150) {
+            messages = messages.slice(messages.length - 150);
+            atTop = false;
+        }
+
+        renderer.setState(
+            message.channel,
+            {
+                ...renderer.state,
+                messages,
+                atTop
+            },
+            { type: 'StayAtBottom', smooth: SMOOTH_SCROLL_ON_RECEIVE }
+        );
+    },
+    edit: async (renderer, id, patch) => {
+        const channel = renderer.channel;
+        if (!channel) return;
+        if (renderer.state.type !== 'RENDER') return;
+            
+        let messages = [ ...renderer.state.messages ];
+        let index = messages.findIndex(x => x._id === id);
+
+        if (index > -1) {
+            let message = { ...messages[index], ...mapMessage(patch) };
+            messages.splice(index, 1, message);
+
+            renderer.setState(
+                channel,
+                {
+                    ...renderer.state,
+                    messages
+                },
+                { type: 'StayAtBottom' }
+            );
+        }
+    },
+    delete: async (renderer, id) => {
+        const channel = renderer.channel;
+        if (!channel) return;
+        if (renderer.state.type !== 'RENDER') return;
+            
+        let messages = [ ...renderer.state.messages ];
+        let index = messages.findIndex(x => x._id === id);
+
+        if (index > -1) {
+            messages.splice(index, 1);
+
+            renderer.setState(
+                channel,
+                {
+                    ...renderer.state,
+                    messages
+                },
+                { type: 'StayAtBottom' }
+            );
+        }
+    },
+    loadTop: async (renderer, generateScroll) => {
+        const channel = renderer.channel;
+        if (!channel) return;
+
+        const state = renderer.state;
+        if (state.type !== 'RENDER') return;
+        if (state.atTop) return;
+
+        const { messages: data } = await renderer.client!.channels.fetchMessagesWithUsers(channel, {
+            before: state.messages[0]._id
+        }, true);
+
+        if (data.length === 0) {
+            return renderer.setState(
+                channel,
+                {
+                    ...state,
+                    atTop: true
+                }
+            );
+        }
+
+        data.reverse();
+        let messages = [ ...data.map(x => mapMessage(x)), ...state.messages ];
+
+        let atTop = false;
+        if (data.length < 50) {
+            atTop = true;
+        }
+
+        let atBottom = state.atBottom;
+        if (messages.length > 150) {
+            messages = messages.slice(0, 150);
+            atBottom = false;
+        }
+
+        renderer.setState(
+            channel,
+            { ...state, atTop, atBottom, messages },
+            generateScroll(messages[messages.length - 1]._id)
+        );
+    },
+    loadBottom: async (renderer, generateScroll) => {
+        const channel = renderer.channel;
+        if (!channel) return;
+
+        const state = renderer.state;
+        if (state.type !== 'RENDER') return;
+        if (state.atBottom) return;
+
+        const { messages: data } = await renderer.client!.channels.fetchMessagesWithUsers(channel, {
+            after: state.messages[state.messages.length - 1]._id,
+            sort: 'Oldest'
+        }, true);
+
+        if (data.length === 0) {
+            return renderer.setState(
+                channel,
+                {
+                    ...state,
+                    atBottom: true
+                }
+            );
+        }
+
+        let messages = [ ...state.messages, ...data.map(x => mapMessage(x)) ];
+
+        let atBottom = false;
+        if (data.length < 50) {
+            atBottom = true;
+        }
+
+        let atTop = state.atTop;
+        if (messages.length > 150) {
+            messages = messages.slice(messages.length - 150);
+            atTop = false;
+        }
+
+        renderer.setState(
+            channel,
+            { ...state, atTop, atBottom, messages },
+            generateScroll(messages[0]._id)
+        );
+    }
+};
diff --git a/src/lib/renderer/types.ts b/src/lib/renderer/types.ts
new file mode 100644
index 0000000000000000000000000000000000000000..da6709ff420d013c237f9ecaca0e8c67a7d0e010
--- /dev/null
+++ b/src/lib/renderer/types.ts
@@ -0,0 +1,32 @@
+import { Message } from "revolt.js";
+import { SingletonRenderer } from "./Singleton";
+import { MessageObject } from "../../context/revoltjs/util";
+
+export type ScrollState =
+    | { type: "Free" }
+    | { type: "Bottom", scrollingUntil?: number }
+    | { type: "ScrollToBottom" | "StayAtBottom", smooth?: boolean }
+    | { type: "OffsetTop"; previousHeight: number }
+    | { type: "ScrollTop"; y: number };
+
+export type RenderState =
+    | {
+            type: "LOADING" | "WAITING_FOR_NETWORK" | "EMPTY";
+        }
+    | {
+            type: "RENDER";
+            atTop: boolean;
+            atBottom: boolean;
+            messages: MessageObject[];
+        };
+
+export interface RendererRoutines {
+    init: (renderer: SingletonRenderer, id: string, smooth?: boolean) => Promise<void>
+    
+    receive: (renderer: SingletonRenderer, message: Message) => Promise<void>;
+    edit: (renderer: SingletonRenderer, id: string, partial: Partial<Message>) => Promise<void>;
+    delete: (renderer: SingletonRenderer, id: string) => Promise<void>;
+
+    loadTop: (renderer: SingletonRenderer, generateScroll: (end: string) => ScrollState) => Promise<void>;
+    loadBottom: (renderer: SingletonRenderer, generateScroll: (start: string) => ScrollState) => Promise<void>;
+}
diff --git a/src/pages/RevoltApp.tsx b/src/pages/RevoltApp.tsx
index 88738fd37e4c1eca0817c8df3547b7f5fbc6ec2a..5bd4a856f9ffb2893b08ed45472995ac445f3a56 100644
--- a/src/pages/RevoltApp.tsx
+++ b/src/pages/RevoltApp.tsx
@@ -11,6 +11,7 @@ import RightSidebar from "../components/navigation/RightSidebar";
 
 import Home from './home/Home';
 import Friends from "./friends/Friends";
+import Channel from "./channels/Channel";
 import Settings from './settings/Settings';
 import Developer from "./developer/Developer";
 import ServerSettings from "./settings/ServerSettings";
@@ -40,6 +41,11 @@ export default function App() {
                     <Route path="/server/:server/settings" component={ServerSettings} />
                     <Route path="/channel/:channel/settings/:page" component={ChannelSettings} />
                     <Route path="/channel/:channel/settings" component={ChannelSettings} />
+
+                    <Route path="/channel/:channel/message/:message" component={Channel} />
+                    <Route path="/server/:server/channel/:channel" component={Channel} />
+                    <Route path="/server/:server" />
+                    <Route path="/channel/:channel" component={Channel} />
                     
                     <Route path="/settings/:page" component={Settings} />
                     <Route path="/settings" component={Settings} />
@@ -57,17 +63,7 @@ export default function App() {
 
 /**
  * 
- * <Route path="/channel/:channel/message/:message">
-                            <ChannelWrapper />
-                        </Route> 
-
-                        <Route path="/server/:server/channel/:channel">
-                            <ChannelWrapper />
-                        </Route>
-                        <Route path="/server/:server" />
-                        <Route path="/channel/:channel">
-                            <ChannelWrapper />
-                        </Route>
+ * 
                         
                         <Route path="/open/:id">
                             <Open />
diff --git a/src/pages/channels/Channel.tsx b/src/pages/channels/Channel.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..4c731ce5453a40d1c8bd5f86d7bedb14c741b424
--- /dev/null
+++ b/src/pages/channels/Channel.tsx
@@ -0,0 +1,44 @@
+import styled from "styled-components";
+import { useParams } from "react-router-dom";
+import Header from "../../components/ui/Header";
+import { useRenderState } from "../../lib/renderer/Singleton";
+import { useChannel, useForceUpdate, useUsers } from "../../context/revoltjs/hooks";
+import { MessageArea } from "./messaging/MessageArea";
+
+const ChannelMain = styled.div`
+    flex-grow: 1;
+    display: flex;
+    min-height: 0;
+    overflow: hidden;
+    flex-direction: row;
+`;
+
+const ChannelContent = styled.div`
+    flex-grow: 1;
+    display: flex;
+    overflow: hidden;
+    flex-direction: column;
+`;
+
+export default function Channel() {
+    const { channel: id } = useParams<{ channel: string }>();
+
+    const ctx = useForceUpdate();
+    const channel = useChannel(id, ctx);
+
+    if (!channel) return null;
+    // const view = useRenderState(id);
+
+    return (
+        <>
+            <Header placement="primary">
+                Channel
+            </Header>
+            <ChannelMain>
+                <ChannelContent>
+                    <MessageArea id={id} />
+                </ChannelContent>
+            </ChannelMain>
+        </>
+    )
+}
diff --git a/src/pages/channels/messaging/ConversationStart.tsx b/src/pages/channels/messaging/ConversationStart.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..21ecadb12b4372707919d2414f781536ebfd2c7f
--- /dev/null
+++ b/src/pages/channels/messaging/ConversationStart.tsx
@@ -0,0 +1,38 @@
+import { Text } from "preact-i18n";
+import styled from "styled-components";
+import { getChannelName } from "../../../context/revoltjs/util";
+import { useChannel, useForceUpdate } from "../../../context/revoltjs/hooks";
+
+const StartBase = styled.div`
+    margin: 18px 16px 10px 16px;
+
+    h1 {
+        font-size: 23px;
+        margin: 0 0 8px 0;
+    }
+
+    h4 {
+        font-weight: 400;
+        margin: 0;
+        font-size: 14px;
+    }
+`;
+
+interface Props {
+    id: string;
+}
+
+export default function ConversationStart({ id }: Props) {
+    const ctx = useForceUpdate();
+    const channel = useChannel(id, ctx);
+    if (!channel) return null;
+
+    return (
+        <StartBase>
+            <h1>{ getChannelName(ctx.client, channel, true) }</h1>
+            <h4>
+                <Text id="app.main.channel.start.group" />
+            </h4>
+        </StartBase>
+    );
+}
diff --git a/src/pages/channels/messaging/MessageArea.tsx b/src/pages/channels/messaging/MessageArea.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..b04122916ebab874da16888780a98f969b5df5d4
--- /dev/null
+++ b/src/pages/channels/messaging/MessageArea.tsx
@@ -0,0 +1,231 @@
+import styled from "styled-components";
+import { createContext } from "preact";
+import { animateScroll } from "react-scroll";
+import MessageRenderer from "./MessageRenderer";
+import ConversationStart from './ConversationStart';
+import useResizeObserver from "use-resize-observer";
+import Preloader from "../../../components/ui/Preloader";
+import RequiresOnline from "../../../context/revoltjs/RequiresOnline";
+import { RenderState, ScrollState } from "../../../lib/renderer/types";
+import { SingletonMessageRenderer } from "../../../lib/renderer/Singleton";
+import { IntermediateContext } from "../../../context/intermediate/Intermediate";
+import { ClientStatus, StatusContext } from "../../../context/revoltjs/RevoltClient";
+import { useContext, useEffect, useLayoutEffect, useRef, useState } from "preact/hooks";
+
+const Area = styled.div`
+    height: 100%;
+    flex-grow: 1;
+    min-height: 0;
+    overflow-x: hidden;
+    overflow-y: scroll;
+    word-break: break-word;
+
+    > div {
+        display: flex;
+        min-height: 100%;
+        flex-direction: column;
+        justify-content: flex-end;
+    }
+`;
+
+interface Props {
+    id: string;
+}
+
+export const MessageAreaWidthContext = createContext(0);
+export const MESSAGE_AREA_PADDING = 82;
+
+export function MessageArea({ id }: Props) {
+    const status = useContext(StatusContext);
+    const { focusTaken } = useContext(IntermediateContext);
+
+    // ? This is the scroll container.
+    const ref = useRef<HTMLDivElement>(null);
+    const { width, height } = useResizeObserver<HTMLDivElement>({ ref });
+
+    // ? Current channel state.
+    const [state, setState] = useState<RenderState>({ type: "LOADING" });
+
+    // ? Hook-based scrolling mechanism.
+    const [scrollState, setSS] = useState<ScrollState>({
+        type: "Free"
+    });
+
+    const setScrollState = (v: ScrollState) => {
+        if (v.type === 'StayAtBottom') {
+            if (scrollState.type === 'Bottom' || atBottom()) {
+                setSS({ type: 'ScrollToBottom', smooth: v.smooth });
+            } else {
+                setSS({ type: 'Free' });
+            }
+        } else {
+            setSS(v);
+        }
+    }
+
+    // ? Determine if we are at the bottom of the scroll container.
+    // -> https://stackoverflow.com/a/44893438
+    // By default, we assume we are at the bottom, i.e. when we first load.
+    const atBottom = (offset = 0) =>
+        ref.current
+            ? Math.floor(ref.current.scrollHeight - ref.current.scrollTop) -
+                  offset <=
+              ref.current.clientHeight
+            : true;
+    
+    const atTop = (offset = 0) => ref.current.scrollTop <= offset;
+
+    // ? Handle events from renderer.
+    useEffect(() => {
+        SingletonMessageRenderer.addListener('state', setState);
+        return () => SingletonMessageRenderer.removeListener('state', setState);
+    }, [ ]);
+
+    useEffect(() => {
+        SingletonMessageRenderer.addListener('scroll', setScrollState);
+        return () => SingletonMessageRenderer.removeListener('scroll', setScrollState);
+    }, [ scrollState ]);
+
+    // ? Load channel initially.
+    useEffect(() => {
+        SingletonMessageRenderer.init(id);
+    }, [ id ]);
+
+    // ? If we are waiting for network, try again.
+    useEffect(() => {
+        switch (status) {
+            case ClientStatus.ONLINE:
+                if (state.type === 'WAITING_FOR_NETWORK') {
+                    SingletonMessageRenderer.init(id);
+                } else {
+                    SingletonMessageRenderer.reloadStale(id);
+                }
+
+                break;
+            case ClientStatus.OFFLINE:
+            case ClientStatus.DISCONNECTED:
+            case ClientStatus.CONNECTING:
+                SingletonMessageRenderer.markStale();
+                break;
+        }
+    }, [ status, state ]);
+
+    // ? Scroll to the bottom before the browser paints.
+    useLayoutEffect(() => {
+        if (scrollState.type === "ScrollToBottom") {
+            setScrollState({ type: "Bottom", scrollingUntil: + new Date() + 150 });
+            
+            animateScroll.scrollToBottom({
+                container: ref.current,
+                duration: scrollState.smooth ? 150 : 0
+            });
+        } else if (scrollState.type === "OffsetTop") {
+            animateScroll.scrollTo(
+                Math.max(
+                    101,
+                    ref.current.scrollTop +
+                        (ref.current.scrollHeight - scrollState.previousHeight)
+                ),
+                {
+                    container: ref.current,
+                    duration: 0
+                }
+            );
+
+            setScrollState({ type: "Free" });
+        } else if (scrollState.type === "ScrollTop") {
+            animateScroll.scrollTo(scrollState.y, {
+                container: ref.current,
+                duration: 0
+            });
+
+            setScrollState({ type: "Free" });
+        }
+    }, [scrollState]);
+
+    // ? When the container is scrolled.
+    // ? Also handle StayAtBottom
+    useEffect(() => {
+        async function onScroll() {
+            if (scrollState.type === "Free" && atBottom()) {
+                setScrollState({ type: "Bottom" });
+            } else if (scrollState.type === "Bottom" && !atBottom()) {
+                if (scrollState.scrollingUntil && scrollState.scrollingUntil > + new Date()) return;
+                setScrollState({ type: "Free" });
+            }
+        }
+
+        ref.current.addEventListener("scroll", onScroll);
+        return () => ref.current.removeEventListener("scroll", onScroll);
+    }, [ref, scrollState]);
+
+    // ? Top and bottom loaders.
+    useEffect(() => {
+        async function onScroll() {
+            if (atTop(100)) {
+                SingletonMessageRenderer.loadTop(ref.current);
+            }
+
+            if (atBottom(100)) {
+                SingletonMessageRenderer.loadBottom(ref.current);
+            }
+        }
+
+        ref.current.addEventListener("scroll", onScroll);
+        return () => ref.current.removeEventListener("scroll", onScroll);
+    }, [ref]);
+
+    // ? Scroll down whenever the message area resizes.
+    function stbOnResize() {
+        if (!atBottom() && scrollState.type === "Bottom") {
+            animateScroll.scrollToBottom({
+                container: ref.current,
+                duration: 0
+            });
+
+            setScrollState({ type: "Bottom" });
+        }
+    }
+
+    // ? Scroll down when container resized.
+    useLayoutEffect(() => {
+        stbOnResize();
+    }, [height]);
+
+    // ? Scroll down whenever the window resizes.
+    useLayoutEffect(() => {
+        document.addEventListener("resize", stbOnResize);
+        return () => document.removeEventListener("resize", stbOnResize);
+    }, [ref, scrollState]);
+
+    // ? Scroll to bottom when pressing 'Escape'.
+    useEffect(() => {
+        function keyUp(e: KeyboardEvent) {
+            if (e.key === "Escape" && !focusTaken) {
+                SingletonMessageRenderer.jumpToBottom(id, true);
+            }
+        }
+
+        document.body.addEventListener("keyup", keyUp);
+        return () => document.body.removeEventListener("keyup", keyUp);
+    }, [ref, focusTaken]);
+
+    return (
+        <MessageAreaWidthContext.Provider value={(width ?? 0) - MESSAGE_AREA_PADDING}>
+            <Area ref={ref}>
+                <div>
+                    {state.type === "LOADING" && <Preloader />}
+                    {state.type === "WAITING_FOR_NETWORK" && (
+                        <RequiresOnline>
+                            <Preloader />
+                        </RequiresOnline>
+                    )}
+                    {state.type === "RENDER" && (
+                        <MessageRenderer id={id} state={state} />
+                    )}
+                    {state.type === "EMPTY" && <ConversationStart id={id} />}
+                </div>
+            </Area>
+        </MessageAreaWidthContext.Provider>
+    );
+}
diff --git a/src/pages/channels/messaging/MessageRenderer.tsx b/src/pages/channels/messaging/MessageRenderer.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..205ecb31ed0c22b128801459f158bb2509d1c33b
--- /dev/null
+++ b/src/pages/channels/messaging/MessageRenderer.tsx
@@ -0,0 +1,179 @@
+import { decodeTime } from "ulid";
+import { useEffect, useState } from "preact/hooks";
+import ConversationStart from "./ConversationStart";
+import { connectState } from "../../../redux/connector";
+import Preloader from "../../../components/ui/Preloader";
+import { RenderState } from "../../../lib/renderer/types";
+import DateDivider from "../../../components/ui/DateDivider";
+import { QueuedMessage } from "../../../redux/reducers/queue";
+import { MessageObject } from "../../../context/revoltjs/util";
+import RequiresOnline from "../../../context/revoltjs/RequiresOnline";
+import { useForceUpdate, useUsers } from "../../../context/revoltjs/hooks";
+import { Children } from "../../../types/Preact";
+import { SystemMessage } from "../../../components/common/messaging/SystemMessage";
+import Message from "../../../components/common/messaging/Message";
+
+interface Props {
+    id: string;
+    state: RenderState;
+    queue: QueuedMessage[];
+}
+
+function MessageRenderer({ id, state, queue }: Props) {
+    if (state.type !== 'RENDER') return null;
+
+    const ctx = useForceUpdate();
+    const users = useUsers();
+    const userId = ctx.client.user!._id;
+
+    /*
+    const view = useView(id);*/
+
+    const [editing, setEditing] = useState<string | undefined>(undefined);
+    const stopEditing = () => {
+        setEditing(undefined);
+        // InternalEventEmitter.emit("focus_textarea", "message");
+    };
+    useEffect(() => {
+        function editLast() {
+            if (state.type !== 'RENDER') return;
+            for (let i = state.messages.length - 1; i >= 0; i--) {
+                if (state.messages[i].author === userId) {
+                    setEditing(state.messages[i]._id);
+                    return;
+                }
+            }
+        }
+
+        // InternalEventEmitter.addListener("edit_last", editLast);
+        // InternalEventEmitter.addListener("edit_message", setEditing);
+
+        return () => {
+            // InternalEventEmitter.removeListener("edit_last", editLast);
+            // InternalEventEmitter.removeListener("edit_message", setEditing);
+        };
+    }, [state.messages]);
+
+    let render: Children[] = [],
+        previous: MessageObject | undefined;
+
+    if (state.atTop) {
+        render.push(<ConversationStart id={id} />);
+    } else {
+        render.push(
+            <RequiresOnline>
+                <Preloader />
+            </RequiresOnline>
+        );
+    }
+
+    let head = true;
+    function compare(
+        current: string,
+        curAuthor: string,
+        previous: string,
+        prevAuthor: string
+    ) {
+        const atime = decodeTime(current),
+            adate = new Date(atime),
+            btime = decodeTime(previous),
+            bdate = new Date(btime);
+
+        if (
+            adate.getFullYear() !== bdate.getFullYear() ||
+            adate.getMonth() !== bdate.getMonth() ||
+            adate.getDate() !== bdate.getDate()
+        ) {
+            render.push(<DateDivider date={adate} />);
+        }
+
+        head = curAuthor !== prevAuthor || Math.abs(btime - atime) >= 420000;
+    }
+
+    for (const message of state.messages) {
+        if (previous) {
+            compare(
+                message._id,
+                message.author,
+                previous._id,
+                previous.author
+            );
+        }
+
+        if (message.author === "00000000000000000000000000") {
+            render.push(<SystemMessage key={message._id} message={message} attachContext />);
+        } else {
+            render.push(
+                <Message message={message}
+                    key={message._id}
+                    head={head}
+                    attachContext />
+            );
+            /*render.push(
+                <Message
+                    editing={editing === message._id ? stopEditing : undefined}
+                    user={users.find(x => x?._id === message.author)}
+                    message={message}
+                    key={message._id}
+                    head={head}
+                />
+            );*/
+        }
+
+        previous = message;
+    }
+
+    const nonces = state.messages.map(x => x.nonce);
+    if (state.atBottom) {
+        for (const msg of queue) {
+            if (msg.channel !== id) continue;
+            if (nonces.includes(msg.id)) continue;
+
+            if (previous) {
+                compare(
+                    msg.id,
+                    userId as string,
+                    previous._id,
+                    previous.author
+                );
+                
+                previous = {
+                    _id: msg.id,
+                    data: { author: userId as string }
+                } as any;
+            }
+
+            /*render.push(
+                <Message
+                    user={users.find(x => x?._id === userId)}
+                    message={msg.data}
+                    queued={msg}
+                    key={msg.id}
+                    head={head}
+                />
+            );*/
+            render.push(
+                <Message message={msg.data}
+                    key={msg.id}
+                    head={head}
+                    attachContext />
+            );
+        }
+
+        render.push(<div>end</div>);
+    } else {
+        render.push(
+            <RequiresOnline>
+                <Preloader />
+            </RequiresOnline>
+        );
+    }
+
+    return <>{ render }</>;
+}
+
+export default connectState<Omit<Props, 'queue'>>(MessageRenderer, state => {
+    return {
+        queue: state.queue
+    };
+});
diff --git a/src/pages/developer/Developer.tsx b/src/pages/developer/Developer.tsx
index e7a5cbc013aeaf395fd7e8872b5639c37f4c3bcd..1a392bc0119055c4afd45a451ab4ff725d93c889 100644
--- a/src/pages/developer/Developer.tsx
+++ b/src/pages/developer/Developer.tsx
@@ -1,4 +1,5 @@
 import { useContext } from "preact/hooks";
+import { TextReact } from "../../lib/i18n";
 import Header from "../../components/ui/Header";
 import PaintCounter from "../../lib/PaintCounter";
 import { AppContext } from "../../context/revoltjs/RevoltClient";
@@ -19,6 +20,9 @@ export default function Developer() {
                 <b>User ID:</b> {client.user!._id} <br/>
                 <b>Permission against self:</b> {userPermission} <br/>
             </div>
+            <div style={{ padding: "16px" }}>
+                <TextReact id="login.open_mail_provider" fields={{ provider: <b>GAMING!</b> }} />
+            </div>
             <div style={{ padding: "16px" }}>
                 {/*<span>
                     <b>Voice Status:</b> {VoiceStatus[voice.status]}
diff --git a/src/pages/settings/ChannelSettings.tsx b/src/pages/settings/ChannelSettings.tsx
index 0f7cf15d422fe38adbb997895fd5b856422385b5..cd4b0f4773a58a716e4d86c96bfc6e35cb089871 100644
--- a/src/pages/settings/ChannelSettings.tsx
+++ b/src/pages/settings/ChannelSettings.tsx
@@ -28,7 +28,7 @@ export default function ChannelSettings() {
         <GenericSettings
             pages={[
                 {
-                    category: <Category variant="uniform" text={getChannelName(ctx.client, channel, [], true)} />,
+                    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" />
diff --git a/src/pages/settings/channel/Overview.tsx b/src/pages/settings/channel/Overview.tsx
index 1efaf59b80e7f512272ce4948e16e0ed660483ce..22eedca28beb3aa67f0b729e2ad48d4cc26fa900 100644
--- a/src/pages/settings/channel/Overview.tsx
+++ b/src/pages/settings/channel/Overview.tsx
@@ -81,7 +81,7 @@ export function Overview({ channel }: Props) {
                     if (!changed) setChanged(true)
                 }}
             />
-            <Button onClick={save} style="contrast" disabled={!changed}>
+            <Button onClick={save} contrast disabled={!changed}>
                 <Text id="app.special.modals.actions.save" />
             </Button>
         </div>
diff --git a/src/pages/settings/server/Overview.tsx b/src/pages/settings/server/Overview.tsx
index a6d4234d2f442c5306f616a1b48c4ca0abe5a75e..d3ba3a3e9f7628266e649983548ce08f5f120dd4 100644
--- a/src/pages/settings/server/Overview.tsx
+++ b/src/pages/settings/server/Overview.tsx
@@ -76,7 +76,7 @@ export function Overview({ server }: Props) {
                     if (!changed) setChanged(true)
                 }}
             />
-            <Button onClick={save} style="contrast" disabled={!changed}>
+            <Button onClick={save} contrast disabled={!changed}>
                 <Text id="app.special.modals.actions.save" />
             </Button>
 
diff --git a/yarn.lock b/yarn.lock
index 0bf73154372bf83958e186465d2f6e28f5d2a038..ce8812d6fed5c943ad29d38d2f1dfbbc7cfb7ac7 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1242,6 +1242,13 @@
     "@types/history" "*"
     "@types/react" "*"
 
+"@types/react-scroll@^1.8.2":
+  version "1.8.2"
+  resolved "https://registry.yarnpkg.com/@types/react-scroll/-/react-scroll-1.8.2.tgz#44bbbadabb9014517eb865d6fa47937535a2234a"
+  integrity sha512-oavV6BZLfaIghX4JSmrm6mJkeVayQlmsFx1Rz8ffGjMngHAI/juZkRZM/zV/H5D0pGqjzACvBmKYUU4YBecwLg==
+  dependencies:
+    "@types/react" "*"
+
 "@types/react@*":
   version "17.0.11"
   resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.11.tgz#67fcd0ddbf5a0b083a0f94e926c7d63f3b836451"
@@ -2745,6 +2752,11 @@ lodash.sortby@^4.7.0:
   resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438"
   integrity sha1-7dFMgk4sycHgsKG0K7UhBRakJDg=
 
+lodash.throttle@^4.1.1:
+  version "4.1.1"
+  resolved "https://registry.yarnpkg.com/lodash.throttle/-/lodash.throttle-4.1.1.tgz#c23e91b710242ac70c37f1e1cda9274cc39bf2f4"
+  integrity sha1-wj6RtxAkKscMN/HhzaknTMOb8vQ=
+
 lodash.truncate@^4.4.2:
   version "4.4.2"
   resolved "https://registry.yarnpkg.com/lodash.truncate/-/lodash.truncate-4.4.2.tgz#5a350da0b1113b837ecfffd5812cbe58d6eae193"
@@ -3209,6 +3221,14 @@ react-router@5.2.0:
     tiny-invariant "^1.0.2"
     tiny-warning "^1.0.0"
 
+react-scroll@^1.8.2:
+  version "1.8.2"
+  resolved "https://registry.yarnpkg.com/react-scroll/-/react-scroll-1.8.2.tgz#68e35b74ae296c88e7863393c9fd49f05afa29f5"
+  integrity sha512-f2ZEG5fsPbPTySI9ekcFpETCcNlqbmwbQj9hhzYK8tkgv+PA8APatSt66o/q0KSkDZxyT98ONTtXp9x0lyowEw==
+  dependencies:
+    lodash.throttle "^4.1.1"
+    prop-types "^15.7.2"
+
 react-side-effect@^2.1.0:
   version "2.1.1"
   resolved "https://registry.yarnpkg.com/react-side-effect/-/react-side-effect-2.1.1.tgz#66c5701c3e7560ab4822a4ee2742dee215d72eb3"
@@ -3301,6 +3321,11 @@ require-from-string@^2.0.2:
   resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909"
   integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==
 
+resize-observer-polyfill@^1.5.1:
+  version "1.5.1"
+  resolved "https://registry.yarnpkg.com/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz#0e9020dd3d21024458d4ebd27e23e40269810464"
+  integrity sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==
+
 resolve-from@^4.0.0:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6"
@@ -3855,6 +3880,13 @@ uri-js@^4.2.2:
   dependencies:
     punycode "^2.1.0"
 
+use-resize-observer@^7.0.0:
+  version "7.0.0"
+  resolved "https://registry.yarnpkg.com/use-resize-observer/-/use-resize-observer-7.0.0.tgz#15f0efbd5a4e08a8cc51901f21a89ba836f2116e"
+  integrity sha512-+RjrQsk/mL8aKy4TGBDiPkUv6whyeoGDMIZYk0gOGHOlnrsjImC+jG6lfAFcBCKAG9epGRL419adhDNdkDCQkA==
+  dependencies:
+    resize-observer-polyfill "^1.5.1"
+
 v8-compile-cache@^2.0.3:
   version "2.3.0"
   resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz#2de19618c66dc247dcfb6f99338035d8245a2cee"