span {
- display: -webkit-box;
- -webkit-box-orient: vertical;
- -webkit-line-clamp: 1;
- line-clamp: 1;
- overflow: hidden;
- min-width: 0;
- }
-}
-
-.containerExpanded {
- box-sizing: border-box;
- max-width: 100%;
- width: 478px;
- display: flex;
- border: 1px solid var(--cpd-color-border-interactive-secondary);
- border-radius: 12px;
- /* Get radius from cpd */
- flex-direction: column;
- color: var(--cpd-color-text-secondary);
- overflow: clip;
-
- background: var(--cpd-color-bg-subtle-secondary);
-
- &.inline {
- flex-direction: row;
- gap: var(--cpd-space-4x);
- padding: var(--cpd-space-3x) var(--cpd-space-4x);
-
- .title {
- margin: 0;
- }
-
- .siteAvatar {
- margin: auto 0;
- }
-
- .siteName {
- margin: 0;
- }
- }
-
- .textContent {
- padding: var(--cpd-space-3x) var(--cpd-space-4x);
-
- &.inline {
- padding: 0;
- }
-
- display: flex;
- flex-direction: column;
- gap: var(--cpd-space-1x);
- }
-
- .caption {
- display: inline-flex;
- flex-direction: column;
- min-width: 0;
- /* Prevent blowout */
- }
-
- .caption {
- flex: 1;
- overflow: hidden;
- /* cause it to wrap rather than clip */
- }
-
- .preview {
- display: flex;
- position: relative;
- max-width: 100%;
- width: 478px;
- height: 200px;
- background-size: cover;
- background-position: center;
- border: none;
- padding: 0;
-
- .playButton[data-kind="primary"] {
- padding: 0;
- width: 50px;
- height: 50px;
- margin: auto;
- background: var(--cpd-color-text-on-solid-primary);
-
- > svg {
- margin: auto;
- border-radius: 50px;
- color: var(--cpd-color-icon-primary);
- }
- }
- }
-
- .siteName {
- margin-top: var(--cpd-space-1x);
- }
-
- .title,
- .description {
- line-clamp: 2;
- -webkit-line-clamp: 2;
- }
-}
-
-.siteName {
- vertical-align: middle;
- display: flex;
- gap: var(--cpd-space-1-5x);
-
- > * {
- /* Center everything */
- margin: auto 0;
- }
-}
-
-.title,
-.description {
- display: -webkit-box;
- -webkit-box-orient: vertical;
- overflow: hidden;
- white-space: normal;
- margin: 0;
-}
-
-.title {
- color: var(--cpd-color-text-primary);
- text-decoration-line: none;
-}
diff --git a/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx b/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx
deleted file mode 100644
index 21a530a63bd..00000000000
--- a/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx
+++ /dev/null
@@ -1,194 +0,0 @@
-/*
- * Copyright 2026 Element Creations Ltd.
- *
- * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
- * Please see LICENSE files in the repository root for full details.
- */
-
-import React from "react";
-import { fn } from "storybook/test";
-
-import type { Meta, StoryFn } from "@storybook/react-vite";
-import { LinkPreview } from "./LinkPreview";
-import { LinkedTextContext } from "../../../../../core/utils/LinkedText";
-import { waitForBackgroundImages } from "../../../../../../.storybook/waitForImages";
-import imageFile from "../../../../../../static/element.png";
-import imageFileWide from "../../../../../../static/wideImage.png";
-import imageFileTall from "../../../../../../static/tallImage.png";
-
-export default {
- title: "EventTiles/LinkPreview",
- component: LinkPreview,
- tags: ["autodocs"],
- args: {
- onImageClick: fn(),
- },
- play: async ({ canvasElement }) => {
- await waitForBackgroundImages(canvasElement);
- },
- argTypes: {
- siteName: {
- control: "text",
- },
- author: {
- control: "text",
- },
- siteIcon: { control: { type: "file", accept: ".png" } },
- image: {},
- },
- parameters: {
- design: {
- type: "figma",
- url: "https://www.figma.com/design/sI9A2kV2K4xeiyqJsL7Ey3/Link-Previews?node-id=87-7920",
- },
- },
-} satisfies Meta;
-
-const Template: StoryFn = (args) => (
-
-
-
-);
-
-export const Default = Template.bind({});
-Default.args = {
- title: "A simple title",
- description: "A simple description",
- link: "https://matrix.org",
- siteName: "Site name",
- image: {
- imageThumb: imageFile,
- imageFull: imageFile,
- alt: "Element logo",
- playable: false,
- mxcImageFull: "mxc://server/file",
- },
-};
-
-export const Title = Template.bind({});
-Title.args = {
- title: "A simple title",
- link: "https://matrix.org",
- siteName: "matrix.org",
-};
-
-export const TitleAndDescription = Template.bind({});
-TitleAndDescription.args = {
- title: "A simple title",
- description: "A simple description with a link to https://matrix.org",
- link: "https://matrix.org",
- siteName: "matrix.org",
-};
-export const WithSiteIcon = Template.bind({});
-WithSiteIcon.args = {
- title: "A simple title",
- link: "https://matrix.org",
- siteName: "matrix.org",
- siteIcon: imageFile,
-};
-
-export const WithSiteIconAndDescription = Template.bind({});
-WithSiteIconAndDescription.args = {
- title: "A simple title",
- description: "A simple description with a link to https://matrix.org",
- link: "https://matrix.org",
- siteName: "matrix.org",
- siteIcon: imageFile,
-};
-
-export const WithTooltip = Template.bind({});
-WithTooltip.args = {
- title: "A simple title",
- description: "A simple description",
- showTooltipOnLink: true,
- link: "https://matrix.org",
- siteName: "matrix.org",
-};
-
-export const Article = Template.bind({});
-Article.args = {
- title: "A linked article",
- description:
- "This is a basic description returned from the linked source, usually with a word or two about what the link contains.",
- link: "https://matrix.org",
- siteName: "blog.example.org",
- image: {
- imageThumb: imageFileWide,
- imageFull: imageFileWide,
- alt: "A dog",
- playable: false,
- mxcImageFull: "mxc://server/file",
- },
-};
-
-export const Video = Template.bind({});
-Video.args = {
- title: "A linked video",
- description:
- "This is a link to a video. You cannot play the video inline yet, but you can click the play button to open the link",
- link: "https://matrix.org",
- siteName: "blog.example.org",
- image: {
- imageThumb: imageFileWide,
- imageFull: imageFileWide,
- alt: "A dog",
- playable: true,
- mxcImageFull: "mxc://server/file",
- },
-};
-
-export const Social = Template.bind({});
-Social.args = {
- description: "Sending a small message",
- link: "https://matrix.org",
- siteName: "socialsite.example.org",
- title: "Test user (@test)",
- author: "@test",
-};
-
-export const SocialWithImage = Template.bind({});
-SocialWithImage.args = {
- description: "Sending a message with an attached image.",
- title: "Test user (@test)",
- link: "https://matrix.org",
- siteName: "socialsite.example.org",
- author: "@test",
- image: {
- imageThumb: imageFileWide,
- imageFull: imageFileWide,
- alt: "A dog",
- playable: false,
- mxcImageFull: "mxc://server/file",
- },
-};
-
-export const WithVeryLongText = Template.bind({});
-WithVeryLongText.args = {
- title: "GitHub - element-hq/not-a-real-repo: A very very long PR title that should be rendered nicely",
- description:
- "This PR doesn't actually exist and neither does the repository. It might exist one day if we go into the business of making paradoxical repository names.",
- link: "https://matrix.org",
- siteName: "GitHub",
- image: {
- imageThumb: imageFile,
- imageFull: imageFile,
- alt: "Element logo",
- playable: false,
- mxcImageFull: "mxc://server/file",
- },
-};
-
-export const WithTallImage = Template.bind({});
-WithTallImage.args = {
- title: "A simple title",
- description: "A simple description",
- link: "https://matrix.org",
- siteName: "Site name",
- image: {
- imageThumb: imageFileTall,
- imageFull: imageFileTall,
- alt: "Element logo",
- playable: false,
- mxcImageFull: "mxc://server/file",
- },
-};
diff --git a/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.test.tsx b/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.test.tsx
deleted file mode 100644
index 1fa218b6ca8..00000000000
--- a/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.test.tsx
+++ /dev/null
@@ -1,45 +0,0 @@
-/*
- * Copyright 2026 Element Creations Ltd.
- *
- * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
- * Please see LICENSE files in the repository root for full details.
- */
-
-import { render, screen } from "@test-utils";
-import { composeStories } from "@storybook/react-vite";
-import { describe, it, expect } from "vitest";
-import React from "react";
-import userEvent from "@testing-library/user-event";
-
-import * as stories from "./LinkPreview.stories.tsx";
-
-const { Default, WithTooltip, Title, TitleAndDescription, Video } = composeStories(stories);
-
-describe("LinkPreview", () => {
- it("renders a preview", () => {
- const { container } = render();
- expect(container).toMatchSnapshot();
- });
- it("renders a preview with just a title", () => {
- const { container } = render();
- expect(container).toMatchSnapshot();
- });
- it("renders a preview with just a title and description", () => {
- const { container } = render();
- expect(container).toMatchSnapshot();
- });
- it("renders a preview with a tooltip", async () => {
- const user = userEvent.setup();
- render();
- await user.tab();
- expect(screen.getByText("A simple title")).toHaveFocus();
- // Tooltip has the URL
- expect(await screen.findByText("https://matrix.org/")).toBeVisible();
- });
- it("renders a playable preview that can be opened with a click", () => {
- const { container } = render();
- expect(container).toMatchSnapshot();
- const button = screen.getByLabelText("Open link");
- expect(button).toHaveAttribute("href", "https://matrix.org");
- });
-});
diff --git a/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.tsx b/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.tsx
deleted file mode 100644
index bf6f1098324..00000000000
--- a/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.tsx
+++ /dev/null
@@ -1,219 +0,0 @@
-/*
- * Copyright 2026 Element Creations Ltd.
- *
- * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
- * Please see LICENSE files in the repository root for full details.
- */
-
-import React, { type JSX } from "react";
-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";
-
-import { useI18n } from "../../../../../core/i18n/i18nContext";
-import type { UrlPreview } from "../types";
-import { LinkedText } from "../../../../../core/utils/LinkedText";
-import styles from "./LinkPreview.module.css";
-
-export interface LinkPreviewActions {
- onImageClick: () => void;
-}
-
-export interface AdditionalClasses {
- /*
- * Additional classes at add to the component
- */
- className?: string;
-}
-
-export type LinkPreviewProps = UrlPreview & LinkPreviewActions & { collapsed: boolean };
-
-export function LinkTitle({
- title,
- showTooltipOnLink,
- link,
- className,
-}: Pick & AdditionalClasses): JSX.Element {
- const caption = new URL(link).toString();
- const anchor = (
-
- {title}
-
- );
- return showTooltipOnLink ? {anchor} : anchor;
-}
-
-export function LinkSiteName({
- siteIcon,
- siteName,
- className,
-}: {
- siteIcon?: string;
- siteName: string;
-} & AdditionalClasses): JSX.Element {
- return (
-
- {siteIcon && }
-
- {siteName}
-
-
- );
-}
-
-/**
- * A condensed link preview that only contains the site icon, the title of the link and the site name.
- */
-function LinkPreviewInline({
- title,
- showTooltipOnLink,
- siteIcon,
- siteName,
- link,
-}: Omit): JSX.Element {
- return (
-
- {siteIcon && (
-
-
-
- )}
-
-
- {siteName && }
-
-
- );
-}
-
-/**
- * LinkPreview renders a single preview component for a single link on an event. It is usually rendered as part of
- * a `UrlPreviewGroupView`.
- */
-export function LinkPreview(props: LinkPreviewProps): JSX.Element {
- if (props.collapsed) {
- return ;
- } else {
- return ;
- }
-}
-
-function createImageClickHandler({ onImageClick, ...preview }: LinkPreviewProps): React.MouseEventHandler {
- return (ev) => {
- if (ev.button != 0 || ev.metaKey) return;
- ev.preventDefault();
-
- if (!preview.image?.imageFull) {
- return;
- }
- onImageClick();
- };
-}
-
-export function LinkPreviewCollapsed(preview: LinkPreviewProps): JSX.Element {
- const { translate: _t } = useI18n();
- let img: JSX.Element | undefined;
-
- if (preview.image && !preview.image.playable) {
- img = (
-
-
-
- );
- }
-
- return (
-
- {img}
-
-
- {preview.siteName && }
-
-
- );
-}
-
-export function LinkPreviewExpanded(preview: LinkPreviewProps): JSX.Element {
- const { translate: _t } = useI18n();
-
- if (!preview.image && !preview.author && !preview.description) {
- return ;
- }
-
- let img: JSX.Element | undefined;
-
- if (preview.image) {
- if (preview.image.playable) {
- // Playable media do not have clickable images so we don't
- // overlay buttons atop buttons, instead we render a
- // button for them to open the media.
- img = (
-
-
-
- );
- } else {
- // Otherwise, the preview can be clicked on.
- img = (
-
- );
- }
- }
-
- return (
-
-`;
diff --git a/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/index.ts b/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/index.ts
deleted file mode 100644
index 2b59dac4660..00000000000
--- a/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/index.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-/*
- * Copyright 2026 Element Creations Ltd.
- *
- * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
- * Please see LICENSE files in the repository root for full details.
- */
-
-export {
- UrlPreviewGroupView,
- type UrlPreviewGroupViewSnapshot,
- type UrlPreviewGroupViewProps,
- type UrlPreviewGroupViewActions,
- type UrlPreviewGroupViewModel,
-} from "./UrlPreviewGroupView";
-
-export { type UrlPreview } from "./types";
diff --git a/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/types.ts b/packages/shared-components/src/room/urlPreview.ts
similarity index 68%
rename from packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/types.ts
rename to packages/shared-components/src/room/urlPreview.ts
index aa5f5a554ed..695ec8c997e 100644
--- a/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/types.ts
+++ b/packages/shared-components/src/room/urlPreview.ts
@@ -82,3 +82,25 @@ export interface UrlPreview {
*/
author?: string;
}
+
+/** Snapshot data for the URL previews attached to an event. */
+export interface UrlPreviewGroupViewSnapshot {
+ /** URL previews to render. */
+ previews: Array;
+ /** Total number of previews available before limiting. */
+ totalPreviewCount: number;
+ /** Whether the preview list is currently limited. */
+ previewsLimited: boolean;
+ /** Whether more previews exist than are currently rendered. */
+ overPreviewLimit: boolean;
+}
+
+/** User actions accepted by the URL preview group. */
+export interface UrlPreviewGroupViewActions {
+ /** Invoked when the preview limit toggle is clicked. */
+ onTogglePreviewLimit: () => void;
+ /** Invoked when the hide-preview action is clicked. */
+ onHideClick: () => Promise;
+ /** Invoked when a preview image is clicked. */
+ onImageClick: (preview: UrlPreview) => void;
+}