Collapsed URL previews in timeline (#34165)

* Fetches link previews for all links in message (instead of just the first one)

* message url component to display preview for multiple urls

* Revert visible behaviour back to only showing one URL preview

While fetching all link previews in the text message.

* Moved URL preview VM to MessageComposer

* Added com.beeper.linkpreviews to messages sent with the markdown compositor

* MSC 4452 implemented for the older (markdown) composer

* claude told me to use logger instead of console

* Previews generated with snapshot instead of vm

* moved attachPreviews to a separate file

* added attach URL previews to rich text editor

* don't let attachUrlPreviews block clearning the composer

* fixed linter errors

* moved url preview behind labs feature gate

* passed linters

* moved lab feature checking to where attach preview is used

* claude wrote some unit tests for url previewing

* fixed linter errors

* added feature to labs.md

* fixed linter errors

* fixed oxfmt error

* set previews to none if all previews failed

* resolved PR reviews besides the ones that requires a larger code change

* moved url preview VM creation to message composer

* removed delay when clearing composer url preview on message send

* Collpased URL previews in timeline

* CSS for collapsed URL previews

* fixed typo thing?

* update class for this component

* remove dead code from css

* minor changes to stop using globals everywhere

* moved debouncing responsibility to urlpreviewVM

* minor lint fixes

* added comment

* remove composer content from the state of urlpreview, moved it to the vm

* urlpreviewwrapper depends on the vm only

* added comments

* edited the comment

* one slow preview no longer blocks up the current batch of previews from loading

* show failed and loading url previews

* collapsed urlpreview styles to match the expanded preview

* css formtting

* composer url preview summary bar

* prefer site icon

* Revert "Merge branch 'url-preview-loading-indicator' into collapsed-url-previews"

This reverts commit b7a1ccb0e74661458e16c128814ba1939bdf400e, reversing
changes made to 1340b216407cf9af95741174819f97a144c13bb3.

* updated shared-components Punit test snapshots

* updated snapshots

* wait for tall image to load before capturing snapshot (claude wrote this)

* try remove requestanimation frame

* use semantic tokens instead of specifying hard values in some places

* Update packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.tsx

Co-authored-by: Florian Duros <florian.duros@ormaz.fr>

* changed css so previews looks less horrendous on different front sizes, particularly very small font sizes

* updated unit test snapshots

* regenerated playwright story screenshots

---------

Co-authored-by: Florian Duros <florian.duros@ormaz.fr>
This commit is contained in:
Sirius
2026-07-20 09:39:48 +00:00
committed by GitHub
co-authored by Florian Duros
parent 63eaba6b07
commit 6c2c962588
14 changed files with 202 additions and 77 deletions
@@ -0,0 +1,37 @@
/*
* 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.
*/
/**
* A Storybook `play` helper that waits for every CSS `background-image` inside the
* rendered story to finish decoding before the visual-regression snapshot is taken.
*
* Components such as `LinkPreview` render their thumbnails as CSS `background-image`s
* rather than `<img>` elements, so there is no load event for the snapshot machinery
* to await. A larger image (e.g. the tall test image) can therefore still be decoding
* when the screenshot is captured, producing a non-deterministic placeholder frame.
* Decoding the images up-front populates the browser cache so the background paints
* synchronously on the next frame.
*/
export async function waitForBackgroundImages(root: HTMLElement): Promise<void> {
const urls = new Set<string>();
for (const el of root.querySelectorAll<HTMLElement>("*")) {
const match = /url\(["']?(.+?)["']?\)/.exec(getComputedStyle(el).backgroundImage);
if (match) urls.add(match[1]);
}
await Promise.all(
[...urls].map(async (src) => {
const img = new Image();
img.src = src;
try {
await img.decode();
} catch {
// Ignore images that fail to decode; the snapshot captures whatever renders.
}
}),
);
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

After

Width:  |  Height:  |  Size: 43 KiB

@@ -9,40 +9,60 @@ button.preview {
background-color: #fff;
}
.preview {
display: flex;
position: relative;
.containerCollapsed {
max-width: 100%;
width: 478px;
height: 200px;
background-size: cover;
background-position: center;
border: none;
padding: 0;
display: flex;
border: 1px solid var(--cpd-color-border-interactive-secondary);
border-radius: 12px;
/* Get radius from cpd */
flex-direction: row;
color: var(--cpd-color-text-secondary);
overflow: clip;
gap: 8px;
padding: var(--cpd-space-3x) var(--cpd-space-4x);
.playButton[data-kind="primary"] {
padding: 0;
width: 50px;
height: 50px;
margin: auto;
background: var(--cpd-color-text-on-solid-primary);
> svg {
margin: auto;
border-radius: 50px;
color: var(--cpd-color-icon-primary);
}
background: var(--cpd-color-bg-subtle-secondary);
.textContent {
display: flex;
flex-direction: column;
}
.preview {
background-size: cover;
background-position: center;
background-repeat: no-repeat;
border-radius: 8px;
border: none;
flex-shrink: 0;
aspect-ratio: 1;
}
.siteName span {
line-height: 135%;
}
.title,
.siteName > span {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
line-clamp: 1;
overflow: hidden;
min-width: 0;
}
}
.container {
.containerExpanded {
box-sizing: border-box;
max-width: 100%;
width: 478px;
display: flex;
border: 1px solid var(--cpd-color-border-interactive-secondary);
border-radius: 12px; /* Get radius from cpd */
border-radius: 12px;
/* Get radius from cpd */
flex-direction: column;
color: var(--cpd-color-gray-900);
color: var(--cpd-color-text-secondary);
overflow: clip;
background: var(--cpd-color-bg-subtle-secondary);
@@ -67,9 +87,11 @@ button.preview {
.textContent {
padding: var(--cpd-space-3x) var(--cpd-space-4x);
&.inline {
padding: 0;
}
display: flex;
flex-direction: column;
gap: var(--cpd-space-1x);
@@ -78,19 +100,58 @@ button.preview {
.caption {
display: inline-flex;
flex-direction: column;
min-width: 0; /* Prevent blowout */
min-width: 0;
/* Prevent blowout */
}
.caption {
flex: 1;
overflow: hidden; /* cause it to wrap rather than clip */
overflow: hidden;
/* cause it to wrap rather than clip */
}
.preview {
display: flex;
position: relative;
max-width: 100%;
width: 478px;
height: 200px;
background-size: cover;
background-position: center;
border: none;
padding: 0;
.playButton[data-kind="primary"] {
padding: 0;
width: 50px;
height: 50px;
margin: auto;
background: var(--cpd-color-text-on-solid-primary);
> svg {
margin: auto;
border-radius: 50px;
color: var(--cpd-color-icon-primary);
}
}
}
.siteName {
margin-top: var(--cpd-space-1x);
}
.title,
.description {
line-clamp: 2;
-webkit-line-clamp: 2;
}
}
.siteName {
margin-top: var(--cpd-space-1x);
vertical-align: middle;
display: flex;
gap: var(--cpd-space-1-5x);
> * {
/* Center everything */
margin: auto 0;
@@ -103,8 +164,6 @@ button.preview {
-webkit-box-orient: vertical;
overflow: hidden;
white-space: normal;
line-clamp: 2;
-webkit-line-clamp: 2;
margin: 0;
}
@@ -11,6 +11,7 @@ import { fn } from "storybook/test";
import type { Meta, StoryFn } from "@storybook/react-vite";
import { LinkPreview } from "./LinkPreview";
import { LinkedTextContext } from "../../../../../core/utils/LinkedText";
import { waitForBackgroundImages } from "../../../../../../.storybook/waitForImages";
import imageFile from "../../../../../../static/element.png";
import imageFileWide from "../../../../../../static/wideImage.png";
import imageFileTall from "../../../../../../static/tallImage.png";
@@ -22,6 +23,9 @@ export default {
args: {
onImageClick: fn(),
},
play: async ({ canvasElement }) => {
await waitForBackgroundImages(canvasElement);
},
argTypes: {
siteName: {
control: "text",
@@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
import React, { type MouseEventHandler, type JSX, useCallback } from "react";
import React, { type JSX } from "react";
import { Tooltip, Text, Avatar, Button } from "@vector-im/compound-web";
import PlaySolidIcon from "@vector-im/compound-design-tokens/assets/web/icons/play-solid";
import classNames from "classnames";
@@ -19,7 +19,7 @@ export interface LinkPreviewActions {
onImageClick: () => void;
}
export type LinkPreviewProps = UrlPreview & LinkPreviewActions;
export type LinkPreviewProps = UrlPreview & LinkPreviewActions & { collapsed: boolean };
export function LinkTitle({
title,
@@ -66,7 +66,7 @@ function LinkPreviewInline({
link,
}: Omit<LinkPreviewProps, "image" | "description" | "author" | "onImageClick">): JSX.Element {
return (
<div className={classNames(styles.container, styles.inline)}>
<div className={classNames(styles.containerExpanded, styles.inline)}>
{siteIcon && (
<div className={styles.siteAvatar}>
<Avatar type="square" size="48px" name={title} id={title} src={siteIcon} />
@@ -84,21 +84,57 @@ function LinkPreviewInline({
* 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 {
export function LinkPreview(props: LinkPreviewProps): JSX.Element {
if (props.collapsed) {
return <LinkPreviewCollapsed {...props} />;
} else {
return <LinkPreviewExpanded {...props} />;
}
}
function createImageClickHandler({ onImageClick, ...preview }: LinkPreviewProps): React.MouseEventHandler {
return (ev) => {
if (ev.button != 0 || ev.metaKey) return;
ev.preventDefault();
if (!preview.image?.imageFull) {
return;
}
onImageClick();
};
}
export function LinkPreviewCollapsed(preview: LinkPreviewProps): JSX.Element {
const { translate: _t } = useI18n();
let img: JSX.Element | undefined;
const onImageClickHandler = useCallback<MouseEventHandler>(
(ev) => {
if (ev.button != 0 || ev.metaKey) return;
ev.preventDefault();
if (preview.image && !preview.image.playable) {
img = (
<button
type="button"
style={{
backgroundImage: `url('${preview.image.imageThumb}')`,
}}
className={styles.preview}
onClick={createImageClickHandler(preview)}
aria-label={_t("timeline|url_preview|view_image")}
/>
);
}
if (!preview.image?.imageFull) {
return;
}
onImageClick();
},
[preview.image?.imageFull, onImageClick],
return (
<div className={styles.containerCollapsed}>
{img}
<div className={styles.textContent}>
<LinkTitle title={preview.title} showTooltipOnLink={preview.showTooltipOnLink} link={preview.link} />
{preview.siteName && <LinkSiteName siteName={preview.siteName} />}
</div>
</div>
);
}
export function LinkPreviewExpanded(preview: LinkPreviewProps): JSX.Element {
const { translate: _t } = useI18n();
if (!preview.image && !preview.author && !preview.description) {
return <LinkPreviewInline {...preview} />;
@@ -139,7 +175,7 @@ export function LinkPreview({ onImageClick, ...preview }: LinkPreviewProps): JSX
backgroundImage: `url('${preview.image.imageThumb}')`,
}}
className={styles.preview}
onClick={onImageClickHandler}
onClick={createImageClickHandler(preview)}
aria-label={_t("timeline|url_preview|view_image")}
/>
);
@@ -147,7 +183,7 @@ export function LinkPreview({ onImageClick, ...preview }: LinkPreviewProps): JSX
}
return (
<div className={styles.container}>
<div className={styles.containerExpanded}>
{img}
<div className={styles.textContent}>
{preview.author && (
@@ -3,7 +3,7 @@
exports[`LinkPreview > renders a playable preview that can be opened with a click 1`] = `
<div>
<div
class="LinkPreview-module_container"
class="LinkPreview-module_containerExpanded"
>
<div
class="LinkPreview-module_preview"
@@ -66,7 +66,7 @@ exports[`LinkPreview > renders a playable preview that can be opened with a clic
exports[`LinkPreview > renders a preview 1`] = `
<div>
<div
class="LinkPreview-module_container"
class="LinkPreview-module_containerExpanded"
>
<button
aria-label="View image"
@@ -106,7 +106,7 @@ exports[`LinkPreview > renders a preview 1`] = `
exports[`LinkPreview > renders a preview with just a title 1`] = `
<div>
<div
class="LinkPreview-module_container LinkPreview-module_inline"
class="LinkPreview-module_containerExpanded LinkPreview-module_inline"
>
<div
class="LinkPreview-module_textContent LinkPreview-module_inline"
@@ -136,7 +136,7 @@ exports[`LinkPreview > renders a preview with just a title 1`] = `
exports[`LinkPreview > renders a preview with just a title and description 1`] = `
<div>
<div
class="LinkPreview-module_container"
class="LinkPreview-module_containerExpanded"
>
<div
class="LinkPreview-module_textContent"
@@ -19,6 +19,7 @@ import {
import { useMockedViewModel } from "../../../../core/viewmodel";
import { LinkedTextContext } from "../../../../core/utils/LinkedText";
import { withViewDocs } from "../../../../../.storybook/withViewDocs";
import { waitForBackgroundImages } from "../../../../../.storybook/waitForImages";
type UrlPreviewGroupViewProps = UrlPreviewGroupViewSnapshot & UrlPreviewGroupViewActions;
@@ -72,6 +73,9 @@ export default {
onImageClick: fn(),
onTogglePreviewLimit: fn(),
},
play: async ({ canvasElement }) => {
await waitForBackgroundImages(canvasElement);
},
parameters: {
design: {
type: "figma",
@@ -99,12 +99,13 @@ export function UrlPreviewGroupView({ vm, className }: UrlPreviewGroupViewProps)
<div className={classNames(className, styles.wrapper)} {...eventPresentationAttributes}>
<HideButton onHideClick={vm.onHideClick} />
<div className={styles.previewGroup}>
{previews.map((preview) => (
{previews.map((preview, i) => (
<LinkPreview
key={preview.link}
onImageClick={() => vm.onImageClick(preview)}
{...preview}
image={preview.image}
collapsed={i !== 0}
/>
))}
{toggleButton}
@@ -40,7 +40,7 @@ exports[`UrlPreviewGroupView > renders a single preview 1`] = `
class="UrlPreviewGroupView-module_previewGroup"
>
<div
class="LinkPreview-module_container"
class="LinkPreview-module_containerExpanded"
>
<button
aria-label="View image"
@@ -119,7 +119,7 @@ exports[`UrlPreviewGroupView > renders multiple previews 1`] = `
class="UrlPreviewGroupView-module_previewGroup"
>
<div
class="LinkPreview-module_container"
class="LinkPreview-module_containerExpanded"
>
<button
aria-label="View image"
@@ -154,12 +154,13 @@ exports[`UrlPreviewGroupView > renders multiple previews 1`] = `
</div>
</div>
<div
class="LinkPreview-module_container"
class="LinkPreview-module_containerCollapsed"
>
<button
aria-label="View image"
class="LinkPreview-module_preview"
style="background-image: url("/static/tallImage.png");"
type="button"
/>
<div
class="LinkPreview-module_textContent"
@@ -172,11 +173,6 @@ exports[`UrlPreviewGroupView > renders multiple previews 1`] = `
>
Two
</a>
<p
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 LinkedText-module_container LinkPreview-module_description"
>
This one has a taller image which should crop nicely.
</p>
<div
class="LinkPreview-module_siteName"
>
@@ -189,12 +185,13 @@ exports[`UrlPreviewGroupView > renders multiple previews 1`] = `
</div>
</div>
<div
class="LinkPreview-module_container"
class="LinkPreview-module_containerCollapsed"
>
<button
aria-label="View image"
class="LinkPreview-module_preview"
style="background-image: url("/static/element.png");"
type="button"
/>
<div
class="LinkPreview-module_textContent"
@@ -207,11 +204,6 @@ exports[`UrlPreviewGroupView > renders multiple previews 1`] = `
>
Three
</a>
<p
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 LinkedText-module_container LinkPreview-module_description"
>
One more description
</p>
<div
class="LinkPreview-module_siteName"
>
@@ -277,7 +269,7 @@ exports[`UrlPreviewGroupView > renders multiple previews which are hidden 1`] =
class="UrlPreviewGroupView-module_previewGroup"
>
<div
class="LinkPreview-module_container"
class="LinkPreview-module_containerExpanded"
>
<button
aria-label="View image"
@@ -365,7 +357,7 @@ exports[`UrlPreviewGroupView > renders with compact density 1`] = `
class="UrlPreviewGroupView-module_previewGroup"
>
<div
class="LinkPreview-module_container"
class="LinkPreview-module_containerExpanded"
>
<button
aria-label="View image"
@@ -400,12 +392,13 @@ exports[`UrlPreviewGroupView > renders with compact density 1`] = `
</div>
</div>
<div
class="LinkPreview-module_container"
class="LinkPreview-module_containerCollapsed"
>
<button
aria-label="View image"
class="LinkPreview-module_preview"
style="background-image: url("/static/tallImage.png");"
type="button"
/>
<div
class="LinkPreview-module_textContent"
@@ -418,11 +411,6 @@ exports[`UrlPreviewGroupView > renders with compact density 1`] = `
>
Two
</a>
<p
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 LinkedText-module_container LinkPreview-module_description"
>
This one has a taller image which should crop nicely.
</p>
<div
class="LinkPreview-module_siteName"
>
@@ -435,12 +423,13 @@ exports[`UrlPreviewGroupView > renders with compact density 1`] = `
</div>
</div>
<div
class="LinkPreview-module_container"
class="LinkPreview-module_containerCollapsed"
>
<button
aria-label="View image"
class="LinkPreview-module_preview"
style="background-image: url("/static/element.png");"
type="button"
/>
<div
class="LinkPreview-module_textContent"
@@ -453,11 +442,6 @@ exports[`UrlPreviewGroupView > renders with compact density 1`] = `
>
Three
</a>
<p
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 LinkedText-module_container LinkPreview-module_description"
>
One more description
</p>
<div
class="LinkPreview-module_siteName"
>