Add URL preview above message composer (#33964)
* Modify LinkPreview to export shared atomics * Implement MessageComposerUrlPreviewView * Create UrlPreviewFetcher utility function * Modify view models * Implement in composer * Support running tests in dom-less vitest environment * Add a playwright test * hide another one * fmt * cleanup * test rte too * fixup * Add back docstring * cleanup * off by one * remove description check * Cleanup hacks * Remove another hack * cleanup * one more * fixup window here too * whoops type * Rename to be cleaeer * Trim URLs first * fix bug
This commit is contained in:
BIN
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
@@ -14,6 +14,7 @@ export * from "./core/AvatarWithDetails";
|
||||
export * from "./core/roving";
|
||||
export * from "./room/composer/Banner";
|
||||
export * from "./room/composer/UploadButton";
|
||||
export * from "./room/composer/MessageComposerUrlPreview/index.ts";
|
||||
export * from "./crypto/SasEmoji";
|
||||
export * from "./menus/UserMenu";
|
||||
export * from "./notifications/NotificationBadgeView";
|
||||
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright (c) 2025 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 {
|
||||
container-type: inline-size;
|
||||
container-name: banner;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--cpd-space-4x);
|
||||
padding: var(--cpd-space-3x) var(--cpd-space-4x);
|
||||
border-top: 1px solid var(--cpd-color-gray-400);
|
||||
|
||||
> div {
|
||||
display: flex;
|
||||
gap: var(--cpd-space-4x);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
> a {
|
||||
color: var(--cpd-color-text-action-accent);
|
||||
font-weight: 400;
|
||||
font-size: 13px;
|
||||
}
|
||||
> .description {
|
||||
margin: 0;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 1;
|
||||
line-clamp: 1;
|
||||
overflow: hidden;
|
||||
/* Cap the max width of a line so it doesn't go on forever. */
|
||||
max-width: 40em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.image {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 6px;
|
||||
object-fit: scale-down;
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
* 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 type { Meta, StoryFn } from "@storybook/react-vite";
|
||||
import siteIconFile from "../../../../static/element.png";
|
||||
import imagePreviewFile from "../../../../static/wideImage.png";
|
||||
import { MessageComposerUrlPreviewView, type MessageComposerUrlPreviewSnapshot } from "./MessageComposerUrlPreview";
|
||||
import { useMockedViewModel } from "../../../core/viewmodel";
|
||||
import { LinkedTextContext } from "../../../core/utils/LinkedText";
|
||||
import { withViewDocs } from "../../../../.storybook/withViewDocs";
|
||||
|
||||
type MessageComposerUrlPreviewWrapperProps = MessageComposerUrlPreviewSnapshot;
|
||||
|
||||
const MessageComposerUrlPreviewViewWrapperImpl = ({
|
||||
...rest
|
||||
}: MessageComposerUrlPreviewWrapperProps): JSX.Element | null => {
|
||||
const vm = useMockedViewModel(rest, {});
|
||||
return (
|
||||
<LinkedTextContext.Provider value={{}}>
|
||||
<MessageComposerUrlPreviewView vm={vm} />
|
||||
</LinkedTextContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const MessageComposerUrlPreviewViewWrapper = withViewDocs(
|
||||
MessageComposerUrlPreviewViewWrapperImpl,
|
||||
MessageComposerUrlPreviewView,
|
||||
);
|
||||
|
||||
export default {
|
||||
title: "Composer/MessageComposerUrlPreview",
|
||||
component: MessageComposerUrlPreviewViewWrapper,
|
||||
tags: ["autodocs"],
|
||||
args: {},
|
||||
parameters: {
|
||||
design: {
|
||||
type: "figma",
|
||||
url: "https://www.figma.com/design/sI9A2kV2K4xeiyqJsL7Ey3/Link-Previews?node-id=243-8476&t=NNrkR2fxEeVenjbU-0",
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof MessageComposerUrlPreviewViewWrapper>;
|
||||
|
||||
const Template: StoryFn<typeof MessageComposerUrlPreviewViewWrapper> = (args) => (
|
||||
<MessageComposerUrlPreviewViewWrapper {...args} />
|
||||
);
|
||||
|
||||
export const Default = Template.bind({});
|
||||
Default.args = {
|
||||
preview: {
|
||||
title: "A simple title",
|
||||
description: "A simple description",
|
||||
link: "https://matrix.org",
|
||||
siteName: "matrix.org",
|
||||
showTooltipOnLink: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithImage = Template.bind({});
|
||||
WithImage.args = {
|
||||
preview: {
|
||||
...Default.args.preview!,
|
||||
image: {
|
||||
imageThumb: imagePreviewFile,
|
||||
imageFull: imagePreviewFile,
|
||||
alt: "The element logo",
|
||||
playable: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
export const WithImageAndSiteIcon = Template.bind({});
|
||||
WithImageAndSiteIcon.args = {
|
||||
preview: {
|
||||
...Default.args.preview!,
|
||||
siteIcon: siteIconFile,
|
||||
image: {
|
||||
imageThumb: imagePreviewFile,
|
||||
imageFull: imagePreviewFile,
|
||||
alt: "The element logo",
|
||||
playable: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithImageAndLoadsOfText = Template.bind({});
|
||||
WithImageAndLoadsOfText.args = {
|
||||
preview: {
|
||||
...Default.args.preview!,
|
||||
description: `Molestiae aliquam quos possimus molestiae id sit nulla rerum. Sunt cumque illum alias. Illo ipsa ut iure quia nulla magnam repellat.
|
||||
|
||||
Esse velit corporis sapiente temporibus quia ipsam. Pariatur est rem veritatis. Inventore sit consequatur odio ipsa error non assumenda. Est eum ex dignissimos voluptatibus voluptatem delectus modi. Nisi quia eius ea quibusdam. Aut eveniet maxime non.
|
||||
|
||||
Impedit qui minus soluta cupiditate. Illo blanditiis sint et dolores rem consequuntur rerum ut. Delectus tempore dolorem veritatis odit enim ut dolores. Sit quae rerum explicabo consequatur tenetur. Labore in doloremque libero.
|
||||
|
||||
Incidunt ut ea quae nobis. Reiciendis inventore quas qui eum voluptatem ex et qui. Adipisci quibusdam dolores hic inventore et suscipit cupiditate consequuntur.
|
||||
|
||||
Temporibus similique sint quo. Omnis tempora quidem explicabo in quidem magnam quia. Aut sunt accusantium ut et ut laborum debitis in. Enim nihil sit consectetur facilis quidem voluptatem. Quod impedit odit veritatis est laudantium tempore sit labore. Atque minima aliquam nostrum et.`,
|
||||
image: {
|
||||
imageThumb: imagePreviewFile,
|
||||
imageFull: imagePreviewFile,
|
||||
alt: "The element logo",
|
||||
playable: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* 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 "./MessageComposerUrlPreview.stories.tsx";
|
||||
|
||||
const { Default, WithImage } = composeStories(stories);
|
||||
|
||||
describe("MessageComposerUrlPreview", () => {
|
||||
it("renders a preview", () => {
|
||||
const { container } = render(<Default />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
it("renders a preview with an image", () => {
|
||||
const { container } = render(<WithImage />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* 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 { Text } from "@vector-im/compound-web";
|
||||
import classNames from "classnames";
|
||||
|
||||
import { type UrlPreview } from "../../timeline/event-tile/UrlPreviewGroupView";
|
||||
import styles from "./MessageComposerUrlPreview.module.css";
|
||||
import { LinkSiteName, LinkTitle } from "../../timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview";
|
||||
import { useViewModel, type ViewModel } from "../../../core/viewmodel";
|
||||
|
||||
/** Snapshot data for rendering a URL preview attached to the composer. */
|
||||
export interface MessageComposerUrlPreviewSnapshot {
|
||||
/** URL preview to render. */
|
||||
preview: UrlPreview | null;
|
||||
}
|
||||
|
||||
/** Props for MessageComposerUrlPreviewView. */
|
||||
export interface MessageComposerUrlPreviewProps {
|
||||
/**
|
||||
* The view model for the component.
|
||||
*/
|
||||
vm: ViewModel<MessageComposerUrlPreviewSnapshot>;
|
||||
/**
|
||||
* Extra CSS classes to apply to the component.
|
||||
*/
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* MessageComposerUrlPreviewView renders a preview of a single URL above the messasge composer.
|
||||
*/
|
||||
export function MessageComposerUrlPreviewView({ vm, className }: MessageComposerUrlPreviewProps): JSX.Element | null {
|
||||
const { preview } = useViewModel(vm);
|
||||
if (!preview) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className={classNames(className, styles.container)}>
|
||||
<div>
|
||||
{preview?.image?.imageThumb && (
|
||||
<img className={styles.image} src={preview.image?.imageThumb} alt={preview.image.alt} />
|
||||
)}
|
||||
<div className={styles.text}>
|
||||
<LinkSiteName {...preview} />
|
||||
<LinkTitle {...preview} />
|
||||
<Text className={styles.description}>{preview?.description}</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`MessageComposerUrlPreview > renders a preview 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="MessageComposerUrlPreview-module_container"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="MessageComposerUrlPreview-module_text"
|
||||
>
|
||||
<div
|
||||
class="LinkPreview-module_siteName"
|
||||
>
|
||||
<span
|
||||
class="_typography_6v6n8_153 _font-body-sm-regular_6v6n8_31"
|
||||
>
|
||||
matrix.org
|
||||
</span>
|
||||
</div>
|
||||
<a
|
||||
class="_typography_6v6n8_153 _font-body-md-semibold_6v6n8_55 LinkPreview-module_title"
|
||||
href="https://matrix.org"
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
>
|
||||
A simple title
|
||||
</a>
|
||||
<p
|
||||
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 MessageComposerUrlPreview-module_description"
|
||||
>
|
||||
A simple description
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`MessageComposerUrlPreview > renders a preview with an image 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="MessageComposerUrlPreview-module_container"
|
||||
>
|
||||
<div>
|
||||
<img
|
||||
alt="The element logo"
|
||||
class="MessageComposerUrlPreview-module_image"
|
||||
src="/static/wideImage.png"
|
||||
/>
|
||||
<div
|
||||
class="MessageComposerUrlPreview-module_text"
|
||||
>
|
||||
<div
|
||||
class="LinkPreview-module_siteName"
|
||||
>
|
||||
<span
|
||||
class="_typography_6v6n8_153 _font-body-sm-regular_6v6n8_31"
|
||||
>
|
||||
matrix.org
|
||||
</span>
|
||||
</div>
|
||||
<a
|
||||
class="_typography_6v6n8_153 _font-body-md-semibold_6v6n8_55 LinkPreview-module_title"
|
||||
href="https://matrix.org"
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
>
|
||||
A simple title
|
||||
</a>
|
||||
<p
|
||||
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 MessageComposerUrlPreview-module_description"
|
||||
>
|
||||
A simple description
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
* 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 * from "./MessageComposerUrlPreview";
|
||||
+25
-25
@@ -87,31 +87,31 @@ button.preview {
|
||||
flex: 1;
|
||||
overflow: hidden; /* cause it to wrap rather than clip */
|
||||
}
|
||||
}
|
||||
|
||||
.title,
|
||||
.description {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
white-space: normal;
|
||||
line-clamp: 2;
|
||||
-webkit-line-clamp: 2;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
color: var(--cpd-color-text-primary);
|
||||
text-decoration-line: none;
|
||||
}
|
||||
|
||||
.siteName {
|
||||
margin-top: var(--cpd-space-1x);
|
||||
vertical-align: middle;
|
||||
display: flex;
|
||||
gap: var(--cpd-space-1-5x);
|
||||
> * {
|
||||
/* Center everything */
|
||||
margin: auto 0;
|
||||
}
|
||||
.siteName {
|
||||
margin-top: var(--cpd-space-1x);
|
||||
vertical-align: middle;
|
||||
display: flex;
|
||||
gap: var(--cpd-space-1-5x);
|
||||
> * {
|
||||
/* Center everything */
|
||||
margin: auto 0;
|
||||
}
|
||||
}
|
||||
|
||||
.title,
|
||||
.description {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
white-space: normal;
|
||||
line-clamp: 2;
|
||||
-webkit-line-clamp: 2;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
color: var(--cpd-color-text-primary);
|
||||
text-decoration-line: none;
|
||||
}
|
||||
|
||||
+2
-2
@@ -21,7 +21,7 @@ export interface LinkPreviewActions {
|
||||
|
||||
export type LinkPreviewProps = UrlPreview & LinkPreviewActions;
|
||||
|
||||
function LinkTitle({
|
||||
export function LinkTitle({
|
||||
title,
|
||||
showTooltipOnLink,
|
||||
link,
|
||||
@@ -44,7 +44,7 @@ function LinkTitle({
|
||||
return showTooltipOnLink ? <Tooltip label={caption}>{anchor}</Tooltip> : anchor;
|
||||
}
|
||||
|
||||
function LinkSiteName({ siteIcon, siteName }: { siteIcon?: string; siteName: string }): JSX.Element {
|
||||
export function LinkSiteName({ siteIcon, siteName }: { siteIcon?: string; siteName: string }): JSX.Element {
|
||||
return (
|
||||
<div className={styles.siteName}>
|
||||
{siteIcon && <Avatar size="16px" name={siteName} id={siteName} src={siteIcon} />}
|
||||
|
||||
+6
-1
@@ -100,7 +100,12 @@ export function UrlPreviewGroupView({ vm, className }: UrlPreviewGroupViewProps)
|
||||
<HideButton onHideClick={vm.onHideClick} />
|
||||
<div className={styles.previewGroup}>
|
||||
{previews.map((preview) => (
|
||||
<LinkPreview key={preview.link} onImageClick={() => vm.onImageClick(preview)} {...preview} />
|
||||
<LinkPreview
|
||||
key={preview.link}
|
||||
onImageClick={() => vm.onImageClick(preview)}
|
||||
{...preview}
|
||||
image={preview.image}
|
||||
/>
|
||||
))}
|
||||
{toggleButton}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user