Port URL Preview components to MVVM (#32525)

* Port over linkifyJS to shared-components.

* Drop rubbish

* update lock

* quickfix test

* drop group id

* Modernize tests

* Remove stories that aren't in use.

* Complete working version

* Add copyright

* tidy up

* update lock

* Update snaps

* update snap

* undo change

* remove unused

* More test updates

* fix typo

* fix margin on preview

* move margin block

* snapupdate

* prettier

* Port url preview logic to a view model.

* More fiddling with VM logic

* Note to self

* Refactor away into a shared component.

* Even more lovely lovely code that makes it look prettier

* translation cleanup

* Even more stuff that I need to fix yay

* Remove .last-run.json

* Update snaps

* Ensure we set showUrlPreview

* Cleanup tests

* lint + add png support

* Add a label

* Cleanup

* Add snaps

* Update snaps

* update playwright

* Refactors

* update snap

* Add missing snap

* Remove editing code (we check this in a better way in componentDidUpdate)

* Add README

* fix the one unused import

* Style shuffling

* Update vis tests

* Finally fix the tooltip

* Remove unused prop

* Add some padding

* fix lint issue

* Design improvements

* new screens

* Update snaps

* Fix CSS specificity

* Remove stale screenshot

* Rename function to match reality

* Port viewmodel tests to snapshots

* finish documenting types

* Stop being dangerous

* Use Linkify+decode for description

* Remove ability for VM to do linkifying.

* Port over linkifyJS to shared-components.

* Drop rubbish

* update lock

* quickfix test

* drop group id

* Modernize tests

* Remove stories that aren't in use.

* Complete working version

* Add copyright

* tidy up

* update lock

* Update snaps

* update snap

* undo change

* remove unused

* More test updates

* fix typo

* fix margin on preview

* move margin block

* snapupdate

* prettier

* cleanup a test mistake

* Fixup sonar issues

* Don't expose linkifyjs to applications, just provide helper functions.

* Add story for documentation.

* remove $

* Use a const

* typo

* cleanup var name

* remove console line

* Changes checkpoint

* Convert to context

* Revert unrelated change.

* more cleanup

* Add a test to cover ignoring incoming data elements

* Make tests happy

* Update tests for LinkedText

* Underlines!

* fix lock

* remove unused linkify packages

* import move

* Remove mod to remove underline

* undo

* fix snap

* another snapshot fix

* More cleanup

* Tidy up based on review.

* fix story

* Pass in args

* update snap

* cleanup

* use source image

* oops

* remove client peg

* Remove unused state

* tidy up code

* Ensure we update the preview when the event content may have changed.

* s/global/globalThis/

* Ensure we don't stretch images

* Update screenshots

* Cleanup
This commit is contained in:
Will Hunt
2026-03-16 10:05:34 +00:00
committed by GitHub
parent b54e4e3b98
commit 3b4027846d
43 changed files with 2244 additions and 500 deletions
+7
View File
@@ -1,5 +1,6 @@
/*
Copyright 2025 New Vector Ltd.
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.
@@ -12,3 +13,9 @@ declare module "*.md?raw" {
const content: string;
export default content;
}
// For importing PNGs for use in testing
declare module "*.png" {
const content: string;
export default content;
}
@@ -0,0 +1,85 @@
/*
* 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.
*/
.thumbnail {
/* Thumbnails are always limited to a maximum of 100px */
max-width: 100px;
max-height: 100px;
/* Ensure we don't stretch the image */
object-fit: cover;
}
.link {
color: var(--cpd-color-text-link-external);
text-decoration-line: none;
}
.container {
display: inline flex;
column-gap: var(--cpd-space-1x);
border-inline-start: 2px solid var(--cpd-color-bg-subtle-primary);
border-radius: 2px;
color: var(--cpd-color-gray-900);
.wrapImageCaption {
display: inline-flex;
flex-direction: row;
flex-wrap: wrap;
row-gap: var(--cpd-space-2x);
flex: 1;
}
.image,
.caption {
display: inline-flex;
flex-direction: column;
margin-inline-start: var(--cpd-space-4x);
min-width: 0; /* Prevent blowout */
}
.image {
/* Clear default <button> styles */
padding: 0;
border: none;
background: none;
/* Consistent image size */
flex: 0 0 100px;
}
.caption {
flex: 1;
overflow: hidden; /* cause it to wrap rather than clip */
}
.title,
.description {
display: inline-block;
-webkit-box-orient: vertical;
overflow: hidden;
white-space: normal;
}
.title {
display: inline-block;
line-clamp: 2;
-webkit-line-clamp: 2;
margin: var(--cpd-space-1x) 0;
> a {
font-weight: var(--cpd-font-weight-semibold);
text-decoration: none;
}
}
.description {
margin: var(--cpd-space-1x) 0;
word-wrap: break-word;
line-clamp: 3;
-webkit-line-clamp: 3;
}
}
@@ -0,0 +1,75 @@
/*
* 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 imageFile from "../../../static/element.png";
import { LinkPreview } from "./LinkPreview";
import { LinkedTextContext } from "../../utils/LinkedText";
export default {
title: "Event/UrlPreviewView",
component: LinkPreview,
tags: ["autodocs"],
args: {
onImageClick: fn(),
},
} satisfies Meta<typeof LinkPreview>;
const Template: StoryFn<typeof LinkPreview> = (args) => (
<LinkedTextContext.Provider value={{}}>
<LinkPreview {...args} />
</LinkedTextContext.Provider>
);
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,
},
};
export const Title = Template.bind({});
Title.args = {
title: "A simple title",
link: "https://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",
};
export const WithTooltip = Template.bind({});
WithTooltip.args = {
title: "A simple title",
description: "A simple description",
showTooltipOnLink: true,
link: "https://matrix.org",
};
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,
},
};
@@ -0,0 +1,39 @@
/*
* 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 } = composeStories(stories);
describe("LinkPreview", () => {
it("renders a preview", () => {
const { container } = render(<Default />);
expect(container).toMatchSnapshot();
});
it("renders a preview with just a title", () => {
const { container } = render(<Title />);
expect(container).toMatchSnapshot();
});
it("renders a preview with just a title and description", () => {
const { container } = render(<TitleAndDescription />);
expect(container).toMatchSnapshot();
});
it("renders a preview with a tooltip", async () => {
const user = userEvent.setup();
render(<WithTooltip />);
await user.tab();
expect(screen.getByText("A simple title")).toHaveFocus();
// Tooltip has the URL
expect(await screen.findByText("https://matrix.org/")).toBeVisible();
});
});
@@ -0,0 +1,89 @@
/*
* 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 MouseEventHandler, type JSX, useCallback, useMemo } from "react";
import { Tooltip, Text } from "@vector-im/compound-web";
import classNames from "classnames";
import { useI18n } from "../../utils/i18nContext";
import styles from "./LinkPreview.module.css";
import type { UrlPreviewViewSnapshotPreview } from "./types";
import { LinkedText } from "../../utils/LinkedText";
export interface LinkPreviewActions {
onImageClick: () => void;
}
export type LinkPreviewProps = UrlPreviewViewSnapshotPreview & LinkPreviewActions;
/**
* 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({ onImageClick, ...preview }: LinkPreviewProps): JSX.Element {
const { translate: _t } = useI18n();
const tooltipCaption = useMemo(() => {
if (preview.showTooltipOnLink) {
return new URL(preview.link, window.location.href).toString();
}
return null;
}, [preview.link, preview.showTooltipOnLink]);
const onImageClickHandler = useCallback<MouseEventHandler>(
(ev) => {
if (ev.button != 0 || ev.metaKey) return;
ev.preventDefault();
if (!preview.image?.imageFull) {
return;
}
onImageClick();
},
[preview.image?.imageFull, onImageClick],
);
let img: JSX.Element | undefined;
// Don't render a button to show the image, just hide it outright
if (preview.image?.imageThumb) {
img = (
<button
aria-label={_t("timeline|url_preview|view_image")}
className={styles.image}
onClick={onImageClickHandler}
>
<img className={styles.thumbnail} src={preview.image.imageThumb} alt="" />
</button>
);
}
const anchor = (
<a className={styles.link} href={preview.link} target="_blank" rel="noreferrer noopener">
{preview.title}
</a>
);
return (
<div className={classNames(styles.container)}>
<div className={styles.wrapImageCaption}>
{img}
<div className={styles.caption}>
<Text type="body" size="md" className={styles.title}>
{tooltipCaption ? <Tooltip label={tooltipCaption}>{anchor}</Tooltip> : anchor}
{preview.siteName && (
<Text as="span" size="md" weight="regular">
{" - " + preview.siteName}
</Text>
)}
</Text>
{preview.description && (
<LinkedText className={styles.description}>{preview.description}</LinkedText>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,33 @@
/*
* 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.
*/
.previewGroup {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--cpd-space-4x);
margin-bottom: var(--cpd-space-4x);
&.compactLayout {
gap: var(--cpd-space-2x);
margin-bottom: var(--cpd-space-2x);
}
.toggleButton[data-kind="tertiary"] {
margin-left: auto;
margin-right: auto;
text-decoration: none;
color: var(--cpd-color-icon-accent-primary);
font-weight: var(--cpd-font-weight-regular);
}
}
.wrapper {
margin-top: var(--cpd-space-4x);
display: flex;
flex-direction: row;
}
@@ -0,0 +1,129 @@
/*
* 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 { fn } from "storybook/test";
import imageFile from "../../../static/element.png";
import tallImageFile from "../../../static/tallImage.png";
import type { Meta, StoryFn } from "@storybook/react-vite";
import {
UrlPreviewGroupView,
type UrlPreviewGroupViewActions,
type UrlPreviewGroupViewSnapshot,
} from "./UrlPreviewGroupView";
import { useMockedViewModel } from "../../viewmodel";
import { LinkedTextContext } from "../../utils/LinkedText";
type UrlPreviewGroupViewProps = UrlPreviewGroupViewSnapshot & UrlPreviewGroupViewActions;
const UrlPreviewGroupViewWrapper = ({
onHideClick,
onImageClick,
onTogglePreviewLimit,
...rest
}: UrlPreviewGroupViewProps): JSX.Element => {
const vm = useMockedViewModel(rest, {
onHideClick,
onImageClick,
onTogglePreviewLimit,
});
return (
<LinkedTextContext.Provider value={{}}>
<UrlPreviewGroupView vm={vm} />
</LinkedTextContext.Provider>
);
};
export default {
title: "Event/UrlPreviewGroupView",
component: UrlPreviewGroupViewWrapper,
tags: ["autodocs"],
args: {
onHideClick: fn(),
onImageClick: fn(),
onTogglePreviewLimit: fn(),
},
} satisfies Meta<typeof UrlPreviewGroupViewWrapper>;
const Template: StoryFn<typeof UrlPreviewGroupViewWrapper> = (args) => <UrlPreviewGroupViewWrapper {...args} />;
export const Default = Template.bind({});
Default.args = {
previews: [
{
title: "A simple title",
description: "A simple description",
link: "https://matrix.org",
image: {
imageThumb: imageFile,
imageFull: imageFile,
},
},
],
};
export const MultiplePreviewsHidden = Template.bind({});
MultiplePreviewsHidden.args = {
previews: [
{
title: "A simple title",
description: "A simple description",
link: "https://matrix.org",
image: {
imageThumb: imageFile,
imageFull: imageFile,
},
},
],
overPreviewLimit: true,
previewsLimited: true,
totalPreviewCount: 10,
};
export const MultiplePreviewsVisible = Template.bind({});
MultiplePreviewsVisible.args = {
previews: [
{
title: "One",
description: "A regular square image.",
link: "https://matrix.org",
image: {
imageThumb: imageFile,
imageFull: imageFile,
},
},
// These images should appear the same size despite having different dimensions.
{
title: "Two",
description: "This one has a taller image which should crop nicely.",
link: "https://matrix.org",
image: {
imageThumb: tallImageFile,
imageFull: tallImageFile,
},
},
{
title: "Three",
description: "One more description",
link: "https://matrix.org",
image: {
imageThumb: imageFile,
imageFull: imageFile,
},
},
],
overPreviewLimit: true,
previewsLimited: false,
totalPreviewCount: 10,
};
export const WithCompactView = Template.bind({});
WithCompactView.args = {
...MultiplePreviewsVisible.args,
compactLayout: true,
};
@@ -0,0 +1,34 @@
/*
* 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 } from "@test-utils";
import { composeStories } from "@storybook/react-vite";
import { describe, it, expect } from "vitest";
import React from "react";
import * as stories from "./UrlPreviewGroupView.stories.tsx";
const { Default, MultiplePreviewsHidden, MultiplePreviewsVisible, WithCompactView } = composeStories(stories);
describe("UrlPreviewGroupView", () => {
it("renders a single preview", () => {
const { container } = render(<Default />);
expect(container).toMatchSnapshot();
});
it("renders multiple previews", () => {
const { container } = render(<MultiplePreviewsVisible />);
expect(container).toMatchSnapshot();
});
it("renders multiple previews which are hidden", () => {
const { container } = render(<MultiplePreviewsHidden />);
expect(container).toMatchSnapshot();
});
it("renders with a compact view", () => {
const { container } = render(<WithCompactView />);
expect(container).toMatchSnapshot();
});
});
@@ -0,0 +1,71 @@
/*
* 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 { Button, IconButton } from "@vector-im/compound-web";
import CloseIcon from "@vector-im/compound-design-tokens/assets/web/icons/close";
import classNames from "classnames";
import { useViewModel, type ViewModel } from "../../viewmodel";
import { useI18n } from "../../utils/i18nContext";
import type { UrlPreviewViewSnapshotPreview } from "./types";
import { LinkPreview } from "./LinkPreview";
import styles from "./UrlPreviewGroupView.module.css";
export interface UrlPreviewGroupViewSnapshot {
previews: Array<UrlPreviewViewSnapshotPreview>;
totalPreviewCount: number;
previewsLimited: boolean;
overPreviewLimit: boolean;
compactLayout: boolean;
}
export interface UrlPreviewGroupViewProps {
vm: ViewModel<UrlPreviewGroupViewSnapshot> & UrlPreviewGroupViewActions;
}
export interface UrlPreviewGroupViewActions {
onTogglePreviewLimit: () => void;
onHideClick: () => Promise<void>;
onImageClick: (preview: UrlPreviewViewSnapshotPreview) => void;
}
/**
* UrlPreviewGroupView renders a list of URL previews for a single event.
*/
export function UrlPreviewGroupView({ vm }: UrlPreviewGroupViewProps): JSX.Element | null {
const { translate: _t } = useI18n();
const { previews, totalPreviewCount, previewsLimited, overPreviewLimit, compactLayout } = useViewModel(vm);
if (previews.length === 0) {
return null;
}
let toggleButton: JSX.Element | undefined;
if (overPreviewLimit) {
toggleButton = (
<Button className={styles.toggleButton} kind="tertiary" size="sm" onClick={vm.onTogglePreviewLimit}>
{previewsLimited
? _t("timeline|url_preview|show_n_more", { count: totalPreviewCount - previews.length })
: _t("action|collapse")}
</Button>
);
}
return (
<div className={styles.wrapper}>
<div className={classNames(styles.previewGroup, compactLayout && styles.compactLayout)}>
{previews.map((preview) => (
<LinkPreview key={preview.link} onImageClick={() => vm.onImageClick(preview)} {...preview} />
))}
{toggleButton}
</div>
<IconButton size="20px" onClick={vm.onHideClick} aria-label={_t("timeline|url_preview|close")}>
<CloseIcon />
</IconButton>
</div>
);
}
@@ -0,0 +1,121 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`LinkPreview > renders a preview 1`] = `
<div>
<div
class="container"
>
<div
class="wrapImageCaption"
>
<button
aria-label="View image"
class="image"
>
<img
alt=""
class="thumbnail"
src="/static/element.png"
/>
</button>
<div
class="caption"
>
<p
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 title"
>
<a
class="link"
href="https://matrix.org"
rel="noreferrer noopener"
target="_blank"
>
A simple title
</a>
<span
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50"
>
- Site name
</span>
</p>
<p
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 container description"
>
A simple description
</p>
</div>
</div>
</div>
</div>
`;
exports[`LinkPreview > renders a preview with just a title 1`] = `
<div>
<div
class="container"
>
<div
class="wrapImageCaption"
>
<div
class="caption"
>
<p
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 title"
>
<a
class="link"
href="https://matrix.org"
rel="noreferrer noopener"
target="_blank"
>
A simple title
</a>
</p>
</div>
</div>
</div>
</div>
`;
exports[`LinkPreview > renders a preview with just a title and description 1`] = `
<div>
<div
class="container"
>
<div
class="wrapImageCaption"
>
<div
class="caption"
>
<p
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 title"
>
<a
class="link"
href="https://matrix.org"
rel="noreferrer noopener"
target="_blank"
>
A simple title
</a>
</p>
<p
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 container description"
>
A simple description with a link to
<a
data-linkified="true"
href="https://matrix.org"
rel="noreferrer noopener"
target="_blank"
>
https://matrix.org
</a>
</p>
</div>
</div>
</div>
</div>
`;
@@ -0,0 +1,492 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`UrlPreviewGroupView > renders a single preview 1`] = `
<div>
<div
class="wrapper"
>
<div
class="previewGroup"
>
<div
class="container"
>
<div
class="wrapImageCaption"
>
<button
aria-label="View image"
class="image"
>
<img
alt=""
class="thumbnail"
src="/static/element.png"
/>
</button>
<div
class="caption"
>
<p
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 title"
>
<a
class="link"
href="https://matrix.org"
rel="noreferrer noopener"
target="_blank"
>
A simple title
</a>
</p>
<p
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 container description"
>
A simple description
</p>
</div>
</div>
</div>
</div>
<button
aria-label="Close preview"
class="_icon-button_1215g_8"
data-kind="primary"
role="button"
style="--cpd-icon-button-size: 20px;"
tabindex="0"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6.293 6.293a1 1 0 0 1 1.414 0L12 10.586l4.293-4.293a1 1 0 1 1 1.414 1.414L13.414 12l4.293 4.293a1 1 0 0 1-1.414 1.414L12 13.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L10.586 12 6.293 7.707a1 1 0 0 1 0-1.414"
/>
</svg>
</div>
</button>
</div>
</div>
`;
exports[`UrlPreviewGroupView > renders multiple previews 1`] = `
<div>
<div
class="wrapper"
>
<div
class="previewGroup"
>
<div
class="container"
>
<div
class="wrapImageCaption"
>
<button
aria-label="View image"
class="image"
>
<img
alt=""
class="thumbnail"
src="/static/element.png"
/>
</button>
<div
class="caption"
>
<p
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 title"
>
<a
class="link"
href="https://matrix.org"
rel="noreferrer noopener"
target="_blank"
>
One
</a>
</p>
<p
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 container description"
>
A regular square image.
</p>
</div>
</div>
</div>
<div
class="container"
>
<div
class="wrapImageCaption"
>
<button
aria-label="View image"
class="image"
>
<img
alt=""
class="thumbnail"
src="/static/tallImage.png"
/>
</button>
<div
class="caption"
>
<p
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 title"
>
<a
class="link"
href="https://matrix.org"
rel="noreferrer noopener"
target="_blank"
>
Two
</a>
</p>
<p
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 container description"
>
This one has a taller image which should crop nicely.
</p>
</div>
</div>
</div>
<div
class="container"
>
<div
class="wrapImageCaption"
>
<button
aria-label="View image"
class="image"
>
<img
alt=""
class="thumbnail"
src="/static/element.png"
/>
</button>
<div
class="caption"
>
<p
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 title"
>
<a
class="link"
href="https://matrix.org"
rel="noreferrer noopener"
target="_blank"
>
Three
</a>
</p>
<p
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 container description"
>
One more description
</p>
</div>
</div>
</div>
<button
class="_button_13vu4_8 toggleButton"
data-kind="tertiary"
data-size="sm"
role="button"
tabindex="0"
>
Collapse
</button>
</div>
<button
aria-label="Close preview"
class="_icon-button_1215g_8"
data-kind="primary"
role="button"
style="--cpd-icon-button-size: 20px;"
tabindex="0"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6.293 6.293a1 1 0 0 1 1.414 0L12 10.586l4.293-4.293a1 1 0 1 1 1.414 1.414L13.414 12l4.293 4.293a1 1 0 0 1-1.414 1.414L12 13.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L10.586 12 6.293 7.707a1 1 0 0 1 0-1.414"
/>
</svg>
</div>
</button>
</div>
</div>
`;
exports[`UrlPreviewGroupView > renders multiple previews which are hidden 1`] = `
<div>
<div
class="wrapper"
>
<div
class="previewGroup"
>
<div
class="container"
>
<div
class="wrapImageCaption"
>
<button
aria-label="View image"
class="image"
>
<img
alt=""
class="thumbnail"
src="/static/element.png"
/>
</button>
<div
class="caption"
>
<p
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 title"
>
<a
class="link"
href="https://matrix.org"
rel="noreferrer noopener"
target="_blank"
>
A simple title
</a>
</p>
<p
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 container description"
>
A simple description
</p>
</div>
</div>
</div>
<button
class="_button_13vu4_8 toggleButton"
data-kind="tertiary"
data-size="sm"
role="button"
tabindex="0"
>
Show 9 other previews
</button>
</div>
<button
aria-label="Close preview"
class="_icon-button_1215g_8"
data-kind="primary"
role="button"
style="--cpd-icon-button-size: 20px;"
tabindex="0"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6.293 6.293a1 1 0 0 1 1.414 0L12 10.586l4.293-4.293a1 1 0 1 1 1.414 1.414L13.414 12l4.293 4.293a1 1 0 0 1-1.414 1.414L12 13.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L10.586 12 6.293 7.707a1 1 0 0 1 0-1.414"
/>
</svg>
</div>
</button>
</div>
</div>
`;
exports[`UrlPreviewGroupView > renders with a compact view 1`] = `
<div>
<div
class="wrapper"
>
<div
class="previewGroup compactLayout"
>
<div
class="container"
>
<div
class="wrapImageCaption"
>
<button
aria-label="View image"
class="image"
>
<img
alt=""
class="thumbnail"
src="/static/element.png"
/>
</button>
<div
class="caption"
>
<p
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 title"
>
<a
class="link"
href="https://matrix.org"
rel="noreferrer noopener"
target="_blank"
>
One
</a>
</p>
<p
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 container description"
>
A regular square image.
</p>
</div>
</div>
</div>
<div
class="container"
>
<div
class="wrapImageCaption"
>
<button
aria-label="View image"
class="image"
>
<img
alt=""
class="thumbnail"
src="/static/tallImage.png"
/>
</button>
<div
class="caption"
>
<p
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 title"
>
<a
class="link"
href="https://matrix.org"
rel="noreferrer noopener"
target="_blank"
>
Two
</a>
</p>
<p
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 container description"
>
This one has a taller image which should crop nicely.
</p>
</div>
</div>
</div>
<div
class="container"
>
<div
class="wrapImageCaption"
>
<button
aria-label="View image"
class="image"
>
<img
alt=""
class="thumbnail"
src="/static/element.png"
/>
</button>
<div
class="caption"
>
<p
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 title"
>
<a
class="link"
href="https://matrix.org"
rel="noreferrer noopener"
target="_blank"
>
Three
</a>
</p>
<p
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 container description"
>
One more description
</p>
</div>
</div>
</div>
<button
class="_button_13vu4_8 toggleButton"
data-kind="tertiary"
data-size="sm"
role="button"
tabindex="0"
>
Collapse
</button>
</div>
<button
aria-label="Close preview"
class="_icon-button_1215g_8"
data-kind="primary"
role="button"
style="--cpd-icon-button-size: 20px;"
tabindex="0"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6.293 6.293a1 1 0 0 1 1.414 0L12 10.586l4.293-4.293a1 1 0 1 1 1.414 1.414L13.414 12l4.293 4.293a1 1 0 0 1-1.414 1.414L12 13.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L10.586 12 6.293 7.707a1 1 0 0 1 0-1.414"
/>
</svg>
</div>
</button>
</div>
</div>
`;
@@ -0,0 +1,15 @@
/*
* 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,
} from "./UrlPreviewGroupView";
export { type UrlPreviewViewSnapshotPreview } from "./types";
@@ -0,0 +1,54 @@
/*
* 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 interface UrlPreviewViewSnapshotPreview {
/**
* The URL for the preview.
*/
link: string;
/**
* Should the link have a tooltip. Should be `true` if the platform does not provide a tooltip.
*/
showTooltipOnLink?: boolean;
/**
* The title of the page being previewed.
*/
title: string;
/**
* The site name to be displayed alongside the title.
*/
siteName?: string;
/**
* Description of the site. May contain links.
*/
description?: string;
/**
* Preview image to display.
*/
image?: {
/**
* The HTTP URI of the the thumbnail.
*/
imageThumb: string;
/**
* The HTTP URI of the full image.
*/
imageFull: string;
/**
* File size in bytes.
*/
fileSize?: number;
/**
* The width of the thumbnail. Must not exceed 100px.
*/
width?: number;
/**
* The height of the thumbnail. Must not exceed 100px.
*/
height?: number;
};
}
@@ -4,6 +4,7 @@
},
"action": {
"back": "Back",
"collapse": "Collapse",
"delete": "Delete",
"dismiss": "Dismiss",
"download": "Download",
@@ -184,7 +185,15 @@
"unsupported": "The encryption used by this room isn't supported."
},
"message_timestamp_received_at": "Received at: %(dateTime)s",
"message_timestamp_sent_at": "Sent at: %(dateTime)s"
"message_timestamp_sent_at": "Sent at: %(dateTime)s",
"url_preview": {
"close": "Close preview",
"show_n_more": {
"one": "Show %(count)s other preview",
"other": "Show %(count)s other previews"
},
"view_image": "View image"
}
},
"widget": {
"context_menu": {
+1
View File
@@ -16,6 +16,7 @@ export * from "./crypto/SasEmoji";
export * from "./event-tiles/EncryptionEventView";
export * from "./event-tiles/EventTileBubble";
export * from "./event-tiles/TextualEventView";
export * from "./event-tiles/UrlPreviewView";
export * from "./message-body/EventContentBody";
export * from "./message-body/MediaBody";
export * from "./message-body/MessageTimestampView";