Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
330465d
slapped on the styles for url previews as well
Siriusmart Jul 30, 2026
8ad024c
remove old urlpreview code
Siriusmart Aug 11, 2026
093dbc0
remove unused code
Siriusmart Aug 11, 2026
2b1fdbd
undo the removal of the legacy mediabody
Siriusmart Aug 12, 2026
63c8645
cleaning up PR for review
Siriusmart Aug 12, 2026
67a40cf
add the collapse previews button back
Siriusmart Aug 12, 2026
580b330
fixed oxlint issues
Siriusmart Aug 12, 2026
a069d3a
claude fixed playwright tests
Siriusmart Aug 12, 2026
85de15f
regenerate stuff for end to end tests
Siriusmart Aug 13, 2026
cf17539
force description line to exist in url previews
Siriusmart Aug 13, 2026
f3eec91
claude fixed end to end tests
Siriusmart Aug 13, 2026
d5ac054
fixed errors from changes in MessageComposerUrlPreview
Siriusmart Aug 18, 2026
710a8f7
Merge branch 'unified-previews/2-file-body-preview-tile' into unified…
Siriusmart Sep 1, 2026
89398df
Merge branch 'unified-previews/2-file-body-preview-tile' into unified…
Siriusmart Sep 1, 2026
492b3f6
Merge branch 'unified-previews/2-file-body-preview-tile' into unified…
Siriusmart Sep 1, 2026
679d116
Cover the URL preview tiles
Siriusmart Sep 1, 2026
48db925
Merge branch 'unified-previews/2-file-body-preview-tile' into unified…
Siriusmart Sep 1, 2026
1a338d5
Merge branch 'unified-previews/2-file-body-preview-tile' into unified…
Siriusmart Sep 2, 2026
c7c48f6
Merge branch 'unified-previews/2-file-body-preview-tile' into unified…
Siriusmart Sep 4, 2026
28143f1
Follow the renamed media preview entry API in TextualBodyFactory
Siriusmart Sep 4, 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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
122 changes: 120 additions & 2 deletions apps/web/src/components/views/messages/TextualBodyFactory.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,14 @@ Please see LICENSE files in the repository root for full details.

import React, { type ComponentProps } from "react";
import { describe, it, expect, vi, beforeEach, afterEach, type MockedObject } from "vitest";
import { type MatrixClient, type MatrixEvent, PushRuleKind, type Room } from "matrix-js-sdk/src/matrix";
import { act, render, waitFor } from "test-utils-rtl";
import {
type IPreviewUrlResponse,
type MatrixClient,
type MatrixEvent,
PushRuleKind,
type Room,
} from "matrix-js-sdk/src/matrix";
import { act, fireEvent, render, screen, waitFor } from "test-utils-rtl";
import { PushProcessor } from "matrix-js-sdk/src/pushprocessor";
import { setMissingEntryGenerator } from "@element-hq/web-shared-components";

Expand All @@ -23,6 +29,8 @@ import MatrixClientContext from "../../../contexts/MatrixClientContext";
import RoomContext from "../../../contexts/RoomContext";
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
import { type MediaEventHelper } from "../../../utils/MediaEventHelper";
import Modal from "../../../Modal";
import ImageView from "../elements/ImageView";

vi.mock("../../../hooks/useMediaVisible", () => ({
__esModule: true,
Expand Down Expand Up @@ -527,4 +535,114 @@ describe("<TextualBody />", () => {
});
});
});
describe("url preview tiles", () => {
const link = "https://matrix.org/";
let matrixClient: MockedObject<MatrixClient>;

const ogData = (overrides: Partial<IPreviewUrlResponse> = {}): IPreviewUrlResponse => ({
"og:title": "Matrix",
"og:type": "website",
"og:description": "An open network for secure, decentralised communication",
"og:site_name": "matrix.org",
"og:url": link,
...overrides,
});

const ogImage = {
"og:image": "mxc://example.org/preview",
"og:image:type": "image/png",
"og:image:width": 480,
"og:image:height": 320,
"matrix:image:size": 100_000,
};

beforeEach(() => {
setMissingEntryGenerator((key) => key.split("|", 2)[1]);
matrixClient = getMockClientWithEventEmitter({
getRoom: vi.fn(),
getUserId: vi.fn(),
...mockClientPushProcessor(),
getAccountData: (): MatrixEvent | undefined => undefined,
getUrlPreview: vi.fn().mockResolvedValue(ogData()),
isGuest: () => false,
mxcUrlToHttp: (s: string) => s,
});
vi.mocked(matrixClient.getRoom).mockReturnValue(mkStubRoom("room_id", "room name", matrixClient));
DMRoomMap.makeShared(defaultMatrixClient);
});

/** Render a message and wait for its previews to have been fetched and rendered. */
const renderPreviews = async (body = `Visit ${link}`): Promise<ReturnType<typeof render>> => {
const result = getComponent({ mxEvent: mkRoomTextMessage(body), showUrlPreview: true }, matrixClient);
await screen.findByRole("link", { name: "Matrix" });
return result;
};

it("renders a preview without an image as a text tile", async () => {
await renderPreviews();

expect(screen.getByRole("link", { name: "Matrix" })).toHaveAttribute("href", link);
expect(screen.getByText("An open network for secure, decentralised communication")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "View image" })).not.toBeInTheDocument();
});

it("falls back to the site name when the preview has no description", async () => {
vi.mocked(matrixClient.getUrlPreview).mockResolvedValue(ogData({ "og:description": undefined }));

await renderPreviews();

expect(screen.getByText("matrix.org")).toBeInTheDocument();
});

it("renders a preview with an image and opens the lightbox when it is clicked", async () => {
vi.mocked(matrixClient.getUrlPreview).mockResolvedValue(ogData(ogImage));
const createDialog = vi.spyOn(Modal, "createDialog").mockReturnValue({} as never);

await renderPreviews();

fireEvent.click(screen.getByRole("button", { name: "View image" }));

expect(createDialog).toHaveBeenCalledWith(
ImageView,
expect.objectContaining({ src: "mxc://example.org/preview", name: "Thumbnail of Matrix" }),
"mx_Dialog_lightbox",
undefined,
true,
);
});

it("opens the previewed link in a new tab", async () => {
const open = vi.spyOn(window, "open").mockReturnValue(null);

await renderPreviews();

fireEvent.click(screen.getByRole("button", { name: "Open link" }));

expect(open).toHaveBeenCalledWith(link, "_blank", "noreferrer");
});

it("expands the group when more previews are available than are shown", async () => {
vi.mocked(matrixClient.getUrlPreview).mockImplementation(async (url: string) =>
ogData({ "og:title": `Preview of ${url}`, "og:url": url }),
);

const { container } = getComponent(
{
mxEvent: mkRoomTextMessage(
"Visit https://one.example.com/ and https://two.example.com/ and https://three.example.com/",
),
showUrlPreview: true,
},
matrixClient,
);

const toggle = await screen.findByRole("button", { name: "Show 1 other preview" });
expect(container.querySelectorAll("a[href^='https://one']")).toHaveLength(2);

fireEvent.click(toggle);

await screen.findByRole("link", { name: "Preview of https://three.example.com/" });
expect(screen.getByRole("button", { name: "Collapse" })).toBeInTheDocument();
});
});
});
96 changes: 92 additions & 4 deletions apps/web/src/components/views/messages/TextualBodyFactory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,21 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/

import React, { type JSX, useContext, useEffect, useRef } from "react";
import React, { type JSX, useContext, useEffect, useMemo, useRef } from "react";
import { logger as rootLogger } from "matrix-js-sdk/src/logger";
import { MsgType } from "matrix-js-sdk/src/matrix";
import {
_t,
EventContentBodyView,
TextualBodyView,
type TextualBodyContentElement,
type UrlPreview,
UrlPreviewGroupView,
useCreateAutoDisposedViewModel,
MediaPreviewGroupPreview,
useViewModel,
linkIcon,
type MediaPreviewGroupEntry,
type MediaPreviewGroupEntryContent,
} from "@element-hq/web-shared-components";

import { type IBodyProps } from "./IBodyProps";
Expand All @@ -34,6 +38,8 @@ import { EditWysiwygComposer } from "../rooms/wysiwyg_composer";
import { UrlPreviewGroupViewModel } from "../../../viewmodels/message-body/UrlPreviewGroupViewModel";
import PlatformPeg from "../../../PlatformPeg";
import { useSettingValue } from "../../../hooks/useSettings";
import { MediaPreviewGroupViewModel } from "../../../viewmodels/message-body/MediaPreviewGroupViewModel";
import PopOutIcon from "@vector-im/compound-design-tokens/assets/web/icons/pop-out";

const logger = rootLogger.getChild("TextualBodyFactory");

Expand Down Expand Up @@ -127,7 +133,82 @@ export function TextualBodyFactory(props: Readonly<IBodyProps>): JSX.Element {
}),
);

const { previews } = useViewModel(urlPreviewVm);
const { previews, totalPreviewCount, previewsLimited, overPreviewLimit } = useViewModel(urlPreviewVm);

// Memoised because it feeds the media preview view model from an effect: a fresh object on every
// render would notify subscribers on every render.
const collapse = useMemo(
() =>
overPreviewLimit
? {
collapsed: previewsLimited,
hiddenCount: totalPreviewCount - previews.length,
onToggle: () => void urlPreviewVm.onTogglePreviewLimit(),
}
: undefined,
[overPreviewLimit, previewsLimited, totalPreviewCount, previews.length, urlPreviewVm],
);

const previewToEntry = (preview: UrlPreview): MediaPreviewGroupEntry => {
let content: MediaPreviewGroupEntryContent;
if (preview.image === undefined) {
content = {
type: "text",
};
} else {
content = {
type: "image",
image: preview.image.imageFull,
imageAlt: preview.title,
imageSize: "banner",
imageOnClick: () => {
Modal.createDialog(
ImageView,
{
src: preview.image!.imageFull, // full-res URL
name: `Thumbnail of ${preview.title}`,
width: preview.image?.width,
height: preview.image?.height,
fileSize: preview.image?.fileSize,
},
"mx_Dialog_lightbox",
undefined,
true,
);
},
};
}

let body: string;
if (preview.description === undefined || preview.description.trim().length === 0) body = preview.siteName;
else body = preview.description!;

return {
id: preview.link,
header: preview.title,
headerUrl: preview.link,
body,
buttons: [
{
label: _t("timeline|url_preview|open_link"),
icon: <PopOutIcon />,
onClick: async () => {
window.open(preview.link, "_blank", "noreferrer");
},
},
],
...linkIcon(),
...content,
};
};

const mediaPreviewVm = useCreateAutoDisposedViewModel(
() =>
new MediaPreviewGroupViewModel({
entries: previews.map(previewToEntry),
collapse,
}),
);

useEffect(() => {
textualBodyVm.setId(props.id);
Expand Down Expand Up @@ -198,6 +279,13 @@ export function TextualBodyFactory(props: Readonly<IBodyProps>): JSX.Element {
});
}, [mediaVisible, urlPreviewVm]);

useEffect(() => {
mediaPreviewVm.setProps({
entries: previews.map(previewToEntry),
collapse,
});
}, [previews, collapse, mediaPreviewVm]);

useEffect(() => {
if (previews.length === 0) {
return;
Expand All @@ -221,7 +309,7 @@ export function TextualBodyFactory(props: Readonly<IBodyProps>): JSX.Element {
vm={textualBodyVm}
body={<EventContentBodyView vm={eventContentBodyVm} as={willHaveWrapper ? "span" : "div"} />}
bodyRef={contentRef}
urlPreviews={<UrlPreviewGroupView vm={urlPreviewVm} className="mx_TextualBody_urlPreviews" />}
urlPreviews={<MediaPreviewGroupPreview vm={mediaPreviewVm} />}
className={getTextualBodyClassName(content.msgtype as MsgType | undefined)}
/>
);
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/i18n/strings/en_EN.json
Original file line number Diff line number Diff line change
Expand Up @@ -3732,7 +3732,10 @@
"one_user": "%(displayName)s is typing …",
"two_users": "%(names)s and %(lastPerson)s are typing …"
},
"undecryptable_tooltip": "This message could not be decrypted"
"undecryptable_tooltip": "This message could not be decrypted",
"url_preview": {
"open_link": "Open link"
}
},
"truncated_list_n_more": {
"other": "And %(count)s more..."
Expand Down
37 changes: 0 additions & 37 deletions packages/shared-components/.storybook/waitForImages.ts

This file was deleted.

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
2 changes: 0 additions & 2 deletions packages/shared-components/src/i18n/strings/en_EN.json
Original file line number Diff line number Diff line change
Expand Up @@ -363,8 +363,6 @@
"pending_moderation": "Message pending moderation",
"pending_moderation_reason": "Message pending moderation: %(reason)s",
"url_preview": {
"close": "Close preview",
"open_link": "Open link",
"show_n_more": {
"one": "Show %(count)s other preview",
"other": "Show %(count)s other previews"
Expand Down
2 changes: 1 addition & 1 deletion packages/shared-components/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export * from "./room/timeline/event-tile/reactions/ReactionsRow";
export * from "./room/timeline/event-tile/reactions/ReactionsRowButton";
export * from "./room/timeline/event-tile/reactions/ReactionsRowButtonTooltip";
export * from "./room/timeline/event-tile/timestamp/MessageTimestampView";
export * from "./room/timeline/event-tile/UrlPreviewGroupView";
export type * from "./room/urlPreview.ts";
export * from "./room/timeline/event-tile/MediaPreviewGroupView";
export * from "./core/rich-list/RichItem";
export * from "./core/rich-list/RichList";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -263,3 +263,24 @@
background-color: var(--cpd-color-bg-decorative-6);
color: var(--cpd-color-text-decorative-6);
}

.siteName {
vertical-align: middle;
display: flex;
gap: var(--cpd-space-1-5x);

> * {
/* Center everything */
margin: auto 0;
}
}

.title {
display: -webkit-box;
-webkit-box-orient: vertical;
overflow: hidden;
white-space: normal;
margin: 0;
color: var(--cpd-color-text-primary);
text-decoration-line: none;
}
Loading
Loading