diff --git a/src/components/common/messaging/Message.tsx b/src/components/common/messaging/Message.tsx
index dfe9793d..82cd15ed 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 (
@@ -30,7 +33,11 @@ export default function Message({ attachContext, message, contrast, content, hea
{ head && }
- { content ?? }
+ { content ?? }
+ { message.attachments?.map((attachment, index) =>
+ 0 || content.length > 0 } />) }
+ { message.embeds?.map((embed, index) =>
+ ) }
)
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 00000000..4f23a898
--- /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 00000000..eb144ebe
--- /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 (
+
spoiler && setSpoiler(false)}
+ >
+ {spoiler && (
+
+ )}
+
+ openScreen({ id: "image_viewer", attachment })
+ }
+ onMouseDown={ev =>
+ ev.button === 1 &&
+ window.open(url, "_blank")
+ }
+ style={{ width, height }}
+ />
+
+ );
+ }
+ case "Audio": {
+ return (
+
+ );
+ }
+ case "Video": {
+ return (
+ spoiler && setSpoiler(false)}>
+ {spoiler && (
+
+ )}
+
+
+
+
+ );
+ }
+ case 'Text': {
+ return (
+
+ );
+ }
+ default: {
+ return (
+
+ );
+ }
+ }
+}
diff --git a/src/components/common/messaging/attachments/AttachmentActions.tsx b/src/components/common/messaging/attachments/AttachmentActions.tsx
new file mode 100644
index 00000000..054fc986
--- /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 (
+
+
+ {filename}
+ {metadata.width + 'x' + metadata.height} ({filesize})
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+ case 'Audio':
+ return (
+
+
+
+ {filename}
+ {filesize}
+
+
+
+
+
+
+
+ )
+ case 'Video':
+ return (
+
+
+
+ {filename}
+ {metadata.width + 'x' + metadata.height} ({filesize})
+
+
+
+
+
+
+
+ )
+ default:
+ return (
+
+
+
+ {filename}
+ {filesize}
+
+
+
+
+
+
+
+ )
+ }
+}
diff --git a/src/components/common/messaging/attachments/TextFile.tsx b/src/components/common/messaging/attachments/TextFile.tsx
new file mode 100644
index 00000000..4dd8d432
--- /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);
+ 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 (
+
+ {
+ content ?
+
{ content }
+ :
+
+
+ }
+
+ )
+}
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 00000000..209117be
--- /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 00000000..647731d4
--- /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 (
+
+
+ { embed.site_name &&
+ { embed.icon_url &&
e.currentTarget.style.display = 'none'} /> }
+
{ embed.site_name }
+
}
+
+ {/*
Author*/}
+ { embed.title &&
{ embed.title } }
+ { embed.description &&
{ embed.description }
}
+
+ { largeMedia &&
}
+
+ {
+ !largeMedia &&
+
+
+ }
+
+ )
+ }
+ case 'Image': {
+ return (
+
+ 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 00000000..7323f03d
--- /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 (
+
+ )
+ case 'Twitch': return (
+
+ )
+ case 'Spotify': return (
+
+ )
+ case 'Soundcloud': return (
+
+ )
+ case 'Bandcamp': {
+ return ;
+ }
+ default: {
+ if (embed.image) {
+ let url = embed.image.url;
+ return (
+
+ 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 00000000..9c119443
--- /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 (
+
+
+ {filename}
+ {embed.width + 'x' + embed.height}
+
+
+
+
+
+
+
+ )
+}