Check in other changes

This commit is contained in:
Half-Shot 2026-04-09 09:11:21 +01:00
parent 8eea71ea2f
commit 2581a4deff
9 changed files with 202 additions and 81 deletions

View File

@ -34,8 +34,9 @@ export interface UrlPreviewGroupViewModelProps {
}
export const MAX_PREVIEWS_WHEN_LIMITED = 2;
export const PREVIEW_WIDTH = 478;
export const PREVIEW_HEIGHT = 200;
export const PREVIEW_WIDTH_PX = 478;
export const PREVIEW_HEIGHT_PX = 200;
export const MIN_PREVIEW_PX = 96;
export const MIN_IMAGE_SIZE_BYTES = 8192;
export enum PreviewVisibility {
@ -124,24 +125,47 @@ export class UrlPreviewGroupViewModel
}
/**
* Calculate the best possible title from an opengraph response.
* Calculate the best possible author from an opengraph response.
* @param response The opengraph response
* @param link The link being used to preview.
* @returns The title value.
* @returns The author value, or undefined if no valid author could be found.
*/
private static getAuthorFromResponse(response: IPreviewUrlResponse): UrlPreview["author"] {
let calculatedAuthor: string | undefined;
if (response["og:type"] === "article") {
if (typeof response["article:author"] === "string" && response["article:author"]) {
return {
name: response["article:author"],
};
calculatedAuthor = response["article:author"];
}
// Otherwise fall through to check the profile.
}
if (typeof response["profile:username"] === "string" && response["profile:username"]) {
return {
name: response["profile:username"],
};
calculatedAuthor = response["profile:username"];
}
if (calculatedAuthor && URL.canParse(calculatedAuthor)) {
// Some sites return URLs as authors which doesn't look good in Element, so discard it.
return;
}
return calculatedAuthor;
}
/**
* Calculate whether the provided image from the preview response is an full size preview or
* a site icon.
* @returns `true` if the image should be used as a preview, otherwise `false`
*/
private static isImagePreview(width?: number, height?: number, bytes?: number): boolean {
// 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 dimensions & size of the file and
// deciding whether to render it as a full preview or icon.
if (width && width < MIN_PREVIEW_PX) {
return false;
}
if (height && height < MIN_PREVIEW_PX) {
return false;
}
if (bytes && bytes < MIN_IMAGE_SIZE_BYTES) {
return false;
}
return true;
}
/**
@ -315,15 +339,14 @@ export class UrlPreviewGroupViewModel
const declaredHeight = UrlPreviewGroupViewModel.getNumberFromOpenGraph(preview["og:image:height"]);
const declaredWidth = UrlPreviewGroupViewModel.getNumberFromOpenGraph(preview["og:image:width"]);
const imageSize = UrlPreviewGroupViewModel.getNumberFromOpenGraph(preview["matrix:image:size"]);
const alt = typeof preview["og:image:alt"] === "string" ? preview["og:image:alt"] : undefined;
// 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");
const isImagePreview = UrlPreviewGroupViewModel.isImagePreview(declaredWidth, declaredHeight, imageSize);
if (isImagePreview) {
const width = Math.min(declaredWidth ?? PREVIEW_WIDTH_PX, PREVIEW_WIDTH_PX);
const height =
thumbHeight(width, declaredHeight, PREVIEW_WIDTH_PX, PREVIEW_WIDTH_PX) ?? PREVIEW_WIDTH_PX;
const thumb = media.getThumbnailOfSourceHttp(PREVIEW_WIDTH_PX, PREVIEW_HEIGHT_PX, "crop");
// No thumb, no preview.
if (thumb) {
image = {
@ -332,6 +355,7 @@ export class UrlPreviewGroupViewModel
width,
height,
fileSize: UrlPreviewGroupViewModel.getNumberFromOpenGraph(preview["matrix:image:size"]),
alt,
};
}
} else if (media.srcHttp) {

View File

@ -7,8 +7,9 @@ Please see LICENSE files in the repository root for full details.
*/
import "jest-canvas-mock";
import { writeFile } from "fs/promises";
import Favicon from "../../src/favicon";
import Favicon, { BadgeOverlayRenderer } from "../../src/favicon";
jest.useFakeTimers();
@ -89,3 +90,19 @@ describe("Favicon", () => {
expect(favicon["canvas"].height).toBe(512);
});
});
describe.only("BadgeOverlayRenderer", () => {
beforeEach(() => {
jest.restoreAllMocks();
});
it("should create a link element if one doesn't yet exist", async () => {
const renderer = new BadgeOverlayRenderer();
console.log("Beep1");
const buffer = await renderer.render("1");
console.log("Beep2");
if (buffer) {
await writeFile(Buffer.from(buffer), "/tmp/badge.png");
}
});
});

View File

@ -125,6 +125,32 @@ describe("UrlPreviewGroupViewModel", () => {
await vm.updateEventElement(msg);
expect(vm.getSnapshot()).toMatchSnapshot();
});
it.each<Partial<IPreviewUrlResponse>>([
{ "matrix:image:size": 8191 },
{ "og:image:width": 95 },
{ "og:image:height": 95 },
])("should preview a URL with a site icon", async (extraResp) => {
const { vm, client } = getViewModel();
client.getUrlPreview.mockResolvedValueOnce({
"og:title": "This is an example!",
"og:type": "document",
"og:url": "https://example.org",
"og:image": IMAGE_MXC,
"og:image:height": 128,
"og:image:width": 128,
"matrix:image:size": 8193,
...extraResp,
});
// eslint-disable-next-line no-restricted-properties
client.mxcUrlToHttp.mockImplementation((url) => {
expect(url).toEqual(IMAGE_MXC);
return "https://example.org/image/src";
});
const msg = document.createElement("div");
msg.innerHTML = '<a href="https://example.org">Test</a>';
await vm.updateEventElement(msg);
expect(vm.getSnapshot().previews[0].siteIcon).toBeTruthy();
});
it("should ignore media when mediaVisible is false", async () => {
const { vm, client } = getViewModel({ mediaVisible: false, visible: true });
client.getUrlPreview.mockResolvedValueOnce({
@ -200,6 +226,41 @@ describe("UrlPreviewGroupViewModel", () => {
expect(vm.getSnapshot()).toMatchSnapshot();
});
describe("calculates author", () => {
it("should use the profile:username if provided", async () => {
const { vm, client } = getViewModel();
client.getUrlPreview.mockResolvedValueOnce({ ...BASIC_PREVIEW_OGDATA, "profile:username": "my username" });
const msg = document.createElement("div");
msg.innerHTML = '<a href="https://example.org">Test</a>';
await vm.updateEventElement(msg);
expect(vm.getSnapshot().previews[0].author).toEqual("my username");
});
it("should use author if the og:type is an article", async () => {
const { vm, client } = getViewModel();
client.getUrlPreview.mockResolvedValueOnce({
...BASIC_PREVIEW_OGDATA,
"og:type": "article",
"article:author": "my name",
});
const msg = document.createElement("div");
msg.innerHTML = '<a href="https://example.org">Test</a>';
await vm.updateEventElement(msg);
expect(vm.getSnapshot().previews[0].author).toEqual("my name");
});
it("should NOT use author if the author is a URL", async () => {
const { vm, client } = getViewModel();
client.getUrlPreview.mockResolvedValueOnce({
...BASIC_PREVIEW_OGDATA,
"og:type": "article",
"article:author": "https://junk.example.org/foo",
});
const msg = document.createElement("div");
msg.innerHTML = '<a href="https://example.org">Test</a>';
await vm.updateEventElement(msg);
expect(vm.getSnapshot().previews[0].author).toBeUndefined();
});
});
it.each([
{ text: "", href: "", hasPreview: false },
{ text: "test", href: "noprotocol.example.org", hasPreview: false },
@ -232,7 +293,7 @@ describe("UrlPreviewGroupViewModel", () => {
// API *may* return a string, so check we parse correctly.
"og:image:height": "500" as unknown as number,
"og:image:width": 500,
"matrix:image:size": 1024,
"matrix:image:size": 10000,
"og:image": IMAGE_MXC,
},
])("handles different kinds of opengraph responses %s", async (og) => {
@ -251,4 +312,21 @@ describe("UrlPreviewGroupViewModel", () => {
await vm.updateEventElement(msg);
expect(vm.getSnapshot().previews[0]).toMatchSnapshot();
});
it.each<string>(["og:video", "og:video:type", "og:audio"])("detects playable links via %s", async (property) => {
const { vm, client } = getViewModel();
// eslint-disable-next-line no-restricted-properties
client.mxcUrlToHttp.mockImplementation((url, width) => {
expect(url).toEqual(IMAGE_MXC);
if (width) {
return "https://example.org/image/thumb";
}
return "https://example.org/image/src";
});
client.getUrlPreview.mockResolvedValueOnce({ ...BASIC_PREVIEW_OGDATA, [property]: IMAGE_MXC });
const msg = document.createElement("div");
msg.innerHTML = `<a href="https://example.org">test</a>`;
await vm.updateEventElement(msg);
expect(vm.getSnapshot().previews[0].playable).toEqual(true);
});
});

View File

@ -2,10 +2,13 @@
exports[`UrlPreviewGroupViewModel handles different kinds of opengraph responses {\\n 'og:url': 'https://example.org',\\n 'og:type': 'document',\\n 'og:description': 'A description',\\n 'og:title': ''\\n} 1`] = `
{
"author": undefined,
"description": undefined,
"image": undefined,
"link": "https://example.org",
"playable": false,
"showTooltipOnLink": undefined,
"siteIcon": undefined,
"siteName": undefined,
"title": "A description",
}
@ -13,10 +16,13 @@ exports[`UrlPreviewGroupViewModel handles different kinds of opengraph responses
exports[`UrlPreviewGroupViewModel handles different kinds of opengraph responses {\\n 'og:url': 'https://example.org',\\n 'og:type': 'document',\\n 'og:site_name': 'Site name',\\n 'og:title': ''\\n} 1`] = `
{
"author": undefined,
"description": undefined,
"image": undefined,
"link": "https://example.org",
"playable": false,
"showTooltipOnLink": undefined,
"siteIcon": undefined,
"siteName": undefined,
"title": "Site name",
}
@ -24,10 +30,13 @@ exports[`UrlPreviewGroupViewModel handles different kinds of opengraph responses
exports[`UrlPreviewGroupViewModel handles different kinds of opengraph responses {\\n 'og:url': 'https://example.org',\\n 'og:type': 'document',\\n 'og:title': 'Basic title'\\n} 1`] = `
{
"author": undefined,
"description": undefined,
"image": undefined,
"link": "https://example.org",
"playable": false,
"showTooltipOnLink": undefined,
"siteIcon": undefined,
"siteName": undefined,
"title": "Basic title",
}
@ -35,27 +44,34 @@ exports[`UrlPreviewGroupViewModel handles different kinds of opengraph responses
exports[`UrlPreviewGroupViewModel handles different kinds of opengraph responses {\\n 'og:url': 'https://example.org',\\n 'og:type': 'document',\\n 'og:title': 'Cool blog',\\n 'og:site_name': 'Cool site'\\n} 1`] = `
{
"author": undefined,
"description": undefined,
"image": undefined,
"link": "https://example.org",
"playable": false,
"showTooltipOnLink": undefined,
"siteIcon": undefined,
"siteName": "Cool site",
"title": "Cool blog",
}
`;
exports[`UrlPreviewGroupViewModel handles different kinds of opengraph responses {\\n 'og:url': 'https://example.org',\\n 'og:type': 'document',\\n 'og:title': 'Media test',\\n 'og:image:height': '500',\\n 'og:image:width': 500,\\n 'matrix:image:size': 1024,\\n 'og:image': 'mxc://example.org/abc'\\n} 1`] = `
exports[`UrlPreviewGroupViewModel handles different kinds of opengraph responses {\\n 'og:url': 'https://example.org',\\n 'og:type': 'document',\\n 'og:title': 'Media test',\\n 'og:image:height': '500',\\n 'og:image:width': 500,\\n 'matrix:image:size': 10000,\\n 'og:image': 'mxc://example.org/abc'\\n} 1`] = `
{
"author": undefined,
"description": undefined,
"image": {
"fileSize": 1024,
"height": 100,
"alt": undefined,
"fileSize": 10000,
"height": 478,
"imageFull": "https://example.org/image/src",
"imageThumb": "https://example.org/image/thumb",
"width": 100,
"width": 478,
},
"link": "https://example.org",
"playable": false,
"showTooltipOnLink": undefined,
"siteIcon": undefined,
"siteName": undefined,
"title": "Media test",
}
@ -67,10 +83,13 @@ exports[`UrlPreviewGroupViewModel should deduplicate multiple versions of the sa
"overPreviewLimit": false,
"previews": [
{
"author": undefined,
"description": "This is a description",
"image": undefined,
"link": "https://example.org",
"playable": false,
"showTooltipOnLink": undefined,
"siteIcon": undefined,
"siteName": "Example.org",
"title": "This is an example!",
},
@ -96,10 +115,13 @@ exports[`UrlPreviewGroupViewModel should handle being hidden and shown by the us
"overPreviewLimit": false,
"previews": [
{
"author": undefined,
"description": "This is a description",
"image": undefined,
"link": "https://example.org",
"playable": false,
"showTooltipOnLink": undefined,
"siteIcon": undefined,
"siteName": "Example.org",
"title": "This is an example!",
},
@ -135,10 +157,13 @@ exports[`UrlPreviewGroupViewModel should ignore media when mediaVisible is false
"overPreviewLimit": false,
"previews": [
{
"author": undefined,
"description": undefined,
"image": undefined,
"link": "https://example.org",
"playable": false,
"showTooltipOnLink": undefined,
"siteIcon": undefined,
"siteName": undefined,
"title": "This is an example!",
},
@ -154,16 +179,20 @@ exports[`UrlPreviewGroupViewModel should preview a URL with media 1`] = `
"overPreviewLimit": false,
"previews": [
{
"author": undefined,
"description": undefined,
"image": {
"alt": undefined,
"fileSize": 10000,
"height": 100,
"height": 128,
"imageFull": "https://example.org/image/src",
"imageThumb": "https://example.org/image/thumb",
"width": 100,
"width": 128,
},
"link": "https://example.org",
"playable": false,
"showTooltipOnLink": undefined,
"siteIcon": undefined,
"siteName": undefined,
"title": "This is an example!",
},
@ -179,10 +208,13 @@ exports[`UrlPreviewGroupViewModel should preview a single valid URL 1`] = `
"overPreviewLimit": false,
"previews": [
{
"author": undefined,
"description": "This is a description",
"image": undefined,
"link": "https://example.org",
"playable": false,
"showTooltipOnLink": undefined,
"siteIcon": undefined,
"siteName": "Example.org",
"title": "This is an example!",
},

View File

@ -5,13 +5,6 @@
* Please see LICENSE files in the repository root for full details.
*/
.thumbnail {
/* 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;
@ -20,6 +13,11 @@
background-size: cover;
background-position: center;
border: none;
padding: 0;
> img {
width: 100%;
object-fit: cover;
}
.playButton[data-kind="primary"] {
padding: 0;
width: 50px;
@ -34,18 +32,6 @@
}
}
.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 {
max-width: 478px;
display: flex;
@ -114,9 +100,4 @@
display: flex;
gap: var(--cpd-space-1-5x);
}
.author {
display: flex;
flex-direction: column;
}
}

View File

@ -104,10 +104,7 @@ Social.args = {
link: "https://matrix.org",
siteName: "socialsite.example.org",
title: "Test user (@test)",
author: {
username: "@test",
name: "Test user",
},
author: "Test user (@test)",
};
export const SocialWithImage = Template.bind({});
@ -116,10 +113,7 @@ SocialWithImage.args = {
title: "Test user (@test)",
link: "https://matrix.org",
siteName: "socialsite.example.org",
author: {
username: "@test",
name: "Test user",
},
author: "Test user (@test)",
image: {
imageThumb: imageFileWide,
imageFull: imageFileWide,

View File

@ -6,7 +6,7 @@
*/
import React, { type MouseEventHandler, type JSX, useCallback, useMemo } from "react";
import { Tooltip, Text, Avatar, IconButton, Button } from "@vector-im/compound-web";
import { Tooltip, Text, Avatar, Button } from "@vector-im/compound-web";
import PlaySolidIcon from "@vector-im/compound-design-tokens/assets/web/icons/play-solid";
import classNames from "classnames";
@ -76,13 +76,9 @@ export function LinkPreview({ onImageClick, ...preview }: LinkPreviewProps): JSX
);
} else {
img = (
<button
style={{
backgroundImage: `image-set(url('${preview.image.imageThumb}') 1x, url('${preview.image.imageFull}') 2x)`,
}}
className={styles.preview}
onClick={onImageClickHandler}
/>
<button className={styles.preview} onClick={onImageClickHandler}>
<img src={preview.image.imageThumb} alt={preview.image.alt} title={preview.image.alt} />
</button>
);
}
}
@ -114,14 +110,9 @@ export function LinkPreview({ onImageClick, ...preview }: LinkPreviewProps): JSX
)}
<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>
<Text as="span" size="md" weight="semibold">
{preview.author}
</Text>
)}
{anchor && tooltipCaption ? <Tooltip label={tooltipCaption}>{anchor}</Tooltip> : anchor}
<LinkedText type="body" size="md" className={styles.description}>

View File

@ -47,19 +47,23 @@ export interface UrlPreview {
*/
fileSize?: number;
/**
* The width of the thumbnail. Must not exceed 100px.
* The width of the thumbnail.
*/
width?: number;
/**
* The height of the thumbnail. Must not exceed 100px.
* The height of the thumbnail.
*/
height?: number;
/**
* Alt text for the image
*/
alt?: string;
};
author?: {
name: string;
username?: string;
};
/**
* Author of the content, if specified.
*/
author?: string;
/**
* Is the media playable.