1
0
mirror of https://github.com/spacebarchat/client.git synced 2024-11-22 02:12:38 +01:00

an attempt to redo embeds

This commit is contained in:
Puyodead1 2023-09-20 23:31:54 -04:00
parent dec2c9d486
commit ad9dbb5f93
No known key found for this signature in database
GPG Key ID: A4FA4FEC0DD353FC
7 changed files with 462 additions and 295 deletions

View File

@ -29,6 +29,7 @@
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"classnames": "^2.3.2",
"dayjs": "^1.11.9",
"framer-motion": "^10.16.4",
"missing-native-js-functions": "^1.4.3",
@ -54,7 +55,8 @@
"remark-gfm": "^3.0.1",
"reoverlay": "^1.0.3",
"styled-components": "^5.3.10",
"unist-util-visit": "^5.0.0"
"unist-util-visit": "^5.0.0",
"use-resize-observer": "^9.1.0"
},
"devDependencies": {
"@craco/craco": "^7.1.0",

View File

@ -80,6 +80,9 @@ dependencies:
'@testing-library/user-event':
specifier: ^13.5.0
version: 13.5.0(@testing-library/dom@9.3.1)
classnames:
specifier: ^2.3.2
version: 2.3.2
dayjs:
specifier: ^1.11.9
version: 1.11.9
@ -158,6 +161,9 @@ dependencies:
unist-util-visit:
specifier: ^5.0.0
version: 5.0.0
use-resize-observer:
specifier: ^9.1.0
version: 9.1.0(react-dom@18.2.0)(react@18.2.0)
devDependencies:
'@craco/craco':
@ -2785,6 +2791,10 @@ packages:
'@jridgewell/sourcemap-codec': 1.4.15
dev: true
/@juggle/resize-observer@3.4.0:
resolution: {integrity: sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==}
dev: false
/@leichtgewicht/ip-codec@2.0.4:
resolution: {integrity: sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A==}
dev: true
@ -12961,6 +12971,17 @@ packages:
requires-port: 1.0.0
dev: true
/use-resize-observer@9.1.0(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-R25VqO9Wb3asSD4eqtcxk8sJalvIOYBqS8MNZlpDSQ4l4xMQxC/J7Id9HoTqPq8FwULIn0PVW+OAqF2dyYbjow==}
peerDependencies:
react: 16.8.0 - 18
react-dom: 16.8.0 - 18
dependencies:
'@juggle/resize-observer': 3.4.0
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: false
/util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
dev: true

View File

@ -0,0 +1,113 @@
.embed {
margin: 0.2em 0;
iframe {
border: none;
border-radius: 4px;
width: 100%;
}
&.image {
cursor: pointer;
}
&.website {
display: flex;
gap: 10px;
flex-direction: row;
> div:nth-child(1) {
gap: 10px;
display: flex;
flex-direction: column;
}
border-inline-start-width: 4px;
border-inline-start-style: solid;
padding: 12px;
width: fit-content;
background: var(--background-secondary);
border-radius: 4px;
.embedProvider {
font-size: 12px;
color: var(--text-secondary);
font-weight: var(--font-weight-regular);
width: fit-content;
}
.embedAuthor {
display: flex;
align-items: center;
}
.embedAuthorIcon {
width: 24px;
height: 24px;
border-radius: 50%;
margin-right: 10px;
object-fit: contain;
}
.embedAuthorName {
font-size: 14px;
font-weight: var(--font-weight-medium);
color: var(--text);
display: inline-block;
width: fit-content;
}
.embedAuthorNameLink {
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
.embedTitle {
display: inline-block;
font-size: 16px;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
font-weight: var(--font-weight-medium);
color: var(--text);
width: fit-content;
}
.embedTitleLink {
color: var(--text-link);
&:hover {
text-decoration: underline;
}
}
.embedDescription {
font-size: 14px;
overflow: hidden;
display: -webkit-box;
white-space: pre-wrap;
-webkit-line-clamp: 6;
-webkit-box-orient: vertical;
width: fit-content;
}
.footer {
font-size: 12px;
}
img.image {
cursor: pointer;
object-fit: fill;
border-radius: 4px;
}
a {
cursor: pointer;
}
}
}

View File

@ -0,0 +1,138 @@
// adapted from Revite
// https://github.com/revoltchat/revite/blob/master/src/components/common/messaging/embed/Embed.tsx
import { APIEmbed, EmbedType } from "@spacebarchat/spacebar-api-types/v9";
import styles from "./Embed.module.css";
interface Props {
embed: APIEmbed;
width?: number;
height: number;
}
function EmbedMedia({ embed, width, height }: Props) {
switch (embed.provider?.name) {
case "YouTube": {
if (!embed.video?.url) return null;
const url = embed.video.url;
return <iframe loading="lazy" src={url} allowFullScreen style={{ height }} />;
}
case "Spotify": {
const url = embed.url;
if (!url) break;
// extract type and id from url
const match = url.match(/https:\/\/open\.spotify\.com\/(track|album|playlist)\/([a-zA-Z0-9]+)/);
if (!match) break;
const type = match[1];
const id = match[2];
return (
<iframe
style={{ borderRadius: "12px", width: "400px", height: "80px" }}
src={`https://open.spotify.com/embed/${type}/${id}`}
frameBorder="0"
allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
loading="lazy"
></iframe>
);
}
case "Soundcloud":
return (
<iframe
src={`https://w.soundcloud.com/player/?url=${encodeURIComponent(
embed.url!,
)}&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"
loading="lazy"
style={{ height }}
/>
);
// not supported by the server
// case "Bandcamp": {
// const url = embed.url;
// if (!url) break;
// // extract type and id from url
// const match = url.match(/https:\/\/([a-zA-Z0-9-]+)\.bandcamp\.com\/(track|album|playlist)\/([a-zA-Z0-9]+)/);
// if (!match) break;
// const type = match[2];
// const id = match[3];
// return (
// <iframe
// src={`https://bandcamp.com/EmbeddedPlayer/${type.toLowerCase()}=${id}/size=large/bgcol=181a1b/linkcol=056cc4/tracklist=false/transparent=true/`}
// seamless
// loading="lazy"
// style={{ border: "0", height: "42px" }}
// />
// );
// }
case "Streamable": {
const url = embed.url;
if (!url) break;
// extract id from url
const match = url.match(/https:\/\/streamable\.com\/([a-zA-Z0-9]+)/);
if (!match) break;
const id = match[1];
return (
<iframe
src={`https://streamable.com/e/${id}?quality=highest`}
frameBorder="0"
allowFullScreen
seamless
loading="lazy"
style={{ height }}
/>
);
}
default: {
if (embed.video) {
const url = embed.video.url;
return (
<video
className={styles.image}
style={{ width, height }}
src={url}
loop={embed.type === EmbedType.GIFV}
controls={embed.type === EmbedType.GIFV}
autoPlay={embed.type === EmbedType.GIFV}
muted={embed.type === EmbedType.GIFV ? true : undefined}
/>
);
} else if (embed.image) {
const url = embed.image.url;
return (
<img
className={styles.image}
src={url}
loading="lazy"
style={{ width: "100%", height: "100%" }}
onClick={() => {
console.log("preview image");
}}
onMouseDown={(ev) => ev.button === 1 && window.open(url, "_blank")}
/>
);
} else if (embed.thumbnail) {
const url = embed.thumbnail.url;
return (
<img
className={styles.image}
src={url}
loading="lazy"
style={{ width, height }}
onClick={() => {
console.log("preview image");
}}
onMouseDown={(ev) => ev.button === 1 && window.open(url, "_blank")}
/>
);
}
}
}
return null;
}
export default EmbedMedia;

View File

@ -7,6 +7,7 @@ import Markdown from "../markdown/MarkdownRenderer";
import MessageAttachment from "./MessageAttachment";
import MessageAuthor from "./MessageAuthor";
import MessageBase, { MessageContent, MessageDetails, MessageInfo } from "./MessageBase";
import MessageEmbed from "./MessageEmbed";
import AttachmentUploadProgress from "./attachments/AttachmentUploadProgress";
interface Props {
@ -40,9 +41,8 @@ function Message({ message, header }: Props) {
message.attachments.map((attachment, index) => (
<MessageAttachment key={index} attachment={attachment} />
))}
{/* {message.embeds?.map((embed, index) => (
<MessageEmbed key={index} embed={embed} />
))} */}
{"embeds" in message &&
message.embeds?.map((embed, index) => <MessageEmbed key={index} embed={embed} />)}
{"files" in message && message.files?.length !== 0 && <AttachmentUploadProgress message={message} />}
</MessageContent>
</MessageBase>

View File

@ -1,265 +1,150 @@
import { APIAttachment, APIEmbed, EmbedType } from "@spacebarchat/spacebar-api-types/v9";
import { ReactNode } from "react";
import styled from "styled-components";
import useLogger from "../../hooks/useLogger";
// adapted from Revite
// https://github.com/revoltchat/revite/blob/master/src/components/common/messaging/embed/Embed.tsx
import { APIEmbed, EmbedType } from "@spacebarchat/spacebar-api-types/v9";
import classNames from "classnames";
import React from "react";
import { decimalColorToHex } from "../../utils/Utils";
import { IContextMenuItem } from "../ContextMenuItem";
import Link from "../Link";
import MessageAttachment from "./MessageAttachment";
import styles from "./Embed.module.css";
import EmbedMedia from "./EmbedMedia";
import { MESSAGE_AREA_PADDING, MessageAreaWidthContext } from "./MessageList";
// TODO: move these to a constants file/configurable
const DESCRIPTION_MAX_CHARS = 345;
const TITLE_MAX_CHARS = 67;
const MAX_EMBED_WIDTH = 400;
const MAX_EMBED_HEIGHT = 640;
const CONTAINER_PADDING = 24;
const MAX_PREVIEW_SIZE = 150;
const EMBEDDABLE_PROVIDERS = ["Spotify", "Bandcamp"];
interface EmbedProps {
interface Props {
embed: APIEmbed;
contextMenuItems: IContextMenuItem[];
}
const Container = styled.div`
max-width: fit-content;
background-color: var(--background-secondary);
margin-top: 5px;
`;
function MessageEmbed({ embed }: Props) {
const maxWidth = Math.min(React.useContext(MessageAreaWidthContext) - MESSAGE_AREA_PADDING, MAX_EMBED_WIDTH);
const Wrapper = styled.div<{ $color?: string }>`
max-width: 430px;
justify-self: start;
border-left-width: 4px;
border-left-style: solid;
border-left-color: ${(props) => props.$color ?? "var(--background-tertiary)"};
display: grid;
box-sizing: border-box;
border-radius: 4px;
`;
function calculateSize(w: number, h: number): { width: number; height: number } {
const limitingWidth = Math.min(maxWidth, w);
const EmbedWrapper = styled.div`
max-width: 500px;
overflow: hidden;
padding: 8px 16px 16px 12px;
display: grid;
grid-template-columns: auto;
grid-template-rows: auto;
`;
const limitingHeight = Math.min(MAX_EMBED_HEIGHT, h);
const EmbedProvider = styled.div`
font-size: 12px;
font-weight: var(--font-weight-regular);
grid-column: 1/1;
margin-top: 10px;
`;
// Calculate smallest possible WxH.
const width = Math.min(limitingWidth, limitingHeight * (w / h));
const EmbedAuthor = styled.div`
display: flex;
align-items: center;
grid-column: 1/1;
margin-top: 10px;
`;
const height = Math.min(limitingHeight, limitingWidth * (h / w));
const EmbedAuthorText = styled.div`
font-size: 14px;
font-weight: var(--font-weight-medium);
`;
const EmbedAuthorLink = styled.a`
font-size: 14px;
font-weight: var(--font-weight-medium);
text-decoration: none;
&:hover {
text-decoration: underline;
return { width, height };
}
`;
const EmbedTitle = styled.div`
font-size: 16px;
font-weight: var(--font-weight-regular);
grid-column: 1/1;
margin-top: 10px;
`;
// Determine special embed size.
let mw, mh;
const largeMedia =
embed.provider?.name === "GitHub" ||
embed.provider?.name === "Streamable" ||
embed.type === EmbedType.Video ||
embed.type === EmbedType.GIFV ||
embed.type === EmbedType.Image;
const EmbedTitleLink = styled.a`
color: var(--text-link);
text-decoration: none;
&:hover {
text-decoration: underline;
if (embed.image) {
mw = embed.image?.width ?? MAX_EMBED_WIDTH;
mh = embed.image?.height ?? 0;
} else if (embed.thumbnail) {
mw = embed.thumbnail.width ?? MAX_EMBED_WIDTH;
mh = embed.thumbnail.height ?? 0;
} else {
switch (embed.provider?.name) {
case "YouTube":
case "Bandcamp": {
mw = embed.video?.width ?? 1280;
mh = embed.video?.height ?? 720;
break;
}
case "Twitch":
case "Lightspeed":
case "Streamable": {
mw = 1280;
mh = 720;
break;
}
default: {
mw = MAX_EMBED_WIDTH;
mh = 1;
}
}
}
`;
const EmbedDescription = styled.div`
font-size: 14px;
font-weight: var(--font-weight-regular);
grid-column: 1/1;
margin-top: 10px;
`;
const EmbedImage = styled.div`
margin-top: 10px;
grid-column: 1/1;
border-radius: 4px;
`;
const EmbedImageContainer = styled.div`
flex-flow: row nowrap;
width: 100%;
height: 100%;
display: flex;
`;
const EmbedImageWrapper = styled.div`
max-width: 100%;
width: 100%;
overflow: hidden;
border-radius: 4px;
`;
const EmbedThumbnail = styled.div`
grid-row: 1/8;
grid-column: 2/2;
margin-left: 15px;
margin-top: 10px;
justify-self: end;
`;
const EmbedFooter = styled.div`
display: flex;
align-items: center;
grid-row: auto/auto;
grid-column: 1/1;
margin-top: 10px;
`;
const EmbedFooterImage = styled.img`
margin-right: 10px;
width: 20px;
height: 20px;
border-radius: 50%;
`;
const EmbedFooterText = styled.span`
font-size: 12px;
font-weight: var(--font-weight-regular);
`;
const YoutubeEmbed = styled.iframe`
outline: none;
border: none;
margin-top: 10px;
border-radius: 4px;
`;
const WrapImageContent = ({ children }: { children: ReactNode }) => {
return (
<EmbedImageContainer>
<EmbedImageWrapper>{children}</EmbedImageWrapper>
</EmbedImageContainer>
);
};
const createEmbedAttachment = (embed: APIEmbed, contextMenuItems: IContextMenuItem[], isYoutubeVideo = false) => {
const image = embed.thumbnail ?? embed.image;
if (!image) return null;
const url = new URL(embed.url!);
const fakeAttachment: APIAttachment = {
id: embed.url as string,
filename: url.pathname.split("/").reverse()[0],
size: -1,
width: image.width,
height: image.height,
proxy_url: image.proxy_url!,
url: image.url,
content_type: "image",
};
const props = {
contextMenuItems,
attachment: fakeAttachment,
};
if (isYoutubeVideo) return createYoutubeEmbed(embed);
if (embed.type === EmbedType.Link)
const { width, height } = calculateSize(mw, mh);
if (embed.type === EmbedType.GIFV || EMBEDDABLE_PROVIDERS.includes(embed.provider?.name ?? "")) {
return (
<EmbedThumbnail>
<WrapImageContent>
<MessageAttachment {...props} maxWidth={70} />
</WrapImageContent>
</EmbedThumbnail>
<EmbedMedia
embed={embed}
width={height * ((embed.image?.width ?? 0) / (embed.image?.height ?? 0))}
height={height}
/>
);
return (
<EmbedImage>
<WrapImageContent>
<MessageAttachment {...props} />
</WrapImageContent>
</EmbedImage>
);
};
const createYoutubeEmbed = (embed: APIEmbed) => {
return <YoutubeEmbed width={400} height={225} src={embed.video!.url} />;
};
export default function MessageEmbed({ embed, contextMenuItems }: EmbedProps) {
const logger = useLogger("MessageEmbed");
// seems like the server sometimes sends thumbnails with 0 width and height, and no urls
const isYoutubeVideo = embed.type == EmbedType.Video && embed.provider?.name == "YouTube";
const thumbnail = createEmbedAttachment(embed, contextMenuItems, isYoutubeVideo);
if (embed.type == EmbedType.Image) return thumbnail;
const titleTrimmed = embed.title
? embed.title?.length > TITLE_MAX_CHARS
? embed.title.substring(0, TITLE_MAX_CHARS) + "..."
: embed.title
: undefined;
const descriptionTrimmed = embed.description
? embed.description.length > DESCRIPTION_MAX_CHARS
? embed.description?.substring(0, DESCRIPTION_MAX_CHARS) + "..."
: embed.description
: undefined;
let title;
if (titleTrimmed) {
if (embed.url)
title = (
<Link href={embed.url} rel="noreferrer noopener" target="_blank">
{titleTrimmed}
</Link>
);
else title = titleTrimmed;
} else title = null;
let author;
if (embed.author)
if (embed.author.url)
author = (
<Link href={embed.author.url} rel="noreferrer noopener" target="_blank">
{embed.author.name}
</Link>
);
else author = <EmbedAuthorText>{embed.author.name}</EmbedAuthorText>;
else null;
}
return (
<Container>
<Wrapper $color={embed.color ? decimalColorToHex(embed.color) : undefined}>
<EmbedWrapper>
{embed.provider && <EmbedProvider>{embed.provider.name}</EmbedProvider>}
{author && <EmbedAuthor>{author}</EmbedAuthor>}
{title && <EmbedTitle>{title}</EmbedTitle>}
{descriptionTrimmed && !isYoutubeVideo && <EmbedDescription>{descriptionTrimmed}</EmbedDescription>}
{thumbnail}
{embed.footer && (
<EmbedFooter>
{embed.footer.icon_url && <EmbedFooterImage src={embed.footer.icon_url} />}
<EmbedFooterText>{embed.footer.text}</EmbedFooterText>
</EmbedFooter>
)}
</EmbedWrapper>
</Wrapper>
</Container>
<div
className={classNames(styles.embed, styles.website)}
style={{
borderInlineStartColor: embed.color ? decimalColorToHex(embed.color) : "var(--background-tertiary)",
maxWidth: width + CONTAINER_PADDING,
}}
>
<div>
{embed.type !== EmbedType.Rich && embed.provider && (
<span className={styles.embedProvider}>{embed.provider.name}</span>
)}
{embed.author && (
<div className={styles.embedAuthor}>
{embed.author.icon_url && (
<img
loading="lazy"
className={styles.embedAuthorIcon}
src={embed.author.icon_url}
draggable={false}
onError={(e) => (e.currentTarget.style.display = "none")}
/>
)}
{embed.author.url ? (
<a
href={embed.url}
target={"_blank"}
className={classNames(styles.embedAuthorName, styles.embedAuthorNameLink)}
>
{embed.author.name}
</a>
) : (
<span className={styles.embedAuthorName}>{embed.author.name}</span>
)}
</div>
)}
{embed.title && (
<>
{embed.url ? (
<a
href={embed.url}
target={"_blank"}
className={classNames(styles.embedTitle, styles.embedTitleLink)}
>
{embed.title}
</a>
) : (
<span className={styles.embedTitle}>{embed.title}</span>
)}
</>
)}
{embed.description && <div className={styles.embedDescription}>{embed.description}</div>}
{largeMedia && <EmbedMedia embed={embed} height={height} />}
</div>
{!largeMedia && embed.thumbnail && <EmbedMedia embed={embed} height={100} />}
</div>
);
}
export default MessageEmbed;

View File

@ -3,6 +3,7 @@ import React from "react";
import InfiniteScroll from "react-infinite-scroll-component";
import PulseLoader from "react-spinners/PulseLoader";
import styled from "styled-components";
import useResizeObserver from "use-resize-observer";
import useLogger from "../../hooks/useLogger";
import { useAppStore } from "../../stores/AppStore";
import { MessageGroup as MessageGroupType } from "../../stores/MessageStore";
@ -12,6 +13,9 @@ import { Permissions } from "../../utils/Permissions";
import { HorizontalDivider } from "../Divider";
import MessageGroup from "./MessageGroup";
export const MessageAreaWidthContext = React.createContext(0);
export const MESSAGE_AREA_PADDING = 82;
const Container = styled.div`
flex: 1 1 auto;
overflow-y: auto;
@ -37,6 +41,8 @@ function MessageList({ guild, channel }: Props) {
const [hasMore, setHasMore] = React.useState(true);
const [canView, setCanView] = React.useState(false);
const messageGroups = channel.messages.groups;
const ref = React.useRef<HTMLDivElement>(null);
const { width } = useResizeObserver<HTMLDivElement>({ ref });
// handles the permission check
React.useEffect(() => {
@ -82,54 +88,56 @@ function MessageList({ guild, channel }: Props) {
);
return (
<Container id="scrollable-div">
{canView ? (
<InfiniteScroll
dataLength={messageGroups.length}
next={fetchMore}
style={{
display: "flex",
flexDirection: "column-reverse",
marginBottom: 30,
}} // to put endMessage and loader to the top.
hasMore={hasMore}
inverse={true}
loader={
<PulseLoader
style={{
display: "flex",
justifyContent: "center",
alignContent: "center",
marginBottom: 30,
}}
color="var(--primary)"
/>
}
scrollableTarget="scrollable-div"
endMessage={
<EndMessageContainer>
<h1 style={{ fontWeight: 700, margin: "8px 0" }}>Welcome to #{channel.name}!</h1>
<p style={{ color: "var(--text-secondary)" }}>
This is the start of the #{channel.name} channel.
</p>
<HorizontalDivider />
</EndMessageContainer>
}
>
{messageGroups.map((group) => renderGroup(group))}
</InfiniteScroll>
) : (
<div
style={{
marginBottom: 30,
paddingLeft: 20,
color: "var(--text-secondary)",
}}
>
You do not have permission to read the history of this channel.
</div>
)}
</Container>
<MessageAreaWidthContext.Provider value={(width ?? 0) - MESSAGE_AREA_PADDING}>
<Container id="scrollable-div" ref={ref}>
{canView ? (
<InfiniteScroll
dataLength={messageGroups.length}
next={fetchMore}
style={{
display: "flex",
flexDirection: "column-reverse",
marginBottom: 30,
}} // to put endMessage and loader to the top.
hasMore={hasMore}
inverse={true}
loader={
<PulseLoader
style={{
display: "flex",
justifyContent: "center",
alignContent: "center",
marginBottom: 30,
}}
color="var(--primary)"
/>
}
scrollableTarget="scrollable-div"
endMessage={
<EndMessageContainer>
<h1 style={{ fontWeight: 700, margin: "8px 0" }}>Welcome to #{channel.name}!</h1>
<p style={{ color: "var(--text-secondary)" }}>
This is the start of the #{channel.name} channel.
</p>
<HorizontalDivider />
</EndMessageContainer>
}
>
{messageGroups.map((group) => renderGroup(group))}
</InfiniteScroll>
) : (
<div
style={{
marginBottom: 30,
paddingLeft: 20,
color: "var(--text-secondary)",
}}
>
You do not have permission to read the history of this channel.
</div>
)}
</Container>
</MessageAreaWidthContext.Provider>
);
}