Commit design update

This commit is contained in:
Half-Shot 2026-04-07 14:54:05 +01:00
parent 2bef316bed
commit 1ab31e6f48
6 changed files with 279 additions and 78 deletions

View File

@ -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;

View File

@ -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;
}
}

View File

@ -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({});

View File

@ -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>
);

View File

@ -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;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 881 KiB