diff --git a/src/components/common/messaging/Message.tsx b/src/components/common/messaging/Message.tsx
index 4de938eaa17b5c16b08ad6f488d9a2575abb2a03..03cd3d16f012a006d5ba19e89dd83b01619a8e97 100644
--- a/src/components/common/messaging/Message.tsx
+++ b/src/components/common/messaging/Message.tsx
@@ -24,15 +24,19 @@ export default function Message({ attachContext, message, contrast, content: rep
 
     const content = message.content as string;
     return (
-        <MessageBase contrast={contrast}
+        <MessageBase id={message._id}
+            contrast={contrast}
             onContextMenu={attachContext ? attachContextMenu('Menu', { message, contextualChannel: message.channel }) : undefined}>
             <MessageInfo>
                 { head ?
                     <UserIcon target={user} size={36} /> :
-                    <MessageDetail message={message} /> }
+                    <MessageDetail message={message} position="left" /> }
             </MessageInfo>
             <MessageContent>
-                { head && <Username user={user} /> }
+                { head && <span className="author">
+                    <Username user={user} />
+                    <MessageDetail message={message} position="top" />
+                </span> }
                 { replacement ?? <Markdown content={content} /> }
                 { message.attachments?.map((attachment, index) =>
                     <Attachment key={index} attachment={attachment} hasContent={ index > 0 || content.length > 0 } />) }
diff --git a/src/components/common/messaging/MessageBase.tsx b/src/components/common/messaging/MessageBase.tsx
index 209e6408beb80f259883b9202f087cd127776f30..7284dba4a2194cc6e88c605ad5c203806bebed77 100644
--- a/src/components/common/messaging/MessageBase.tsx
+++ b/src/components/common/messaging/MessageBase.tsx
@@ -1,6 +1,8 @@
 import dayjs from "dayjs";
-import styled, { css } from "styled-components";
+import Tooltip from "../Tooltip";
 import { decodeTime } from "ulid";
+import { Text } from "preact-i18n";
+import styled, { css } from "styled-components";
 import { MessageObject } from "../../../context/revoltjs/util";
 
 export interface BaseMessageProps {
@@ -50,6 +52,12 @@ export default styled.div<BaseMessageProps>`
     ${ props => props.status && css`
         color: var(--error);
     ` }
+
+    .author {
+        gap: 8px;
+        display: flex;
+        align-items: center;
+    }
     
     .copy {
         width: 0;
@@ -98,14 +106,47 @@ export const MessageContent = styled.div`
     justify-content: center;
 `;
 
-export function MessageDetail({ message }: { message: MessageObject }) {
+export const DetailBase = styled.div`
+    gap: 4px;
+    font-size: 10px;
+    display: inline-flex;
+    color: var(--tertiary-foreground);
+`;
+
+export function MessageDetail({ message, position }: { message: MessageObject, position: 'left' | 'top' }) {
+    if (position === 'left') {
+        if (message.edited) {
+            return (
+                <span>
+                    <span className="copy">
+                        [<time>{dayjs(decodeTime(message._id)).format("H:mm")}</time>]
+                    </span>
+                    <Tooltip content={dayjs(message.edited).format("LLLL")}>
+                        <Text id="app.main.channel.edited" />
+                    </Tooltip>
+                </span>
+            )
+        } else {
+            return (
+                <>
+                    <time>
+                        <i className="copy">[</i>
+                        { dayjs(decodeTime(message._id)).format("H:mm") }
+                        <i className="copy">]</i>
+                    </time>
+                </>
+            )
+        }
+    }
+
     return (
-        <>
+        <DetailBase>
             <time>
-                <i className="copy">[</i>
-                {dayjs(decodeTime(message._id)).format("H:mm")}
-                <i className="copy">]</i>
+                {dayjs(decodeTime(message._id)).calendar()}
             </time>
-        </>
+            { message.edited && <Tooltip content={dayjs(message.edited).format("LLLL")}>
+                <Text id="app.main.channel.edited" />
+            </Tooltip> }
+        </DetailBase>
     )
 }
diff --git a/src/components/common/messaging/MessageBox.tsx b/src/components/common/messaging/MessageBox.tsx
index 32b4e3474de892657bca87d02d6f6abfb94b2a3e..f0e6a8a14a6534fcea491b3248da70bdf20902b4 100644
--- a/src/components/common/messaging/MessageBox.tsx
+++ b/src/components/common/messaging/MessageBox.tsx
@@ -1,24 +1,59 @@
 import { ulid } from "ulid";
 import { Channel } from "revolt.js";
-import TextArea from "../../ui/TextArea";
-import { useContext } from "preact/hooks";
+import styled from "styled-components";
 import { defer } from "../../../lib/defer";
 import IconButton from "../../ui/IconButton";
 import { Send } from '@styled-icons/feather';
+import Axios, { CancelTokenSource } from "axios";
+import { useTranslation } from "../../../lib/i18n";
+import { useCallback, useContext, useState } from "preact/hooks";
 import { connectState } from "../../../redux/connector";
 import { WithDispatcher } from "../../../redux/reducers";
 import { takeError } from "../../../context/revoltjs/util";
+import TextAreaAutoSize from "../../../lib/TextAreaAutoSize";
 import { AppContext } from "../../../context/revoltjs/RevoltClient";
 import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
+import { useIntermediate } from "../../../context/intermediate/Intermediate";
+import { FileUploader, grabFiles, uploadFile } from "../../../context/revoltjs/FileUploads";
 import { SingletonMessageRenderer, SMOOTH_SCROLL_ON_RECEIVE } from "../../../lib/renderer/Singleton";
 
+import FilePreview from './bars/FilePreview';
+import { debounce } from "../../../lib/debounce";
+
 type Props = WithDispatcher & {
     channel: Channel;
     draft?: string;
 };
 
+export type UploadState =
+    | { type: "none" }
+    | { type: "attached"; files: File[] }
+    | { type: "uploading"; files: File[]; percent: number; cancel: CancelTokenSource }
+    | { type: "sending"; files: File[] }
+    | { type: "failed"; files: File[]; error: string };
+
+const Base = styled.div`
+    display: flex;
+    padding: 0 12px;
+    background: var(--message-box);
+
+    textarea {
+        font-size: .875rem;
+        background: transparent;
+    }
+`;
+
+const Action = styled.div`
+    display: grid;
+    place-items: center;
+`;
+
 function MessageBox({ channel, draft, dispatcher }: Props) {
+    const [ uploadState, setUploadState ] = useState<UploadState>({ type: 'none' });
+    const [typing, setTyping] = useState<boolean | number>(false);
+    const { openScreen } = useIntermediate();
     const client = useContext(AppContext);
+    const translate = useTranslation();
 
     function setMessage(content?: string) {
         if (content) {
@@ -36,12 +71,16 @@ function MessageBox({ channel, draft, dispatcher }: Props) {
     }
 
     async function send() {
-        const nonce = ulid();
-
+        if (uploadState.type === 'uploading' || uploadState.type === 'sending') return;
+        
         const content = draft?.trim() ?? '';
+        if (uploadState.type === 'attached') return sendFile(content);
         if (content.length === 0) return;
-
+        
+        stopTyping();
         setMessage();
+
+        const nonce = ulid();
         dispatcher({
             type: "QUEUE_ADD",
             nonce,
@@ -55,7 +94,6 @@ function MessageBox({ channel, draft, dispatcher }: Props) {
         });
 
         defer(() => SingletonMessageRenderer.jumpToBottom(channel._id, SMOOTH_SCROLL_ON_RECEIVE));
-        // Sounds.playOutbound();
 
         try {
             await client.channels.sendMessage(channel._id, {
@@ -71,21 +109,164 @@ function MessageBox({ channel, draft, dispatcher }: Props) {
         }
     }
 
+    async function sendFile(content: string) {
+        if (uploadState.type !== 'attached') return;
+        let attachments = [];
+
+        const cancel = Axios.CancelToken.source();
+        const files = uploadState.files;
+        stopTyping();
+        setUploadState({ type: "uploading", files, percent: 0, cancel });
+
+        try {
+            for (let i=0;i<files.length;i++) {
+                if (i>0)continue; // ! FIXME: temp, allow multiple uploads on server
+                const file = files[i];
+                attachments.push(
+                    await uploadFile(client.configuration!.features.autumn.url, 'attachments', file, {
+                        onUploadProgress: e =>
+                        setUploadState({
+                            type: "uploading",
+                            files,
+                            percent: Math.round(((i * 100) + (100 * e.loaded) / e.total) / (files.length)),
+                            cancel
+                        }),
+                        cancelToken: cancel.token
+                    })
+                );
+            }
+        } catch (err) {
+            if (err?.message === "cancel") {
+                setUploadState({
+                    type: "attached",
+                    files
+                });
+            } else {
+                setUploadState({
+                    type: "failed",
+                    files,
+                    error: takeError(err)
+                });
+            }
+
+            return;
+        }
+
+        setUploadState({
+            type: "sending",
+            files
+        });
+
+        const nonce = ulid();
+        try {
+            await client.channels.sendMessage(channel._id, {
+                content,
+                nonce,
+                attachment: attachments[0] // ! FIXME: temp, allow multiple uploads on server
+            });
+        } catch (err) {
+            setUploadState({
+                type: "failed",
+                files,
+                error: takeError(err)
+            });
+
+            return;
+        }
+
+        setMessage();
+        setUploadState({ type: "none" });
+    }
+    
+    function startTyping() {
+        if (typeof typing === 'number' && + new Date() < typing) return;
+
+        const ws = client.websocket;
+        if (ws.connected) {
+            setTyping(+ new Date() + 4000);
+            ws.send({
+                type: "BeginTyping",
+                channel: channel._id
+            });
+        }
+    }
+
+    function stopTyping(force?: boolean) {
+        if (force || typing) {
+            const ws = client.websocket;
+            if (ws.connected) {
+                setTyping(false);
+                ws.send({
+                    type: "EndTyping",
+                    channel: channel._id
+                });
+            }
+        }
+    }
+
+    const debouncedStopTyping = useCallback(debounce(stopTyping, 1000), [ channel._id ]);
+
     return (
-        <div style={{ display: 'flex' }}>
-            <TextArea
-                value={draft}
-                onKeyDown={e => {
-                    if (!e.shiftKey && e.key === "Enter" && !isTouchscreenDevice) {
-                        e.preventDefault();
-                        return send();
+        <>
+            <FilePreview state={uploadState} addFile={() => uploadState.type === 'attached' &&
+                grabFiles(20_000_000, files => setUploadState({ type: 'attached', files: [ ...uploadState.files, ...files ] }),
+                    () => openScreen({ id: "error", error: "FileTooLarge" }), true)}
+                removeFile={index => {
+                    if (uploadState.type !== 'attached') return;
+                    if (uploadState.files.length === 1) {
+                        setUploadState({ type: 'none' });
+                    } else {
+                        setUploadState({ type: 'attached', files: uploadState.files.filter((_, i) => index !== i) });
+                    }
+                }} />
+            <Base>
+                <Action>
+                    <FileUploader
+                        size={24}
+                        behaviour='multi'
+                        style='attachment'
+                        fileType='attachments'
+                        maxFileSize={20_000_000}
+
+                        attached={uploadState.type !== 'none'}
+                        uploading={uploadState.type === 'uploading' || uploadState.type === 'sending'}
+
+                        remove={async () => setUploadState({ type: "none" })}
+                        onChange={files => setUploadState({ type: "attached", files })}
+                        cancel={() => uploadState.type === 'uploading' && uploadState.cancel.cancel("cancel")}
+                    />
+                </Action>
+                <TextAreaAutoSize
+                    hideBorder
+                    maxRows={5}
+                    padding={15}
+                    value={draft ?? ''}
+                    onKeyDown={e => {
+                        if (!e.shiftKey && e.key === "Enter" && !isTouchscreenDevice) {
+                            e.preventDefault();
+                            return send();
+                        }
+                        
+                        debouncedStopTyping(true);
+                    }}
+                    placeholder={
+                        channel.channel_type === "DirectMessage" ? translate("app.main.channel.message_who", {
+                            person: client.users.get(client.channels.getRecipient(channel._id))?.username })
+                        : channel.channel_type === "SavedMessages" ? translate("app.main.channel.message_saved")
+                        : translate("app.main.channel.message_where", { channel_name: channel.name })
                     }
-                }}
-                onChange={e => setMessage(e.currentTarget.value)} />
-            <IconButton onClick={send}>
-                <Send size={20} />
-            </IconButton>
-        </div>
+                    disabled={uploadState.type === 'uploading' || uploadState.type === 'sending'}
+                    onChange={e => {
+                        setMessage(e.currentTarget.value);
+                        startTyping();
+                    }} />
+                <Action>
+                    <IconButton onClick={send}>
+                        <Send size={20} />
+                    </IconButton>
+                </Action>
+            </Base>
+        </>
     )
 }
 
diff --git a/src/components/common/messaging/SystemMessage.tsx b/src/components/common/messaging/SystemMessage.tsx
index 26de5f59e12341a824dbde2b0680114c90515d35..e239076d71c2fd1dae4338a14ee6081f6e2dbeac 100644
--- a/src/components/common/messaging/SystemMessage.tsx
+++ b/src/components/common/messaging/SystemMessage.tsx
@@ -1,14 +1,11 @@
 import { User } from "revolt.js";
-import classNames from "classnames";
+import styled from "styled-components";
+import UserShort from "../user/UserShort";
+import { TextReact } from "../../../lib/i18n";
 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 "../user/UserIcon";
-import Username from "../user/UserShort";
-import UserShort from "../user/UserShort";
 import MessageBase, { MessageDetail, MessageInfo } from "./MessageBase";
-import styled from "styled-components";
+import { useForceUpdate, useUser } from "../../../context/revoltjs/hooks";
 
 const SystemContent = styled.div`
     gap: 4px;
@@ -144,7 +141,7 @@ export function SystemMessage({ attachContext, message }: Props) {
                 { message, contextualChannel: message.channel }
             ) : undefined}>
             <MessageInfo>
-                <MessageDetail message={message} />
+                <MessageDetail message={message} position="left" />
             </MessageInfo>
             <SystemContent>{children}</SystemContent>
         </MessageBase>
diff --git a/src/components/common/messaging/attachments/AttachmentActions.tsx b/src/components/common/messaging/attachments/AttachmentActions.tsx
index f33eebfa18a45008facd2d6cea06af26413cf5a8..cf701ad892b06c8a07670eb98e54f538376dfcdf 100644
--- a/src/components/common/messaging/attachments/AttachmentActions.tsx
+++ b/src/components/common/messaging/attachments/AttachmentActions.tsx
@@ -2,6 +2,7 @@ import { useContext } from 'preact/hooks';
 import styles from './Attachment.module.scss';
 import IconButton from '../../../ui/IconButton';
 import { Attachment } from "revolt.js/dist/api/objects";
+import { determineFileSize } from '../../../../lib/fileSize';
 import { AppContext } from '../../../../context/revoltjs/RevoltClient';
 import { Download, ExternalLink, File, Headphones, Video } from '@styled-icons/feather';
 
@@ -9,16 +10,6 @@ interface Props {
     attachment: Attachment;
 }
 
-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`;
-}
-
 export default function AttachmentActions({ attachment }: Props) {
     const client = useContext(AppContext);
     const { filename, metadata, size } = attachment;
diff --git a/src/components/common/messaging/bars/FilePreview.tsx b/src/components/common/messaging/bars/FilePreview.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..605efbbbc77304856febde112ed976602af8b237
--- /dev/null
+++ b/src/components/common/messaging/bars/FilePreview.tsx
@@ -0,0 +1,158 @@
+import { Text } from "preact-i18n";
+import styled from "styled-components";
+import { UploadState } from "../MessageBox";
+import { useEffect, useState } from 'preact/hooks';
+import { XCircle, Plus, Share, X } from "@styled-icons/feather";
+import { determineFileSize } from '../../../../lib/fileSize';
+
+interface Props {
+    state: UploadState,
+    addFile: () => void,
+    removeFile: (index: number) => void
+}
+
+const Container = styled.div`
+    gap: 4px;
+    padding: 8px;
+    display: flex;
+    user-select: none;
+    flex-direction: column;
+    background: var(--message-box);
+`;
+
+const Carousel = styled.div`
+    gap: 8px;
+    display: flex;
+    overflow-x: scroll;
+    flex-direction: row;
+`;
+
+const Entry = styled.div`
+    display: flex;
+    flex-direction: column;
+
+    img {
+        height: 100px;
+        margin-bottom: 4px;
+        border-radius: 4px;
+        object-fit: contain;
+        background: var(--secondary-background);
+    }
+
+    span.fn {
+        margin: auto;
+        font-size: .8em;
+        overflow: hidden;
+        max-width: 180px;
+        text-align: center;
+        white-space: nowrap;
+        text-overflow: ellipsis;
+        color: var(--secondary-foreground);
+    }
+
+    span.size {
+        font-size: .6em;
+        color: var(--tertiary-foreground);
+        text-align: center;
+    }
+
+    div {
+        position: relative;
+        height: 0;
+
+        div {
+            display: grid;
+            height: 100px;
+            cursor: pointer;
+            border-radius: 4px;
+            place-items: center;
+            
+            opacity: 0;
+            transition: 0.1s ease opacity;
+            background: rgba(0, 0, 0, 0.5);
+
+            &:hover {
+                opacity: 1;
+            }
+        }
+    }
+`;
+
+const Description = styled.div`
+    gap: 4px;
+    display: flex;
+    font-size: 0.9em;
+    align-items: center;
+    color: var(--secondary-foreground);
+`;
+
+const EmptyEntry = styled.div`
+    width: 100px;
+    height: 100px;
+    display: grid;
+    flex-shrink: 0;
+    cursor: pointer;
+    border-radius: 4px;
+    place-items: center;
+    background: var(--primary-background);
+    transition: 0.1s ease background-color;
+
+    &:hover {
+        background: var(--secondary-background);
+    }
+`;
+
+function FileEntry({ file, remove }: { file: File, remove?: () => void }) {
+    if (!file.type.startsWith('image/')) return (
+        <Entry>
+            <div><div onClick={remove}><XCircle size={36} /></div></div>
+            
+            <span class="fn">{file.name}</span>
+            <span class="size">{determineFileSize(file.size)}</span>
+        </Entry>
+    );
+
+    const [ url, setURL ] = useState('');
+
+    useEffect(() => {
+        let url: string = URL.createObjectURL(file);
+        setURL(url);
+        return () => URL.revokeObjectURL(url);
+    }, [ file ]);
+
+    return (
+        <Entry>
+            { remove && <div><div onClick={remove}><XCircle size={36} /></div></div> }
+            <img src={url}
+                 alt={file.name} />
+            <span class="fn">{file.name}</span>
+            <span class="size">{determineFileSize(file.size)}</span>
+        </Entry>
+    )
+}
+
+export default function FilePreview({ state, addFile, removeFile }: Props) {
+    if (state.type === 'none') return null;
+
+    return (
+        <Container>
+            <Carousel>
+                { state.files.map((file, index) => <FileEntry file={file} key={file.name} remove={state.type === 'attached' ? () => removeFile(index) : undefined} />) }
+                { state.type === 'attached' && <EmptyEntry onClick={addFile}><Plus size={48} /></EmptyEntry> }
+            </Carousel>
+            { state.files.length > 1 && state.type === 'attached' && <Description>Warning: Only first file will be uploaded, this will be changed in a future update.</Description> }
+            { state.type === 'uploading' && <Description>
+                <Share size={24} />
+                <Text id="app.main.channel.uploading_file" /> ({state.percent}%)
+            </Description> }
+            { state.type === 'sending' && <Description>
+                <Share size={24} />
+                Sending...
+            </Description> }
+            { state.type === 'failed' && <Description>
+                <X size={24} />
+                <Text id={`error.${state.error}`} />
+            </Description> }
+        </Container>
+    );
+}
diff --git a/src/components/common/messaging/bars/JumpToBottom.tsx b/src/components/common/messaging/bars/JumpToBottom.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..14b9a9735d8d921833024532f0db70bde0ef9160
--- /dev/null
+++ b/src/components/common/messaging/bars/JumpToBottom.tsx
@@ -0,0 +1,53 @@
+import { Text } from "preact-i18n";
+import styled from "styled-components";
+import { ArrowDown } from "@styled-icons/feather";
+import { SingletonMessageRenderer, useRenderState } from "../../../../lib/renderer/Singleton";
+
+const Bar = styled.div`
+    z-index: 10;
+    position: relative;
+
+    > div {
+        top: -26px;
+        width: 100%;
+        position: absolute;
+        border-radius: 4px 4px 0 0;
+        display: flex;
+        cursor: pointer;
+        font-size: 13px;
+        padding: 4px 8px;
+        user-select: none;
+        color: var(--secondary-foreground);
+        background: var(--secondary-background);
+        justify-content: space-between;
+        transition: color ease-in-out .08s;
+
+        > div {
+            display: flex;
+            align-items: center;
+            gap: 6px;
+        }
+
+        &:hover {
+            color: var(--primary-text);
+        }
+
+        &:active {
+            transform: translateY(1px);
+        }
+    }
+`;
+
+export default function JumpToBottom({ id }: { id: string }) {
+    const view = useRenderState(id);
+    if (!view || view.type !== 'RENDER' || view.atBottom) return null;
+
+    return (
+        <Bar>
+            <div onClick={() => SingletonMessageRenderer.jumpToBottom(id, true)}>
+                <div><Text id="app.main.channel.misc.viewing_old" /></div>
+                <div><Text id="app.main.channel.misc.jump_present" /> <ArrowDown size={18} strokeWidth={2}/></div>
+            </div>
+        </Bar>
+    )
+}
diff --git a/src/components/common/messaging/bars/TypingIndicator.tsx b/src/components/common/messaging/bars/TypingIndicator.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..d88601590166ef2a4d7eafea917ce16802f77b89
--- /dev/null
+++ b/src/components/common/messaging/bars/TypingIndicator.tsx
@@ -0,0 +1,111 @@
+import { User } from 'revolt.js';
+import { Text } from "preact-i18n";
+import styled from 'styled-components';
+import { useContext } from 'preact/hooks';
+import { connectState } from '../../../../redux/connector';
+import { useUsers } from '../../../../context/revoltjs/hooks';
+import { TypingUser } from '../../../../redux/reducers/typing';
+import { AppContext } from '../../../../context/revoltjs/RevoltClient';
+
+interface Props {
+    typing?: TypingUser[]
+}
+
+const Base = styled.div`
+    position: relative;
+
+    > div {
+        height: 24px;
+        margin-top: -24px;
+        position: absolute;
+
+        gap: 8px;
+        display: flex;
+        padding: 0 10px;
+        user-select: none;
+        align-items: center;
+        flex-direction: row;
+        width: calc(100% - 3px);
+        color: var(--secondary-foreground);
+        background: var(--secondary-background);
+    }
+
+    .avatars {
+        display: flex;
+        
+        img {
+            width: 16px;
+            height: 16px;
+            object-fit: cover;
+            border-radius: 50%;
+
+            &:not(:first-child) {
+                margin-left: -4px;
+            }
+        }
+    }
+
+    .usernames {
+        min-width: 0;
+        font-size: 13px;
+        overflow: hidden;
+        white-space: nowrap;
+        text-overflow: ellipsis;
+    }
+`;
+
+export function TypingIndicator({ typing }: Props) {
+    if (typing && typing.length > 0) {
+        const client = useContext(AppContext);
+        const users = useUsers(typing.map(x => x.id))
+            .filter(x => typeof x !== 'undefined') as User[];
+
+        users.sort((a, b) => a._id.toUpperCase().localeCompare(b._id.toUpperCase()));
+
+        let text;
+        if (users.length >= 5) {
+            text = <Text id="app.main.channel.typing.several" />;
+        } else if (users.length > 1) {
+            const usersCopy = [...users];
+            text = (
+                <Text
+                    id="app.main.channel.typing.multiple"
+                    fields={{
+                        user: usersCopy.pop()?.username,
+                        userlist: usersCopy.map(x => x.username).join(", ")
+                    }}
+                />
+            );
+        } else {
+            text = (
+                <Text
+                    id="app.main.channel.typing.single"
+                    fields={{ user: users[0].username }}
+                />
+            );
+        }
+
+        return (
+            <Base>
+                <div>
+                    <div className="avatars">
+                        {users.map(user => (
+                            <img
+                                src={client.users.getAvatarURL(user._id, { max_side: 256 }, true)}
+                            />
+                        ))}
+                    </div>
+                    <div className="usernames">{text}</div>
+                </div>
+            </Base>
+        );
+    }
+
+    return null;
+}
+
+export default connectState<{ id: string }>(TypingIndicator, (state, props) => {
+    return {
+        typing: state.typing && state.typing[props.id]
+    };
+});
diff --git a/src/components/navigation/RightSidebar.tsx b/src/components/navigation/RightSidebar.tsx
index 775f58340938c53d1a0af9c583d80ff888ae8c0e..45371a8fa4eea465cc45c2dc74294e17606eaa1f 100644
--- a/src/components/navigation/RightSidebar.tsx
+++ b/src/components/navigation/RightSidebar.tsx
@@ -1,19 +1,18 @@
 import { Route, Switch } from "react-router";
 import SidebarBase from "./SidebarBase";
 
-// import { MemberSidebar } from "./right/MemberSidebar";
+import MemberSidebar from "./right/MemberSidebar";
 
 export default function RightSidebar() {
     return (
         <SidebarBase>
             <Switch>
-                {/* 
                 <Route path="/server/:server/channel/:channel">
                     <MemberSidebar />
                 </Route>
                 <Route path="/channel/:channel">
                     <MemberSidebar />
-                </Route> */ }
+                </Route>
             </Switch>
         </SidebarBase>
     );
diff --git a/src/components/navigation/SidebarBase.tsx b/src/components/navigation/SidebarBase.tsx
index cefe6b7d8b66ff808834d45f182e9e6540095454..b9060c0634b51e3b48643c96ad645dc2805bb390 100644
--- a/src/components/navigation/SidebarBase.tsx
+++ b/src/components/navigation/SidebarBase.tsx
@@ -12,3 +12,22 @@ export default styled.div`
         padding-bottom: 50px;
     ` }
 `;
+
+export const GenericSidebarBase = styled.div`
+    height: 100%;
+    width: 240px;
+    display: flex;
+    flex-shrink: 0;
+    flex-direction: column;
+    background: var(--secondary-background);
+`;
+
+export const GenericSidebarList = styled.div`
+    padding: 6px;
+    flex-grow: 1;
+    overflow-y: scroll;
+
+    > svg {
+        width: 100%;
+    }
+`;
diff --git a/src/components/navigation/left/HomeSidebar.tsx b/src/components/navigation/left/HomeSidebar.tsx
index 9c6cf7a4c59df032e99990477bb3770335af3789..4437ccc7d8e8ef6444a7c9c72910a56cf024e90d 100644
--- a/src/components/navigation/left/HomeSidebar.tsx
+++ b/src/components/navigation/left/HomeSidebar.tsx
@@ -2,7 +2,6 @@ import { Localizer, Text } from "preact-i18n";
 import { useContext } from "preact/hooks";
 import { Home, Users, Tool, Save } from "@styled-icons/feather";
 
-import styled from "styled-components";
 import Category from '../../ui/Category';
 import PaintCounter from "../../../lib/PaintCounter";
 import UserHeader from "../../common/user/UserHeader";
@@ -16,6 +15,7 @@ import { Users as UsersNS } from 'revolt.js/dist/api/objects';
 import ButtonItem, { ChannelButton } from '../items/ButtonItem';
 import { AppContext } from "../../../context/revoltjs/RevoltClient";
 import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
+import { GenericSidebarBase, GenericSidebarList } from "../SidebarBase";
 import { Link, Redirect, useLocation, useParams } from "react-router-dom";
 import { useIntermediate } from "../../../context/intermediate/Intermediate";
 import { useDMs, useForceUpdate, useUsers } from "../../../context/revoltjs/hooks";
@@ -24,25 +24,6 @@ type Props = WithDispatcher & {
     unreads: Unreads;
 }
 
-const HomeBase = styled.div`
-    height: 100%;
-    width: 240px;
-    display: flex;
-    flex-shrink: 0;
-    flex-direction: column;
-    background: var(--secondary-background);
-`;
-
-const HomeList = styled.div`
-    padding: 6px;
-    flex-grow: 1;
-    overflow-y: scroll;
-
-    > svg {
-        width: 100%;
-    }
-`;
-
 function HomeSidebar(props: Props) {
     const { pathname } = useLocation();
     const client = useContext(AppContext);
@@ -68,10 +49,10 @@ function HomeSidebar(props: Props) {
     channelsArr.sort((b, a) => a.timestamp.localeCompare(b.timestamp));
 
     return (
-        <HomeBase>
+        <GenericSidebarBase>
             <UserHeader user={client.user!} />
             <ConnectionStatus />
-            <HomeList>
+            <GenericSidebarList>
                 {!isTouchscreenDevice && (
                     <>
                         <Link to="/">
@@ -146,8 +127,8 @@ function HomeSidebar(props: Props) {
                     );
                 })}
                 <PaintCounter />
-            </HomeList>
-        </HomeBase>
+            </GenericSidebarList>
+        </GenericSidebarBase>
     );
 };
 
diff --git a/src/components/navigation/right/ChannelDebugInfo.tsx b/src/components/navigation/right/ChannelDebugInfo.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..8584288176818f11a8eb58ea2634eaceb6f4650b
--- /dev/null
+++ b/src/components/navigation/right/ChannelDebugInfo.tsx
@@ -0,0 +1,37 @@
+import { useRenderState } from "../../../lib/renderer/Singleton";
+
+interface Props {
+    id: string;
+}
+
+export function ChannelDebugInfo({ id }: Props) {
+    if (process.env.NODE_ENV !== "development") return null;
+    let view = useRenderState(id);
+    if (!view) return null;
+
+    return (
+        <span style={{ display: "block", padding: "12px 10px 0 10px" }}>
+            <span
+                style={{
+                    display: "block",
+                    fontSize: "12px",
+                    textTransform: "uppercase",
+                    fontWeight: "600"
+                }}
+            >
+                Channel Info
+            </span>
+            <p style={{ fontSize: "10px", userSelect: "text" }}>
+                State: <b>{ view.type }</b> <br />
+                { view.type === 'RENDER' && view.messages.length > 0 &&
+                    <>
+                        Start: <b>{view.messages[0]._id}</b> <br />
+                        End: <b>{view.messages[view.messages.length - 1]._id}</b> <br />
+                        At Top: <b>{view.atTop ? "Yes" : "No"}</b> <br />
+                        At Bottom: <b>{view.atBottom ? "Yes" : "No"}</b>
+                    </>
+                }
+            </p>
+        </span>
+    );
+}
diff --git a/src/components/navigation/right/MemberSidebar.tsx b/src/components/navigation/right/MemberSidebar.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..973835490c81954d65487f4a350492e53e0dc52c
--- /dev/null
+++ b/src/components/navigation/right/MemberSidebar.tsx
@@ -0,0 +1,206 @@
+import { Text } from "preact-i18n";
+import { useContext, useEffect, useState } from "preact/hooks";
+
+import { User } from "revolt.js";
+import Category from "../../ui/Category";
+import { useParams } from "react-router";
+import { UserButton } from "../items/ButtonItem";
+import { ChannelDebugInfo } from "./ChannelDebugInfo";
+import { Channels, Servers, Users } from "revolt.js/dist/api/objects";
+import { GenericSidebarBase, GenericSidebarList } from "../SidebarBase";
+import { ClientboundNotification } from "revolt.js/dist/websocket/notifications";
+import { HookContext, useChannel, useForceUpdate, useUsers } from "../../../context/revoltjs/hooks";
+
+import placeholderSVG from "../items/placeholder.svg";
+import { AppContext, ClientStatus, StatusContext } from "../../../context/revoltjs/RevoltClient";
+
+interface Props {
+    ctx: HookContext
+}
+
+export default function MemberSidebar(props: { channel?: Channels.Channel }) {
+    const ctx = useForceUpdate();
+    const { channel: cid } = useParams<{ channel: string }>();
+    const channel = props.channel ?? useChannel(cid, ctx);
+
+    switch (channel?.channel_type) {
+        case 'Group': return <GroupMemberSidebar channel={channel} ctx={ctx} />;
+        case 'TextChannel': return <ServerMemberSidebar channel={channel} ctx={ctx} />;
+        default: return null;
+    }
+}
+
+export function GroupMemberSidebar({ channel, ctx }: Props & { channel: Channels.GroupChannel }) {
+    const users = useUsers(undefined, ctx);
+    let members = channel.recipients
+        .map(x => users.find(y => y?._id === x))
+        .filter(x => typeof x !== "undefined") as User[];
+
+    /*const voice = useContext(VoiceContext);
+    const voiceActive = voice.roomId === channel._id;
+
+    let voiceParticipants: User[] = [];
+    if (voiceActive) {
+        const idArray = Array.from(voice.participants.keys());
+        voiceParticipants = idArray
+            .map(x => users.find(y => y?._id === x))
+            .filter(x => typeof x !== "undefined") as User[];
+
+        members = members.filter(member => idArray.indexOf(member._id) === -1);
+
+        voiceParticipants.sort((a, b) => a.username.localeCompare(b.username));
+    }*/
+
+    members.sort((a, b) => {
+        // ! FIXME: should probably rewrite all this code
+        let l = ((a.online &&
+            a.status?.presence !== Users.Presence.Invisible) ??
+            false) as any | 0;
+        let r = ((b.online &&
+            b.status?.presence !== Users.Presence.Invisible) ??
+            false) as any | 0;
+
+        let n = r - l;
+        if (n !== 0) {
+            return n;
+        }
+
+        return a.username.localeCompare(b.username);
+    });
+
+    return (
+        <GenericSidebarBase>
+            <GenericSidebarList>
+                <ChannelDebugInfo id={channel._id} />
+                {/*voiceActive && voiceParticipants.length !== 0 && (
+                    <Fragment>
+                        <Category
+                            type="members"
+                            text={
+                                <span>
+                                    <Text id="app.main.categories.participants" />{" "}
+                                    — {voiceParticipants.length}
+                                </span>
+                            }
+                        />
+                        {voiceParticipants.map(
+                            user =>
+                                user && (
+                                    <LinkProfile user_id={user._id}>
+                                        <UserButton
+                                            key={user._id}
+                                            user={user}
+                                            context={channel}
+                                        />
+                                    </LinkProfile>
+                                )
+                        )}
+                    </Fragment>
+                )*/}
+                {!(members.length === 0 /*&& voiceActive*/) && (
+                    <Category
+                        variant="uniform"
+                        text={
+                            <span>
+                                <Text id="app.main.categories.members" /> —{" "}
+                                {channel.recipients.length}
+                            </span>
+                        }
+                    />
+                )}
+                {members.length === 0 && /*!voiceActive &&*/ <img src={placeholderSVG} />}
+                {members.map(
+                    user =>
+                        user && (
+                            // <LinkProfile user_id={user._id}>
+                                <UserButton
+                                    key={user._id}
+                                    user={user}
+                                    context={channel}
+                                />
+                            // </LinkProfile>
+                        )
+                )}
+            </GenericSidebarList>
+        </GenericSidebarBase>
+    );
+}
+
+export function ServerMemberSidebar({ channel, ctx }: Props & { channel: Channels.TextChannel }) {
+    const [members, setMembers] = useState<Servers.Member[] | undefined>(undefined);
+    const users = useUsers(members?.map(x => x._id.user) ?? []).filter(x => typeof x !== 'undefined', ctx) as Users.User[];
+    const status = useContext(StatusContext);
+    const client = useContext(AppContext);
+
+    useEffect(() => {
+        if (status === ClientStatus.ONLINE && typeof members === 'undefined') {
+            client.servers.members.fetchMembers(channel.server)
+                .then(members => setMembers(members))
+        }
+    }, [ status ]);
+
+    // ! FIXME: temporary code
+    useEffect(() => {
+        function onPacket(packet: ClientboundNotification) {
+            if (!members) return;
+            if (packet.type === 'ServerMemberJoin') {
+                if (packet.id !== channel.server) return;
+                setMembers([ ...members, { _id: { server: packet.id, user: packet.user } } ]);
+            } else if (packet.type === 'ServerMemberLeave') {
+                if (packet.id !== channel.server) return;
+                setMembers(members.filter(x => !(x._id.user === packet.user && x._id.server === packet.id)));
+            }
+        }
+
+        client.addListener('packet', onPacket);
+        return () => client.removeListener('packet', onPacket);
+    }, [ members ]);
+
+    // copy paste from above
+    users.sort((a, b) => {
+        // ! FIXME: should probably rewrite all this code
+        let l = ((a.online &&
+            a.status?.presence !== Users.Presence.Invisible) ??
+            false) as any | 0;
+        let r = ((b.online &&
+            b.status?.presence !== Users.Presence.Invisible) ??
+            false) as any | 0;
+
+        let n = r - l;
+        if (n !== 0) {
+            return n;
+        }
+
+        return a.username.localeCompare(b.username);
+    });
+
+    return (
+        <GenericSidebarBase>
+            <GenericSidebarList>
+                <ChannelDebugInfo id={channel._id} />
+                <Category
+                    variant="uniform"
+                    text={
+                        <span>
+                            <Text id="app.main.categories.members" /> —{" "}
+                            {users.length}
+                        </span>
+                    }
+                />
+                {users.length === 0 && <img src={placeholderSVG} />}
+                {users.map(
+                    user =>
+                        user && (
+                            // <LinkProfile user_id={user._id}>
+                                <UserButton
+                                    key={user._id}
+                                    user={user}
+                                    context={channel}
+                                />
+                            // </LinkProfile>
+                        )
+                )}
+            </GenericSidebarList>
+        </GenericSidebarBase>
+    );
+}
diff --git a/src/components/ui/ComboBox.tsx b/src/components/ui/ComboBox.tsx
index de2796c57420493a08dbf68e33db7d6df669c37b..ef9ba9f91c0045cf077ffab655b46077cad7fc26 100644
--- a/src/components/ui/ComboBox.tsx
+++ b/src/components/ui/ComboBox.tsx
@@ -5,4 +5,12 @@ export default styled.select`
     border-radius: 2px;
     color: var(--secondary-foreground);
     background: var(--secondary-background);
+
+    border: none;
+    outline: 2px solid transparent;
+    transition: outline-color 0.2s ease-in-out;
+
+    &:focus {
+        outline-color: var(--accent);
+    }
 `;
diff --git a/src/components/ui/InputBox.tsx b/src/components/ui/InputBox.tsx
index b27e6c57c30e20937913ae976451dde029a39a44..ac3746468d64539919f3ed1b1f77783bb4c62631 100644
--- a/src/components/ui/InputBox.tsx
+++ b/src/components/ui/InputBox.tsx
@@ -8,18 +8,21 @@ export default styled.input<Props>`
     z-index: 1;
     padding: 8px 16px;
     border-radius: 6px;
+    
     color: var(--foreground);
-    border: 2px solid transparent;
     background: var(--primary-background);
     transition: 0.2s ease background-color;
-    transition: border-color 0.2s ease-in-out;
+    
+    border: none;
+    outline: 2px solid transparent;
+    transition: outline-color 0.2s ease-in-out;
 
     &:hover {
         background: var(--secondary-background);
     }
 
     &:focus {
-        border: 2px solid var(--accent);
+        outline: 2px solid var(--accent);
     }
 
     ${(props) =>
diff --git a/src/components/ui/TextArea.tsx b/src/components/ui/TextArea.tsx
index cb4bc81ab0305f9014be9ce204d480916b78f634..7cdde0865c6519bf85cdca6f6e64b14772e1277e 100644
--- a/src/components/ui/TextArea.tsx
+++ b/src/components/ui/TextArea.tsx
@@ -7,23 +7,39 @@ import styled, { css } from "styled-components";
 export interface TextAreaProps {
     code?: boolean;
     padding?: number;
+    lineHeight?: number;
+    hideBorder?: boolean;
 }
 
+export const TEXT_AREA_BORDER_WIDTH = 2;
+export const DEFAULT_TEXT_AREA_PADDING = 16;
+export const DEFAULT_LINE_HEIGHT = 20;
+
 export default styled.textarea<TextAreaProps>`
     width: 100%;
     resize: none;
     display: block;
-    border-radius: 4px;
-    padding: ${ props => props.padding ?? 16 }px;
-
     color: var(--foreground);
-    border: 2px solid transparent;
     background: var(--secondary-background);
-    transition: border-color .2s ease-in-out;
+    padding: ${ props => props.padding ?? DEFAULT_TEXT_AREA_PADDING }px;
+    line-height: ${ props => props.lineHeight ?? DEFAULT_LINE_HEIGHT }px;
+
+    ${ props => props.hideBorder && css`
+        border: none;
+    ` }
+
+    ${ props => !props.hideBorder && css`
+        border-radius: 4px;
+        transition: border-color .2s ease-in-out;
+        border: ${TEXT_AREA_BORDER_WIDTH}px solid transparent;
+    ` }
 
     &:focus {
         outline: none;
-        border: 2px solid var(--accent);
+
+        ${ props => !props.hideBorder && css`
+            border: ${TEXT_AREA_BORDER_WIDTH}px solid var(--accent);
+        ` }
     }
 
     ${ props => props.code ? css`
diff --git a/src/context/revoltjs/FileUploads.tsx b/src/context/revoltjs/FileUploads.tsx
index 61df463076e472ab6fe2849b7a8ba8fe80ecd636..d48525038a110f33d26c7142beccc181bdea5a0d 100644
--- a/src/context/revoltjs/FileUploads.tsx
+++ b/src/context/revoltjs/FileUploads.tsx
@@ -1,18 +1,15 @@
-// ! FIXME: also TEMP CODE
-// ! RE-WRITE WITH STYLED-COMPONENTS
-
 import { Text } from "preact-i18n";
 import { takeError } from "./util";
 import classNames from "classnames";
+import { AppContext } from "./RevoltClient";
 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 { Edit, Plus, X, XCircle } from "@styled-icons/feather";
 import { useIntermediate } from "../intermediate/Intermediate";
-import { AppContext } from "./RevoltClient";
 
 type Props = {
     maxFileSize: number
@@ -20,6 +17,7 @@ type Props = {
     fileType: 'backgrounds' | 'icons' | 'avatars' | 'attachments' | 'banners'
 } & (
     { behaviour: 'ask', onChange: (file: File) => void } |
+    { behaviour: 'multi', onChange: (files: File[]) => void } |
     { behaviour: 'upload', onUpload: (id: string) => Promise<void> }
 ) & (
     { style: 'icon' | 'banner', defaultPreview?: string, previewURL?: string, width?: number, height?: number } |
@@ -40,6 +38,26 @@ export async function uploadFile(autumnURL: string, tag: string, file: File, con
     return res.data.id;
 }
 
+export function grabFiles(maxFileSize: number, cb: (files: File[]) => void, tooLarge: () => void, multiple?: boolean) {
+    const input = document.createElement("input");
+    input.type = "file";
+    input.multiple = multiple ?? false;
+
+    input.onchange = async e => {
+        const files = (e.target as any)?.files;
+        if (!files) return;
+        for (let file of files) {
+            if (file.size > maxFileSize) {
+                return tooLarge();
+            }
+        }
+
+        cb(Array.from(files));
+    };
+
+    input.click();
+}
+
 export function FileUploader(props: Props) {
     const { fileType, maxFileSize, remove } = props;
     const { openScreen } = useIntermediate();
@@ -50,35 +68,25 @@ export function FileUploader(props: Props) {
     function onClick() {
         if (uploading) return;
 
-        const input = document.createElement("input");
-        input.type = "file";
-
-        input.onchange = async e => {
+        grabFiles(maxFileSize, async files => {
             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));
-                    }
+                if (props.behaviour === 'multi') {
+                    props.onChange(files);
+                } else if (props.behaviour === 'ask') {
+                    props.onChange(files[0]);
+                } else {
+                    await props.onUpload(await uploadFile(client.configuration!.features.autumn.url, fileType, files[0]));
                 }
             } catch (err) {
                 return openScreen({ id: "error", error: takeError(err) });
             } finally {
                 setUploading(false);
             }
-        };
-
-        input.click();
+        }, () =>
+            openScreen({ id: "error", error: "FileTooLarge" }),
+            props.behaviour === 'multi');
     }
 
     function removeOrUpload() {
@@ -139,7 +147,7 @@ export function FileUploader(props: Props) {
                     if (attached) return remove();
                     onClick();
                 }}>
-                { attached ? <X size={size} /> : <Plus size={size} />}
+                { uploading ? <XCircle size={size} /> : attached ? <X size={size} /> : <Plus size={size} />}
             </IconButton>
         )
     }
diff --git a/src/context/revoltjs/Notifications.tsx b/src/context/revoltjs/Notifications.tsx
index 09b8eaf1f8620b7a8199bd8d077e87e781bcb006..0845a62ab8b86105a6371c1c7fb1ced7a4f31140 100644
--- a/src/context/revoltjs/Notifications.tsx
+++ b/src/context/revoltjs/Notifications.tsx
@@ -1,8 +1,8 @@
 import { decodeTime } from "ulid";
 import { AppContext } from "./RevoltClient";
+import { useTranslation } from "../../lib/i18n";
 import { Users } from "revolt.js/dist/api/objects";
 import { useContext, useEffect } from "preact/hooks";
-import { IntlContext, translate } from "preact-i18n";
 import { connectState } from "../../redux/connector";
 import { playSound } from "../../assets/sounds/Audio";
 import { Message, SYSTEM_USER_ID, User } from "revolt.js";
@@ -25,7 +25,7 @@ async function createNotification(title: string, options: globalThis.Notificatio
 }
 
 function Notifier(props: Props) {
-    const { intl } = useContext(IntlContext) as any;
+    const translate = useTranslation();
     const showNotification = props.options?.desktopEnabled ?? false;
     // const playIncoming = props.options?.soundEnabled ?? true;
     // const playOutgoing = props.options?.outgoingSoundEnabled ?? true;
@@ -88,45 +88,37 @@ function Notifier(props: Props) {
         } else {
             let users = client.users;
             switch (msg.content.type) {
-                // ! FIXME: update to support new replacements
                 case "user_added":
-                    body = `${users.get(msg.content.id)?.username} ${translate(
-                        "app.main.channel.system.user_joined",
-                        "",
-                        intl.dictionary
-                    )} (${translate(
-                        "app.main.channel.system.added_by",
-                        "",
-                        intl.dictionary
-                    )} ${users.get(msg.content.by)?.username})`;
-                    icon = client.users.getAvatarURL(msg.content.id, { max_side: 256 });
-                    break;
                 case "user_remove":
-                    body = `${users.get(msg.content.id)?.username} ${translate(
-                        "app.main.channel.system.user_left",
-                        "",
-                        intl.dictionary
-                    )} (${translate(
-                        "app.main.channel.system.added_by",
-                        "",
-                        intl.dictionary
-                    )} ${users.get(msg.content.by)?.username})`;
+                    body = translate(
+                        `app.main.channel.system.${msg.content.type === 'user_added' ? 'added_by' : 'removed_by'}`,
+                        { user: users.get(msg.content.id)?.username, other_user: users.get(msg.content.by)?.username }
+                    );
                     icon = client.users.getAvatarURL(msg.content.id, { max_side: 256 });
                     break;
+                case "user_joined":
                 case "user_left":
-                    body = `${users.get(msg.content.id)?.username} ${translate(
-                        "app.main.channel.system.user_left",
-                        "",
-                        intl.dictionary
-                    )}`;
+                case "user_kicked":
+                case "user_banned":
+                    body = translate(
+                        `app.main.channel.system.${msg.content.type}`,
+                        { user: users.get(msg.content.id)?.username }
+                    );
                     icon = client.users.getAvatarURL(msg.content.id, { max_side: 256 });
                     break;
                 case "channel_renamed":
-                    body = `${users.get(msg.content.by)?.username} ${translate(
-                        "app.main.channel.system.channel_renamed",
-                        "",
-                        intl.dictionary
-                    )} ${msg.content.name}`;
+                    body = translate(
+                        `app.main.channel.system.channel_renamed`,
+                        { user: users.get(msg.content.by)?.username, name: msg.content.name }
+                    );
+                    icon = client.users.getAvatarURL(msg.content.by, { max_side: 256 });
+                    break;
+                case "channel_description_changed":
+                case "channel_icon_changed":
+                    body = translate(
+                        `app.main.channel.system.${msg.content.type}`,
+                        { user: users.get(msg.content.by)?.username }
+                    );
                     icon = client.users.getAvatarURL(msg.content.by, { max_side: 256 });
                     break;
             }
@@ -173,20 +165,10 @@ function Notifier(props: Props) {
         let event;
         switch (user.relationship) {
             case Users.Relationship.Incoming:
-                event = translate(
-                    "notifications.sent_request",
-                    "",
-                    intl.dictionary,
-                    { person: user.username }
-                );
+                event = translate("notifications.sent_request", { person: user.username });
                 break;
             case Users.Relationship.Friend:
-                event = translate(
-                    "notifications.now_friends",
-                    "",
-                    intl.dictionary,
-                    { person: user.username }
-                );
+                event = translate("notifications.now_friends", { person: user.username });
                 break;
             default:
                 return;
diff --git a/src/lib/TextAreaAutoSize.tsx b/src/lib/TextAreaAutoSize.tsx
index 2b1fd73fe1c494e3991c8ce1fd3a156bd414c81a..deca4350a7e1dd585d1f6dcff727f267a7b0a3ce 100644
--- a/src/lib/TextAreaAutoSize.tsx
+++ b/src/lib/TextAreaAutoSize.tsx
@@ -1,6 +1,5 @@
-import styled from "styled-components";
-import TextArea, { TextAreaProps } from "../components/ui/TextArea";
-import { useEffect, useLayoutEffect, useRef, useState } from "preact/hooks";
+import TextArea, { DEFAULT_LINE_HEIGHT, DEFAULT_TEXT_AREA_PADDING, TextAreaProps, TEXT_AREA_BORDER_WIDTH } from "../components/ui/TextArea";
+import { useEffect, useRef } from "preact/hooks";
 
 type TextAreaAutoSizeProps = Omit<JSX.HTMLAttributes<HTMLTextAreaElement>, 'style' | 'value'> & TextAreaProps & {
     autoFocus?: boolean,
@@ -9,51 +8,15 @@ type TextAreaAutoSizeProps = Omit<JSX.HTMLAttributes<HTMLTextAreaElement>, 'styl
     value: string
 };
 
-const lineHeight = 20;
-
-const Ghost = styled.div`
-    width: 100%;
-    overflow: hidden;
-    position: relative;
-
-    > div {
-        width: 100%;
-        white-space: pre-wrap;
-        
-        top: 0;
-        position: absolute;
-        visibility: hidden;
-    }
-`;
-
 export default function TextAreaAutoSize(props: TextAreaAutoSizeProps) {
-    const { autoFocus, minHeight, maxRows, value, padding, children, as, ...textAreaProps } = props;
-
-    const heightPadding = (padding ?? 0) * 2;
-    const minimumHeight = (minHeight ?? lineHeight) + heightPadding;
+    const { autoFocus, minHeight, maxRows, value, padding, lineHeight, hideBorder, children, as, ...textAreaProps } = props;
+    const line = lineHeight ?? DEFAULT_LINE_HEIGHT;
 
-    var height = Math.max(Math.min(value.split('\n').length, maxRows ?? Infinity) * lineHeight + heightPadding, minimumHeight);
+    const heightPadding = ((padding ?? DEFAULT_TEXT_AREA_PADDING) + (hideBorder ? 0 : TEXT_AREA_BORDER_WIDTH)) * 2;
+    const height = Math.max(Math.min(value.split('\n').length, maxRows ?? Infinity) * line + heightPadding, minHeight ?? 0);
+    console.log(value.split('\n').length, line, heightPadding, height);
     const ref = useRef<HTMLTextAreaElement>();
 
-    /*function setHeight(h: number = lineHeight) {
-        let newHeight = Math.min(
-            Math.max(
-                lineHeight,
-                maxRows ? Math.min(h, maxRows * lineHeight) : h
-            ),
-            minHeight ?? Infinity
-        );
-
-        if (heightPadding) newHeight += heightPadding;
-        if (height !== newHeight) {
-            setHeightState(newHeight);
-        }
-    }*/
-
-    {/*useLayoutEffect(() => {
-        setHeight(ghost.current.clientHeight);
-    }, [ghost, value]);*/}
-
     useEffect(() => {
         autoFocus && ref.current.focus();
     }, [value]);
@@ -89,17 +52,12 @@ export default function TextAreaAutoSize(props: TextAreaAutoSizeProps) {
         return () => document.body.removeEventListener("keydown", keyDown);
     }, [ref]);
 
-    return <>
-        <TextArea
-            ref={ref}
-            value={value}
-            padding={padding}
-            style={{ height }}
-            {...textAreaProps} />
-        {/*<Ghost><div ref={ghost}>
-            { props.value.split('\n')
-                .map(x => `\u0020${x}`)
-                .join('\n') }
-        </div></Ghost>*/}
-    </>;
+    return <TextArea
+        ref={ref}
+        value={value}
+        padding={padding}
+        style={{ height }}
+        hideBorder={hideBorder}
+        lineHeight={lineHeight}
+        {...textAreaProps} />;
 }
diff --git a/src/lib/i18n.tsx b/src/lib/i18n.tsx
index 5f270b2eac5c7524f0dfd58c0f7d6a781f3c0d17..a58dff14a608828cac37490602356f021122efe8 100644
--- a/src/lib/i18n.tsx
+++ b/src/lib/i18n.tsx
@@ -1,4 +1,4 @@
-import { IntlContext } from "preact-i18n";
+import { IntlContext, translate } from "preact-i18n";
 import { useContext } from "preact/hooks";
 import { Children } from "../types/Preact";
 
@@ -52,3 +52,8 @@ export function TextReact({ id, fields }: Props) {
 
     return <>{ recursiveReplaceFields(entry as string, fields) }</>;
 }
+
+export function useTranslation() {
+    const { intl } = useContext(IntlContext) as unknown as IntlType;
+    return (id: string, fields?: Object, plural?: number, fallback?: string) => translate(id, "", intl.dictionary, fields, plural, fallback);
+}
diff --git a/src/pages/channels/Channel.tsx b/src/pages/channels/Channel.tsx
index 5e4ec842e22c9d129f7833ffb7f3d98b4bfd9866..8d32b3b365f05b995b5a6c3d5ff025455ece395f 100644
--- a/src/pages/channels/Channel.tsx
+++ b/src/pages/channels/Channel.tsx
@@ -1,10 +1,15 @@
 import styled from "styled-components";
+import { useState } from "preact/hooks";
+import ChannelHeader from "./ChannelHeader";
 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";
+// import { useRenderState } from "../../lib/renderer/Singleton";
+import { isTouchscreenDevice } from "../../lib/isTouchscreenDevice";
 import MessageBox from "../../components/common/messaging/MessageBox";
+import { useChannel, useForceUpdate } from "../../context/revoltjs/hooks";
+import MemberSidebar from "../../components/navigation/right/MemberSidebar";
+import JumpToBottom from "../../components/common/messaging/bars/JumpToBottom";
+import TypingIndicator from "../../components/common/messaging/bars/TypingIndicator";
 
 const ChannelMain = styled.div`
     flex-grow: 1;
@@ -21,26 +26,30 @@ const ChannelContent = styled.div`
     flex-direction: column;
 `;
 
-export default function Channel() {
-    const { channel: id } = useParams<{ channel: string }>();
-
+export function Channel({ id }: { id: string }) {
     const ctx = useForceUpdate();
     const channel = useChannel(id, ctx);
 
     if (!channel) return null;
-    // const view = useRenderState(id);
+    const [ showMembers, setMembers ] = useState(true);
 
     return (
         <>
-            <Header placement="primary">
-                Channel
-            </Header>
+            <ChannelHeader channel={channel} toggleSidebar={() => setMembers(!showMembers)} />
             <ChannelMain>
                 <ChannelContent>
                     <MessageArea id={id} />
+                    <TypingIndicator id={channel._id} />
+                    <JumpToBottom id={id} />
                     <MessageBox channel={channel} />
                 </ChannelContent>
+                { !isTouchscreenDevice && showMembers && <MemberSidebar channel={channel} /> }
             </ChannelMain>
         </>
     )
 }
+
+export default function() {
+    const { channel } = useParams<{ channel: string }>();
+    return <Channel id={channel} key={channel} />;
+}
diff --git a/src/pages/channels/ChannelHeader.tsx b/src/pages/channels/ChannelHeader.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..929ac9fba214ccabef142b6d61afde96142369ab
--- /dev/null
+++ b/src/pages/channels/ChannelHeader.tsx
@@ -0,0 +1,136 @@
+import styled from "styled-components";
+import { Channel, User } from "revolt.js";
+import { useContext } from "preact/hooks";
+import { useHistory } from "react-router-dom";
+import Header from "../../components/ui/Header";
+import IconButton from "../../components/ui/IconButton";
+import Markdown from "../../components/markdown/Markdown";
+import { getChannelName } from "../../context/revoltjs/util";
+import UserStatus from "../../components/common/user/UserStatus";
+import { AppContext } from "../../context/revoltjs/RevoltClient";
+import { isTouchscreenDevice } from "../../lib/isTouchscreenDevice";
+import { useStatusColour } from "../../components/common/user/UserIcon";
+import { useIntermediate } from "../../context/intermediate/Intermediate";
+import { Save, AtSign, Users, Hash, UserPlus, Settings, Sidebar as SidebarIcon } from "@styled-icons/feather";
+
+interface Props {
+    channel: Channel,
+    toggleSidebar?: () => void
+}
+
+const Info = styled.div`
+    flex-grow: 1;
+    min-width: 0;
+    overflow: hidden;
+    white-space: nowrap;
+
+    * {
+        display: inline-block;
+    }
+
+    .divider {
+        height: 14px;
+        margin: 0 5px;
+        padding-left: 1px;
+        background-color: var(--tertiary-background);
+    }
+
+    .status {
+        width: 10px;
+        height: 10px;
+        border-radius: 50%;
+        display: inline-block;
+        margin-inline-end: 6px;
+    }
+
+    .desc {
+        cursor: pointer;
+        font-size: 0.8em;
+        font-weight: 400;
+        color: var(--secondary-foreground);
+    }
+`;
+
+export default function ChannelHeader({ channel, toggleSidebar }: Props) {
+    const { openScreen } = useIntermediate();
+    const client = useContext(AppContext);
+    const history = useHistory();
+
+    const name = getChannelName(client, channel);
+    let icon, recipient;
+    switch (channel.channel_type) {
+        case "SavedMessages":
+            icon = <Save size={20} strokeWidth={1.5} />;
+            break;
+        case "DirectMessage":
+            icon = <AtSign size={20} strokeWidth={1.5} />;
+            const uid = client.channels.getRecipient(channel._id);
+            recipient = client.users.get(uid);
+            break;
+        case "Group":
+            icon = <Users size={20} strokeWidth={1.5} />;
+            break;
+        case "TextChannel":
+            icon = <Hash size={20} strokeWidth={1.5} />;
+            break;
+    }
+
+    return (
+        <Header placement="primary">
+            { icon }
+            <Info>
+                <span className="name">{ name }</span>
+                {channel.channel_type === "DirectMessage" && (
+                    <>
+                        <div className="divider" />
+                        <span className="desc">
+                            <div className="status" style={{ backgroundColor: useStatusColour(recipient as User) }} />
+                            <UserStatus user={recipient as User} />
+                        </span>
+                    </>
+                )}
+                {(channel.channel_type === "Group" || channel.channel_type === "TextChannel") && channel.description && (
+                    <>
+                        <div className="divider" />
+                        <span
+                            className="desc"
+                            onClick={() =>
+                                openScreen({
+                                    id: "channel_info",
+                                    channel_id: channel._id
+                                })
+                            }>
+                            <Markdown content={channel.description.split("\n")[0] ?? ""} disallowBigEmoji />
+                        </span>
+                    </>
+                )}
+            </Info>
+            <>
+                { channel.channel_type === "Group" && (
+                    <>
+                        <IconButton onClick={() =>
+                            openScreen({
+                                id: "user_picker",
+                                omit: channel.recipients,
+                                callback: async users => {
+                                    for (const user of users) {
+                                        await client.channels.addMember(channel._id, user);
+                                    }
+                                }
+                            })}>
+                            <UserPlus size={22} />
+                        </IconButton>
+                        <IconButton onClick={() => history.push(`/channel/${channel._id}/settings`)}>
+                            <Settings size={22} />
+                        </IconButton>
+                    </>
+                ) }
+                { channel.channel_type === "Group" && !isTouchscreenDevice && (
+                    <IconButton onClick={toggleSidebar}>
+                        <SidebarIcon size={22} />
+                    </IconButton>
+                ) }
+            </>
+        </Header>
+    )
+}
diff --git a/src/pages/channels/messaging/MessageArea.tsx b/src/pages/channels/messaging/MessageArea.tsx
index b04122916ebab874da16888780a98f969b5df5d4..6e609662a324560480a36cf370be85639188dd31 100644
--- a/src/pages/channels/messaging/MessageArea.tsx
+++ b/src/pages/channels/messaging/MessageArea.tsx
@@ -23,6 +23,7 @@ const Area = styled.div`
     > div {
         display: flex;
         min-height: 100%;
+        padding-bottom: 20px;
         flex-direction: column;
         justify-content: flex-end;
     }
diff --git a/src/pages/channels/messaging/MessageRenderer.tsx b/src/pages/channels/messaging/MessageRenderer.tsx
index 9e690e79e1cb225415d7b13340671c8b614e64c1..b1b5493653ecf7d299989f01716596e5a16a8a15 100644
--- a/src/pages/channels/messaging/MessageRenderer.tsx
+++ b/src/pages/channels/messaging/MessageRenderer.tsx
@@ -160,7 +160,7 @@ function MessageRenderer({ id, state, queue }: Props) {
             );
         }
 
-        render.push(<div>end</div>);
+        // render.push(<div>end</div>);
     } else {
         render.push(
             <RequiresOnline>
diff --git a/src/pages/settings/channel/Overview.tsx b/src/pages/settings/channel/Overview.tsx
index a73a3fde855f2303c242d39f087b416db1b6661d..30c36b9f62ef3814d69350234c95488d812d25c8 100644
--- a/src/pages/settings/channel/Overview.tsx
+++ b/src/pages/settings/channel/Overview.tsx
@@ -3,7 +3,7 @@ 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 TextArea from "../../../components/ui/TextArea";
+import TextAreaAutoSize from "../../../lib/TextAreaAutoSize";
 import { useContext, useEffect, useState } from "preact/hooks";
 import { AppContext } from "../../../context/revoltjs/RevoltClient";
 import { FileUploader } from "../../../context/revoltjs/FileUploads";
@@ -70,9 +70,9 @@ export function Overview({ channel }: Props) {
                     <Text id="app.main.groups.description" /> :
                     <Text id="app.main.servers.channel_description" /> }
             </h3>
-            <TextArea
-                // maxRows={10}
-                // minHeight={60}
+            <TextAreaAutoSize
+                maxRows={10}
+                minHeight={60}
                 maxLength={1024}
                 value={description}
                 placeholder={"Add a description..."}
diff --git a/src/pages/settings/panes/Appearance.tsx b/src/pages/settings/panes/Appearance.tsx
index 4fa442efa3802a2fefb7329e687014e5e8160391..8ee5dd408477bce886e3b74b6c16288d9c0f9c5e 100644
--- a/src/pages/settings/panes/Appearance.tsx
+++ b/src/pages/settings/panes/Appearance.tsx
@@ -2,10 +2,10 @@ import { Text } from "preact-i18n";
 import styles from "./Panes.module.scss";
 import { debounce } from "../../../lib/debounce";
 import Button from "../../../components/ui/Button";
-import TextArea from "../../../components/ui/TextArea";
 import InputBox from "../../../components/ui/InputBox";
 import { connectState } from "../../../redux/connector";
 import { WithDispatcher } from "../../../redux/reducers";
+import TextAreaAutoSize from "../../../lib/TextAreaAutoSize";
 import ColourSwatches from "../../../components/ui/ColourSwatches";
 import { EmojiPacks, Settings } from "../../../redux/reducers/settings";
 import { Theme, ThemeContext, ThemeOptions } from "../../../context/Theme";
@@ -267,19 +267,13 @@ export function Component(props: Props & WithDispatcher) {
                 <h3>
                     <Text id="app.settings.pages.appearance.custom_css" />
                 </h3>
-                <TextArea
-                    // maxRows={20}
-                    // minHeight={480}
+                <TextAreaAutoSize
+                    maxRows={20}
+                    minHeight={480}
                     code
                     value={css}
-                    onChange={ev => setCSS(ev.currentTarget.value)}
-                />
+                    onChange={ev => setCSS(ev.currentTarget.value)} />
             </details>
-
-            {/*<h3>
-                <Text id="app.settings.pages.appearance.sync" />
-            </h3>
-            <p>Coming soon!</p>*/}
         </div>
     );
 }
diff --git a/src/pages/settings/server/Overview.tsx b/src/pages/settings/server/Overview.tsx
index 3c8e2ec16748d468df1148fd1248e9d43187f143..d6d0c2842233f583ecb5458448149045a23dcb18 100644
--- a/src/pages/settings/server/Overview.tsx
+++ b/src/pages/settings/server/Overview.tsx
@@ -2,8 +2,8 @@ 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 TextArea from "../../../components/ui/TextArea";
 import InputBox from "../../../components/ui/InputBox";
+import TextAreaAutoSize from "../../../lib/TextAreaAutoSize";
 import { useContext, useEffect, useState } from "preact/hooks";
 import { AppContext } from "../../../context/revoltjs/RevoltClient";
 import { FileUploader } from "../../../context/revoltjs/FileUploads";
@@ -65,9 +65,9 @@ export function Overview({ server }: Props) {
             <h3>
                 <Text id="app.main.servers.description" />
             </h3>
-            <TextArea
-                // maxRows={10}
-                // minHeight={60}
+            <TextAreaAutoSize
+                maxRows={10}
+                minHeight={60}
                 maxLength={1024}
                 value={description}
                 placeholder={"Add a topic..."}