diff --git a/apps/web/res/css/views/rooms/_LinkPreviewWidget.pcss b/apps/web/res/css/views/rooms/_LinkPreviewWidget.pcss index 99f4418c31..13e8b352c8 100644 --- a/apps/web/res/css/views/rooms/_LinkPreviewWidget.pcss +++ b/apps/web/res/css/views/rooms/_LinkPreviewWidget.pcss @@ -1,20 +1,24 @@ /* -Copyright 2024 New Vector Ltd. -Copyright 2015, 2016 OpenMarket Ltd + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ +.thumbnail { + max-width: 100px; + max-height: 100px; +} -.mx_LinkPreviewWidget { - margin: $spacing-16 0 $spacing-16 auto; +.container { + margin: var(--cpd-space-4x) 0 var(--cpd-space-4x) auto; display: flex; - column-gap: $spacing-4; - border-inline-start: 2px solid $preview-widget-bar-color; + column-gap: var(--cpd-space-1x); + border-inline-start: 2px solid var(--cpd-color-bg-subtle-primary); border-radius: 2px; color: $info-plinth-fg-color; - .mx_MatrixChat_useCompactLayout & { + &.compactLayout & { margin-top: 6px; margin-bottom: 6px; } @@ -23,12 +27,12 @@ Please see LICENSE files in the repository root for full details. .mx_LinkPreviewWidget_wrapImageCaption { display: flex; flex-wrap: wrap; - row-gap: $spacing-8; + row-gap: var(--cpd-space-2x); flex: 1; .mx_LinkPreviewWidget_image, .mx_LinkPreviewWidget_caption { - margin-inline-start: $spacing-16; + margin-inline-start: var(--cpd-space-4x); min-width: 0; /* Prevent blowout */ } @@ -61,7 +65,7 @@ Please see LICENSE files in the repository root for full details. } .mx_LinkPreviewWidget_description { - margin-top: $spacing-8; + margin-top: var(--cpd-space-2x); word-wrap: break-word; -webkit-line-clamp: 3; } diff --git a/apps/web/src/components/views/messages/TextualBody.tsx b/apps/web/src/components/views/messages/TextualBody.tsx index 17851f59ee..7c1dfca4e2 100644 --- a/apps/web/src/components/views/messages/TextualBody.tsx +++ b/apps/web/src/components/views/messages/TextualBody.tsx @@ -7,7 +7,8 @@ Please see LICENSE files in the repository root for full details. */ import React, { type JSX, createRef, type SyntheticEvent, type MouseEvent } from "react"; -import { MsgType } from "matrix-js-sdk/src/matrix"; +import { MatrixEvent, MsgType } from "matrix-js-sdk/src/matrix"; +import type { UrlPreviewViewSnapshotPreview } from "@element-hq/web-shared-components"; import EventContentBody from "./EventContentBody.tsx"; import { formatDate } from "../../../DateUtils"; @@ -21,7 +22,6 @@ import { Action } from "../../../dispatcher/actions"; import QuestionDialog from "../dialogs/QuestionDialog"; import MessageEditHistoryDialog from "../dialogs/MessageEditHistoryDialog"; import EditMessageComposer from "../rooms/EditMessageComposer"; -import LinkPreviewGroup from "../rooms/LinkPreviewGroup"; import { type IBodyProps } from "./IBodyProps"; import RoomContext from "../../../contexts/RoomContext"; import AccessibleButton from "../elements/AccessibleButton"; @@ -30,6 +30,23 @@ import { EditWysiwygComposer } from "../rooms/wysiwyg_composer"; import { type IEventTileOps } from "../rooms/EventTile"; import { UrlPreviewViewModel } from "../../../viewmodels/message-body/UrlPreviewViewModel.ts"; import { MatrixClientPeg } from "../../../MatrixClientPeg.ts"; +import { useMediaVisible } from "../../../hooks/useMediaVisible.ts"; + +/** + * Wrapper component for LinkPreviewGroup. Can be removed when TextualBody is ported to a + * functional component. + */ +function LinkPreviewGroupWrapper({ + vm, + mxEvent, +}: { + vm: UrlPreviewViewModel; + mxEvent: MatrixEvent; +}): React.ReactElement { + const [mediaVisible] = useMediaVisible(mxEvent); + + return ; +} export default class TextualBody extends React.Component { private readonly contentRef = createRef(); @@ -44,6 +61,40 @@ export default class TextualBody extends React.Component { } } + public readonly onUrlPreviewImageClicked = (preview: UrlPreviewViewSnapshotPreview): void => { + /* private onImageClick = (ev: React.MouseEvent): void => { + const p = this.props.preview; + if (ev.button != 0 || ev.metaKey) return; + ev.preventDefault(); + + if (!p.image?.imageFull) { + return; + } + + const params: Omit, "onFinished"> = { + src: p.image.imageFull, + width: p.image.width, + height: p.image.height, + name: p.title, + fileSize: p.image.size, + link: p.link, + }; + + if (this.image.current) { + const clientRect = this.image.current.getBoundingClientRect(); + + params.thumbnailInfo = { + width: clientRect.width, + height: clientRect.height, + positionX: clientRect.x, + positionY: clientRect.y, + }; + } + + Modal.createDialog(ImageView, params, "mx_Dialog_lightbox", undefined, true); + }; */ + }; + public componentDidUpdate(prevProps: Readonly): void { // TODO: This is crap crap crap, we should delegate this sort of logic to the // VM and have it figure this out. @@ -56,6 +107,7 @@ export default class TextualBody extends React.Component { eventRef: this.contentRef, eventSendTime: this.props.mxEvent.getTs(), eventId: this.props.mxEvent.getId(), + onImageClicked: this.onUrlPreviewImageClicked.bind(this), }); this.urlPreviewVMRef.current = urlPreviewVM; } else if (!this.props.editState) { @@ -286,7 +338,7 @@ export default class TextualBody extends React.Component { } const urlPreviewWidget = this.urlPreviewVMRef.current && ( - + ); if (isEmote) { diff --git a/apps/web/src/viewmodels/message-body/UrlPreviewViewModel.ts b/apps/web/src/viewmodels/message-body/UrlPreviewViewModel.ts index 4a2ed337d8..393293f73c 100644 --- a/apps/web/src/viewmodels/message-body/UrlPreviewViewModel.ts +++ b/apps/web/src/viewmodels/message-body/UrlPreviewViewModel.ts @@ -5,14 +5,22 @@ * Please see LICENSE files in the repository root for full details. */ -import { BaseViewModel } from "@element-hq/web-shared-components"; -import { decode } from "html-entities"; +import { + BaseViewModel, + type UrlPreviewGroupViewSnapshot, + type UrlPreviewGroupViewActions, + type UrlPreviewViewSnapshotPreview, +} from "@element-hq/web-shared-components"; +// import { decode } from "html-entities"; import { logger as rootLogger } from "matrix-js-sdk/src/logger"; import { type IPreviewUrlResponse, type MatrixClient, MatrixError } from "matrix-js-sdk/src/matrix"; import type { RefObject } from "react"; import { isPermalinkHost } from "../../utils/permalinks/Permalinks"; import { mediaFromMxc } from "../../customisations/Media"; +import { linkifyAndSanitizeHtml } from "../../Linkify"; +import PlatformPeg from "../../PlatformPeg"; +import { thumbHeight } from "../../ImageUtils"; const logger = rootLogger.getChild("UrlPreviewViewModel"); @@ -21,28 +29,7 @@ export interface UrlPreviewViewModelProps { eventSendTime: number; eventRef: RefObject; eventId?: string; -} - -export interface UrlPreviewViewSnapshotInterfacePreview { - link: string; - title: string; - siteName?: string; - description?: string; - image?: { - imageThumb: string; - imageFull: string; - size?: number; - width?: number; - height?: number; - }; -} - -interface UrlPreviewViewSnapshotInterface { - previews: Array; - hidden: boolean; - totalPreviewCount: number; - previewsLimited: boolean; - overPreviewLimit: boolean; + onImageClicked: (preview: UrlPreviewViewSnapshotPreview) => void; } export const MAX_PREVIEWS_WHEN_LIMITED = 2; @@ -74,7 +61,10 @@ function getTitleFromOpenGraph(response: IPreviewUrlResponse, link: string): str /** * ViewModel for fetching and rendering room previews. */ -export class UrlPreviewViewModel extends BaseViewModel { +export class UrlPreviewViewModel + extends BaseViewModel + implements UrlPreviewGroupViewActions +{ private static isLinkPreviewable(node: Element): boolean { // don't try to preview relative links const href = node.getAttribute("href"); @@ -129,7 +119,7 @@ export class UrlPreviewViewModel extends BaseViewModel { + private async fetchPreview(link: string, ts: number): Promise { const cached = this.previewCache.get(link); if (cached) { return cached; @@ -143,24 +133,37 @@ export class UrlPreviewViewModel extends BaseViewModel(); + private previewCache = new Map(); + private readonly onImageClicked: (preview: UrlPreviewViewSnapshotPreview) => void; public constructor(props: UrlPreviewViewModelProps) { super(props, { @@ -195,6 +199,7 @@ export class UrlPreviewViewModel extends BaseViewModel { this.showUrlPreview = true; void this.computeSnapshot(); // FIXME: persist this somewhere smarter than local storage global.localStorage?.removeItem(this.storageKey); - } + }; - public onHideClick(): void { + public readonly onHideClick = (): void => { this.showUrlPreview = false; void this.computeSnapshot(); // FIXME: persist this somewhere smarter than local storage global.localStorage?.setItem(this.storageKey, "1"); - } + }; - public onTogglePreviewLimit(): void { + public readonly onTogglePreviewLimit = (): void => { this.limitPreviews = !this.limitPreviews; void this.computeSnapshot(); - } + }; + + public readonly onImageClick = (preview: UrlPreviewViewSnapshotPreview): void => { + // Render a lightbox. + this.onImageClicked(preview); + }; } diff --git a/packages/shared-components/src/event-tiles/UrlPreviewView/LinkPreview.module.css b/packages/shared-components/src/event-tiles/UrlPreviewView/LinkPreview.module.css new file mode 100644 index 0000000000..13e8b352c8 --- /dev/null +++ b/packages/shared-components/src/event-tiles/UrlPreviewView/LinkPreview.module.css @@ -0,0 +1,73 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +.thumbnail { + max-width: 100px; + max-height: 100px; +} + +.container { + margin: var(--cpd-space-4x) 0 var(--cpd-space-4x) auto; + display: flex; + column-gap: var(--cpd-space-1x); + border-inline-start: 2px solid var(--cpd-color-bg-subtle-primary); + border-radius: 2px; + color: $info-plinth-fg-color; + + &.compactLayout & { + margin-top: 6px; + margin-bottom: 6px; + } + + /* Exclude mx_LinkPreviewGroup_hide from wrapping */ + .mx_LinkPreviewWidget_wrapImageCaption { + display: flex; + flex-wrap: wrap; + row-gap: var(--cpd-space-2x); + flex: 1; + + .mx_LinkPreviewWidget_image, + .mx_LinkPreviewWidget_caption { + margin-inline-start: var(--cpd-space-4x); + min-width: 0; /* Prevent blowout */ + } + + .mx_LinkPreviewWidget_image { + flex: 0 0 100px; + text-align: center; + cursor: pointer; + } + + .mx_LinkPreviewWidget_caption { + flex: 1; + overflow: hidden; /* cause it to wrap rather than clip */ + } + + .mx_LinkPreviewWidget_title, + .mx_LinkPreviewWidget_description { + display: -webkit-box; + -webkit-box-orient: vertical; + overflow: hidden; + white-space: normal; + } + + .mx_LinkPreviewWidget_title { + font-weight: bold; + -webkit-line-clamp: 2; + + .mx_LinkPreviewWidget_siteName { + font-weight: normal; + } + } + + .mx_LinkPreviewWidget_description { + margin-top: var(--cpd-space-2x); + word-wrap: break-word; + -webkit-line-clamp: 3; + } + } +} diff --git a/packages/shared-components/src/event-tiles/UrlPreviewView/LinkPreview.stories.tsx b/packages/shared-components/src/event-tiles/UrlPreviewView/LinkPreview.stories.tsx new file mode 100644 index 0000000000..8d2f78cf97 --- /dev/null +++ b/packages/shared-components/src/event-tiles/UrlPreviewView/LinkPreview.stories.tsx @@ -0,0 +1,75 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import React from "react"; +import { fn } from "storybook/test"; +import type { Meta, StoryFn } from "@storybook/react-vite"; + +import { LinkPreview } from "./LinkPreview"; + +export default { + title: "Event/UrlPreviewView", + component: LinkPreview, + tags: ["autodocs"], + args: { + onHideClick: fn(), + onImageClick: fn(), + }, +} as Meta; + +const Template: StoryFn = (args) => ; + +export const Default = Template.bind({}); +Default.args = { + mediaVisible: true, + title: "A simple title", + description: "A simple description", + link: "https://matrix.org", + siteName: "Site name", + image: { + imageThumb: "https://images.dog.ceo/breeds/kuvasz/n02104029_1369.jpg", + imageFull: "https://images.dog.ceo/breeds/kuvasz/n02104029_1369.jpg", + }, +}; + +export const Title = Template.bind({}); +Title.args = { + mediaVisible: true, + title: "A simple title", + link: "https://matrix.org", +}; + +export const TitleAndDescription = Template.bind({}); +TitleAndDescription.args = { + mediaVisible: true, + title: "A simple title", + description: "With a good ol description", + link: "https://matrix.org", +}; + +export const WithTooltip = Template.bind({}); +WithTooltip.args = { + mediaVisible: true, + title: "A simple title", + description: "With a good ol description", + showTooltipOnLink: true, + link: "https://matrix.org", +}; + +export const WithCompactLayout = Template.bind({}); +WithCompactLayout.args = { + mediaVisible: true, + compactLayout: true, + title: "A simple title", + description: "A simple description", + link: "https://matrix.org", + siteName: "Site name", + image: { + imageThumb: "https://images.dog.ceo/breeds/kuvasz/n02104029_1369.jpg", + imageFull: "https://images.dog.ceo/breeds/kuvasz/n02104029_1369.jpg", + }, +}; diff --git a/packages/shared-components/src/event-tiles/UrlPreviewView/LinkPreview.test.tsx b/packages/shared-components/src/event-tiles/UrlPreviewView/LinkPreview.test.tsx new file mode 100644 index 0000000000..16a5a74e4c --- /dev/null +++ b/packages/shared-components/src/event-tiles/UrlPreviewView/LinkPreview.test.tsx @@ -0,0 +1,22 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { render } from "@test-utils"; +import { composeStories } from "@storybook/react-vite"; +import { describe, it, expect } from "vitest"; +import React from "react"; + +import * as stories from "./LinkPreview.stories.tsx"; + +const { Default } = composeStories(stories); + +describe("LinkPreview", () => { + it("renders an empty view", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/packages/shared-components/src/event-tiles/UrlPreviewView/LinkPreview.tsx b/packages/shared-components/src/event-tiles/UrlPreviewView/LinkPreview.tsx new file mode 100644 index 0000000000..ad077b373f --- /dev/null +++ b/packages/shared-components/src/event-tiles/UrlPreviewView/LinkPreview.tsx @@ -0,0 +1,108 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import React, { type MouseEventHandler, type JSX, useCallback } from "react"; +import { IconButton, Tooltip } from "@vector-im/compound-web"; +import CloseIcon from "@vector-im/compound-design-tokens/assets/web/icons/close"; +import classNames from "classnames"; + +import { useI18n } from "../../utils/i18nContext"; +import styles from "./LinkPreview.module.css"; +import type { UrlPreviewViewSnapshotPreview } from "./types"; + +export interface LinkPreviewActions { + onHideClick?: () => void; + onImageClick: () => void; +} + +interface LinkPreviewAdditionalProps { + mediaVisible: boolean; + compactLayout?: boolean; +} + +export type LinkPreviewProps = UrlPreviewViewSnapshotPreview & LinkPreviewActions & LinkPreviewAdditionalProps; + +export function LinkPreview({ + onHideClick, + onImageClick, + mediaVisible, + compactLayout, + ...preview +}: LinkPreviewProps): JSX.Element { + const { translate: _t } = useI18n(); + const hideButton = onHideClick && ( + onHideClick()} + aria-label={_t("timeline|url_preview|close")} + > + + + ); + + const onImageClickHandler = useCallback( + (ev) => { + if (ev.button != 0 || ev.metaKey) return; + ev.preventDefault(); + + if (!preview.image?.imageFull) { + return; + } + onImageClick(); + }, + [preview, onImageClick], + ); + + let img: JSX.Element | undefined; + // Don't render a button to show the image, just hide it outright + if (preview.image?.imageThumb && mediaVisible) { + // Image width and height sanitized in the view model. + img = ( + + + + ); + } + + const anchor = ( + + {preview.title} + + ); + return ( + + + {img} + + + {preview.showTooltipOnLink ? ( + {anchor} + ) : ( + anchor + )} + {preview.siteName && ( + {" - " + preview.siteName} + )} + + {preview.description && ( + + )} + + + {hideButton} + + ); +} diff --git a/packages/shared-components/src/event-tiles/UrlPreviewView/UrlPreviewGroupView.module.css b/packages/shared-components/src/event-tiles/UrlPreviewView/UrlPreviewGroupView.module.css new file mode 100644 index 0000000000..e6807a5afd --- /dev/null +++ b/packages/shared-components/src/event-tiles/UrlPreviewView/UrlPreviewGroupView.module.css @@ -0,0 +1,24 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +.container { + .mx_LinkPreviewGroup_hide { + cursor: pointer; + width: 18px; + height: 18px; + + svg { + flex: 0 0 40px; + visibility: hidden; + } + } + + &:hover .mx_LinkPreviewGroup_hide svg, + .mx_LinkPreviewGroup_hide:focus-visible:focus svg { + visibility: visible; + } +} diff --git a/packages/shared-components/src/event-tiles/UrlPreviewView/UrlPreviewGroupView.stories.tsx b/packages/shared-components/src/event-tiles/UrlPreviewView/UrlPreviewGroupView.stories.tsx new file mode 100644 index 0000000000..2151d9f8f0 --- /dev/null +++ b/packages/shared-components/src/event-tiles/UrlPreviewView/UrlPreviewGroupView.stories.tsx @@ -0,0 +1,97 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import React, { type JSX } from "react"; +import { fn } from "storybook/test"; +import type { Meta, StoryFn } from "@storybook/react-vite"; + +import { + UrlPreviewGroupView, + type UrlPreviewGroupViewActions, + type UrlPreviewGroupViewSnapshot, +} from "./UrlPreviewGroupView"; +import { useMockedViewModel } from "../../viewmodel"; + +type UrlPreviewGroupViewProps = UrlPreviewGroupViewSnapshot & UrlPreviewGroupViewActions & { mediaVisible: boolean }; + +const UrlPreviewGroupViewWrapper = ({ + mediaVisible, + onHideClick, + onImageClick, + onTogglePreviewLimit, + ...rest +}: UrlPreviewGroupViewProps): JSX.Element => { + const vm = useMockedViewModel(rest, { + onHideClick, + onImageClick, + onTogglePreviewLimit, + }); + return ; +}; + +export default { + title: "Event/UrlPreviewGroupView", + component: UrlPreviewGroupViewWrapper, + tags: ["autodocs"], + args: { + onHideClick: fn(), + onImageClick: fn(), + onTogglePreviewLimit: fn(), + }, +} as Meta; + +const Template: StoryFn = (args) => ; + +export const Default = Template.bind({}); +Default.args = { + mediaVisible: true, + previews: [ + { + title: "A simple title", + description: "A simple description", + link: "https://matrix.org", + image: { + imageThumb: "https://images.dog.ceo/breeds/kuvasz/n02104029_1369.jpg", + imageFull: "https://images.dog.ceo/breeds/kuvasz/n02104029_1369.jpg", + }, + }, + ], +}; + +export const MultiplePreviews = Template.bind({}); +MultiplePreviews.args = { + mediaVisible: true, + previews: [ + { + title: "One", + description: "Good dog", + link: "https://matrix.org", + image: { + imageThumb: "https://images.dog.ceo/breeds/otterhound/n02091635_979.jpg", + imageFull: "https://images.dog.ceo/breeds/otterhound/n02091635_979.jpg", + }, + }, + { + title: "Two", + description: "Good dog", + link: "https://matrix.org", + image: { + imageThumb: "https://images.dog.ceo/breeds/eskimo/n02109961_930.jpg", + imageFull: "https://images.dog.ceo/breeds/eskimo/n02109961_930.jpg", + }, + }, + { + title: "Three", + description: "Good dog", + link: "https://matrix.org", + image: { + imageThumb: "https://images.dog.ceo/breeds/pekinese/n02086079_22136.jpg", + imageFull: "https://images.dog.ceo/breeds/pekinese/n02086079_22136.jpg", + }, + }, + ], +}; diff --git a/packages/shared-components/src/event-tiles/UrlPreviewView/UrlPreviewGroupView.test.tsx b/packages/shared-components/src/event-tiles/UrlPreviewView/UrlPreviewGroupView.test.tsx new file mode 100644 index 0000000000..843af0e3df --- /dev/null +++ b/packages/shared-components/src/event-tiles/UrlPreviewView/UrlPreviewGroupView.test.tsx @@ -0,0 +1,22 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { render } from "@test-utils"; +import { composeStories } from "@storybook/react-vite"; +import { describe, it, expect } from "vitest"; +import React from "react"; + +import * as stories from "./UrlPreviewGroupView.stories.tsx"; + +const { Default } = composeStories(stories); + +describe("UrlPreviewGroupView", () => { + it("renders an empty view", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/packages/shared-components/src/event-tiles/UrlPreviewView/UrlPreviewGroupView.tsx b/packages/shared-components/src/event-tiles/UrlPreviewView/UrlPreviewGroupView.tsx new file mode 100644 index 0000000000..1c0e690122 --- /dev/null +++ b/packages/shared-components/src/event-tiles/UrlPreviewView/UrlPreviewGroupView.tsx @@ -0,0 +1,78 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import React, { type JSX } from "react"; +import { Button } from "@vector-im/compound-web"; + +import { useViewModel, type ViewModel } from "../../viewmodel"; +import { useI18n } from "../../utils/i18nContext"; +import styles from "./UrlPreviewGroupView.module.css"; +import type { UrlPreviewViewSnapshotPreview } from "./types"; +import { LinkPreview } from "./LinkPreview"; + +export interface UrlPreviewGroupViewSnapshot { + previews: Array; + hidden: boolean; + totalPreviewCount: number; + previewsLimited: boolean; + overPreviewLimit: boolean; +} + +export interface UrlPreviewGroupViewProps { + vm: ViewModel & UrlPreviewGroupViewActions; + // TODO: Move to VM + mediaVisible: boolean; +} + +export interface UrlPreviewGroupViewActions { + onTogglePreviewLimit: () => void; + onHideClick: () => void; + onImageClick: (preview: UrlPreviewViewSnapshotPreview) => void; +} + +/** + * UrlPreviewGroupView renders a compact event tile with an icon, title, and optional subtitle/content. + * + * @example + * ```tsx + * } title="Room created" /> + * ``` + */ +export function UrlPreviewGroupView({ vm, mediaVisible }: UrlPreviewGroupViewProps): JSX.Element | null { + const { translate: _t } = useI18n(); + console.log(vm); + const { previews, hidden, totalPreviewCount, previewsLimited, overPreviewLimit } = useViewModel(vm); + if (hidden) { + return null; + } + + let toggleButton: JSX.Element | undefined; + if (overPreviewLimit) { + toggleButton = ( + vm.onTogglePreviewLimit()}> + {previewsLimited + ? _t("timeline|url_preview|show_n_more", { count: totalPreviewCount - previews.length }) + : _t("action|collapse")} + + ); + } + + return ( + + {previews.map((preview, i) => ( + vm.onImageClick(preview)} + {...preview} + /> + ))} + {toggleButton} + + ); +} diff --git a/packages/shared-components/src/event-tiles/UrlPreviewView/index.ts b/packages/shared-components/src/event-tiles/UrlPreviewView/index.ts new file mode 100644 index 0000000000..7f732494e9 --- /dev/null +++ b/packages/shared-components/src/event-tiles/UrlPreviewView/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +export { + UrlPreviewGroupView, + type UrlPreviewGroupViewSnapshot, + type UrlPreviewGroupViewProps, + type UrlPreviewGroupViewActions, +} from "./UrlPreviewGroupView"; + +export { type UrlPreviewViewSnapshotPreview } from "./types"; diff --git a/packages/shared-components/src/event-tiles/UrlPreviewView/types.ts b/packages/shared-components/src/event-tiles/UrlPreviewView/types.ts new file mode 100644 index 0000000000..97b8f50453 --- /dev/null +++ b/packages/shared-components/src/event-tiles/UrlPreviewView/types.ts @@ -0,0 +1,30 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +export interface UrlPreviewViewSnapshotPreview { + /** + * The URL for the preview. + */ + link: string; + /** + * Should the link have a tooltip. Should be `true` if the platform does not provide a tooltip. + */ + showTooltipOnLink?: boolean; + /** + * + */ + title: string; + siteName?: string; + description?: string; + image?: { + imageThumb: string; + imageFull: string; + fileSize?: number; + width?: number; + height?: number; + }; +} diff --git a/packages/shared-components/src/index.ts b/packages/shared-components/src/index.ts index 5ee6c4d7f2..90cea905cf 100644 --- a/packages/shared-components/src/index.ts +++ b/packages/shared-components/src/index.ts @@ -16,6 +16,7 @@ export * from "./crypto/SasEmoji"; export * from "./event-tiles/EncryptionEventView"; export * from "./event-tiles/EventTileBubble"; export * from "./event-tiles/TextualEventView"; +export * from "./event-tiles/UrlPreviewView"; export * from "./message-body/MediaBody"; export * from "./message-body/MessageTimestampView"; export * from "./message-body/DecryptionFailureBodyView"; diff --git a/packages/shared-components/test-results/.last-run.json b/packages/shared-components/test-results/.last-run.json new file mode 100644 index 0000000000..5fca3f84bc --- /dev/null +++ b/packages/shared-components/test-results/.last-run.json @@ -0,0 +1,4 @@ +{ + "status": "failed", + "failedTests": [] +} \ No newline at end of file