From 2581a4deff554b82ee2b23fbc9837596a0bbf678 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Thu, 9 Apr 2026 09:11:21 +0100 Subject: [PATCH] Check in other changes --- .../message-body/UrlPreviewGroupViewModel.ts | 62 +++++++++----- .../test/unit-tests/__snapshots__/icon.png | 0 apps/web/test/unit-tests/favicon-test.ts | 19 ++++- .../UrlPreviewGroupViewModel-test.ts | 80 ++++++++++++++++++- .../UrlPreviewGroupViewModel-test.ts.snap | 44 ++++++++-- .../LinkPreview/LinkPreview.module.css | 29 ++----- .../LinkPreview/LinkPreview.stories.tsx | 10 +-- .../LinkPreview/LinkPreview.tsx | 23 ++---- .../event-tiles/UrlPreviewGroupView/types.ts | 16 ++-- 9 files changed, 202 insertions(+), 81 deletions(-) create mode 100644 apps/web/test/unit-tests/__snapshots__/icon.png diff --git a/apps/web/src/viewmodels/message-body/UrlPreviewGroupViewModel.ts b/apps/web/src/viewmodels/message-body/UrlPreviewGroupViewModel.ts index 4e3eb54633..05eb105109 100644 --- a/apps/web/src/viewmodels/message-body/UrlPreviewGroupViewModel.ts +++ b/apps/web/src/viewmodels/message-body/UrlPreviewGroupViewModel.ts @@ -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) { diff --git a/apps/web/test/unit-tests/__snapshots__/icon.png b/apps/web/test/unit-tests/__snapshots__/icon.png new file mode 100644 index 0000000000..e69de29bb2 diff --git a/apps/web/test/unit-tests/favicon-test.ts b/apps/web/test/unit-tests/favicon-test.ts index 32c1ea9966..b1da46f502 100644 --- a/apps/web/test/unit-tests/favicon-test.ts +++ b/apps/web/test/unit-tests/favicon-test.ts @@ -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"); + } + }); +}); diff --git a/apps/web/test/viewmodels/message-body/UrlPreviewGroupViewModel-test.ts b/apps/web/test/viewmodels/message-body/UrlPreviewGroupViewModel-test.ts index 782cc8d7ee..8e8bc911e5 100644 --- a/apps/web/test/viewmodels/message-body/UrlPreviewGroupViewModel-test.ts +++ b/apps/web/test/viewmodels/message-body/UrlPreviewGroupViewModel-test.ts @@ -125,6 +125,32 @@ describe("UrlPreviewGroupViewModel", () => { await vm.updateEventElement(msg); expect(vm.getSnapshot()).toMatchSnapshot(); }); + it.each>([ + { "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 = 'Test'; + 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 = 'Test'; + 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 = 'Test'; + 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 = 'Test'; + 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(["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 = `test`; + await vm.updateEventElement(msg); + expect(vm.getSnapshot().previews[0].playable).toEqual(true); + }); }); diff --git a/apps/web/test/viewmodels/message-body/__snapshots__/UrlPreviewGroupViewModel-test.ts.snap b/apps/web/test/viewmodels/message-body/__snapshots__/UrlPreviewGroupViewModel-test.ts.snap index 1f412d86cb..91b37a9ce4 100644 --- a/apps/web/test/viewmodels/message-body/__snapshots__/UrlPreviewGroupViewModel-test.ts.snap +++ b/apps/web/test/viewmodels/message-body/__snapshots__/UrlPreviewGroupViewModel-test.ts.snap @@ -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!", }, diff --git a/packages/shared-components/src/event-tiles/UrlPreviewGroupView/LinkPreview/LinkPreview.module.css b/packages/shared-components/src/event-tiles/UrlPreviewGroupView/LinkPreview/LinkPreview.module.css index 0ce030c85b..0f3234c853 100644 --- a/packages/shared-components/src/event-tiles/UrlPreviewGroupView/LinkPreview/LinkPreview.module.css +++ b/packages/shared-components/src/event-tiles/UrlPreviewGroupView/LinkPreview/LinkPreview.module.css @@ -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; - } } diff --git a/packages/shared-components/src/event-tiles/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx b/packages/shared-components/src/event-tiles/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx index c70e818df4..506c24d332 100644 --- a/packages/shared-components/src/event-tiles/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx +++ b/packages/shared-components/src/event-tiles/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx @@ -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, diff --git a/packages/shared-components/src/event-tiles/UrlPreviewGroupView/LinkPreview/LinkPreview.tsx b/packages/shared-components/src/event-tiles/UrlPreviewGroupView/LinkPreview/LinkPreview.tsx index a1e096c940..2a017b9bb1 100644 --- a/packages/shared-components/src/event-tiles/UrlPreviewGroupView/LinkPreview/LinkPreview.tsx +++ b/packages/shared-components/src/event-tiles/UrlPreviewGroupView/LinkPreview/LinkPreview.tsx @@ -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 = ( - ); } } @@ -114,14 +110,9 @@ export function LinkPreview({ onImageClick, ...preview }: LinkPreviewProps): JSX )}
{preview.author && ( -
- - {preview.author.username} - - - {preview.author.name} - -
+ + {preview.author} + )} {anchor && tooltipCaption ? {anchor} : anchor} diff --git a/packages/shared-components/src/event-tiles/UrlPreviewGroupView/types.ts b/packages/shared-components/src/event-tiles/UrlPreviewGroupView/types.ts index ab523adda0..083dfe33ba 100644 --- a/packages/shared-components/src/event-tiles/UrlPreviewGroupView/types.ts +++ b/packages/shared-components/src/event-tiles/UrlPreviewGroupView/types.ts @@ -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.