mirror of
https://github.com/vector-im/element-web.git
synced 2026-05-05 20:26:19 +02:00
Commit design update
This commit is contained in:
parent
2bef316bed
commit
1ab31e6f48
@ -34,8 +34,9 @@ export interface UrlPreviewGroupViewModelProps {
|
||||
}
|
||||
|
||||
export const MAX_PREVIEWS_WHEN_LIMITED = 2;
|
||||
export const PREVIEW_WIDTH = 100;
|
||||
export const PREVIEW_HEIGHT = 100;
|
||||
export const PREVIEW_WIDTH = 478;
|
||||
export const PREVIEW_HEIGHT = 200;
|
||||
export const MIN_IMAGE_SIZE_BYTES = 8192;
|
||||
|
||||
export enum PreviewVisibility {
|
||||
/**
|
||||
@ -122,6 +123,27 @@ export class UrlPreviewGroupViewModel
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the best possible title from an opengraph response.
|
||||
* @param response The opengraph response
|
||||
* @param link The link being used to preview.
|
||||
* @returns The title value.
|
||||
*/
|
||||
private static getAuthorFromResponse(response: IPreviewUrlResponse): UrlPreview["author"] {
|
||||
if (response["og:type"] === "article") {
|
||||
if (typeof response["article:author"] === "string" && response["article:author"]) {
|
||||
return {
|
||||
name: response["article:author"],
|
||||
};
|
||||
}
|
||||
}
|
||||
if (typeof response["profile:username"] === "string" && response["profile:username"]) {
|
||||
return {
|
||||
name: response["profile:username"],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if an anchor element can be rendered into a preview.
|
||||
* If it can, return the value of `href`
|
||||
@ -278,6 +300,8 @@ export class UrlPreviewGroupViewModel
|
||||
}
|
||||
|
||||
const { title, description, siteName } = UrlPreviewGroupViewModel.getBaseMetadataFromResponse(preview, link);
|
||||
const author = UrlPreviewGroupViewModel.getAuthorFromResponse(preview);
|
||||
const playable = !!preview["og:video"] || !!preview["og:video:type"] || !!preview["og:audio"];
|
||||
const hasImage = preview["og:image"] && typeof preview?.["og:image"] === "string";
|
||||
// Ensure we have something relevant to render.
|
||||
// The title must not just be the link, or we must have an image.
|
||||
@ -285,32 +309,46 @@ export class UrlPreviewGroupViewModel
|
||||
return null;
|
||||
}
|
||||
let image: UrlPreview["image"];
|
||||
let siteIcon: string | undefined;
|
||||
if (typeof preview["og:image"] === "string" && this.visibility > PreviewVisibility.MediaHidden) {
|
||||
const media = mediaFromMxc(preview["og:image"], this.client);
|
||||
const declaredHeight = UrlPreviewGroupViewModel.getNumberFromOpenGraph(preview["og:image:height"]);
|
||||
const declaredWidth = UrlPreviewGroupViewModel.getNumberFromOpenGraph(preview["og:image:width"]);
|
||||
const width = Math.min(declaredWidth ?? PREVIEW_WIDTH, PREVIEW_WIDTH);
|
||||
const height = thumbHeight(width, declaredHeight, PREVIEW_WIDTH, PREVIEW_WIDTH) ?? PREVIEW_WIDTH;
|
||||
const thumb = media.getThumbnailOfSourceHttp(PREVIEW_WIDTH, PREVIEW_HEIGHT, "scale");
|
||||
// No thumb, no preview.
|
||||
if (thumb) {
|
||||
image = {
|
||||
imageThumb: thumb,
|
||||
imageFull: media.srcHttp ?? thumb,
|
||||
width,
|
||||
height,
|
||||
fileSize: UrlPreviewGroupViewModel.getNumberFromOpenGraph(preview["matrix:image:size"]),
|
||||
};
|
||||
const imageSize = UrlPreviewGroupViewModel.getNumberFromOpenGraph(preview["matrix:image:size"]);
|
||||
|
||||
// We can't currently distinguish from a preview image and a favicon. Neither OpenGraph nor Matrix
|
||||
// have a clear distinction, so we're using a heuristic here to check the size of the file and
|
||||
// deciding whether to render it as a full preview or icon.
|
||||
const isIcon = imageSize && imageSize < MIN_IMAGE_SIZE_BYTES;
|
||||
if (!isIcon) {
|
||||
const width = Math.min(declaredWidth ?? PREVIEW_WIDTH, PREVIEW_WIDTH);
|
||||
const height = thumbHeight(width, declaredHeight, PREVIEW_WIDTH, PREVIEW_WIDTH) ?? PREVIEW_WIDTH;
|
||||
const thumb = media.getThumbnailOfSourceHttp(PREVIEW_WIDTH, PREVIEW_HEIGHT, "scale");
|
||||
// No thumb, no preview.
|
||||
if (thumb) {
|
||||
image = {
|
||||
imageThumb: thumb,
|
||||
imageFull: media.srcHttp ?? thumb,
|
||||
width,
|
||||
height,
|
||||
fileSize: UrlPreviewGroupViewModel.getNumberFromOpenGraph(preview["matrix:image:size"]),
|
||||
};
|
||||
}
|
||||
} else if (media.srcHttp) {
|
||||
siteIcon = media.srcHttp;
|
||||
}
|
||||
}
|
||||
|
||||
const result = {
|
||||
link,
|
||||
title,
|
||||
author,
|
||||
description,
|
||||
siteName,
|
||||
siteIcon,
|
||||
showTooltipOnLink: link !== title && PlatformPeg.get()?.needsUrlTooltips(),
|
||||
image,
|
||||
playable,
|
||||
} satisfies UrlPreview;
|
||||
this.previewCache.set(link, result);
|
||||
return result;
|
||||
|
||||
@ -6,51 +6,75 @@
|
||||
*/
|
||||
|
||||
.thumbnail {
|
||||
/* Thumbnails are always limited to a maximum of 100px */
|
||||
max-width: 100px;
|
||||
max-height: 100px;
|
||||
/* Thumbnails are always limited to a maxiumum of 100px */
|
||||
max-height: 200px;
|
||||
/* Ensure we don't stretch the image */
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.preview {
|
||||
display: flex;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
border: none;
|
||||
.playButton[data-kind="primary"] {
|
||||
padding: 0;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
margin: auto;
|
||||
color: var(--cpd-color-gray-800);
|
||||
> svg {
|
||||
margin: auto;
|
||||
border-radius: 50px;
|
||||
color: var(--cpd-color-gray-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.link {
|
||||
color: var(--cpd-color-text-link-external);
|
||||
text-decoration-line: none;
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
aspect-ratio: 1; /* will make width equal to height (500px container) */
|
||||
object-fit: cover; /* use the one you need */
|
||||
border-radius: 6px;
|
||||
margin-top: auto;
|
||||
margin-bottom: auto;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: inline flex;
|
||||
column-gap: var(--cpd-space-1x);
|
||||
border-inline-start: 2px solid var(--cpd-color-bg-subtle-primary);
|
||||
border-radius: 2px;
|
||||
max-width: 478px;
|
||||
display: flex;
|
||||
border: 1px solid var(--cpd-color-border-interactive-secondary);
|
||||
border-radius: 12px; /* Get radius from cpd */
|
||||
flex-direction: column;
|
||||
color: var(--cpd-color-gray-900);
|
||||
overflow: clip;
|
||||
|
||||
.wrapImageCaption {
|
||||
display: inline-flex;
|
||||
&.inline {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
row-gap: var(--cpd-space-2x);
|
||||
flex: 1;
|
||||
|
||||
.siteAvatar {
|
||||
margin: auto var(--cpd-space-2x);
|
||||
}
|
||||
}
|
||||
|
||||
.textContent {
|
||||
padding: var(--cpd-space-3x) var(--cpd-space-4x);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--cpd-space-2x);
|
||||
}
|
||||
|
||||
.image,
|
||||
.caption {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
margin-inline-start: var(--cpd-space-4x);
|
||||
min-width: 0; /* Prevent blowout */
|
||||
}
|
||||
|
||||
.image {
|
||||
/* Clear default <button> styles */
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
|
||||
/* Consistent image size */
|
||||
flex: 0 0 100px;
|
||||
}
|
||||
|
||||
.caption {
|
||||
flex: 1;
|
||||
overflow: hidden; /* cause it to wrap rather than clip */
|
||||
@ -69,17 +93,30 @@
|
||||
line-clamp: 2;
|
||||
-webkit-line-clamp: 2;
|
||||
margin: var(--cpd-space-1x) 0;
|
||||
|
||||
> a {
|
||||
font-weight: var(--cpd-font-weight-semibold);
|
||||
text-decoration: none;
|
||||
}
|
||||
font-weight: var(--cpd-font-weight-semibold);
|
||||
color: var(--cpd-color-text-primary);
|
||||
text-decoration-line: none;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: var(--cpd-space-1x) 0;
|
||||
word-wrap: break-word;
|
||||
line-clamp: 3;
|
||||
-webkit-line-clamp: 3;
|
||||
font-size: var(--cpd-font-size-body-lg);
|
||||
margin: 0;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.siteName {
|
||||
margin-top: var(--cpd-space-2x);
|
||||
vertical-align: middle;
|
||||
display: flex;
|
||||
gap: var(--cpd-space-1-5x);
|
||||
}
|
||||
|
||||
.author {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,9 +9,10 @@ import React from "react";
|
||||
import { fn } from "storybook/test";
|
||||
|
||||
import type { Meta, StoryFn } from "@storybook/react-vite";
|
||||
import imageFile from "../../../../static/element.png";
|
||||
import { LinkPreview } from "./LinkPreview";
|
||||
import { LinkedTextContext } from "../../../core/utils/LinkedText";
|
||||
import imageFile from "../../../../static/element.png";
|
||||
import imageFileWide from "../../../../static/wideImage.png";
|
||||
|
||||
export default {
|
||||
title: "Event/UrlPreviewGroupView/LinkPreview",
|
||||
@ -44,6 +45,7 @@ export const Title = Template.bind({});
|
||||
Title.args = {
|
||||
title: "A simple title",
|
||||
link: "https://matrix.org",
|
||||
siteName: "matrix.org",
|
||||
};
|
||||
|
||||
export const TitleAndDescription = Template.bind({});
|
||||
@ -51,6 +53,7 @@ TitleAndDescription.args = {
|
||||
title: "A simple title",
|
||||
description: "A simple description with a link to https://matrix.org",
|
||||
link: "https://matrix.org",
|
||||
siteName: "matrix.org",
|
||||
};
|
||||
|
||||
export const WithTooltip = Template.bind({});
|
||||
@ -59,6 +62,62 @@ WithTooltip.args = {
|
||||
description: "A simple description",
|
||||
showTooltipOnLink: true,
|
||||
link: "https://matrix.org",
|
||||
siteName: "matrix.org",
|
||||
};
|
||||
|
||||
export const Article = Template.bind({});
|
||||
Article.args = {
|
||||
title: "A linked article",
|
||||
description:
|
||||
"This is a basic description returned from the linked source, usually with a word or two about what the link contains.",
|
||||
link: "https://matrix.org",
|
||||
siteName: "blog.example.org",
|
||||
image: {
|
||||
imageThumb: imageFileWide,
|
||||
imageFull: imageFileWide,
|
||||
},
|
||||
};
|
||||
|
||||
export const Video = Template.bind({});
|
||||
Video.args = {
|
||||
title: "A linked video",
|
||||
description:
|
||||
"This is a link to a video. You cannot play the video inline yet, but you can click the play button to open the link",
|
||||
link: "https://matrix.org",
|
||||
siteName: "blog.example.org",
|
||||
playable: true,
|
||||
image: {
|
||||
imageThumb: imageFileWide,
|
||||
imageFull: imageFileWide,
|
||||
},
|
||||
};
|
||||
|
||||
export const Social = Template.bind({});
|
||||
Social.args = {
|
||||
description: "Sending a small message",
|
||||
link: "https://matrix.org",
|
||||
siteName: "socialsite.example.org",
|
||||
title: "Test user (@test)",
|
||||
author: {
|
||||
username: "@test",
|
||||
name: "Test user",
|
||||
},
|
||||
};
|
||||
|
||||
export const SocialWithImage = Template.bind({});
|
||||
SocialWithImage.args = {
|
||||
description: "Sending a message with an attached image.",
|
||||
title: "Test user (@test)",
|
||||
link: "https://matrix.org",
|
||||
siteName: "socialsite.example.org",
|
||||
author: {
|
||||
username: "@test",
|
||||
name: "Test user",
|
||||
},
|
||||
image: {
|
||||
imageThumb: imageFileWide,
|
||||
imageFull: imageFileWide,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithVeryLongText = Template.bind({});
|
||||
|
||||
@ -6,13 +6,14 @@
|
||||
*/
|
||||
|
||||
import React, { type MouseEventHandler, type JSX, useCallback, useMemo } from "react";
|
||||
import { Tooltip, Text } from "@vector-im/compound-web";
|
||||
import { Tooltip, Text, Avatar, IconButton, Button } from "@vector-im/compound-web";
|
||||
import PlaySolidIcon from "@vector-im/compound-design-tokens/assets/web/icons/play-solid";
|
||||
import classNames from "classnames";
|
||||
|
||||
import { useI18n } from "../../../core/i18n/i18nContext";
|
||||
import styles from "./LinkPreview.module.css";
|
||||
import type { UrlPreview } from "../types";
|
||||
import { LinkedText } from "../../../core/utils/LinkedText";
|
||||
import styles from "./LinkPreview.module.css";
|
||||
|
||||
export interface LinkPreviewActions {
|
||||
onImageClick: () => void;
|
||||
@ -50,39 +51,92 @@ export function LinkPreview({ onImageClick, ...preview }: LinkPreviewProps): JSX
|
||||
let img: JSX.Element | undefined;
|
||||
// Don't render a button to show the image, just hide it outright
|
||||
if (preview.image?.imageThumb) {
|
||||
img = (
|
||||
<button
|
||||
aria-label={_t("timeline|url_preview|view_image")}
|
||||
className={styles.image}
|
||||
onClick={onImageClickHandler}
|
||||
>
|
||||
<img className={styles.thumbnail} src={preview.image.imageThumb} alt="" />
|
||||
</button>
|
||||
);
|
||||
if (preview.playable) {
|
||||
img = (
|
||||
<div
|
||||
style={{
|
||||
backgroundImage: `url('${preview.image.imageThumb}')`,
|
||||
}}
|
||||
className={styles.preview}
|
||||
>
|
||||
{preview.playable && (
|
||||
<Button
|
||||
as="a"
|
||||
href={preview.link}
|
||||
aria-label={_t("timeline|url_preview|view_image")}
|
||||
className={styles.playButton}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
kind="primary"
|
||||
>
|
||||
<PlaySolidIcon width="24px" height="24px" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
img = (
|
||||
<button
|
||||
style={{
|
||||
backgroundImage: `image-set(url('${preview.image.imageThumb}') 1x, url('${preview.image.imageFull}') 2x)`,
|
||||
}}
|
||||
className={styles.preview}
|
||||
onClick={onImageClickHandler}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const anchor = (
|
||||
<a className={styles.link} href={preview.link} target="_blank" rel="noreferrer noopener">
|
||||
<Text
|
||||
as="a"
|
||||
type="body"
|
||||
weight="semibold"
|
||||
size="lg"
|
||||
className={styles.title}
|
||||
href={preview.link}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
{preview.title}
|
||||
</a>
|
||||
</Text>
|
||||
);
|
||||
|
||||
const useInline = !preview.image && !preview.author;
|
||||
|
||||
return (
|
||||
<div className={classNames(styles.container)}>
|
||||
<div className={styles.wrapImageCaption}>
|
||||
{img}
|
||||
<div className={styles.caption}>
|
||||
<Text type="body" size="md" className={styles.title}>
|
||||
{tooltipCaption ? <Tooltip label={tooltipCaption}>{anchor}</Tooltip> : anchor}
|
||||
{preview.siteName && (
|
||||
<Text as="span" size="md" weight="regular">
|
||||
{" - " + preview.siteName}
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
{preview.description && (
|
||||
<LinkedText className={styles.description}>{preview.description}</LinkedText>
|
||||
)}
|
||||
<div className={classNames(styles.container, useInline && styles.inline)}>
|
||||
{img}
|
||||
{useInline && (
|
||||
<div className={styles.siteAvatar}>
|
||||
<Avatar type="square" size="48px" name={preview.title} id={preview.title} src={preview.siteIcon} />
|
||||
</div>
|
||||
)}
|
||||
<div className={classNames(styles.textContent)}>
|
||||
{preview.author && (
|
||||
<div className={styles.author}>
|
||||
<Text as="span" size="md" weight="semibold">
|
||||
{preview.author.username}
|
||||
</Text>
|
||||
<Text as="span" size="sm" weight="regular">
|
||||
{preview.author.name}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
{anchor && tooltipCaption ? <Tooltip label={tooltipCaption}>{anchor}</Tooltip> : anchor}
|
||||
<LinkedText type="body" size="md" className={styles.description}>
|
||||
{preview.description}
|
||||
</LinkedText>
|
||||
{preview.siteName && (
|
||||
<div className={styles.siteName}>
|
||||
{!useInline && (
|
||||
<Avatar size="16px" name={preview.siteName} id={preview.siteName} src={preview.siteIcon} />
|
||||
)}
|
||||
<Text as="span" size="sm" weight="regular">
|
||||
{preview.siteName}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -5,7 +5,6 @@
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
/** Represents a URL preview. */
|
||||
export interface UrlPreview {
|
||||
/**
|
||||
* The URL for the preview.
|
||||
@ -23,6 +22,10 @@ export interface UrlPreview {
|
||||
* The site name to be displayed alongside the title.
|
||||
*/
|
||||
siteName?: string;
|
||||
/**
|
||||
* The HTTP URI of the the sites icon.
|
||||
*/
|
||||
siteIcon?: string;
|
||||
/**
|
||||
* Description of the site. May contain links.
|
||||
*/
|
||||
@ -52,4 +55,14 @@ export interface UrlPreview {
|
||||
*/
|
||||
height?: number;
|
||||
};
|
||||
|
||||
author?: {
|
||||
name: string;
|
||||
username?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Is the media playable.
|
||||
*/
|
||||
playable?: boolean;
|
||||
}
|
||||
|
||||
BIN
packages/shared-components/static/wideImage.png
Normal file
BIN
packages/shared-components/static/wideImage.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 881 KiB |
Loading…
x
Reference in New Issue
Block a user