mirror of
https://github.com/vector-im/element-web.git
synced 2026-03-09 23:41:50 +01:00
Refactor away into a shared component.
This commit is contained in:
parent
3eb0c6a260
commit
3005ceff73
@ -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;
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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",
|
||||
},
|
||||
};
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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,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;
|
||||
};
|
||||
}
|
||||
@ -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";
|
||||
|
||||
4
packages/shared-components/test-results/.last-run.json
Normal file
4
packages/shared-components/test-results/.last-run.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"status": "failed",
|
||||
"failedTests": []
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user