Refactor away into a shared component.

This commit is contained in:
Half-Shot 2026-02-17 13:15:57 +00:00
parent 3eb0c6a260
commit 3005ceff73
15 changed files with 670 additions and 55 deletions

View File

@ -1,20 +1,24 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2015, 2016 OpenMarket 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.
*/
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 {
max-width: 100px;
max-height: 100px;
}
.mx_LinkPreviewWidget {
margin: $spacing-16 0 $spacing-16 auto;
.container {
margin: var(--cpd-space-4x) 0 var(--cpd-space-4x) auto;
display: flex;
column-gap: $spacing-4;
border-inline-start: 2px solid $preview-widget-bar-color;
column-gap: var(--cpd-space-1x);
border-inline-start: 2px solid var(--cpd-color-bg-subtle-primary);
border-radius: 2px;
color: $info-plinth-fg-color;
.mx_MatrixChat_useCompactLayout & {
&.compactLayout & {
margin-top: 6px;
margin-bottom: 6px;
}
@ -23,12 +27,12 @@ Please see LICENSE files in the repository root for full details.
.mx_LinkPreviewWidget_wrapImageCaption {
display: flex;
flex-wrap: wrap;
row-gap: $spacing-8;
row-gap: var(--cpd-space-2x);
flex: 1;
.mx_LinkPreviewWidget_image,
.mx_LinkPreviewWidget_caption {
margin-inline-start: $spacing-16;
margin-inline-start: var(--cpd-space-4x);
min-width: 0; /* Prevent blowout */
}
@ -61,7 +65,7 @@ Please see LICENSE files in the repository root for full details.
}
.mx_LinkPreviewWidget_description {
margin-top: $spacing-8;
margin-top: var(--cpd-space-2x);
word-wrap: break-word;
-webkit-line-clamp: 3;
}

View File

@ -7,7 +7,8 @@ Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX, createRef, type SyntheticEvent, type MouseEvent } from "react";
import { MsgType } from "matrix-js-sdk/src/matrix";
import { MatrixEvent, MsgType } from "matrix-js-sdk/src/matrix";
import type { UrlPreviewViewSnapshotPreview } from "@element-hq/web-shared-components";
import EventContentBody from "./EventContentBody.tsx";
import { formatDate } from "../../../DateUtils";
@ -21,7 +22,6 @@ import { Action } from "../../../dispatcher/actions";
import QuestionDialog from "../dialogs/QuestionDialog";
import MessageEditHistoryDialog from "../dialogs/MessageEditHistoryDialog";
import EditMessageComposer from "../rooms/EditMessageComposer";
import LinkPreviewGroup from "../rooms/LinkPreviewGroup";
import { type IBodyProps } from "./IBodyProps";
import RoomContext from "../../../contexts/RoomContext";
import AccessibleButton from "../elements/AccessibleButton";
@ -30,6 +30,23 @@ import { EditWysiwygComposer } from "../rooms/wysiwyg_composer";
import { type IEventTileOps } from "../rooms/EventTile";
import { UrlPreviewViewModel } from "../../../viewmodels/message-body/UrlPreviewViewModel.ts";
import { MatrixClientPeg } from "../../../MatrixClientPeg.ts";
import { useMediaVisible } from "../../../hooks/useMediaVisible.ts";
/**
* Wrapper component for LinkPreviewGroup. Can be removed when TextualBody is ported to a
* functional component.
*/
function LinkPreviewGroupWrapper({
vm,
mxEvent,
}: {
vm: UrlPreviewViewModel;
mxEvent: MatrixEvent;
}): React.ReactElement {
const [mediaVisible] = useMediaVisible(mxEvent);
return <LinkPreviewGroup mediaVisible={mediaVisible} vm={vm} />;
}
export default class TextualBody extends React.Component<IBodyProps> {
private readonly contentRef = createRef<HTMLDivElement>();
@ -44,6 +61,40 @@ export default class TextualBody extends React.Component<IBodyProps> {
}
}
public readonly onUrlPreviewImageClicked = (preview: UrlPreviewViewSnapshotPreview): void => {
/* private onImageClick = (ev: React.MouseEvent): void => {
const p = this.props.preview;
if (ev.button != 0 || ev.metaKey) return;
ev.preventDefault();
if (!p.image?.imageFull) {
return;
}
const params: Omit<ComponentProps<typeof ImageView>, "onFinished"> = {
src: p.image.imageFull,
width: p.image.width,
height: p.image.height,
name: p.title,
fileSize: p.image.size,
link: p.link,
};
if (this.image.current) {
const clientRect = this.image.current.getBoundingClientRect();
params.thumbnailInfo = {
width: clientRect.width,
height: clientRect.height,
positionX: clientRect.x,
positionY: clientRect.y,
};
}
Modal.createDialog(ImageView, params, "mx_Dialog_lightbox", undefined, true);
}; */
};
public componentDidUpdate(prevProps: Readonly<IBodyProps>): void {
// TODO: This is crap crap crap, we should delegate this sort of logic to the
// VM and have it figure this out.
@ -56,6 +107,7 @@ export default class TextualBody extends React.Component<IBodyProps> {
eventRef: this.contentRef,
eventSendTime: this.props.mxEvent.getTs(),
eventId: this.props.mxEvent.getId(),
onImageClicked: this.onUrlPreviewImageClicked.bind(this),
});
this.urlPreviewVMRef.current = urlPreviewVM;
} else if (!this.props.editState) {
@ -286,7 +338,7 @@ export default class TextualBody extends React.Component<IBodyProps> {
}
const urlPreviewWidget = this.urlPreviewVMRef.current && (
<LinkPreviewGroup vm={this.urlPreviewVMRef.current} mxEvent={mxEvent} />
<LinkPreviewGroupWrapper vm={this.urlPreviewVMRef.current} mxEvent={mxEvent} />
);
if (isEmote) {

View File

@ -5,14 +5,22 @@
* Please see LICENSE files in the repository root for full details.
*/
import { BaseViewModel } from "@element-hq/web-shared-components";
import { decode } from "html-entities";
import {
BaseViewModel,
type UrlPreviewGroupViewSnapshot,
type UrlPreviewGroupViewActions,
type UrlPreviewViewSnapshotPreview,
} from "@element-hq/web-shared-components";
// import { decode } from "html-entities";
import { logger as rootLogger } from "matrix-js-sdk/src/logger";
import { type IPreviewUrlResponse, type MatrixClient, MatrixError } from "matrix-js-sdk/src/matrix";
import type { RefObject } from "react";
import { isPermalinkHost } from "../../utils/permalinks/Permalinks";
import { mediaFromMxc } from "../../customisations/Media";
import { linkifyAndSanitizeHtml } from "../../Linkify";
import PlatformPeg from "../../PlatformPeg";
import { thumbHeight } from "../../ImageUtils";
const logger = rootLogger.getChild("UrlPreviewViewModel");
@ -21,28 +29,7 @@ export interface UrlPreviewViewModelProps {
eventSendTime: number;
eventRef: RefObject<HTMLDivElement | null>;
eventId?: string;
}
export interface UrlPreviewViewSnapshotInterfacePreview {
link: string;
title: string;
siteName?: string;
description?: string;
image?: {
imageThumb: string;
imageFull: string;
size?: number;
width?: number;
height?: number;
};
}
interface UrlPreviewViewSnapshotInterface {
previews: Array<UrlPreviewViewSnapshotInterfacePreview>;
hidden: boolean;
totalPreviewCount: number;
previewsLimited: boolean;
overPreviewLimit: boolean;
onImageClicked: (preview: UrlPreviewViewSnapshotPreview) => void;
}
export const MAX_PREVIEWS_WHEN_LIMITED = 2;
@ -74,7 +61,10 @@ function getTitleFromOpenGraph(response: IPreviewUrlResponse, link: string): str
/**
* ViewModel for fetching and rendering room previews.
*/
export class UrlPreviewViewModel extends BaseViewModel<UrlPreviewViewSnapshotInterface, UrlPreviewViewModelProps> {
export class UrlPreviewViewModel
extends BaseViewModel<UrlPreviewGroupViewSnapshot, UrlPreviewViewModelProps>
implements UrlPreviewGroupViewActions
{
private static isLinkPreviewable(node: Element): boolean {
// don't try to preview relative links
const href = node.getAttribute("href");
@ -129,7 +119,7 @@ export class UrlPreviewViewModel extends BaseViewModel<UrlPreviewViewSnapshotInt
return [...links];
}
private async fetchPreview(link: string, ts: number): Promise<UrlPreviewViewSnapshotInterfacePreview | null> {
private async fetchPreview(link: string, ts: number): Promise<UrlPreviewViewSnapshotPreview | null> {
const cached = this.previewCache.get(link);
if (cached) {
return cached;
@ -143,24 +133,37 @@ export class UrlPreviewViewModel extends BaseViewModel<UrlPreviewViewSnapshotInt
if (!hasTitle || !hasDescription || !hasImage) {
return null;
}
const media =
typeof preview["og:image"] === "string" ? mediaFromMxc(preview["og:image"], this.client) : undefined;
const title = getTitleFromOpenGraph(preview, link);
const needsTooltip = link !== title && PlatformPeg.get()?.needsUrlTooltips();
// TODO: Magic numbers
const imageMaxHeight = 100;
const declaredHeight = getNumberFromOpenGraph(preview["og:image:height"]);
const width = Math.min(getNumberFromOpenGraph(preview["og:image:width"]) || 101, 100);
const height = thumbHeight(width, declaredHeight, imageMaxHeight, imageMaxHeight) ?? imageMaxHeight;
const result = {
link,
title: getTitleFromOpenGraph(preview, link),
title,
showTooltipOnLink: needsTooltip,
description:
typeof preview["og:description"] === "string" ? decode(preview["og:description"]) : undefined,
typeof preview["og:description"] === "string"
? linkifyAndSanitizeHtml(preview["og:description"])
: undefined,
image: media
? {
// TODO: Check nulls
imageThumb: media.getThumbnailHttp(PREVIEW_WIDTH, PREVIEW_HEIGHT, "scale")!,
imageFull: media.srcHttp!,
width: getNumberFromOpenGraph(preview["og:image:width"]),
height: getNumberFromOpenGraph(preview["og:image:height"]),
size: getNumberFromOpenGraph(preview["matrix:image:size"]),
width,
height,
fileSize: getNumberFromOpenGraph(preview["matrix:image:size"]),
}
: undefined,
};
} satisfies UrlPreviewViewSnapshotPreview;
this.previewCache.set(link, result);
return result;
} catch (error) {
@ -180,7 +183,8 @@ export class UrlPreviewViewModel extends BaseViewModel<UrlPreviewViewSnapshotInt
private showUrlPreview: boolean;
private readonly storageKey: string;
private limitPreviews = true;
private previewCache = new Map<string, UrlPreviewViewSnapshotInterfacePreview>();
private previewCache = new Map<string, UrlPreviewViewSnapshotPreview>();
private readonly onImageClicked: (preview: UrlPreviewViewSnapshotPreview) => void;
public constructor(props: UrlPreviewViewModelProps) {
super(props, {
@ -195,6 +199,7 @@ export class UrlPreviewViewModel extends BaseViewModel<UrlPreviewViewSnapshotInt
this.eventSendTime = props.eventSendTime;
this.storageKey = props.eventId ?? `hide_preview_${props.eventId}`;
this.showUrlPreview = window.localStorage.getItem(this.storageKey) !== "1";
this.onImageClicked = props.onImageClicked;
void this.computeSnapshot();
}
@ -240,22 +245,27 @@ export class UrlPreviewViewModel extends BaseViewModel<UrlPreviewViewSnapshotInt
void this.computeSnapshot();
}
public onShowClick(): void {
public readonly onShowClick = (): void => {
this.showUrlPreview = true;
void this.computeSnapshot();
// FIXME: persist this somewhere smarter than local storage
global.localStorage?.removeItem(this.storageKey);
}
};
public onHideClick(): void {
public readonly onHideClick = (): void => {
this.showUrlPreview = false;
void this.computeSnapshot();
// FIXME: persist this somewhere smarter than local storage
global.localStorage?.setItem(this.storageKey, "1");
}
};
public onTogglePreviewLimit(): void {
public readonly onTogglePreviewLimit = (): void => {
this.limitPreviews = !this.limitPreviews;
void this.computeSnapshot();
}
};
public readonly onImageClick = (preview: UrlPreviewViewSnapshotPreview): void => {
// Render a lightbox.
this.onImageClicked(preview);
};
}

View File

@ -0,0 +1,73 @@
/*
* 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 {
max-width: 100px;
max-height: 100px;
}
.container {
margin: var(--cpd-space-4x) 0 var(--cpd-space-4x) auto;
display: flex;
column-gap: var(--cpd-space-1x);
border-inline-start: 2px solid var(--cpd-color-bg-subtle-primary);
border-radius: 2px;
color: $info-plinth-fg-color;
&.compactLayout & {
margin-top: 6px;
margin-bottom: 6px;
}
/* Exclude mx_LinkPreviewGroup_hide from wrapping */
.mx_LinkPreviewWidget_wrapImageCaption {
display: flex;
flex-wrap: wrap;
row-gap: var(--cpd-space-2x);
flex: 1;
.mx_LinkPreviewWidget_image,
.mx_LinkPreviewWidget_caption {
margin-inline-start: var(--cpd-space-4x);
min-width: 0; /* Prevent blowout */
}
.mx_LinkPreviewWidget_image {
flex: 0 0 100px;
text-align: center;
cursor: pointer;
}
.mx_LinkPreviewWidget_caption {
flex: 1;
overflow: hidden; /* cause it to wrap rather than clip */
}
.mx_LinkPreviewWidget_title,
.mx_LinkPreviewWidget_description {
display: -webkit-box;
-webkit-box-orient: vertical;
overflow: hidden;
white-space: normal;
}
.mx_LinkPreviewWidget_title {
font-weight: bold;
-webkit-line-clamp: 2;
.mx_LinkPreviewWidget_siteName {
font-weight: normal;
}
}
.mx_LinkPreviewWidget_description {
margin-top: var(--cpd-space-2x);
word-wrap: break-word;
-webkit-line-clamp: 3;
}
}
}

View File

@ -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 { LinkPreview } from "./LinkPreview";
export default {
title: "Event/UrlPreviewView",
component: LinkPreview,
tags: ["autodocs"],
args: {
onHideClick: fn(),
onImageClick: fn(),
},
} as Meta<typeof LinkPreview>;
const Template: StoryFn<typeof LinkPreview> = (args) => <LinkPreview {...args} />;
export const Default = Template.bind({});
Default.args = {
mediaVisible: true,
title: "A simple title",
description: "A simple description",
link: "https://matrix.org",
siteName: "Site name",
image: {
imageThumb: "https://images.dog.ceo/breeds/kuvasz/n02104029_1369.jpg",
imageFull: "https://images.dog.ceo/breeds/kuvasz/n02104029_1369.jpg",
},
};
export const Title = Template.bind({});
Title.args = {
mediaVisible: true,
title: "A simple title",
link: "https://matrix.org",
};
export const TitleAndDescription = Template.bind({});
TitleAndDescription.args = {
mediaVisible: true,
title: "A simple title",
description: "With a good ol description",
link: "https://matrix.org",
};
export const WithTooltip = Template.bind({});
WithTooltip.args = {
mediaVisible: true,
title: "A simple title",
description: "With a good ol description",
showTooltipOnLink: true,
link: "https://matrix.org",
};
export const WithCompactLayout = Template.bind({});
WithCompactLayout.args = {
mediaVisible: true,
compactLayout: true,
title: "A simple title",
description: "A simple description",
link: "https://matrix.org",
siteName: "Site name",
image: {
imageThumb: "https://images.dog.ceo/breeds/kuvasz/n02104029_1369.jpg",
imageFull: "https://images.dog.ceo/breeds/kuvasz/n02104029_1369.jpg",
},
};

View File

@ -0,0 +1,22 @@
/*
* 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 "./LinkPreview.stories.tsx";
const { Default } = composeStories(stories);
describe("LinkPreview", () => {
it("renders an empty view", () => {
const { container } = render(<Default />);
expect(container).toMatchSnapshot();
});
});

View File

@ -0,0 +1,108 @@
/*
* 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 } from "react";
import { IconButton, Tooltip } from "@vector-im/compound-web";
import CloseIcon from "@vector-im/compound-design-tokens/assets/web/icons/close";
import classNames from "classnames";
import { useI18n } from "../../utils/i18nContext";
import styles from "./LinkPreview.module.css";
import type { UrlPreviewViewSnapshotPreview } from "./types";
export interface LinkPreviewActions {
onHideClick?: () => void;
onImageClick: () => void;
}
interface LinkPreviewAdditionalProps {
mediaVisible: boolean;
compactLayout?: boolean;
}
export type LinkPreviewProps = UrlPreviewViewSnapshotPreview & LinkPreviewActions & LinkPreviewAdditionalProps;
export function LinkPreview({
onHideClick,
onImageClick,
mediaVisible,
compactLayout,
...preview
}: LinkPreviewProps): JSX.Element {
const { translate: _t } = useI18n();
const hideButton = onHideClick && (
<IconButton
className="mx_LinkPreviewGroup_hide"
onClick={() => onHideClick()}
aria-label={_t("timeline|url_preview|close")}
>
<CloseIcon width="20px" height="20px" />
</IconButton>
);
const onImageClickHandler = useCallback<MouseEventHandler>(
(ev) => {
if (ev.button != 0 || ev.metaKey) return;
ev.preventDefault();
if (!preview.image?.imageFull) {
return;
}
onImageClick();
},
[preview, onImageClick],
);
let img: JSX.Element | undefined;
// Don't render a button to show the image, just hide it outright
if (preview.image?.imageThumb && mediaVisible) {
// Image width and height sanitized in the view model.
img = (
<div className={styles.mx_LinkPreviewWidget_image} style={{ height: preview.image.height }}>
<img
className={styles.thumbnail}
src={preview.image.imageThumb}
onClick={onImageClickHandler}
role="button"
alt=""
/>
</div>
);
}
const anchor = (
<a href={preview.link} target="_blank" rel="noreferrer noopener">
{preview.title}
</a>
);
return (
<div className={classNames(styles.container, compactLayout && "compactLayout")}>
<div className={styles.mx_LinkPreviewWidget_wrapImageCaption}>
{img}
<div className={styles.mx_LinkPreviewWidget_caption}>
<div className={styles.mx_LinkPreviewWidget_title}>
{preview.showTooltipOnLink ? (
<Tooltip label={new URL(preview.link, window.location.href).toString()}>{anchor}</Tooltip>
) : (
anchor
)}
{preview.siteName && (
<span className={styles.mx_LinkPreviewWidget_siteName}>{" - " + preview.siteName}</span>
)}
</div>
{preview.description && (
<div
className={styles.mx_LinkPreviewWidget_description}
dangerouslySetInnerHTML={{ __html: preview.description }}
/>
)}
</div>
</div>
{hideButton}
</div>
);
}

View File

@ -0,0 +1,24 @@
/*
* 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.
*/
.container {
.mx_LinkPreviewGroup_hide {
cursor: pointer;
width: 18px;
height: 18px;
svg {
flex: 0 0 40px;
visibility: hidden;
}
}
&:hover .mx_LinkPreviewGroup_hide svg,
.mx_LinkPreviewGroup_hide:focus-visible:focus svg {
visibility: visible;
}
}

View File

@ -0,0 +1,97 @@
/*
* 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 type { Meta, StoryFn } from "@storybook/react-vite";
import {
UrlPreviewGroupView,
type UrlPreviewGroupViewActions,
type UrlPreviewGroupViewSnapshot,
} from "./UrlPreviewGroupView";
import { useMockedViewModel } from "../../viewmodel";
type UrlPreviewGroupViewProps = UrlPreviewGroupViewSnapshot & UrlPreviewGroupViewActions & { mediaVisible: boolean };
const UrlPreviewGroupViewWrapper = ({
mediaVisible,
onHideClick,
onImageClick,
onTogglePreviewLimit,
...rest
}: UrlPreviewGroupViewProps): JSX.Element => {
const vm = useMockedViewModel(rest, {
onHideClick,
onImageClick,
onTogglePreviewLimit,
});
return <UrlPreviewGroupView vm={vm} mediaVisible />;
};
export default {
title: "Event/UrlPreviewGroupView",
component: UrlPreviewGroupViewWrapper,
tags: ["autodocs"],
args: {
onHideClick: fn(),
onImageClick: fn(),
onTogglePreviewLimit: fn(),
},
} as Meta<typeof UrlPreviewGroupViewWrapper>;
const Template: StoryFn<typeof UrlPreviewGroupViewWrapper> = (args) => <UrlPreviewGroupViewWrapper {...args} />;
export const Default = Template.bind({});
Default.args = {
mediaVisible: true,
previews: [
{
title: "A simple title",
description: "A simple description",
link: "https://matrix.org",
image: {
imageThumb: "https://images.dog.ceo/breeds/kuvasz/n02104029_1369.jpg",
imageFull: "https://images.dog.ceo/breeds/kuvasz/n02104029_1369.jpg",
},
},
],
};
export const MultiplePreviews = Template.bind({});
MultiplePreviews.args = {
mediaVisible: true,
previews: [
{
title: "One",
description: "Good dog",
link: "https://matrix.org",
image: {
imageThumb: "https://images.dog.ceo/breeds/otterhound/n02091635_979.jpg",
imageFull: "https://images.dog.ceo/breeds/otterhound/n02091635_979.jpg",
},
},
{
title: "Two",
description: "Good dog",
link: "https://matrix.org",
image: {
imageThumb: "https://images.dog.ceo/breeds/eskimo/n02109961_930.jpg",
imageFull: "https://images.dog.ceo/breeds/eskimo/n02109961_930.jpg",
},
},
{
title: "Three",
description: "Good dog",
link: "https://matrix.org",
image: {
imageThumb: "https://images.dog.ceo/breeds/pekinese/n02086079_22136.jpg",
imageFull: "https://images.dog.ceo/breeds/pekinese/n02086079_22136.jpg",
},
},
],
};

View File

@ -0,0 +1,22 @@
/*
* 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 } = composeStories(stories);
describe("UrlPreviewGroupView", () => {
it("renders an empty view", () => {
const { container } = render(<Default />);
expect(container).toMatchSnapshot();
});
});

View File

@ -0,0 +1,78 @@
/*
* 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 } from "@vector-im/compound-web";
import { useViewModel, type ViewModel } from "../../viewmodel";
import { useI18n } from "../../utils/i18nContext";
import styles from "./UrlPreviewGroupView.module.css";
import type { UrlPreviewViewSnapshotPreview } from "./types";
import { LinkPreview } from "./LinkPreview";
export interface UrlPreviewGroupViewSnapshot {
previews: Array<UrlPreviewViewSnapshotPreview>;
hidden: boolean;
totalPreviewCount: number;
previewsLimited: boolean;
overPreviewLimit: boolean;
}
export interface UrlPreviewGroupViewProps {
vm: ViewModel<UrlPreviewGroupViewSnapshot> & UrlPreviewGroupViewActions;
// TODO: Move to VM
mediaVisible: boolean;
}
export interface UrlPreviewGroupViewActions {
onTogglePreviewLimit: () => void;
onHideClick: () => void;
onImageClick: (preview: UrlPreviewViewSnapshotPreview) => void;
}
/**
* UrlPreviewGroupView renders a compact event tile with an icon, title, and optional subtitle/content.
*
* @example
* ```tsx
* <UrlPreviewGroupView icon={<Icon />} title="Room created" />
* ```
*/
export function UrlPreviewGroupView({ vm, mediaVisible }: UrlPreviewGroupViewProps): JSX.Element | null {
const { translate: _t } = useI18n();
console.log(vm);
const { previews, hidden, totalPreviewCount, previewsLimited, overPreviewLimit } = useViewModel(vm);
if (hidden) {
return null;
}
let toggleButton: JSX.Element | undefined;
if (overPreviewLimit) {
toggleButton = (
<Button kind="tertiary" onClick={() => vm.onTogglePreviewLimit()}>
{previewsLimited
? _t("timeline|url_preview|show_n_more", { count: totalPreviewCount - previews.length })
: _t("action|collapse")}
</Button>
);
}
return (
<div className={styles.container}>
{previews.map((preview, i) => (
<LinkPreview
mediaVisible={mediaVisible}
key={preview.link}
onHideClick={i == 0 ? vm.onHideClick : undefined}
onImageClick={() => vm.onImageClick(preview)}
{...preview}
/>
))}
{toggleButton}
</div>
);
}

View File

@ -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";

View File

@ -0,0 +1,30 @@
/*
* 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;
/**
*
*/
title: string;
siteName?: string;
description?: string;
image?: {
imageThumb: string;
imageFull: string;
fileSize?: number;
width?: number;
height?: number;
};
}

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/MediaBody";
export * from "./message-body/MessageTimestampView";
export * from "./message-body/DecryptionFailureBodyView";

View File

@ -0,0 +1,4 @@
{
"status": "failed",
"failedTests": []
}