diff --git a/apps/web/playwright/snapshots/messages/messages.spec.ts/preview-basic-linux.png b/apps/web/playwright/snapshots/messages/messages.spec.ts/preview-basic-linux.png index 37df57eff50..20771ef96db 100644 Binary files a/apps/web/playwright/snapshots/messages/messages.spec.ts/preview-basic-linux.png and b/apps/web/playwright/snapshots/messages/messages.spec.ts/preview-basic-linux.png differ diff --git a/apps/web/playwright/snapshots/messages/messages.spec.ts/preview-with-thumb-linux.png b/apps/web/playwright/snapshots/messages/messages.spec.ts/preview-with-thumb-linux.png index 107da1c041c..bf10f94d859 100644 Binary files a/apps/web/playwright/snapshots/messages/messages.spec.ts/preview-with-thumb-linux.png and b/apps/web/playwright/snapshots/messages/messages.spec.ts/preview-with-thumb-linux.png differ diff --git a/apps/web/src/components/views/messages/TextualBodyFactory.test.tsx b/apps/web/src/components/views/messages/TextualBodyFactory.test.tsx index bf3ac2d824e..42104d04be3 100644 --- a/apps/web/src/components/views/messages/TextualBodyFactory.test.tsx +++ b/apps/web/src/components/views/messages/TextualBodyFactory.test.tsx @@ -10,8 +10,14 @@ Please see LICENSE files in the repository root for full details. import React, { type ComponentProps } from "react"; import { describe, it, expect, vi, beforeEach, afterEach, type MockedObject } from "vitest"; -import { type MatrixClient, type MatrixEvent, PushRuleKind, type Room } from "matrix-js-sdk/src/matrix"; -import { act, render, waitFor } from "test-utils-rtl"; +import { + type IPreviewUrlResponse, + type MatrixClient, + type MatrixEvent, + PushRuleKind, + type Room, +} from "matrix-js-sdk/src/matrix"; +import { act, fireEvent, render, screen, waitFor } from "test-utils-rtl"; import { PushProcessor } from "matrix-js-sdk/src/pushprocessor"; import { setMissingEntryGenerator } from "@element-hq/web-shared-components"; @@ -23,6 +29,8 @@ import MatrixClientContext from "../../../contexts/MatrixClientContext"; import RoomContext from "../../../contexts/RoomContext"; import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; import { type MediaEventHelper } from "../../../utils/MediaEventHelper"; +import Modal from "../../../Modal"; +import ImageView from "../elements/ImageView"; vi.mock("../../../hooks/useMediaVisible", () => ({ __esModule: true, @@ -527,4 +535,114 @@ describe("", () => { }); }); }); + describe("url preview tiles", () => { + const link = "https://matrix.org/"; + let matrixClient: MockedObject; + + const ogData = (overrides: Partial = {}): IPreviewUrlResponse => ({ + "og:title": "Matrix", + "og:type": "website", + "og:description": "An open network for secure, decentralised communication", + "og:site_name": "matrix.org", + "og:url": link, + ...overrides, + }); + + const ogImage = { + "og:image": "mxc://example.org/preview", + "og:image:type": "image/png", + "og:image:width": 480, + "og:image:height": 320, + "matrix:image:size": 100_000, + }; + + beforeEach(() => { + setMissingEntryGenerator((key) => key.split("|", 2)[1]); + matrixClient = getMockClientWithEventEmitter({ + getRoom: vi.fn(), + getUserId: vi.fn(), + ...mockClientPushProcessor(), + getAccountData: (): MatrixEvent | undefined => undefined, + getUrlPreview: vi.fn().mockResolvedValue(ogData()), + isGuest: () => false, + mxcUrlToHttp: (s: string) => s, + }); + vi.mocked(matrixClient.getRoom).mockReturnValue(mkStubRoom("room_id", "room name", matrixClient)); + DMRoomMap.makeShared(defaultMatrixClient); + }); + + /** Render a message and wait for its previews to have been fetched and rendered. */ + const renderPreviews = async (body = `Visit ${link}`): Promise> => { + const result = getComponent({ mxEvent: mkRoomTextMessage(body), showUrlPreview: true }, matrixClient); + await screen.findByRole("link", { name: "Matrix" }); + return result; + }; + + it("renders a preview without an image as a text tile", async () => { + await renderPreviews(); + + expect(screen.getByRole("link", { name: "Matrix" })).toHaveAttribute("href", link); + expect(screen.getByText("An open network for secure, decentralised communication")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "View image" })).not.toBeInTheDocument(); + }); + + it("falls back to the site name when the preview has no description", async () => { + vi.mocked(matrixClient.getUrlPreview).mockResolvedValue(ogData({ "og:description": undefined })); + + await renderPreviews(); + + expect(screen.getByText("matrix.org")).toBeInTheDocument(); + }); + + it("renders a preview with an image and opens the lightbox when it is clicked", async () => { + vi.mocked(matrixClient.getUrlPreview).mockResolvedValue(ogData(ogImage)); + const createDialog = vi.spyOn(Modal, "createDialog").mockReturnValue({} as never); + + await renderPreviews(); + + fireEvent.click(screen.getByRole("button", { name: "View image" })); + + expect(createDialog).toHaveBeenCalledWith( + ImageView, + expect.objectContaining({ src: "mxc://example.org/preview", name: "Thumbnail of Matrix" }), + "mx_Dialog_lightbox", + undefined, + true, + ); + }); + + it("opens the previewed link in a new tab", async () => { + const open = vi.spyOn(window, "open").mockReturnValue(null); + + await renderPreviews(); + + fireEvent.click(screen.getByRole("button", { name: "Open link" })); + + expect(open).toHaveBeenCalledWith(link, "_blank", "noreferrer"); + }); + + it("expands the group when more previews are available than are shown", async () => { + vi.mocked(matrixClient.getUrlPreview).mockImplementation(async (url: string) => + ogData({ "og:title": `Preview of ${url}`, "og:url": url }), + ); + + const { container } = getComponent( + { + mxEvent: mkRoomTextMessage( + "Visit https://one.example.com/ and https://two.example.com/ and https://three.example.com/", + ), + showUrlPreview: true, + }, + matrixClient, + ); + + const toggle = await screen.findByRole("button", { name: "Show 1 other preview" }); + expect(container.querySelectorAll("a[href^='https://one']")).toHaveLength(2); + + fireEvent.click(toggle); + + await screen.findByRole("link", { name: "Preview of https://three.example.com/" }); + expect(screen.getByRole("button", { name: "Collapse" })).toBeInTheDocument(); + }); + }); }); diff --git a/apps/web/src/components/views/messages/TextualBodyFactory.tsx b/apps/web/src/components/views/messages/TextualBodyFactory.tsx index 56fb20fc498..1e7f5506efa 100644 --- a/apps/web/src/components/views/messages/TextualBodyFactory.tsx +++ b/apps/web/src/components/views/messages/TextualBodyFactory.tsx @@ -5,17 +5,21 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { type JSX, useContext, useEffect, useRef } from "react"; +import React, { type JSX, useContext, useEffect, useMemo, useRef } from "react"; import { logger as rootLogger } from "matrix-js-sdk/src/logger"; import { MsgType } from "matrix-js-sdk/src/matrix"; import { + _t, EventContentBodyView, TextualBodyView, type TextualBodyContentElement, type UrlPreview, - UrlPreviewGroupView, useCreateAutoDisposedViewModel, + MediaPreviewGroupPreview, useViewModel, + linkIcon, + type MediaPreviewGroupEntry, + type MediaPreviewGroupEntryContent, } from "@element-hq/web-shared-components"; import { type IBodyProps } from "./IBodyProps"; @@ -34,6 +38,8 @@ import { EditWysiwygComposer } from "../rooms/wysiwyg_composer"; import { UrlPreviewGroupViewModel } from "../../../viewmodels/message-body/UrlPreviewGroupViewModel"; import PlatformPeg from "../../../PlatformPeg"; import { useSettingValue } from "../../../hooks/useSettings"; +import { MediaPreviewGroupViewModel } from "../../../viewmodels/message-body/MediaPreviewGroupViewModel"; +import PopOutIcon from "@vector-im/compound-design-tokens/assets/web/icons/pop-out"; const logger = rootLogger.getChild("TextualBodyFactory"); @@ -127,7 +133,82 @@ export function TextualBodyFactory(props: Readonly): JSX.Element { }), ); - const { previews } = useViewModel(urlPreviewVm); + const { previews, totalPreviewCount, previewsLimited, overPreviewLimit } = useViewModel(urlPreviewVm); + + // Memoised because it feeds the media preview view model from an effect: a fresh object on every + // render would notify subscribers on every render. + const collapse = useMemo( + () => + overPreviewLimit + ? { + collapsed: previewsLimited, + hiddenCount: totalPreviewCount - previews.length, + onToggle: () => void urlPreviewVm.onTogglePreviewLimit(), + } + : undefined, + [overPreviewLimit, previewsLimited, totalPreviewCount, previews.length, urlPreviewVm], + ); + + const previewToEntry = (preview: UrlPreview): MediaPreviewGroupEntry => { + let content: MediaPreviewGroupEntryContent; + if (preview.image === undefined) { + content = { + type: "text", + }; + } else { + content = { + type: "image", + image: preview.image.imageFull, + imageAlt: preview.title, + imageSize: "banner", + imageOnClick: () => { + Modal.createDialog( + ImageView, + { + src: preview.image!.imageFull, // full-res URL + name: `Thumbnail of ${preview.title}`, + width: preview.image?.width, + height: preview.image?.height, + fileSize: preview.image?.fileSize, + }, + "mx_Dialog_lightbox", + undefined, + true, + ); + }, + }; + } + + let body: string; + if (preview.description === undefined || preview.description.trim().length === 0) body = preview.siteName; + else body = preview.description!; + + return { + id: preview.link, + header: preview.title, + headerUrl: preview.link, + body, + buttons: [ + { + label: _t("timeline|url_preview|open_link"), + icon: , + onClick: async () => { + window.open(preview.link, "_blank", "noreferrer"); + }, + }, + ], + ...linkIcon(), + ...content, + }; + }; + + const mediaPreviewVm = useCreateAutoDisposedViewModel( + () => + new MediaPreviewGroupViewModel({ + entries: previews.map(previewToEntry), + collapse, + }), + ); useEffect(() => { textualBodyVm.setId(props.id); @@ -198,6 +279,13 @@ export function TextualBodyFactory(props: Readonly): JSX.Element { }); }, [mediaVisible, urlPreviewVm]); + useEffect(() => { + mediaPreviewVm.setProps({ + entries: previews.map(previewToEntry), + collapse, + }); + }, [previews, collapse, mediaPreviewVm]); + useEffect(() => { if (previews.length === 0) { return; @@ -221,7 +309,7 @@ export function TextualBodyFactory(props: Readonly): JSX.Element { vm={textualBodyVm} body={} bodyRef={contentRef} - urlPreviews={} + urlPreviews={} className={getTextualBodyClassName(content.msgtype as MsgType | undefined)} /> ); diff --git a/apps/web/src/i18n/strings/en_EN.json b/apps/web/src/i18n/strings/en_EN.json index 913292f5073..e71e6df6c42 100644 --- a/apps/web/src/i18n/strings/en_EN.json +++ b/apps/web/src/i18n/strings/en_EN.json @@ -3732,7 +3732,10 @@ "one_user": "%(displayName)s is typing …", "two_users": "%(names)s and %(lastPerson)s are typing …" }, - "undecryptable_tooltip": "This message could not be decrypted" + "undecryptable_tooltip": "This message could not be decrypted", + "url_preview": { + "open_link": "Open link" + } }, "truncated_list_n_more": { "other": "And %(count)s more..." diff --git a/packages/shared-components/.storybook/waitForImages.ts b/packages/shared-components/.storybook/waitForImages.ts deleted file mode 100644 index d31efced8ce..00000000000 --- a/packages/shared-components/.storybook/waitForImages.ts +++ /dev/null @@ -1,37 +0,0 @@ -/* - * 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 `` 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 { - const urls = new Set(); - for (const el of root.querySelectorAll("*")) { - 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. - } - }), - ); -} diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/article-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/article-auto.png deleted file mode 100644 index 0c1983fd3b8..00000000000 Binary files a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/article-auto.png and /dev/null differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/default-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/default-auto.png deleted file mode 100644 index e773c6f5bad..00000000000 Binary files a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/default-auto.png and /dev/null differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/social-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/social-auto.png deleted file mode 100644 index 33f68b6f9ac..00000000000 Binary files a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/social-auto.png and /dev/null differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/social-with-image-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/social-with-image-auto.png deleted file mode 100644 index 3e9c274d232..00000000000 Binary files a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/social-with-image-auto.png and /dev/null differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/title-and-description-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/title-and-description-auto.png deleted file mode 100644 index 52654ac68f7..00000000000 Binary files a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/title-and-description-auto.png and /dev/null differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/title-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/title-auto.png deleted file mode 100644 index 0ae4d86b32e..00000000000 Binary files a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/title-auto.png and /dev/null differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/video-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/video-auto.png deleted file mode 100644 index c3d04a40290..00000000000 Binary files a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/video-auto.png and /dev/null differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/with-site-icon-and-description-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/with-site-icon-and-description-auto.png deleted file mode 100644 index 36ac33c42ee..00000000000 Binary files a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/with-site-icon-and-description-auto.png and /dev/null differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/with-site-icon-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/with-site-icon-auto.png deleted file mode 100644 index 69626fe5ecb..00000000000 Binary files a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/with-site-icon-auto.png and /dev/null differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/with-tall-image-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/with-tall-image-auto.png deleted file mode 100644 index 16a0e9c2ba3..00000000000 Binary files a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/with-tall-image-auto.png and /dev/null differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/with-tooltip-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/with-tooltip-auto.png deleted file mode 100644 index 27a4bd9ed95..00000000000 Binary files a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/with-tooltip-auto.png and /dev/null differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/with-very-long-text-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/with-very-long-text-auto.png deleted file mode 100644 index e3d544bab7b..00000000000 Binary files a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/with-very-long-text-auto.png and /dev/null differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/UrlPreviewGroupView.stories.tsx/default-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/UrlPreviewGroupView.stories.tsx/default-auto.png deleted file mode 100644 index 8c6ce55e5b4..00000000000 Binary files a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/UrlPreviewGroupView.stories.tsx/default-auto.png and /dev/null differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/UrlPreviewGroupView.stories.tsx/in-bubble-layout-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/UrlPreviewGroupView.stories.tsx/in-bubble-layout-auto.png deleted file mode 100644 index 2c5d5c37e23..00000000000 Binary files a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/UrlPreviewGroupView.stories.tsx/in-bubble-layout-auto.png and /dev/null differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/UrlPreviewGroupView.stories.tsx/in-bubble-layout-narrow-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/UrlPreviewGroupView.stories.tsx/in-bubble-layout-narrow-auto.png deleted file mode 100644 index 2c5d5c37e23..00000000000 Binary files a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/UrlPreviewGroupView.stories.tsx/in-bubble-layout-narrow-auto.png and /dev/null differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/UrlPreviewGroupView.stories.tsx/multiple-previews-hidden-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/UrlPreviewGroupView.stories.tsx/multiple-previews-hidden-auto.png deleted file mode 100644 index 020d822bd34..00000000000 Binary files a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/UrlPreviewGroupView.stories.tsx/multiple-previews-hidden-auto.png and /dev/null differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/UrlPreviewGroupView.stories.tsx/multiple-previews-visible-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/UrlPreviewGroupView.stories.tsx/multiple-previews-visible-auto.png deleted file mode 100644 index cb1fdc1a618..00000000000 Binary files a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/UrlPreviewGroupView.stories.tsx/multiple-previews-visible-auto.png and /dev/null differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/UrlPreviewGroupView.stories.tsx/with-compact-view-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/UrlPreviewGroupView.stories.tsx/with-compact-view-auto.png deleted file mode 100644 index ffe18d8fc21..00000000000 Binary files a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/UrlPreviewGroupView.stories.tsx/with-compact-view-auto.png and /dev/null differ diff --git a/packages/shared-components/src/i18n/strings/en_EN.json b/packages/shared-components/src/i18n/strings/en_EN.json index b24aea3c806..57fa6c8edcb 100644 --- a/packages/shared-components/src/i18n/strings/en_EN.json +++ b/packages/shared-components/src/i18n/strings/en_EN.json @@ -363,8 +363,6 @@ "pending_moderation": "Message pending moderation", "pending_moderation_reason": "Message pending moderation: %(reason)s", "url_preview": { - "close": "Close preview", - "open_link": "Open link", "show_n_more": { "one": "Show %(count)s other preview", "other": "Show %(count)s other previews" diff --git a/packages/shared-components/src/index.ts b/packages/shared-components/src/index.ts index 1ff92621cda..37c1a8e4c2b 100644 --- a/packages/shared-components/src/index.ts +++ b/packages/shared-components/src/index.ts @@ -67,7 +67,7 @@ export * from "./room/timeline/event-tile/reactions/ReactionsRow"; export * from "./room/timeline/event-tile/reactions/ReactionsRowButton"; export * from "./room/timeline/event-tile/reactions/ReactionsRowButtonTooltip"; export * from "./room/timeline/event-tile/timestamp/MessageTimestampView"; -export * from "./room/timeline/event-tile/UrlPreviewGroupView"; +export type * from "./room/urlPreview.ts"; export * from "./room/timeline/event-tile/MediaPreviewGroupView"; export * from "./core/rich-list/RichItem"; export * from "./core/rich-list/RichList"; diff --git a/packages/shared-components/src/room/composer/MessageComposerUrlPreview/MessageComposerUrlPreview.module.css b/packages/shared-components/src/room/composer/MessageComposerUrlPreview/MessageComposerUrlPreview.module.css index bade2e81dd3..99cbcca26c2 100644 --- a/packages/shared-components/src/room/composer/MessageComposerUrlPreview/MessageComposerUrlPreview.module.css +++ b/packages/shared-components/src/room/composer/MessageComposerUrlPreview/MessageComposerUrlPreview.module.css @@ -263,3 +263,24 @@ background-color: var(--cpd-color-bg-decorative-6); color: var(--cpd-color-text-decorative-6); } + +.siteName { + vertical-align: middle; + display: flex; + gap: var(--cpd-space-1-5x); + + > * { + /* Center everything */ + margin: auto 0; + } +} + +.title { + display: -webkit-box; + -webkit-box-orient: vertical; + overflow: hidden; + white-space: normal; + margin: 0; + color: var(--cpd-color-text-primary); + text-decoration-line: none; +} diff --git a/packages/shared-components/src/room/composer/MessageComposerUrlPreview/MessageComposerUrlPreview.tsx b/packages/shared-components/src/room/composer/MessageComposerUrlPreview/MessageComposerUrlPreview.tsx index 23798f7bfd5..9d6e51b9872 100644 --- a/packages/shared-components/src/room/composer/MessageComposerUrlPreview/MessageComposerUrlPreview.tsx +++ b/packages/shared-components/src/room/composer/MessageComposerUrlPreview/MessageComposerUrlPreview.tsx @@ -5,17 +5,22 @@ * Please see LICENSE files in the repository root for full details. */ -import React, { useCallback, type JSX } from "react"; +import React, { type JSX, useCallback } from "react"; +import { + IconButton, + InlineSpinner, + Text, + Tooltip, + // note: useIdColorHash is not used as a hook here + useIdColorHash as idColorHash, +} from "@vector-im/compound-web"; import classNames from "classnames"; -// note: useIdColorHash is not used as a hook here -import { IconButton, InlineSpinner, useIdColorHash as idColorHash } from "@vector-im/compound-web"; import { ErrorSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; import ChevronDownIcon from "@vector-im/compound-design-tokens/assets/web/icons/chevron-down"; import CloseIcon from "@vector-im/compound-design-tokens/assets/web/icons/close"; -import { type UrlPreview } from "../../timeline/event-tile/UrlPreviewGroupView"; +import { type UrlPreview } from "../../urlPreview"; import styles from "./MessageComposerUrlPreview.module.css"; -import { LinkSiteName, LinkTitle } from "../../timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview"; import { useViewModel, type ViewModel } from "../../../core/viewmodel"; import { useI18n } from "../../../core/i18n/i18nContext"; @@ -53,6 +58,40 @@ export type MessageComposerUrlPreviewSnapshotEntry = MessageComposerUrlPreviewSn matched_url: string; }; +function LinkTitle({ + title, + showTooltipOnLink, + link, + className, +}: Pick & { className?: string }): JSX.Element { + const caption = new URL(link).toString(); + const anchor = ( + + {title} + + ); + return showTooltipOnLink ? {anchor} : anchor; +} + +function LinkSiteName({ siteName, className }: Pick & { className?: string }): JSX.Element { + return ( +
+ + {siteName} + +
+ ); +} + /** Snapshot data for rendering a URL preview attached to the composer. */ export interface MessageComposerUrlPreviewSnapshot { /** URL preview to render. */ diff --git a/packages/shared-components/src/room/composer/MessageComposerUrlPreview/__snapshots__/MessageComposerUrlPreview.test.tsx.snap b/packages/shared-components/src/room/composer/MessageComposerUrlPreview/__snapshots__/MessageComposerUrlPreview.test.tsx.snap index 5396ceca0b7..db44d90328f 100644 --- a/packages/shared-components/src/room/composer/MessageComposerUrlPreview/__snapshots__/MessageComposerUrlPreview.test.tsx.snap +++ b/packages/shared-components/src/room/composer/MessageComposerUrlPreview/__snapshots__/MessageComposerUrlPreview.test.tsx.snap @@ -97,7 +97,7 @@ exports[`MessageComposerUrlPreview > failed entries > renders the failed placeho class="MessageComposerUrlPreview-module_text" > failed entries > renders the failed placeho Failed to fetch preview
loading entries > renders the loading place class="MessageComposerUrlPreview-module_text" > loading entries > renders the loading place Fetching preview…
renders a mix of loaded, loading and failed class="MessageComposerUrlPreview-module_text" > renders a mix of loaded, loading and failed A simple title
renders a mix of loaded, loading and failed class="MessageComposerUrlPreview-module_text" > renders a mix of loaded, loading and failed Fetching preview…
renders a mix of loaded, loading and failed class="MessageComposerUrlPreview-module_text" > renders a mix of loaded, loading and failed Failed to fetch preview
renders the expanded previews when not coll class="MessageComposerUrlPreview-module_text" > renders the expanded previews when not coll A simple title
span { - display: -webkit-box; - -webkit-box-orient: vertical; - -webkit-line-clamp: 1; - line-clamp: 1; - overflow: hidden; - min-width: 0; - } -} - -.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 */ - flex-direction: column; - color: var(--cpd-color-text-secondary); - overflow: clip; - - background: var(--cpd-color-bg-subtle-secondary); - - &.inline { - flex-direction: row; - gap: var(--cpd-space-4x); - padding: var(--cpd-space-3x) var(--cpd-space-4x); - - .title { - margin: 0; - } - - .siteAvatar { - margin: auto 0; - } - - .siteName { - margin: 0; - } - } - - .textContent { - padding: var(--cpd-space-3x) var(--cpd-space-4x); - - &.inline { - padding: 0; - } - - display: flex; - flex-direction: column; - gap: var(--cpd-space-1x); - } - - .caption { - display: inline-flex; - flex-direction: column; - min-width: 0; - /* Prevent blowout */ - } - - .caption { - flex: 1; - 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 { - 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; - margin: 0; -} - -.title { - color: var(--cpd-color-text-primary); - text-decoration-line: none; -} diff --git a/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx b/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx deleted file mode 100644 index 21a530a63bd..00000000000 --- a/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx +++ /dev/null @@ -1,194 +0,0 @@ -/* - * 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"; -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"; - -export default { - title: "EventTiles/LinkPreview", - component: LinkPreview, - tags: ["autodocs"], - args: { - onImageClick: fn(), - }, - play: async ({ canvasElement }) => { - await waitForBackgroundImages(canvasElement); - }, - argTypes: { - siteName: { - control: "text", - }, - author: { - control: "text", - }, - siteIcon: { control: { type: "file", accept: ".png" } }, - image: {}, - }, - parameters: { - design: { - type: "figma", - url: "https://www.figma.com/design/sI9A2kV2K4xeiyqJsL7Ey3/Link-Previews?node-id=87-7920", - }, - }, -} satisfies Meta; - -const Template: StoryFn = (args) => ( - - - -); - -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, - alt: "Element logo", - playable: false, - mxcImageFull: "mxc://server/file", - }, -}; - -export const Title = Template.bind({}); -Title.args = { - title: "A simple title", - link: "https://matrix.org", - siteName: "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", - siteName: "matrix.org", -}; -export const WithSiteIcon = Template.bind({}); -WithSiteIcon.args = { - title: "A simple title", - link: "https://matrix.org", - siteName: "matrix.org", - siteIcon: imageFile, -}; - -export const WithSiteIconAndDescription = Template.bind({}); -WithSiteIconAndDescription.args = { - title: "A simple title", - description: "A simple description with a link to https://matrix.org", - link: "https://matrix.org", - siteName: "matrix.org", - siteIcon: imageFile, -}; - -export const WithTooltip = Template.bind({}); -WithTooltip.args = { - title: "A simple title", - description: "A simple description", - showTooltipOnLink: true, - link: "https://matrix.org", - siteName: "matrix.org", -}; - -export const Article = Template.bind({}); -Article.args = { - title: "A linked article", - description: - "This is a basic description returned from the linked source, usually with a word or two about what the link contains.", - link: "https://matrix.org", - siteName: "blog.example.org", - image: { - imageThumb: imageFileWide, - imageFull: imageFileWide, - alt: "A dog", - playable: false, - mxcImageFull: "mxc://server/file", - }, -}; - -export const Video = Template.bind({}); -Video.args = { - title: "A linked video", - description: - "This is a link to a video. You cannot play the video inline yet, but you can click the play button to open the link", - link: "https://matrix.org", - siteName: "blog.example.org", - image: { - imageThumb: imageFileWide, - imageFull: imageFileWide, - alt: "A dog", - playable: true, - mxcImageFull: "mxc://server/file", - }, -}; - -export const Social = Template.bind({}); -Social.args = { - description: "Sending a small message", - link: "https://matrix.org", - siteName: "socialsite.example.org", - title: "Test user (@test)", - author: "@test", -}; - -export const SocialWithImage = Template.bind({}); -SocialWithImage.args = { - description: "Sending a message with an attached image.", - title: "Test user (@test)", - link: "https://matrix.org", - siteName: "socialsite.example.org", - author: "@test", - image: { - imageThumb: imageFileWide, - imageFull: imageFileWide, - alt: "A dog", - playable: false, - mxcImageFull: "mxc://server/file", - }, -}; - -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, - alt: "Element logo", - playable: false, - mxcImageFull: "mxc://server/file", - }, -}; - -export const WithTallImage = Template.bind({}); -WithTallImage.args = { - title: "A simple title", - description: "A simple description", - link: "https://matrix.org", - siteName: "Site name", - image: { - imageThumb: imageFileTall, - imageFull: imageFileTall, - alt: "Element logo", - playable: false, - mxcImageFull: "mxc://server/file", - }, -}; diff --git a/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.test.tsx b/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.test.tsx deleted file mode 100644 index 1fa218b6ca8..00000000000 --- a/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.test.tsx +++ /dev/null @@ -1,45 +0,0 @@ -/* - * 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, Video } = composeStories(stories); - -describe("LinkPreview", () => { - it("renders a preview", () => { - const { container } = render(); - expect(container).toMatchSnapshot(); - }); - it("renders a preview with just a title", () => { - const { container } = render(); - 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(); - }); - it("renders a playable preview that can be opened with a click", () => { - const { container } = render(<Video />); - expect(container).toMatchSnapshot(); - const button = screen.getByLabelText("Open link"); - expect(button).toHaveAttribute("href", "https://matrix.org"); - }); -}); diff --git a/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.tsx b/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.tsx deleted file mode 100644 index bf6f1098324..00000000000 --- a/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.tsx +++ /dev/null @@ -1,219 +0,0 @@ -/* - * 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 { 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"; - -import { useI18n } from "../../../../../core/i18n/i18nContext"; -import type { UrlPreview } from "../types"; -import { LinkedText } from "../../../../../core/utils/LinkedText"; -import styles from "./LinkPreview.module.css"; - -export interface LinkPreviewActions { - onImageClick: () => void; -} - -export interface AdditionalClasses { - /* - * Additional classes at add to the component - */ - className?: string; -} - -export type LinkPreviewProps = UrlPreview & LinkPreviewActions & { collapsed: boolean }; - -export function LinkTitle({ - title, - showTooltipOnLink, - link, - className, -}: Pick<LinkPreviewProps, "title" | "showTooltipOnLink" | "link"> & AdditionalClasses): JSX.Element { - const caption = new URL(link).toString(); - const anchor = ( - <Text - as="a" - type="body" - weight="semibold" - size="md" - className={classNames(styles.title, className)} - href={link} - target="_blank" - rel="noreferrer noopener" - > - {title} - </Text> - ); - return showTooltipOnLink ? <Tooltip label={caption}>{anchor}</Tooltip> : anchor; -} - -export function LinkSiteName({ - siteIcon, - siteName, - className, -}: { - siteIcon?: string; - siteName: string; -} & AdditionalClasses): JSX.Element { - return ( - <div className={classNames(styles.siteName, className)}> - {siteIcon && <Avatar size="16px" name={siteName} id={siteName} src={siteIcon} />} - <Text as="span" size="sm" weight="regular"> - {siteName} - </Text> - </div> - ); -} - -/** - * A condensed link preview that only contains the site icon, the title of the link and the site name. - */ -function LinkPreviewInline({ - title, - showTooltipOnLink, - siteIcon, - siteName, - link, -}: Omit<LinkPreviewProps, "image" | "description" | "author" | "onImageClick">): JSX.Element { - return ( - <div className={classNames(styles.containerExpanded, styles.inline)}> - {siteIcon && ( - <div className={styles.siteAvatar}> - <Avatar type="square" size="48px" name={title} id={title} src={siteIcon} /> - </div> - )} - <div className={classNames(styles.textContent, styles.inline)}> - <LinkTitle title={title} showTooltipOnLink={showTooltipOnLink} link={link} /> - {siteName && <LinkSiteName siteName={siteName} />} - </div> - </div> - ); -} - -/** - * 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(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; - - if (preview.image && !preview.image.playable) { - img = ( - <div className={styles.preview}> - <button - type="button" - style={{ - backgroundImage: `url('${preview.image.imageThumb}')`, - }} - onClick={createImageClickHandler(preview)} - aria-label={_t("timeline|url_preview|view_image")} - /> - </div> - ); - } - - 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} />; - } - - let img: JSX.Element | undefined; - - if (preview.image) { - if (preview.image.playable) { - // Playable media do not have clickable images so we don't - // overlay buttons atop buttons, instead we render a - // button for them to open the media. - img = ( - <div - style={{ - backgroundImage: `url('${preview.image.imageThumb}')`, - }} - className={styles.preview} - > - <Button - as="a" - href={preview.link} - aria-label={_t("timeline|url_preview|open_link")} - className={styles.playButton} - target="_blank" - rel="noreferrer noopener" - kind="primary" - > - <PlaySolidIcon width="24px" height="24px" /> - </Button> - </div> - ); - } else { - // Otherwise, the preview can be clicked on. - img = ( - <button - style={{ - backgroundImage: `url('${preview.image.imageThumb}')`, - }} - className={styles.preview} - onClick={createImageClickHandler(preview)} - aria-label={_t("timeline|url_preview|view_image")} - type="button" - /> - ); - } - } - - return ( - <div className={styles.containerExpanded}> - {img} - <div className={styles.textContent}> - {preview.author && ( - <Text as="span" size="md" weight="semibold"> - {preview.author} - </Text> - )} - <LinkTitle title={preview.title} showTooltipOnLink={preview.showTooltipOnLink} link={preview.link} /> - <LinkedText type="body" size="md" className={styles.description}> - {preview.description} - </LinkedText> - {preview.siteName && <LinkSiteName siteName={preview.siteName} siteIcon={preview.siteIcon} />} - </div> - </div> - ); -} diff --git a/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/__snapshots__/LinkPreview.test.tsx.snap b/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/__snapshots__/LinkPreview.test.tsx.snap deleted file mode 100644 index e63f1951bb4..00000000000 --- a/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/__snapshots__/LinkPreview.test.tsx.snap +++ /dev/null @@ -1,178 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`LinkPreview > renders a playable preview that can be opened with a click 1`] = ` -<div> - <div - class="LinkPreview-module_containerExpanded" - > - <div - class="LinkPreview-module_preview" - style="background-image: url("/static/wideImage.png");" - > - <a - aria-label="Open link" - class="_button_1nw83_8 LinkPreview-module_playButton" - data-kind="primary" - data-size="lg" - href="https://matrix.org" - rel="noreferrer noopener" - role="link" - tabindex="0" - target="_blank" - > - <svg - fill="currentColor" - height="24px" - viewBox="0 0 24 24" - width="24px" - xmlns="http://www.w3.org/2000/svg" - > - <path - d="m8.98 4.677 9.921 5.58c1.36.764 1.36 2.722 0 3.486l-9.92 5.58C7.647 20.073 6 19.11 6 17.58V6.42c0-1.53 1.647-2.493 2.98-1.743" - /> - </svg> - </a> - </div> - <div - class="LinkPreview-module_textContent" - > - <a - class="_typography_6v6n8_153 _font-body-md-semibold_6v6n8_55 LinkPreview-module_title" - href="https://matrix.org" - rel="noreferrer noopener" - target="_blank" - > - A linked video - </a> - <p - class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 LinkedText-module_container LinkPreview-module_description" - > - This is a link to a video. You cannot play the video inline yet, but you can click the play button to open the link - </p> - <div - class="LinkPreview-module_siteName" - > - <span - class="_typography_6v6n8_153 _font-body-sm-regular_6v6n8_31" - > - blog.example.org - </span> - </div> - </div> - </div> -</div> -`; - -exports[`LinkPreview > renders a preview 1`] = ` -<div> - <div - class="LinkPreview-module_containerExpanded" - > - <button - aria-label="View image" - class="LinkPreview-module_preview" - style="background-image: url("/static/element.png");" - type="button" - /> - <div - class="LinkPreview-module_textContent" - > - <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 LinkedText-module_container LinkPreview-module_description" - > - A simple description - </p> - <div - class="LinkPreview-module_siteName" - > - <span - class="_typography_6v6n8_153 _font-body-sm-regular_6v6n8_31" - > - Site name - </span> - </div> - </div> - </div> -</div> -`; - -exports[`LinkPreview > renders a preview with just a title 1`] = ` -<div> - <div - class="LinkPreview-module_containerExpanded LinkPreview-module_inline" - > - <div - class="LinkPreview-module_textContent LinkPreview-module_inline" - > - <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> - <div - class="LinkPreview-module_siteName" - > - <span - class="_typography_6v6n8_153 _font-body-sm-regular_6v6n8_31" - > - matrix.org - </span> - </div> - </div> - </div> -</div> -`; - -exports[`LinkPreview > renders a preview with just a title and description 1`] = ` -<div> - <div - class="LinkPreview-module_containerExpanded" - > - <div - class="LinkPreview-module_textContent" - > - <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 LinkedText-module_container LinkPreview-module_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 - class="LinkPreview-module_siteName" - > - <span - class="_typography_6v6n8_153 _font-body-sm-regular_6v6n8_31" - > - matrix.org - </span> - </div> - </div> - </div> -</div> -`; diff --git a/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/index.ts b/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/index.ts deleted file mode 100644 index be300783a8c..00000000000 --- a/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -/* - * 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 { LinkPreview } from "./LinkPreview"; diff --git a/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/UrlPreviewGroupView.module.css b/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/UrlPreviewGroupView.module.css deleted file mode 100644 index 0d40aee220e..00000000000 --- a/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/UrlPreviewGroupView.module.css +++ /dev/null @@ -1,42 +0,0 @@ -/* - * 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. - */ - -.hideButton { - position: absolute; - top: var(--cpd-space-2x); - right: var(--cpd-space-2x); - z-index: 1; -} - -.wrapper { - margin-top: var(--cpd-space-4x); - position: relative; - width: fit-content; - max-width: 100%; - - .previewGroup { - display: flex; - flex-direction: column; - gap: var(--cpd-space-4x); - margin-bottom: var(--cpd-space-4x); - - .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[data-event-density="compact"] { - .previewGroup { - gap: var(--cpd-space-2x); - margin-bottom: var(--cpd-space-2x); - } -} diff --git a/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/UrlPreviewGroupView.stories.tsx b/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/UrlPreviewGroupView.stories.tsx deleted file mode 100644 index 4cf61dfb72e..00000000000 --- a/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/UrlPreviewGroupView.stories.tsx +++ /dev/null @@ -1,198 +0,0 @@ -/* - * 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 { Decorator, Meta, StoryFn } from "@storybook/react-vite"; -import { - UrlPreviewGroupView, - type UrlPreviewGroupViewActions, - type UrlPreviewGroupViewSnapshot, -} from "./UrlPreviewGroupView"; -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; - -const UrlPreviewGroupViewWrapperImpl = ({ - onHideClick, - onImageClick, - onTogglePreviewLimit, - ...rest -}: UrlPreviewGroupViewProps): JSX.Element => { - const vm = useMockedViewModel(rest, { - onHideClick, - onImageClick, - onTogglePreviewLimit, - }); - return ( - <LinkedTextContext.Provider value={{}}> - <UrlPreviewGroupView vm={vm} /> - </LinkedTextContext.Provider> - ); -}; - -const UrlPreviewGroupViewWrapper = withViewDocs(UrlPreviewGroupViewWrapperImpl, UrlPreviewGroupView); - -/** - * Mimics the CSS context of .mx_EventTile_line (bubble layout) + TextualBodyView.root that - * surrounds UrlPreviewGroupView in the real app. - */ -const withBubbleLayoutContext: Decorator = (Story) => ( - <div - style={{ - display: "flex", - width: "fit-content", - maxWidth: "70%", - padding: "9px 60px 9px 9px", - background: "var(--cpd-color-bg-subtle-primary)", - borderRadius: "12px", - }} - > - <div style={{ overflowX: "hidden", overflowY: "hidden", maxWidth: "100%" }}> - <Story /> - </div> - </div> -); - -export default { - title: "Timeline/Timeline Event/UrlPreviewGroupView", - component: UrlPreviewGroupViewWrapper, - tags: ["autodocs"], - args: { - onHideClick: fn(), - onImageClick: fn(), - onTogglePreviewLimit: fn(), - }, - play: async ({ canvasElement }) => { - await waitForBackgroundImages(canvasElement); - }, - parameters: { - design: { - type: "figma", - url: "https://www.figma.com/design/sI9A2kV2K4xeiyqJsL7Ey3/Link-Previews?node-id=87-7920", - }, - }, -} 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", - showTooltipOnLink: false, - siteName: "matrix.org", - image: { - imageThumb: imageFile, - imageFull: imageFile, - alt: "The element logo", - playable: false, - mxcImageFull: "mxc://server/file", - }, - }, - ], -}; - -export const MultiplePreviewsHidden = Template.bind({}); -MultiplePreviewsHidden.args = { - previews: Default.args.previews, - 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/one", - siteName: "matrix.org", - showTooltipOnLink: false, - image: { - imageThumb: imageFile, - imageFull: imageFile, - alt: "The element logo", - playable: false, - mxcImageFull: "mxc://server/file", - }, - }, - // 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/two", - siteName: "matrix.org", - showTooltipOnLink: false, - image: { - imageThumb: tallImageFile, - imageFull: tallImageFile, - alt: "A dog", - playable: false, - mxcImageFull: "mxc://server/file", - }, - }, - { - title: "Three", - description: "One more description", - link: "https://matrix.org/three", - siteName: "matrix.org", - showTooltipOnLink: false, - image: { - imageThumb: imageFile, - imageFull: imageFile, - alt: "The element logo", - playable: false, - mxcImageFull: "mxc://server/file", - }, - }, - ], - overPreviewLimit: true, - previewsLimited: false, - totalPreviewCount: 10, -}; - -export const WithCompactView = Template.bind({}); -WithCompactView.args = { - ...MultiplePreviewsVisible.args, -}; -WithCompactView.globals = { - eventDensity: "compact", -}; - -// Testing that within the bubble layout, we still scale appropriately. - -export const InBubbleLayout = Default.bind({}); -InBubbleLayout.args = { - ...Default.args, -}; -InBubbleLayout.globals = { eventLayout: "bubble" }; -// Purely for testing that bubbles have not regressed -InBubbleLayout.tags = ["!autodocs"]; -InBubbleLayout.decorators = [withBubbleLayoutContext]; - -export const InBubbleLayoutNarrow = Default.bind({}); -InBubbleLayoutNarrow.args = { - ...InBubbleLayout.args, -}; -InBubbleLayoutNarrow.globals = { ...InBubbleLayout.globals }; -InBubbleLayoutNarrow.decorators = [...InBubbleLayout.decorators]; -InBubbleLayoutNarrow.parameters = { - initialGlobals: { - viewport: { value: "mobile1", isRotated: false }, - }, -}; diff --git a/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/UrlPreviewGroupView.test.tsx b/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/UrlPreviewGroupView.test.tsx deleted file mode 100644 index 0885d88cb46..00000000000 --- a/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/UrlPreviewGroupView.test.tsx +++ /dev/null @@ -1,36 +0,0 @@ -/* - * 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 compact density", () => { - const { container } = render(<WithCompactView />, { - presentation: { density: "compact" }, - }); - expect(container).toMatchSnapshot(); - }); -}); diff --git a/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/UrlPreviewGroupView.tsx b/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/UrlPreviewGroupView.tsx deleted file mode 100644 index 12285921fae..00000000000 --- a/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/UrlPreviewGroupView.tsx +++ /dev/null @@ -1,115 +0,0 @@ -/* - * 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 "../../../../core/viewmodel"; -import { useI18n } from "../../../../core/i18n/i18nContext"; -import type { UrlPreview } from "./types"; -import { LinkPreview } from "./LinkPreview"; -import styles from "./UrlPreviewGroupView.module.css"; -import { useEventPresentationAttributes } from "../../EventPresentation/EventPresentationContext"; - -/** Snapshot data for rendering URL previews attached to an event. */ -export interface UrlPreviewGroupViewSnapshot { - /** URL previews to render. */ - previews: Array<UrlPreview>; - /** Total number of previews available before limiting. */ - totalPreviewCount: number; - /** Whether the preview list is currently limited. */ - previewsLimited: boolean; - /** Whether more previews exist than are currently rendered. */ - overPreviewLimit: boolean; -} - -/** Props for the URL preview group view. */ -export interface UrlPreviewGroupViewProps { - /** - * The view model for the component. - */ - vm: ViewModel<UrlPreviewGroupViewSnapshot> & UrlPreviewGroupViewActions; - /** - * Extra CSS classes to apply to the component. - */ - className?: string; -} - -/** User actions emitted by the URL preview group view. */ -export interface UrlPreviewGroupViewActions { - /** Invoked when the preview limit toggle is clicked. */ - onTogglePreviewLimit: () => void; - /** Invoked when the hide-preview action is clicked. */ - onHideClick: () => Promise<void>; - /** Invoked when a preview image is clicked. */ - onImageClick: (preview: UrlPreview) => void; -} - -/** View model contract for the URL preview group view. */ -export type UrlPreviewGroupViewModel = ViewModel<UrlPreviewGroupViewSnapshot, UrlPreviewGroupViewActions>; - -function HideButton({ onHideClick }: { onHideClick: UrlPreviewGroupViewActions["onHideClick"] }): JSX.Element { - const { translate: _t } = useI18n(); - return ( - <div className={styles.hideButton}> - <IconButton - kind="secondary" - size="28px" - onClick={onHideClick} - aria-label={_t("timeline|url_preview|close")} - > - <CloseIcon /> - </IconButton> - </div> - ); -} - -/** - * Renders the URL preview group attached to a single event. - * - * The view lays out one or more link previews, can collapse or expand - * overflowed previews, and exposes a control to hide the group. - */ -export function UrlPreviewGroupView({ vm, className }: UrlPreviewGroupViewProps): JSX.Element | null { - const { translate: _t } = useI18n(); - const eventPresentationAttributes = useEventPresentationAttributes(); - const { previews, totalPreviewCount, previewsLimited, overPreviewLimit } = useViewModel(vm); - if (previews.length === 0) { - return null; - } - - let toggleButton: JSX.Element | undefined; - if (overPreviewLimit) { - toggleButton = ( - <Button className={styles.toggleButton} kind="tertiary" size="md" onClick={vm.onTogglePreviewLimit}> - {previewsLimited - ? _t("timeline|url_preview|show_n_more", { count: totalPreviewCount - previews.length }) - : _t("action|collapse")} - </Button> - ); - } - - return ( - <div className={classNames(className, styles.wrapper)} {...eventPresentationAttributes}> - <HideButton onHideClick={vm.onHideClick} /> - <div className={styles.previewGroup}> - {previews.map((preview, i) => ( - <LinkPreview - key={preview.link} - onImageClick={() => vm.onImageClick(preview)} - {...preview} - image={preview.image} - collapsed={i !== 0} - /> - ))} - {toggleButton} - </div> - </div> - ); -} diff --git a/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/__snapshots__/UrlPreviewGroupView.test.tsx.snap b/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/__snapshots__/UrlPreviewGroupView.test.tsx.snap deleted file mode 100644 index 551b0b193f9..00000000000 --- a/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/__snapshots__/UrlPreviewGroupView.test.tsx.snap +++ /dev/null @@ -1,484 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`UrlPreviewGroupView > renders a single preview 1`] = ` -<div> - <div - class="UrlPreviewGroupView-module_wrapper" - data-event-density="default" - data-event-layout="group" - > - <div - class="UrlPreviewGroupView-module_hideButton" - > - <button - aria-label="Close preview" - class="_icon-button_1215g_8" - data-kind="secondary" - role="button" - style="--cpd-icon-button-size: 28px;" - 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 - class="UrlPreviewGroupView-module_previewGroup" - > - <div - class="LinkPreview-module_containerExpanded" - > - <button - aria-label="View image" - class="LinkPreview-module_preview" - style="background-image: url("/static/element.png");" - type="button" - /> - <div - class="LinkPreview-module_textContent" - > - <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 LinkedText-module_container LinkPreview-module_description" - > - A simple description - </p> - <div - class="LinkPreview-module_siteName" - > - <span - class="_typography_6v6n8_153 _font-body-sm-regular_6v6n8_31" - > - matrix.org - </span> - </div> - </div> - </div> - </div> - </div> -</div> -`; - -exports[`UrlPreviewGroupView > renders multiple previews 1`] = ` -<div> - <div - class="UrlPreviewGroupView-module_wrapper" - data-event-density="default" - data-event-layout="group" - > - <div - class="UrlPreviewGroupView-module_hideButton" - > - <button - aria-label="Close preview" - class="_icon-button_1215g_8" - data-kind="secondary" - role="button" - style="--cpd-icon-button-size: 28px;" - 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 - class="UrlPreviewGroupView-module_previewGroup" - > - <div - class="LinkPreview-module_containerExpanded" - > - <button - aria-label="View image" - class="LinkPreview-module_preview" - style="background-image: url("/static/element.png");" - type="button" - /> - <div - class="LinkPreview-module_textContent" - > - <a - class="_typography_6v6n8_153 _font-body-md-semibold_6v6n8_55 LinkPreview-module_title" - href="https://matrix.org/one" - rel="noreferrer noopener" - target="_blank" - > - One - </a> - <p - class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 LinkedText-module_container LinkPreview-module_description" - > - A regular square image. - </p> - <div - class="LinkPreview-module_siteName" - > - <span - class="_typography_6v6n8_153 _font-body-sm-regular_6v6n8_31" - > - matrix.org - </span> - </div> - </div> - </div> - <div - class="LinkPreview-module_containerCollapsed" - > - <div - class="LinkPreview-module_preview" - > - <button - aria-label="View image" - style="background-image: url("/static/tallImage.png");" - type="button" - /> - </div> - <div - class="LinkPreview-module_textContent" - > - <a - class="_typography_6v6n8_153 _font-body-md-semibold_6v6n8_55 LinkPreview-module_title" - href="https://matrix.org/two" - rel="noreferrer noopener" - target="_blank" - > - Two - </a> - <div - class="LinkPreview-module_siteName" - > - <span - class="_typography_6v6n8_153 _font-body-sm-regular_6v6n8_31" - > - matrix.org - </span> - </div> - </div> - </div> - <div - class="LinkPreview-module_containerCollapsed" - > - <div - class="LinkPreview-module_preview" - > - <button - aria-label="View image" - style="background-image: url("/static/element.png");" - type="button" - /> - </div> - <div - class="LinkPreview-module_textContent" - > - <a - class="_typography_6v6n8_153 _font-body-md-semibold_6v6n8_55 LinkPreview-module_title" - href="https://matrix.org/three" - rel="noreferrer noopener" - target="_blank" - > - Three - </a> - <div - class="LinkPreview-module_siteName" - > - <span - class="_typography_6v6n8_153 _font-body-sm-regular_6v6n8_31" - > - matrix.org - </span> - </div> - </div> - </div> - <button - class="_button_1nw83_8 UrlPreviewGroupView-module_toggleButton" - data-kind="tertiary" - data-size="md" - role="button" - tabindex="0" - > - Collapse - </button> - </div> - </div> -</div> -`; - -exports[`UrlPreviewGroupView > renders multiple previews which are hidden 1`] = ` -<div> - <div - class="UrlPreviewGroupView-module_wrapper" - data-event-density="default" - data-event-layout="group" - > - <div - class="UrlPreviewGroupView-module_hideButton" - > - <button - aria-label="Close preview" - class="_icon-button_1215g_8" - data-kind="secondary" - role="button" - style="--cpd-icon-button-size: 28px;" - 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 - class="UrlPreviewGroupView-module_previewGroup" - > - <div - class="LinkPreview-module_containerExpanded" - > - <button - aria-label="View image" - class="LinkPreview-module_preview" - style="background-image: url("/static/element.png");" - type="button" - /> - <div - class="LinkPreview-module_textContent" - > - <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 LinkedText-module_container LinkPreview-module_description" - > - A simple description - </p> - <div - class="LinkPreview-module_siteName" - > - <span - class="_typography_6v6n8_153 _font-body-sm-regular_6v6n8_31" - > - matrix.org - </span> - </div> - </div> - </div> - <button - class="_button_1nw83_8 UrlPreviewGroupView-module_toggleButton" - data-kind="tertiary" - data-size="md" - role="button" - tabindex="0" - > - Show 9 other previews - </button> - </div> - </div> -</div> -`; - -exports[`UrlPreviewGroupView > renders with compact density 1`] = ` -<div> - <div - class="UrlPreviewGroupView-module_wrapper" - data-event-density="compact" - data-event-layout="group" - > - <div - class="UrlPreviewGroupView-module_hideButton" - > - <button - aria-label="Close preview" - class="_icon-button_1215g_8" - data-kind="secondary" - role="button" - style="--cpd-icon-button-size: 28px;" - 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 - class="UrlPreviewGroupView-module_previewGroup" - > - <div - class="LinkPreview-module_containerExpanded" - > - <button - aria-label="View image" - class="LinkPreview-module_preview" - style="background-image: url("/static/element.png");" - type="button" - /> - <div - class="LinkPreview-module_textContent" - > - <a - class="_typography_6v6n8_153 _font-body-md-semibold_6v6n8_55 LinkPreview-module_title" - href="https://matrix.org/one" - rel="noreferrer noopener" - target="_blank" - > - One - </a> - <p - class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 LinkedText-module_container LinkPreview-module_description" - > - A regular square image. - </p> - <div - class="LinkPreview-module_siteName" - > - <span - class="_typography_6v6n8_153 _font-body-sm-regular_6v6n8_31" - > - matrix.org - </span> - </div> - </div> - </div> - <div - class="LinkPreview-module_containerCollapsed" - > - <div - class="LinkPreview-module_preview" - > - <button - aria-label="View image" - style="background-image: url("/static/tallImage.png");" - type="button" - /> - </div> - <div - class="LinkPreview-module_textContent" - > - <a - class="_typography_6v6n8_153 _font-body-md-semibold_6v6n8_55 LinkPreview-module_title" - href="https://matrix.org/two" - rel="noreferrer noopener" - target="_blank" - > - Two - </a> - <div - class="LinkPreview-module_siteName" - > - <span - class="_typography_6v6n8_153 _font-body-sm-regular_6v6n8_31" - > - matrix.org - </span> - </div> - </div> - </div> - <div - class="LinkPreview-module_containerCollapsed" - > - <div - class="LinkPreview-module_preview" - > - <button - aria-label="View image" - style="background-image: url("/static/element.png");" - type="button" - /> - </div> - <div - class="LinkPreview-module_textContent" - > - <a - class="_typography_6v6n8_153 _font-body-md-semibold_6v6n8_55 LinkPreview-module_title" - href="https://matrix.org/three" - rel="noreferrer noopener" - target="_blank" - > - Three - </a> - <div - class="LinkPreview-module_siteName" - > - <span - class="_typography_6v6n8_153 _font-body-sm-regular_6v6n8_31" - > - matrix.org - </span> - </div> - </div> - </div> - <button - class="_button_1nw83_8 UrlPreviewGroupView-module_toggleButton" - data-kind="tertiary" - data-size="md" - role="button" - tabindex="0" - > - Collapse - </button> - </div> - </div> -</div> -`; diff --git a/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/index.ts b/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/index.ts deleted file mode 100644 index 2b59dac4660..00000000000 --- a/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -/* - * 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, - type UrlPreviewGroupViewModel, -} from "./UrlPreviewGroupView"; - -export { type UrlPreview } from "./types"; diff --git a/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/types.ts b/packages/shared-components/src/room/urlPreview.ts similarity index 68% rename from packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/types.ts rename to packages/shared-components/src/room/urlPreview.ts index aa5f5a554ed..695ec8c997e 100644 --- a/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/types.ts +++ b/packages/shared-components/src/room/urlPreview.ts @@ -82,3 +82,25 @@ export interface UrlPreview { */ author?: string; } + +/** Snapshot data for the URL previews attached to an event. */ +export interface UrlPreviewGroupViewSnapshot { + /** URL previews to render. */ + previews: Array<UrlPreview>; + /** Total number of previews available before limiting. */ + totalPreviewCount: number; + /** Whether the preview list is currently limited. */ + previewsLimited: boolean; + /** Whether more previews exist than are currently rendered. */ + overPreviewLimit: boolean; +} + +/** User actions accepted by the URL preview group. */ +export interface UrlPreviewGroupViewActions { + /** Invoked when the preview limit toggle is clicked. */ + onTogglePreviewLimit: () => void; + /** Invoked when the hide-preview action is clicked. */ + onHideClick: () => Promise<void>; + /** Invoked when a preview image is clicked. */ + onImageClick: (preview: UrlPreview) => void; +}