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
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 56 KiB |
@@ -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": {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# static
|
||||
|
||||
This directory contains resources to be used in component stories and tests to demonstate behaviours (e.g. images used in previews).
|
||||
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 107 KiB |