From 1e12c6bc1aee35594dcd0c2abd69720239788f11 Mon Sep 17 00:00:00 2001 From: cristianoliveira Date: Wed, 11 Nov 2026 08:09:29 +0100 Subject: [PATCH] refactor(conversation): share file thumbnail presentation (WPB-29908) --- .../script/components/fileCard/fileCard.tsx | 2 + .../fileCardPreview/fileCardPreview.styles.ts | 47 +++++++++ .../fileCardPreview/fileCardPreview.test.tsx | 99 +++++++++++++++++++ .../fileCardPreview/fileCardPreview.tsx | 54 ++++++++++ .../fileAssetWithPreview.styles.ts | 29 ------ .../fileAssetWithPreview.test.tsx | 91 +++++++++++++++++ .../fileAssetWithPreview.tsx | 24 ++--- 7 files changed, 301 insertions(+), 45 deletions(-) create mode 100644 apps/webapp/src/script/components/fileCard/fileCardPreview/fileCardPreview.styles.ts create mode 100644 apps/webapp/src/script/components/fileCard/fileCardPreview/fileCardPreview.test.tsx create mode 100644 apps/webapp/src/script/components/fileCard/fileCardPreview/fileCardPreview.tsx create mode 100644 apps/webapp/src/script/components/messagesList/message/contentMessage/asset/multipartAssets/fileAssetCard/fileAssetWithPreview/fileAssetWithPreview.test.tsx diff --git a/apps/webapp/src/script/components/fileCard/fileCard.tsx b/apps/webapp/src/script/components/fileCard/fileCard.tsx index 1746b1fda3e..7c07f965911 100644 --- a/apps/webapp/src/script/components/fileCard/fileCard.tsx +++ b/apps/webapp/src/script/components/fileCard/fileCard.tsx @@ -25,6 +25,7 @@ import {FileCardHeader} from './fileCardHeader/fileCardHeader'; import {FileCardIcon} from './fileCardIcon/fileCardIcon'; import {FileCardLoading} from './fileCardLoading/fileCardLoading'; import {FileCardName} from './fileCardName/fileCardName'; +import {FileCardPreview} from './fileCardPreview/fileCardPreview'; import {FileCardRoot} from './fileCardRoot/fileCardRoot'; import {FileCardType} from './fileCardType/fileCardType'; @@ -85,6 +86,7 @@ export const FileCard = { CloseButton: FileCardCloseButton, Name: FileCardName, Content: FileCardContent, + Preview: FileCardPreview, Loading: FileCardLoading, Error: FileCardError, }; diff --git a/apps/webapp/src/script/components/fileCard/fileCardPreview/fileCardPreview.styles.ts b/apps/webapp/src/script/components/fileCard/fileCardPreview/fileCardPreview.styles.ts new file mode 100644 index 00000000000..c923fc913ef --- /dev/null +++ b/apps/webapp/src/script/components/fileCard/fileCardPreview/fileCardPreview.styles.ts @@ -0,0 +1,47 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import type {CSSObject} from '@emotion/react'; + +export const wrapperStyles: CSSObject = { + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + width: '100%', + backgroundColor: 'var(--foreground-fade-8)', + border: '1px solid var(--border-color)', + borderRadius: '10px', + color: 'var(--gray-70)', + aspectRatio: '16/9', + overflow: 'hidden', + padding: 0, + margin: 0, + cursor: 'pointer', + + '@supports not (aspect-ratio: 16/9)': { + paddingBottom: '56.25%', + }, +}; + +export const imageStyles: CSSObject = { + width: '100%', + height: '100%', + objectFit: 'cover', + objectPosition: 'top', +}; diff --git a/apps/webapp/src/script/components/fileCard/fileCardPreview/fileCardPreview.test.tsx b/apps/webapp/src/script/components/fileCard/fileCardPreview/fileCardPreview.test.tsx new file mode 100644 index 00000000000..c79a0654761 --- /dev/null +++ b/apps/webapp/src/script/components/fileCard/fileCardPreview/fileCardPreview.test.tsx @@ -0,0 +1,99 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {fireEvent, render, screen} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import {FileCard} from '../fileCard'; + +describe('FileCard.Preview', () => { + it('runs the caller action through an accessible native button', async () => { + const onClick = jest.fn(); + render(); + + await userEvent.click(screen.getByRole('button', {name: 'Download report.pdf'})); + + expect(onClick).toHaveBeenCalledTimes(1); + expect(screen.getByRole('presentation')).toHaveAttribute('src', 'preview.png'); + }); + + it('supports keyboard activation without forcing dialog behavior', async () => { + const onClick = jest.fn(); + render(); + + await userEvent.tab(); + await userEvent.keyboard('{Enter}'); + + expect(onClick).toHaveBeenCalledTimes(1); + expect(screen.getByRole('button')).not.toHaveAttribute('aria-haspopup'); + }); + + it('preserves dialog metadata and disabled state supplied by the caller', async () => { + const onClick = jest.fn(); + render( + , + ); + + const button = screen.getByRole('button', {name: 'Open report.pdf'}); + await userEvent.click(button); + + expect(button).toBeDisabled(); + expect(button).toHaveAttribute('aria-controls', 'pdf-dialog'); + expect(button).toHaveAttribute('aria-haspopup', 'dialog'); + expect(button).toHaveAttribute('aria-expanded', 'false'); + expect(onClick).not.toHaveBeenCalled(); + }); + + it('allows the caller to control image visibility and provide a loading overlay', () => { + const onImageLoad = jest.fn(); + const {rerender} = render( + + Loading preview + , + ); + + const image = screen.getByRole('presentation', {hidden: true}); + expect(image).not.toBeVisible(); + expect(screen.getByRole('status')).toHaveTextContent('Loading preview'); + fireEvent.load(image); + expect(onImageLoad).toHaveBeenCalledTimes(1); + + rerender(); + expect(screen.getByRole('presentation')).toBeVisible(); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + }); + + it('reports image errors without choosing a feature-specific fallback', () => { + const onImageError = jest.fn(); + render(); + + fireEvent.error(screen.getByRole('presentation')); + + expect(onImageError).toHaveBeenCalledTimes(1); + expect(screen.getByRole('button', {name: 'Download report.pdf'})).toBeInTheDocument(); + }); +}); diff --git a/apps/webapp/src/script/components/fileCard/fileCardPreview/fileCardPreview.tsx b/apps/webapp/src/script/components/fileCard/fileCardPreview/fileCardPreview.tsx new file mode 100644 index 00000000000..c182e8f500b --- /dev/null +++ b/apps/webapp/src/script/components/fileCard/fileCardPreview/fileCardPreview.tsx @@ -0,0 +1,54 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import type {ButtonHTMLAttributes, ReactElement, ReactEventHandler} from 'react'; + +import {imageStyles, wrapperStyles} from './fileCardPreview.styles'; + +interface FileCardPreviewProps extends ButtonHTMLAttributes { + 'aria-label': string; + src?: string; + imageVisible?: boolean; + onImageLoad?: ReactEventHandler; + onImageError?: ReactEventHandler; +} + +export const FileCardPreview = ({ + src, + imageVisible = true, + onImageLoad, + onImageError, + children, + type = 'button', + ...buttonProps +}: FileCardPreviewProps): ReactElement => { + return ( + + ); +}; diff --git a/apps/webapp/src/script/components/messagesList/message/contentMessage/asset/multipartAssets/fileAssetCard/fileAssetWithPreview/fileAssetWithPreview.styles.ts b/apps/webapp/src/script/components/messagesList/message/contentMessage/asset/multipartAssets/fileAssetCard/fileAssetWithPreview/fileAssetWithPreview.styles.ts index 9eea627497a..bf065f3311c 100644 --- a/apps/webapp/src/script/components/messagesList/message/contentMessage/asset/multipartAssets/fileAssetCard/fileAssetWithPreview/fileAssetWithPreview.styles.ts +++ b/apps/webapp/src/script/components/messagesList/message/contentMessage/asset/multipartAssets/fileAssetCard/fileAssetWithPreview/fileAssetWithPreview.styles.ts @@ -19,35 +19,6 @@ import {CSSObject} from '@emotion/react'; -export const contentWrapperStyles: CSSObject = { - display: 'flex', - justifyContent: 'center', - alignItems: 'center', - width: '100%', - backgroundColor: 'var(--foreground-fade-8)', - border: '1px solid var(--border-color)', - borderRadius: '10px', - color: 'var(--gray-70)', - aspectRatio: '16/9', - overflow: 'hidden', - padding: 0, - margin: 0, - cursor: 'pointer', - - // Fallback for the above aspect-ratio - '@supports not (aspect-ratio: 16/9)': { - paddingBottom: '56.25%', - }, -}; - -export const imageStyles: CSSObject = { - width: '100%', - height: '100%', - objectFit: 'cover', - objectPosition: 'top', - opacity: 'var(--opacity)', -}; - export const infoOverlayStyles: CSSObject = { position: 'absolute', top: 0, diff --git a/apps/webapp/src/script/components/messagesList/message/contentMessage/asset/multipartAssets/fileAssetCard/fileAssetWithPreview/fileAssetWithPreview.test.tsx b/apps/webapp/src/script/components/messagesList/message/contentMessage/asset/multipartAssets/fileAssetCard/fileAssetWithPreview/fileAssetWithPreview.test.tsx new file mode 100644 index 00000000000..e5d3be42b1f --- /dev/null +++ b/apps/webapp/src/script/components/messagesList/message/contentMessage/asset/multipartAssets/fileAssetCard/fileAssetWithPreview/fileAssetWithPreview.test.tsx @@ -0,0 +1,91 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {fireEvent, render, screen} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import {container} from 'tsyringe'; + +import {FileAssetWithPreview} from './fileAssetWithPreview'; + +import {withThemeAndRootContext} from '../../../../../../../../auth/util/test/testUtil'; +import { + createRootContextValueForTest, + createRootProviderWrapperForTest, +} from '../../../../../../../../page/testSupport/rootContextTestSupport'; +import {CellsRepository} from '../../../../../../../../repositories/cells/cellsRepository'; +import {translateForTest} from '../../../../../../../../util/test/translateForTest'; + +describe('Cells FileAssetWithPreview', () => { + const props = { + id: 'pdf-preview', + src: 'https://example.com/report.pdf', + extension: 'pdf', + name: 'report', + size: '1 MB', + imagePreviewUrl: 'https://example.com/thumbnail.png', + isLoading: true, + isError: false, + senderName: 'Sender', + timestamp: 1700000000000, + }; + const wrapper = createRootProviderWrapperForTest(createRootContextValueForTest({translate: translateForTest})); + + beforeEach(() => { + container.registerInstance(CellsRepository, {} as CellsRepository); + }); + + afterEach(() => { + container.reset(); + }); + + it('opens the existing preview dialog while the original file is loading', async () => { + render(withThemeAndRootContext(, wrapper)); + const button = screen.getByRole('button', {name: 'cells.options.open'}); + const image = screen.getByRole('presentation', {hidden: true}); + expect(image).not.toBeVisible(); + fireEvent.load(image); + expect(image).toBeVisible(); + + await userEvent.click(button); + + expect(await screen.findByRole('dialog')).toBeInTheDocument(); + expect(button).toHaveAttribute('aria-expanded', 'true'); + expect(button).toHaveAttribute('aria-haspopup', 'dialog'); + }); + + it('keeps the unavailable-file fallback and disables opening an unavailable asset', () => { + render(withThemeAndRootContext(, wrapper)); + + expect(screen.getByRole('button', {name: 'cells.options.open'})).toBeDisabled(); + expect(screen.queryByText('cells.unavailableFilePreview')).not.toBeInTheDocument(); + expect(screen.getAllByText('cells.unavailableFile').length).toBeGreaterThan(0); + }); + + it('keeps the unavailable-preview message when the file has no thumbnail', () => { + render( + withThemeAndRootContext( + , + wrapper, + ), + ); + + expect(screen.getByText('cells.unavailableFilePreview')).toBeInTheDocument(); + expect(screen.getByRole('button', {name: 'cells.options.open'})).toBeEnabled(); + }); +}); diff --git a/apps/webapp/src/script/components/messagesList/message/contentMessage/asset/multipartAssets/fileAssetCard/fileAssetWithPreview/fileAssetWithPreview.tsx b/apps/webapp/src/script/components/messagesList/message/contentMessage/asset/multipartAssets/fileAssetCard/fileAssetWithPreview/fileAssetWithPreview.tsx index bb588cc2633..7ebbf516d7f 100644 --- a/apps/webapp/src/script/components/messagesList/message/contentMessage/asset/multipartAssets/fileAssetCard/fileAssetWithPreview/fileAssetWithPreview.tsx +++ b/apps/webapp/src/script/components/messagesList/message/contentMessage/asset/multipartAssets/fileAssetCard/fileAssetWithPreview/fileAssetWithPreview.tsx @@ -17,13 +17,11 @@ * */ -import {CSSProperties, useState, type FunctionComponent} from 'react'; +import {useState, type FunctionComponent} from 'react'; import { - contentWrapperStyles, errorIconStyles, errorTextStyles, - imageStyles, infoOverlayStyles, infoWrapperStyles, loaderIconStyles, @@ -89,8 +87,12 @@ export const FileAssetWithPreview: FunctionComponent {!isError && } - +