Skip to content

Commit 5551d52

Browse files
committed
Merge remote-tracking branch 'origin/develop' into renovate/vector-im
2 parents 4daa84b + 7b89d84 commit 5551d52

56 files changed

Lines changed: 1781 additions & 1303 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/web/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@
8989
"opus-recorder": "^8.0.3",
9090
"pako": "^2.0.3",
9191
"png-chunks-extract": "^1.0.0",
92-
"posthog-js": "1.364.7",
92+
"posthog-js": "1.369.3",
9393
"qrcode": "1.5.4",
9494
"re-resizable": "6.11.2",
9595
"react": "catalog:",
@@ -214,7 +214,7 @@
214214
"postcss-preset-env": "11.2.1",
215215
"postcss-scss": "4.0.9",
216216
"postcss-simple-vars": "7.0.1",
217-
"prettier": "3.8.1",
217+
"prettier": "3.8.3",
218218
"process": "^0.11.10",
219219
"raw-loader": "^4.0.2",
220220
"semver": "^7.5.2",

apps/web/playwright/e2e/messages/messages.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,7 @@ test.describe("Message url previews", () => {
252252
"og:title": "A simple site",
253253
"og:description": "And with a brief description",
254254
"og:image": mxc,
255+
"og:image:alt": "The riot logo",
255256
},
256257
});
257258
});
2.16 KB
Loading
13.4 KB
Loading

apps/web/src/PosthogTrackers.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ import { type Interaction as InteractionEvent } from "@matrix-org/analytics-even
1313
import { type PinUnpinAction } from "@matrix-org/analytics-events/types/typescript/PinUnpinAction";
1414
import { type RoomListSortingAlgorithmChanged } from "@matrix-org/analytics-events/types/typescript/RoomListSortingAlgorithmChanged";
1515
import { type UrlPreviewRendered } from "@matrix-org/analytics-events/types/typescript/UrlPreviewRendered";
16-
import { type UrlPreview } from "@element-hq/web-shared-components";
1716

1817
import PageType from "./PageTypes";
1918
import Views from "./Views";
@@ -151,7 +150,7 @@ export default class PosthogTrackers {
151150
* @param isEncrypted Whether the event (and effectively the room) was encrypted.
152151
* @param previews The previews generated from the event.
153152
*/
154-
public trackUrlPreview(eventId: string, isEncrypted: boolean, previews: UrlPreview[]): void {
153+
public trackUrlPreview(eventId: string, isEncrypted: boolean, previews: { image?: unknown }[]): void {
155154
// Discount any previews that we have already tracked.
156155
if (this.previewedEventIds.get(eventId)) {
157156
return;

apps/web/src/viewmodels/message-body/UrlPreviewGroupViewModel.ts

Lines changed: 85 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,10 @@ export interface UrlPreviewGroupViewModelProps {
3434
}
3535

3636
export const MAX_PREVIEWS_WHEN_LIMITED = 2;
37-
export const PREVIEW_WIDTH = 100;
38-
export const PREVIEW_HEIGHT = 100;
37+
export const PREVIEW_WIDTH_PX = 478;
38+
export const PREVIEW_HEIGHT_PX = 200;
39+
export const MIN_PREVIEW_PX = 96;
40+
export const MIN_IMAGE_SIZE_BYTES = 8192;
3941

4042
export enum PreviewVisibility {
4143
/**
@@ -100,28 +102,77 @@ export class UrlPreviewGroupViewModel
100102
typeof response["og:description"] === "string" && response["og:description"].trim()
101103
? response["og:description"].trim()
102104
: undefined;
103-
let siteName =
105+
const siteName =
104106
typeof response["og:site_name"] === "string" && response["og:site_name"].trim()
105107
? response["og:site_name"].trim()
106-
: undefined;
108+
: new URL(link).hostname;
107109

110+
// If there is no title, use the description as the title.
108111
if (!title && description) {
109112
title = description;
110113
description = undefined;
111114
} else if (!title && siteName) {
112115
title = siteName;
113-
siteName = undefined;
114116
} else if (!title) {
115117
title = link;
116118
}
117119

120+
// If the description matches the site name, don't bother with a description.
121+
if (description && description.toLowerCase() === siteName.toLowerCase()) {
122+
description = undefined;
123+
}
124+
118125
return {
119126
title,
120127
description: description && decode(description),
121128
siteName,
122129
};
123130
}
124131

132+
/**
133+
* Calculate the best possible author from an opengraph response.
134+
* @param response The opengraph response
135+
* @returns The author value, or undefined if no valid author could be found.
136+
*/
137+
private static getAuthorFromResponse(response: IPreviewUrlResponse): UrlPreview["author"] {
138+
let calculatedAuthor: string | undefined;
139+
if (response["og:type"] === "article") {
140+
if (typeof response["article:author"] === "string" && response["article:author"]) {
141+
calculatedAuthor = response["article:author"];
142+
}
143+
// Otherwise fall through to check the profile.
144+
}
145+
if (typeof response["profile:username"] === "string" && response["profile:username"]) {
146+
calculatedAuthor = response["profile:username"];
147+
}
148+
if (calculatedAuthor && URL.canParse(calculatedAuthor)) {
149+
// Some sites return URLs as authors which doesn't look good in Element, so discard it.
150+
return;
151+
}
152+
return calculatedAuthor;
153+
}
154+
155+
/**
156+
* Calculate whether the provided image from the preview response is an full size preview or
157+
* a site icon.
158+
* @returns `true` if the image should be used as a preview, otherwise `false`
159+
*/
160+
private static isImagePreview(width?: number, height?: number, bytes?: number): boolean {
161+
// We can't currently distinguish from a preview image and a favicon. Neither OpenGraph nor Matrix
162+
// have a clear distinction, so we're using a heuristic here to check the dimensions & size of the file and
163+
// deciding whether to render it as a full preview or icon.
164+
if (width && width < MIN_PREVIEW_PX) {
165+
return false;
166+
}
167+
if (height && height < MIN_PREVIEW_PX) {
168+
return false;
169+
}
170+
if (bytes && bytes < MIN_IMAGE_SIZE_BYTES) {
171+
return false;
172+
}
173+
return true;
174+
}
175+
125176
/**
126177
* Determine if an anchor element can be rendered into a preview.
127178
* If it can, return the value of `href`
@@ -278,38 +329,54 @@ export class UrlPreviewGroupViewModel
278329
}
279330

280331
const { title, description, siteName } = UrlPreviewGroupViewModel.getBaseMetadataFromResponse(preview, link);
332+
const author = UrlPreviewGroupViewModel.getAuthorFromResponse(preview);
281333
const hasImage = preview["og:image"] && typeof preview?.["og:image"] === "string";
282334
// Ensure we have something relevant to render.
283335
// The title must not just be the link, or we must have an image.
284336
if (title === link && !hasImage) {
285337
return null;
286338
}
287339
let image: UrlPreview["image"];
340+
let siteIcon: string | undefined;
288341
if (typeof preview["og:image"] === "string" && this.visibility > PreviewVisibility.MediaHidden) {
289342
const media = mediaFromMxc(preview["og:image"], this.client);
290343
const declaredHeight = UrlPreviewGroupViewModel.getNumberFromOpenGraph(preview["og:image:height"]);
291344
const declaredWidth = UrlPreviewGroupViewModel.getNumberFromOpenGraph(preview["og:image:width"]);
292-
const width = Math.min(declaredWidth ?? PREVIEW_WIDTH, PREVIEW_WIDTH);
293-
const height = thumbHeight(width, declaredHeight, PREVIEW_WIDTH, PREVIEW_WIDTH) ?? PREVIEW_WIDTH;
294-
const thumb = media.getThumbnailOfSourceHttp(PREVIEW_WIDTH, PREVIEW_HEIGHT, "scale");
295-
// No thumb, no preview.
296-
if (thumb) {
297-
image = {
298-
imageThumb: thumb,
299-
imageFull: media.srcHttp ?? thumb,
300-
width,
301-
height,
302-
fileSize: UrlPreviewGroupViewModel.getNumberFromOpenGraph(preview["matrix:image:size"]),
303-
};
345+
const imageSize = UrlPreviewGroupViewModel.getNumberFromOpenGraph(preview["matrix:image:size"]);
346+
const alt = typeof preview["og:image:alt"] === "string" ? preview["og:image:alt"] : undefined;
347+
348+
const isImagePreview = UrlPreviewGroupViewModel.isImagePreview(declaredWidth, declaredHeight, imageSize);
349+
if (isImagePreview) {
350+
const width = Math.min(declaredWidth ?? PREVIEW_WIDTH_PX, PREVIEW_WIDTH_PX);
351+
const height =
352+
thumbHeight(width, declaredHeight, PREVIEW_WIDTH_PX, PREVIEW_WIDTH_PX) ?? PREVIEW_WIDTH_PX;
353+
const thumb = media.getThumbnailOfSourceHttp(PREVIEW_WIDTH_PX, PREVIEW_HEIGHT_PX, "scale");
354+
const playable = !!preview["og:video"] || !!preview["og:video:type"] || !!preview["og:audio"];
355+
// No thumb, no preview.
356+
if (thumb) {
357+
image = {
358+
imageThumb: thumb,
359+
imageFull: media.srcHttp ?? thumb,
360+
width,
361+
height,
362+
fileSize: UrlPreviewGroupViewModel.getNumberFromOpenGraph(preview["matrix:image:size"]),
363+
alt,
364+
playable,
365+
};
366+
}
367+
} else if (media.srcHttp) {
368+
siteIcon = media.srcHttp;
304369
}
305370
}
306371

307372
const result = {
308373
link,
309374
title,
375+
author,
310376
description,
311377
siteName,
312-
showTooltipOnLink: link !== title && PlatformPeg.get()?.needsUrlTooltips(),
378+
siteIcon,
379+
showTooltipOnLink: !!(link !== title && PlatformPeg.get()?.needsUrlTooltips()),
313380
image,
314381
} satisfies UrlPreview;
315382
this.previewCache.set(link, result);

apps/web/test/unit-tests/PosthogTrackers-test.ts

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,20 +18,10 @@ describe("PosthogTrackers", () => {
1818
const tracker = new PosthogTrackers();
1919
tracker.trackUrlPreview("$123456", false, [
2020
{
21-
title: "A preview",
22-
image: {
23-
imageThumb: "abc",
24-
imageFull: "abc",
25-
},
26-
link: "a-link",
27-
},
28-
]);
29-
tracker.trackUrlPreview("$123456", false, [
30-
{
31-
title: "A second preview",
32-
link: "a-link",
21+
image: {},
3322
},
3423
]);
24+
tracker.trackUrlPreview("$123456", false, [{}]);
3525
// Ignores subsequent calls.
3626
expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith({
3727
eventName: "UrlPreviewRendered",

apps/web/test/viewmodels/message-body/UrlPreviewGroupViewModel-test.ts

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,32 @@ describe("UrlPreviewGroupViewModel", () => {
125125
await vm.updateEventElement(msg);
126126
expect(vm.getSnapshot()).toMatchSnapshot();
127127
});
128+
it.each<Partial<IPreviewUrlResponse>>([
129+
{ "matrix:image:size": 8191 },
130+
{ "og:image:width": 95 },
131+
{ "og:image:height": 95 },
132+
])("should preview a URL with a site icon", async (extraResp) => {
133+
const { vm, client } = getViewModel();
134+
client.getUrlPreview.mockResolvedValueOnce({
135+
"og:title": "This is an example!",
136+
"og:type": "document",
137+
"og:url": "https://example.org",
138+
"og:image": IMAGE_MXC,
139+
"og:image:height": 128,
140+
"og:image:width": 128,
141+
"matrix:image:size": 8193,
142+
...extraResp,
143+
});
144+
// eslint-disable-next-line no-restricted-properties
145+
client.mxcUrlToHttp.mockImplementation((url) => {
146+
expect(url).toEqual(IMAGE_MXC);
147+
return "https://example.org/image/src";
148+
});
149+
const msg = document.createElement("div");
150+
msg.innerHTML = '<a href="https://example.org">Test</a>';
151+
await vm.updateEventElement(msg);
152+
expect(vm.getSnapshot().previews[0].siteIcon).toBeTruthy();
153+
});
128154
it("should ignore media when mediaVisible is false", async () => {
129155
const { vm, client } = getViewModel({ mediaVisible: false, visible: true });
130156
client.getUrlPreview.mockResolvedValueOnce({
@@ -200,6 +226,41 @@ describe("UrlPreviewGroupViewModel", () => {
200226
expect(vm.getSnapshot()).toMatchSnapshot();
201227
});
202228

229+
describe("calculates author", () => {
230+
it("should use the profile:username if provided", async () => {
231+
const { vm, client } = getViewModel();
232+
client.getUrlPreview.mockResolvedValueOnce({ ...BASIC_PREVIEW_OGDATA, "profile:username": "my username" });
233+
const msg = document.createElement("div");
234+
msg.innerHTML = '<a href="https://example.org">Test</a>';
235+
await vm.updateEventElement(msg);
236+
expect(vm.getSnapshot().previews[0].author).toEqual("my username");
237+
});
238+
it("should use author if the og:type is an article", async () => {
239+
const { vm, client } = getViewModel();
240+
client.getUrlPreview.mockResolvedValueOnce({
241+
...BASIC_PREVIEW_OGDATA,
242+
"og:type": "article",
243+
"article:author": "my name",
244+
});
245+
const msg = document.createElement("div");
246+
msg.innerHTML = '<a href="https://example.org">Test</a>';
247+
await vm.updateEventElement(msg);
248+
expect(vm.getSnapshot().previews[0].author).toEqual("my name");
249+
});
250+
it("should NOT use author if the author is a URL", async () => {
251+
const { vm, client } = getViewModel();
252+
client.getUrlPreview.mockResolvedValueOnce({
253+
...BASIC_PREVIEW_OGDATA,
254+
"og:type": "article",
255+
"article:author": "https://junk.example.org/foo",
256+
});
257+
const msg = document.createElement("div");
258+
msg.innerHTML = '<a href="https://example.org">Test</a>';
259+
await vm.updateEventElement(msg);
260+
expect(vm.getSnapshot().previews[0].author).toBeUndefined();
261+
});
262+
});
263+
203264
it.each([
204265
{ text: "", href: "", hasPreview: false },
205266
{ text: "test", href: "noprotocol.example.org", hasPreview: false },
@@ -232,7 +293,7 @@ describe("UrlPreviewGroupViewModel", () => {
232293
// API *may* return a string, so check we parse correctly.
233294
"og:image:height": "500" as unknown as number,
234295
"og:image:width": 500,
235-
"matrix:image:size": 1024,
296+
"matrix:image:size": 10000,
236297
"og:image": IMAGE_MXC,
237298
},
238299
])("handles different kinds of opengraph responses %s", async (og) => {
@@ -251,4 +312,25 @@ describe("UrlPreviewGroupViewModel", () => {
251312
await vm.updateEventElement(msg);
252313
expect(vm.getSnapshot().previews[0]).toMatchSnapshot();
253314
});
315+
316+
it.each<string>(["og:video", "og:video:type", "og:audio"])("detects playable links via %s", async (property) => {
317+
const { vm, client } = getViewModel();
318+
// eslint-disable-next-line no-restricted-properties
319+
client.mxcUrlToHttp.mockImplementation((url, width) => {
320+
expect(url).toEqual(IMAGE_MXC);
321+
if (width) {
322+
return "https://example.org/image/thumb";
323+
}
324+
return "https://example.org/image/src";
325+
});
326+
client.getUrlPreview.mockResolvedValueOnce({
327+
...BASIC_PREVIEW_OGDATA,
328+
"og:image": IMAGE_MXC,
329+
[property]: "anything",
330+
});
331+
const msg = document.createElement("div");
332+
msg.innerHTML = `<a href="https://example.org">test</a>`;
333+
await vm.updateEventElement(msg);
334+
expect(vm.getSnapshot().previews[0].image?.playable).toEqual(true);
335+
});
254336
});

0 commit comments

Comments
 (0)