Skip to content
Snippets Groups Projects
Commit d1bff986 authored by insert's avatar insert
Browse files

Port attachments and embeds.

parent a24bcf9f
Branches
No related merge requests found
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>
)
......
.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);
}
}
}
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>
);
}
}
}
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>
)
}
}
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>
)
}
.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;
}
}
}
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;
}
}
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;
}
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>
)
}
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment