Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
1ab31e6
Commit design update
Half-Shot Apr 7, 2026
8eea71e
Add figma links
Half-Shot Apr 7, 2026
2581a4d
Check in other changes
Half-Shot Apr 9, 2026
380c957
revert accidental change
Half-Shot Apr 9, 2026
a573cf1
Iterative update
Half-Shot Apr 9, 2026
4a4123f
linting n test fiddles
Half-Shot Apr 9, 2026
137a1b3
linting
Half-Shot Apr 9, 2026
d72c85f
Cleanup
Half-Shot Apr 9, 2026
938bbf1
update snaps
Half-Shot Apr 9, 2026
549dc68
Move URL previews to new home
Half-Shot Apr 9, 2026
854a506
Fix paths
Half-Shot Apr 9, 2026
84b77a6
compress img
Half-Shot Apr 9, 2026
f3515c0
Merge remote-tracking branch 'origin/develop' into hs/url-preview-new…
Half-Shot Apr 9, 2026
033e1f1
Add back all the stories
Half-Shot Apr 9, 2026
0c2ac8e
Improved rendering
Half-Shot Apr 9, 2026
2662a98
Fixup
Half-Shot Apr 9, 2026
1d88ebd
Update previews again
Half-Shot Apr 10, 2026
c77ecb7
lint
Half-Shot Apr 10, 2026
741ef2b
update stories
Half-Shot Apr 10, 2026
750398c
Update snaps again
Half-Shot Apr 10, 2026
a174ca9
More screenshots
Half-Shot Apr 10, 2026
5d93568
Also these
Half-Shot Apr 10, 2026
2b6e8fb
Update snaps
Half-Shot Apr 10, 2026
bc0df03
include site name
Half-Shot Apr 10, 2026
a9097fa
Update snaps again
Half-Shot Apr 10, 2026
8e4c0fa
Use a scale so the images don't go blur
Half-Shot Apr 10, 2026
b10fc9f
update snaps again
Half-Shot Apr 10, 2026
5f45b84
Update snaps
Half-Shot Apr 14, 2026
52f4915
Merge remote-tracking branch 'origin/develop' into hs/url-preview-new…
Half-Shot Apr 14, 2026
68ce33c
remove mistaken playwright cfg
Half-Shot Apr 14, 2026
bf75fc7
update pw snaps
Half-Shot Apr 14, 2026
52db36e
update snap
Half-Shot Apr 14, 2026
5bf554e
Merge branch 'develop' into hs/url-preview-new-design
Half-Shot Apr 14, 2026
59b30fb
Merge remote-tracking branch 'origin/develop' into hs/url-preview-new…
Half-Shot Apr 16, 2026
354fb93
update previews
Half-Shot Apr 16, 2026
5180575
Update with new designs
Half-Shot Apr 21, 2026
c9d850c
Update screenshots
Half-Shot Apr 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/web/playwright/e2e/messages/messages.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,7 @@ test.describe("Message url previews", () => {
"og:title": "A simple site",
"og:description": "And with a brief description",
"og:image": mxc,
"og:image:alt": "The riot logo",
},
});
});
Expand Down
Comment thread
Half-Shot marked this conversation as resolved.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Comment thread
Half-Shot marked this conversation as resolved.
Comment thread
Half-Shot marked this conversation as resolved.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 1 addition & 2 deletions apps/web/src/PosthogTrackers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import { type Interaction as InteractionEvent } from "@matrix-org/analytics-even
import { type PinUnpinAction } from "@matrix-org/analytics-events/types/typescript/PinUnpinAction";
import { type RoomListSortingAlgorithmChanged } from "@matrix-org/analytics-events/types/typescript/RoomListSortingAlgorithmChanged";
import { type UrlPreviewRendered } from "@matrix-org/analytics-events/types/typescript/UrlPreviewRendered";
import { type UrlPreview } from "@element-hq/web-shared-components";

import PageType from "./PageTypes";
import Views from "./Views";
Expand Down Expand Up @@ -151,7 +150,7 @@ export default class PosthogTrackers {
* @param isEncrypted Whether the event (and effectively the room) was encrypted.
* @param previews The previews generated from the event.
*/
public trackUrlPreview(eventId: string, isEncrypted: boolean, previews: UrlPreview[]): void {
public trackUrlPreview(eventId: string, isEncrypted: boolean, previews: { image?: unknown }[]): void {
// Discount any previews that we have already tracked.
if (this.previewedEventIds.get(eventId)) {
return;
Expand Down
103 changes: 85 additions & 18 deletions apps/web/src/viewmodels/message-body/UrlPreviewGroupViewModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,10 @@
}

export const MAX_PREVIEWS_WHEN_LIMITED = 2;
export const PREVIEW_WIDTH = 100;
export const PREVIEW_HEIGHT = 100;
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 {
/**
Expand Down Expand Up @@ -100,28 +102,77 @@
typeof response["og:description"] === "string" && response["og:description"].trim()
? response["og:description"].trim()
: undefined;
let siteName =
const siteName =
typeof response["og:site_name"] === "string" && response["og:site_name"].trim()
? response["og:site_name"].trim()
: undefined;
: new URL(link).hostname;

// If there is no title, use the description as the title.
if (!title && description) {
title = description;
description = undefined;
} else if (!title && siteName) {
title = siteName;
siteName = undefined;
} else if (!title) {
title = link;
}

// If the description matches the site name, don't bother with a description.
if (description && description.toLowerCase() === siteName.toLowerCase()) {
description = undefined;
}

return {
title,
description: description && decode(description),
siteName,
};
}

/**
* Calculate the best possible author from an opengraph response.
* @param response The opengraph response
* @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"]) {
calculatedAuthor = response["article:author"];
}
// Otherwise fall through to check the profile.
}
if (typeof response["profile:username"] === "string" && 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;
}

/**
* Determine if an anchor element can be rendered into a preview.
* If it can, return the value of `href`
Expand Down Expand Up @@ -258,7 +309,7 @@
* @returns A Promise that returns the snapshot needed to render the preview, or null
* if the resource could not be previewed.
*/
private async fetchPreview(link: string): Promise<UrlPreview | null> {

Check failure on line 312 in apps/web/src/viewmodels/message-body/UrlPreviewGroupViewModel.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 20 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=element-web&issues=AZ1yaYYNCJQWnI7x2KOh&open=AZ1yaYYNCJQWnI7x2KOh&pullRequest=33061
const cached = this.previewCache.get(link);
if (cached) {
return cached;
Expand All @@ -278,38 +329,54 @@
}

const { title, description, siteName } = UrlPreviewGroupViewModel.getBaseMetadataFromResponse(preview, link);
const author = UrlPreviewGroupViewModel.getAuthorFromResponse(preview);
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.
if (title === link && !hasImage) {
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"]);
const alt = typeof preview["og:image:alt"] === "string" ? preview["og:image:alt"] : undefined;

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, "scale");
const playable = !!preview["og:video"] || !!preview["og:video:type"] || !!preview["og:audio"];
// No thumb, no preview.
if (thumb) {
image = {
imageThumb: thumb,
imageFull: media.srcHttp ?? thumb,
width,
height,
fileSize: UrlPreviewGroupViewModel.getNumberFromOpenGraph(preview["matrix:image:size"]),
alt,
playable,
};
}
} else if (media.srcHttp) {
siteIcon = media.srcHttp;
}
}

const result = {
link,
title,
author,
description,
siteName,
showTooltipOnLink: link !== title && PlatformPeg.get()?.needsUrlTooltips(),
siteIcon,
showTooltipOnLink: !!(link !== title && PlatformPeg.get()?.needsUrlTooltips()),
image,
} satisfies UrlPreview;
this.previewCache.set(link, result);
Expand Down
14 changes: 2 additions & 12 deletions apps/web/test/unit-tests/PosthogTrackers-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,20 +18,10 @@ describe("PosthogTrackers", () => {
const tracker = new PosthogTrackers();
tracker.trackUrlPreview("$123456", false, [
{
title: "A preview",
image: {
imageThumb: "abc",
imageFull: "abc",
},
link: "a-link",
},
]);
tracker.trackUrlPreview("$123456", false, [
{
title: "A second preview",
link: "a-link",
image: {},
},
]);
tracker.trackUrlPreview("$123456", false, [{}]);
// Ignores subsequent calls.
expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith({
eventName: "UrlPreviewRendered",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -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) => {
Expand All @@ -251,4 +312,25 @@ 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,
"og:image": IMAGE_MXC,
[property]: "anything",
});
const msg = document.createElement("div");
msg.innerHTML = `<a href="https://example.org">test</a>`;
await vm.updateEventElement(msg);
expect(vm.getSnapshot().previews[0].image?.playable).toEqual(true);
});
});
Loading
Loading