From d1bff986356981203b3ce55ad74ac859de0a4d9f Mon Sep 17 00:00:00 2001
From: Paul <paulmakles@gmail.com>
Date: Sun, 20 Jun 2021 22:09:18 +0100
Subject: [PATCH] Port attachments and embeds.

---
 src/components/common/messaging/Message.tsx   |  11 +-
 .../attachments/Attachment.module.scss        | 119 ++++++++++++++
 .../messaging/attachments/Attachment.tsx      | 152 ++++++++++++++++++
 .../attachments/AttachmentActions.tsx         |  98 +++++++++++
 .../common/messaging/attachments/TextFile.tsx |  57 +++++++
 .../common/messaging/embed/Embed.module.scss  |  97 +++++++++++
 .../common/messaging/embed/Embed.tsx          | 145 +++++++++++++++++
 .../common/messaging/embed/EmbedMedia.tsx     |  78 +++++++++
 .../messaging/embed/EmbedMediaActions.tsx     |  26 +++
 9 files changed, 781 insertions(+), 2 deletions(-)
 create mode 100644 src/components/common/messaging/attachments/Attachment.module.scss
 create mode 100644 src/components/common/messaging/attachments/Attachment.tsx
 create mode 100644 src/components/common/messaging/attachments/AttachmentActions.tsx
 create mode 100644 src/components/common/messaging/attachments/TextFile.tsx
 create mode 100644 src/components/common/messaging/embed/Embed.module.scss
 create mode 100644 src/components/common/messaging/embed/Embed.tsx
 create mode 100644 src/components/common/messaging/embed/EmbedMedia.tsx
 create mode 100644 src/components/common/messaging/embed/EmbedMediaActions.tsx

diff --git a/src/components/common/messaging/Message.tsx b/src/components/common/messaging/Message.tsx
index dfe9793..82cd15e 100644
--- a/src/components/common/messaging/Message.tsx
+++ b/src/components/common/messaging/Message.tsx
@@ -1,7 +1,9 @@
+import Embed from "./embed/Embed";
 import UserIcon from "../user/UserIcon";
 import { Username } from "../user/UserShort";
 import Markdown from "../../markdown/Markdown";
 import { Children } from "../../../types/Preact";
+import Attachment from "./attachments/Attachment";
 import { attachContextMenu } from "preact-context-menu";
 import { useUser } from "../../../context/revoltjs/hooks";
 import { MessageObject } from "../../../context/revoltjs/util";
@@ -15,11 +17,12 @@ interface Props {
     head?: boolean
 }
 
-export default function Message({ attachContext, message, contrast, content, head }: Props) {
+export default function Message({ attachContext, message, contrast, content: replacement, 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);
 
+    const content = message.content as string;
     return (
         <MessageBase contrast={contrast}
             onContextMenu={attachContext ? attachContextMenu('Menu', { message, contextualChannel: message.channel }) : undefined}>
@@ -30,7 +33,11 @@ export default function Message({ attachContext, message, contrast, content, hea
             </MessageInfo>
             <MessageContent>
                 { head && <Username user={user} /> }
-                { content ?? <Markdown content={message.content as string} /> }
+                { content ?? <Markdown content={content} /> }
+                { message.attachments?.map((attachment, index) =>
+                    <Attachment key={index} attachment={attachment} hasContent={ index > 0 || content.length > 0 } />) }
+                { message.embeds?.map((embed, index) =>
+                    <Embed key={index} embed={embed} />) }
             </MessageContent>
         </MessageBase>
     )
diff --git a/src/components/common/messaging/attachments/Attachment.module.scss b/src/components/common/messaging/attachments/Attachment.module.scss
new file mode 100644
index 0000000..4f23a89
--- /dev/null
+++ b/src/components/common/messaging/attachments/Attachment.module.scss
@@ -0,0 +1,119 @@
+.attachment {
+    border-radius: 6px;
+    margin: .125rem 0 .125rem;
+    
+    &[data-spoiler="true"] {
+        filter: blur(30px);
+        pointer-events: none;
+    }
+
+    &[data-has-content="true"] {
+        margin-top: 4px;
+    }
+
+    &.image {
+        cursor: pointer;
+    }
+
+    &.video {        
+        .actions {
+            padding: 10px 12px;
+            border-radius: 6px 6px 0 0;
+        }
+
+        video {
+            width: 100%;
+            border-radius: 0 0 6px 6px;
+        }
+    }
+
+    &.audio {
+        gap: 4px;
+        padding: 6px;
+        display: flex;
+        border-radius: 6px;
+        flex-direction: column;
+        background: var(--secondary-background);
+        max-width: 400px;
+
+        > audio {
+            width: 100%;
+        }
+    }
+
+    &.file {
+        > div {
+            width: 400px;
+            padding: 12px;
+            user-select: none;
+            width: fit-content;
+            border-radius: 6px;
+        }
+    }
+
+    &.text {
+        display: flex;
+        overflow: hidden;
+        max-width: 800px;
+        border-radius: 6px;
+        flex-direction: column;
+
+        .textContent {
+            height: 140px;
+            padding: 12px;
+            overflow-x: auto;
+            overflow-y: auto;
+            border-radius: 0 !important;
+            background: var(--secondary-header);
+    
+            pre {
+                margin: 0;
+            }
+    
+            pre code {
+                font-family: "Fira Mono", sans-serif;
+            }
+
+            &[data-loading="true"] {
+                display: flex;
+                
+                > * {
+                    flex-grow: 1;
+                }
+            }
+        }
+    }
+}
+
+.actions {
+    gap: 8px;
+    padding: 8px;
+    display: flex;
+    overflow: none;
+    max-width: 100%;
+    align-items: center;
+    flex-direction: row;
+    color: var(--foreground);
+    background: var(--secondary-background);
+
+    > svg {
+        flex-shrink: 0;
+    }
+    
+    .info {
+        display: flex;
+        flex-direction: column;
+        flex-grow: 1;
+
+        > span {
+            text-overflow: ellipsis;
+            white-space: nowrap;
+            overflow: hidden;
+        }
+
+        .filesize {
+            font-size: 10px;
+            color: var(--secondary-foreground);
+        }
+    }
+}
diff --git a/src/components/common/messaging/attachments/Attachment.tsx b/src/components/common/messaging/attachments/Attachment.tsx
new file mode 100644
index 0000000..eb144eb
--- /dev/null
+++ b/src/components/common/messaging/attachments/Attachment.tsx
@@ -0,0 +1,152 @@
+import TextFile from "./TextFile";
+import { Text } from "preact-i18n";
+import classNames from "classnames";
+import styles from "./Attachment.module.scss";
+import AttachmentActions from "./AttachmentActions";
+import { useContext, useState } from "preact/hooks";
+import { AppContext } from "../../../../context/revoltjs/RevoltClient";
+import { Attachment as AttachmentRJS } from "revolt.js/dist/api/objects";
+import { useIntermediate } from "../../../../context/intermediate/Intermediate";
+import { MessageAreaWidthContext } from "../../../../pages/channels/messaging/MessageArea";
+
+interface Props {
+    attachment: AttachmentRJS;
+    hasContent: boolean;
+}
+
+const MAX_ATTACHMENT_WIDTH = 480;
+const MAX_ATTACHMENT_HEIGHT = 640;
+
+export default function Attachment({ attachment, hasContent }: Props) {
+    const client = useContext(AppContext);
+    const { openScreen } = useIntermediate();
+    const { filename, metadata } = attachment;
+    const [ spoiler, setSpoiler ] = useState(filename.startsWith("SPOILER_"));
+    const maxWidth = Math.min(useContext(MessageAreaWidthContext), MAX_ATTACHMENT_WIDTH);
+
+    const url = client.generateFileURL(attachment, { width: MAX_ATTACHMENT_WIDTH * 1.5 }, true);
+    let width  = 0,
+        height = 0;
+    
+    if (metadata.type === 'Image' || metadata.type === 'Video') {
+        let limitingWidth = Math.min(
+            maxWidth,
+            metadata.width
+        );
+
+        let limitingHeight = Math.min(
+            MAX_ATTACHMENT_HEIGHT,
+            metadata.height
+        );
+
+        // Calculate smallest possible WxH.
+        width = Math.min(
+            limitingWidth,
+            limitingHeight * (metadata.width / metadata.height)
+        );
+
+        height = Math.min(
+            limitingHeight,
+            limitingWidth * (metadata.height / metadata.width)
+        );
+    }
+
+    switch (metadata.type) {
+        case "Image": {
+            return (
+                <div
+                    style={{ width }}
+                    className={styles.container}
+                    onClick={() => spoiler && setSpoiler(false)}
+                >
+                    {spoiler && (
+                        <div className={styles.overflow}>
+                            <div style={{ width, height }}>
+                                <span><Text id="app.main.channel.misc.spoiler_attachment" /></span>
+                            </div>
+                        </div>
+                    )}
+                    <img
+                        src={url}
+                        alt={filename}
+                        data-spoiler={spoiler}
+                        data-has-content={hasContent}
+                        className={classNames(styles.attachment, styles.image)}
+                        onClick={() =>
+                            openScreen({ id: "image_viewer", attachment })
+                        }
+                        onMouseDown={ev =>
+                            ev.button === 1 &&
+                            window.open(url, "_blank")
+                        }
+                        style={{ width, height }}
+                    />
+                </div>
+            );
+        }
+        case "Audio": {
+            return (
+                <div
+                    className={classNames(styles.attachment, styles.audio)}
+                    data-has-content={hasContent}
+                >
+                    <AttachmentActions attachment={attachment} />
+                    <audio src={url} controls />
+                </div>
+            );
+        }
+        case "Video": {
+            return (
+                <div
+                    className={styles.container}
+                    onClick={() => spoiler && setSpoiler(false)}>
+                    {spoiler && (
+                        <div className={styles.overflow}>
+                            <div style={{ width, height }}>
+                                <span><Text id="app.main.channel.misc.spoiler_attachment" /></span>
+                            </div>
+                        </div>
+                    )}
+                    <div
+                        style={{ width }}
+                        data-spoiler={spoiler}
+                        data-has-content={hasContent}
+                        className={classNames(styles.attachment, styles.video)}
+                    >
+                        <AttachmentActions attachment={attachment} />
+                        <video
+                            src={url}
+                            controls
+                            style={{ width, height }}
+                            onMouseDown={ev =>
+                                ev.button === 1 &&
+                                window.open(url, "_blank")
+                            }
+                        />
+                    </div>
+                </div>
+            );
+        }
+        case 'Text': {
+            return (
+                <div
+                    className={classNames(styles.attachment, styles.text)}
+                    data-has-content={hasContent}
+                >
+                    <TextFile attachment={attachment} />
+                    <AttachmentActions attachment={attachment} />
+                </div>
+            );
+        }
+        default: {
+            return (
+                <div
+                    className={classNames(styles.attachment, styles.file)}
+                    data-has-content={hasContent}
+                >
+                    <AttachmentActions attachment={attachment} />
+                </div>
+            );
+        }
+    }
+}
diff --git a/src/components/common/messaging/attachments/AttachmentActions.tsx b/src/components/common/messaging/attachments/AttachmentActions.tsx
new file mode 100644
index 0000000..054fc98
--- /dev/null
+++ b/src/components/common/messaging/attachments/AttachmentActions.tsx
@@ -0,0 +1,98 @@
+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 { AppContext } from '../../../../context/revoltjs/RevoltClient';
+import { Download, ExternalLink, File, Headphones, Video } from '@styled-icons/feather';
+
+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;
+
+    const url = client.generateFileURL(attachment) as string;
+    const open_url = `${url}/${filename}`;
+    const download_url = url.replace('attachments', 'attachments/download')
+
+    const filesize = determineFileSize(size as any);
+
+    switch (metadata.type) {
+        case 'Image':
+            return (
+                <div className={styles.actions}>
+                    <div className={styles.info}>
+                        <span className={styles.filename}>{filename}</span>
+                        <span className={styles.filesize}>{metadata.width + 'x' + metadata.height} ({filesize})</span>
+                    </div>
+                    <a href={open_url} target="_blank">
+                        <IconButton>
+                            <ExternalLink size={24} />
+                        </IconButton>
+                    </a>
+                    <a href={download_url} download target="_blank">
+                        <IconButton>
+                            <Download size={24} />
+                        </IconButton>
+                    </a>
+                </div>
+            )
+        case 'Audio':
+            return (
+                <div className={styles.actions}>
+                    <Headphones size={24} strokeWidth={1.5} />
+                    <div className={styles.info}>
+                        <span className={styles.filename}>{filename}</span>
+                        <span className={styles.filesize}>{filesize}</span>
+                    </div>
+                    <a href={download_url} download target="_blank">
+                        <IconButton>
+                            <Download size={24} strokeWidth={1.5} />
+                        </IconButton>
+                    </a>
+                </div>
+            )
+        case 'Video':
+            return (
+                <div className={styles.actions}>
+                    <Video size={24} strokeWidth={1.5} />
+                    <div className={styles.info}>
+                        <span className={styles.filename}>{filename}</span>
+                        <span className={styles.filesize}>{metadata.width + 'x' + metadata.height} ({filesize})</span>
+                    </div>
+                    <a href={download_url} download target="_blank">
+                        <IconButton>
+                            <Download size={24} strokeWidth={1.5}/>
+                        </IconButton>
+                    </a>
+                </div>
+            )
+        default:
+            return (
+                <div className={styles.actions}>
+                    <File size={24} strokeWidth={1.5} />
+                    <div className={styles.info}>
+                        <span className={styles.filename}>{filename}</span>
+                        <span className={styles.filesize}>{filesize}</span>
+                    </div>
+                    <a href={download_url} download target="_blank">
+                        <IconButton>
+                            <Download size={24} strokeWidth={1.5} />
+                        </IconButton>
+                    </a>
+                </div>
+            )
+    }
+}
diff --git a/src/components/common/messaging/attachments/TextFile.tsx b/src/components/common/messaging/attachments/TextFile.tsx
new file mode 100644
index 0000000..4dd8d43
--- /dev/null
+++ b/src/components/common/messaging/attachments/TextFile.tsx
@@ -0,0 +1,57 @@
+import axios from 'axios';
+import Preloader from '../../../ui/Preloader';
+import styles from './Attachment.module.scss';
+import { Attachment } from 'revolt.js/dist/api/objects';
+import { useContext, useEffect, useState } from 'preact/hooks';
+import RequiresOnline from '../../../../context/revoltjs/RequiresOnline';
+import { AppContext, StatusContext } from '../../../../context/revoltjs/RevoltClient';
+
+interface Props {
+    attachment: Attachment;
+}
+
+const fileCache: { [key: string]: string } = {};
+
+export default function TextFile({ attachment }: Props) {
+    const [ content, setContent ] = useState<undefined | string>(undefined);
+    const [ loading, setLoading ] = useState(false);
+    const status = useContext(StatusContext);
+    const client = useContext(AppContext);
+
+    const url = client.generateFileURL(attachment);
+
+    useEffect(() => {
+        if (typeof content !== 'undefined') return;
+        if (loading) return;
+        setLoading(true);
+
+        let cached = fileCache[attachment._id];
+        if (cached) {
+            setContent(cached);
+            setLoading(false);
+        } else {
+            axios.get(url)
+                .then(res => {
+                    setContent(res.data);
+                    fileCache[attachment._id] = res.data;
+                    setLoading(false);
+                })
+                .catch(() => {
+                    console.error("Failed to load text file. [", attachment._id, "]");
+                    setLoading(false)
+                })
+        }
+    }, [ content, loading, status ]);
+
+    return (
+        <div className={styles.textContent} data-loading={typeof content === 'undefined'}>
+            {
+                content ?
+                    <pre><code>{ content }</code></pre>
+                    : <RequiresOnline>
+                        <Preloader />
+                    </RequiresOnline>
+            }
+        </div>
+    )
+}
diff --git a/src/components/common/messaging/embed/Embed.module.scss b/src/components/common/messaging/embed/Embed.module.scss
new file mode 100644
index 0000000..209117b
--- /dev/null
+++ b/src/components/common/messaging/embed/Embed.module.scss
@@ -0,0 +1,97 @@
+.embed {
+    margin: .2em 0;
+
+    iframe {
+        border: none;
+        border-radius: 4px;
+    }
+
+    &.image {
+        cursor: pointer;
+    }
+
+    &.website {
+        gap: 6px;
+        display: flex;
+        flex-direction: row;
+
+        > div:nth-child(1) {
+            gap: 6px;
+            flex-grow: 1;
+            display: flex;
+            flex-direction: column;
+        }
+
+        border-inline-start-width: 4px;
+        border-inline-start-style: solid;
+
+        padding: 12px;
+        width: fit-content;
+        border-radius: 4px;
+        background: var(--primary-header);
+
+        .siteinfo {
+            display: flex;
+            align-items: center;
+            gap: 6px;
+            user-select: none;
+
+            .favicon {
+                width: 14px;
+                height: 14px;
+                flex-shrink: 0;
+            }
+
+            .site {
+                font-size: 11px;
+                overflow: hidden;
+                white-space: nowrap;
+                text-overflow: ellipsis;
+                color: var(--secondary-foreground);
+            }
+        }
+
+        .author {
+            font-size: 1em;
+            color: var(--primary-text);
+            display: inline-block;
+
+            &:hover {
+                text-decoration: underline;
+            }
+        }
+
+        .title {
+            display: inline-block;
+            font-size: 1.1em;
+            overflow: hidden;
+            display: -webkit-box;
+            -webkit-line-clamp: 2;
+            -webkit-box-orient: vertical;
+
+            &:hover {
+                text-decoration: underline;
+            }
+        }
+
+        .description {
+            margin: 0;
+            font-size: 12px;
+            overflow: hidden;
+            display: -webkit-box;
+            white-space: pre-wrap;
+            // -webkit-line-clamp: 6;
+            // -webkit-box-orient: vertical;
+        }
+
+        .footer {
+            font-size: 12px;
+        }
+        
+        img.image {
+            cursor: pointer;
+            object-fit: contain;
+            border-radius: 3px;
+        }
+    }
+}
diff --git a/src/components/common/messaging/embed/Embed.tsx b/src/components/common/messaging/embed/Embed.tsx
new file mode 100644
index 0000000..647731d
--- /dev/null
+++ b/src/components/common/messaging/embed/Embed.tsx
@@ -0,0 +1,145 @@
+import classNames from 'classnames';
+import EmbedMedia from './EmbedMedia';
+import styles from "./Embed.module.scss";
+import { useContext } from 'preact/hooks';
+import { Embed as EmbedRJS } from "revolt.js/dist/api/objects";
+import { useIntermediate } from '../../../../context/intermediate/Intermediate';
+import { MessageAreaWidthContext } from '../../../../pages/channels/messaging/MessageArea';
+
+interface Props {
+    embed: EmbedRJS;
+}
+
+const MAX_EMBED_WIDTH = 480;
+const MAX_EMBED_HEIGHT = 640;
+const CONTAINER_PADDING = 24;
+const MAX_PREVIEW_SIZE = 150;
+
+export default function Embed({ embed }: Props) {
+    // ! FIXME: temp code
+    // ! add proxy function to client
+    function proxyImage(url: string) {
+        return 'https://jan.revolt.chat/proxy?url=' + encodeURIComponent(url);
+    }
+
+    const { openScreen } = useIntermediate();
+    const maxWidth = Math.min(useContext(MessageAreaWidthContext) - CONTAINER_PADDING, MAX_EMBED_WIDTH);
+
+    function calculateSize(w: number, h: number): { width: number, height: number } {
+        let limitingWidth = Math.min(
+            maxWidth,
+            w
+        );
+
+        let limitingHeight = Math.min(
+            MAX_EMBED_HEIGHT,
+            h
+        );
+
+        // Calculate smallest possible WxH.
+        let width = Math.min(
+            limitingWidth,
+            limitingHeight * (w / h)
+        );
+
+        let height = Math.min(
+            limitingHeight,
+            limitingWidth * (h / w)
+        );
+
+        return { width, height };
+    }
+
+    switch (embed.type) {
+        case 'Website': {
+            // ! FIXME: move this to january
+            /*if (embed.url && YOUTUBE_RE.test(embed.url)) {
+                embed.color = '#FF424F';
+            }
+
+            if (embed.url && TWITCH_RE.test(embed.url)) {
+                embed.color = '#7B68EE';
+            }
+
+            if (embed.url && SPOTIFY_RE.test(embed.url)) {
+                embed.color = '#1ABC9C';
+            }
+
+            if (embed.url && SOUNDCLOUD_RE.test(embed.url)) {
+                embed.color = '#FF7F50';
+            }*/
+
+            // Determine special embed size.
+            let mw, mh;
+            let largeMedia = (embed.special && embed.special.type !== 'None') || embed.image?.size === 'Large';
+            switch (embed.special?.type) {
+                case 'YouTube':
+                case 'Bandcamp': {
+                    mw = embed.video?.width ?? 1280;
+                    mh = embed.video?.height ?? 720;
+                    break;
+                }
+                case 'Twitch': {
+                    mw = 1280;
+                    mh = 720;
+                    break;
+                }
+                default: {
+                    if (embed.image?.size === 'Preview') {
+                        mw = MAX_EMBED_WIDTH;
+                        mh = Math.min(embed.image.height ?? 0, MAX_PREVIEW_SIZE);
+                    } else {
+                        mw = embed.image?.width ?? MAX_EMBED_WIDTH;
+                        mh = embed.image?.height ?? 0;
+                    }
+                }
+            }
+
+            let { width, height } = calculateSize(mw, mh);
+            return (
+                <div
+                    className={classNames(styles.embed, styles.website)}
+                    style={{
+                        borderInlineStartColor: embed.color ?? 'var(--tertiary-background)',
+                        width: width + CONTAINER_PADDING
+                    }}>
+                    <div>
+                        { embed.site_name && <div className={styles.siteinfo}>
+                            { embed.icon_url && <img className={styles.favicon} src={proxyImage(embed.icon_url)} draggable={false} onError={e => e.currentTarget.style.display = 'none'} /> }
+                            <div className={styles.site}>{ embed.site_name } </div>
+                        </div> }
+
+                        {/*<span><a href={embed.url} target={"_blank"} className={styles.author}>Author</a></span>*/}
+                        { embed.title && <span><a href={embed.url} target={"_blank"} className={styles.title}>{ embed.title }</a></span> }
+                        { embed.description && <div className={styles.description}>{ embed.description }</div> }
+
+                        { largeMedia && <EmbedMedia embed={embed} height={height} /> }
+                    </div>
+                    {
+                        !largeMedia && <div>
+                            <EmbedMedia embed={embed} width={height * ((embed.image?.width ?? 0) / (embed.image?.height ?? 0))} height={height} />
+                        </div>
+                    }
+                </div>
+            )
+        }
+        case 'Image': {
+            return (
+                <img className={classNames(styles.embed, styles.image)}
+                    style={calculateSize(embed.width, embed.height)}
+                    src={proxyImage(embed.url)}
+                    type="text/html"
+                    frameBorder="0"
+                    onClick={() =>
+                        openScreen({ id: "image_viewer", embed })
+                    }
+                    onMouseDown={ev =>
+                        ev.button === 1 &&
+                        window.open(embed.url, "_blank")
+                    }
+                />
+            )
+        }
+        default: return null;
+    }
+}
diff --git a/src/components/common/messaging/embed/EmbedMedia.tsx b/src/components/common/messaging/embed/EmbedMedia.tsx
new file mode 100644
index 0000000..7323f03
--- /dev/null
+++ b/src/components/common/messaging/embed/EmbedMedia.tsx
@@ -0,0 +1,78 @@
+import styles from './Embed.module.scss';
+import { Embed } from "revolt.js/dist/api/objects";
+import { useIntermediate } from '../../../../context/intermediate/Intermediate';
+
+interface Props {
+    embed: Embed;
+    width?: number;
+    height: number;
+}
+
+export default function EmbedMedia({ embed, width, height }: Props) {
+    // ! FIXME: temp code
+    // ! add proxy function to client
+    function proxyImage(url: string) {
+        return 'https://jan.revolt.chat/proxy?url=' + encodeURIComponent(url);
+    }
+
+    if (embed.type !== 'Website') return null;
+    const { openScreen } = useIntermediate();
+
+    switch (embed.special?.type) {
+        case 'YouTube': return (
+            <iframe 
+                src={`https://www.youtube-nocookie.com/embed/${embed.special.id}?modestbranding=1`}
+                allowFullScreen
+                style={{ height }} />
+        )
+        case 'Twitch': return (
+            <iframe
+                src={`https://player.twitch.tv/?${embed.special.content_type.toLowerCase()}=${embed.special.id}&parent=${window.location.hostname}&autoplay=false`}
+                frameBorder="0"
+                allowFullScreen
+                scrolling="no"
+                style={{ height, }} />
+        )
+        case 'Spotify': return (
+            <iframe
+                src={`https://open.spotify.com/embed/${embed.special.content_type}/${embed.special.id}`}
+                frameBorder="0"
+                allowFullScreen
+                allowTransparency
+                style={{ height }} />
+        )
+        case 'Soundcloud': return (
+            <iframe
+                src={`https://w.soundcloud.com/player/?url=${encodeURIComponent(embed.url as string)}&color=%23FF7F50&auto_play=false&hide_related=false&show_comments=true&show_user=true&show_reposts=false&show_teaser=true&visual=true`}
+                frameBorder="0"
+                scrolling="no"
+                style={{ height }} />
+        )
+        case 'Bandcamp': {
+            return <iframe
+                src={`https://bandcamp.com/EmbeddedPlayer/${embed.special.content_type.toLowerCase()}=${embed.special.id}/size=large/bgcol=181a1b/linkcol=056cc4/tracklist=false/transparent=true/`}
+                seamless
+                style={{ height }} />;
+        }
+        default: {
+            if (embed.image) {
+                let url = embed.image.url;
+                return (
+                    <img
+                        className={styles.image}
+                        src={proxyImage(url)}
+                        style={{ width, height }}
+                        onClick={() =>
+                            openScreen({ id: "image_viewer", embed: embed.image })
+                        }
+                        onMouseDown={ev =>
+                            ev.button === 1 &&
+                            window.open(url, "_blank")
+                        } />
+                );
+            }
+        }
+    }
+
+    return null;
+}
diff --git a/src/components/common/messaging/embed/EmbedMediaActions.tsx b/src/components/common/messaging/embed/EmbedMediaActions.tsx
new file mode 100644
index 0000000..9c11944
--- /dev/null
+++ b/src/components/common/messaging/embed/EmbedMediaActions.tsx
@@ -0,0 +1,26 @@
+import styles from './Embed.module.scss';
+import IconButton from '../../../ui/IconButton';
+import { ExternalLink } from '@styled-icons/feather';
+import { EmbedImage } from "revolt.js/dist/api/objects";
+
+interface Props {
+    embed: EmbedImage;
+}
+
+export default function EmbedMediaActions({ embed }: Props) {
+    const filename = embed.url.split('/').pop();
+
+    return (
+        <div className={styles.actions}>
+            <div className={styles.info}>
+                <span className={styles.filename}>{filename}</span>
+                <span className={styles.filesize}>{embed.width + 'x' + embed.height}</span>
+            </div>
+            <a href={embed.url} target="_blank">
+                <IconButton>
+                    <ExternalLink size={24} />
+                </IconButton>
+            </a>
+        </div>
+    )
+}
-- 
GitLab