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 && ( +
+
+ +
+
+ )} + {filename} + 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 ( +
+
+ ) + 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 ( +