diff --git a/webapp/channels/src/components/file_preview/file_preview.test.tsx b/webapp/channels/src/components/file_preview/file_preview.test.tsx
index 1c2b7f3edf8..f55871ce188 100644
--- a/webapp/channels/src/components/file_preview/file_preview.test.tsx
+++ b/webapp/channels/src/components/file_preview/file_preview.test.tsx
@@ -5,12 +5,16 @@ import React from 'react';
import {getFileUrl} from 'mattermost-redux/utils/file_utils';
+import FilePreviewModal from 'components/file_preview_modal';
+
import {renderWithContext, screen, userEvent} from 'tests/react_testing_utils';
+import {ModalIdentifiers} from 'utils/constants';
import FilePreview from './file_preview';
describe('FilePreview', () => {
const onRemove = jest.fn();
+ const openModal = jest.fn();
const fileInfos = [
{
width: 100,
@@ -20,7 +24,7 @@ describe('FilePreview', () => {
type: 'image/png',
extension: 'png',
has_preview_image: true,
- user_id: '',
+ user_id: 'user_id_1',
channel_id: 'channel_id',
create_at: 0,
update_at: 0,
@@ -60,6 +64,9 @@ describe('FilePreview', () => {
uploadsInProgress,
onRemove,
uploadsProgressPercent,
+ actions: {
+ openModal,
+ },
};
test('should match snapshot', () => {
@@ -109,6 +116,129 @@ describe('FilePreview', () => {
expect(newOnRemove).toHaveBeenCalled();
});
+ test('should call openModal when image thumbnail is clicked', async () => {
+ openModal.mockClear();
+ renderWithContext(
+
,
+ );
+
+ const user = userEvent.setup();
+ const thumb = screen.getByLabelText(/file thumbnail.*test_filename/i);
+ await user.click(thumb);
+
+ expect(openModal).toHaveBeenCalledTimes(1);
+ expect(openModal).toHaveBeenCalledWith({
+ modalId: ModalIdentifiers.FILE_PREVIEW_MODAL,
+ dialogType: FilePreviewModal,
+ dialogProps: {
+ post: {user_id: 'user_id_1', channel_id: 'channel_id'},
+ fileInfos,
+ startIndex: 0,
+ },
+ });
+ });
+
+ test('should call openModal when non-image file thumbnail is clicked', async () => {
+ const pdfFileInfos = [{
+ ...fileInfos[0],
+ id: 'file_id_pdf',
+ name: 'document.pdf',
+ type: 'application/pdf',
+ extension: 'pdf',
+ width: 0,
+ height: 0,
+ has_preview_image: false,
+ }];
+ openModal.mockClear();
+ renderWithContext(
+
,
+ );
+
+ const user = userEvent.setup();
+ const thumb = screen.getByLabelText(/file thumbnail.*document\.pdf/i);
+ await user.click(thumb);
+
+ expect(openModal).toHaveBeenCalledTimes(1);
+ expect(openModal).toHaveBeenCalledWith({
+ modalId: ModalIdentifiers.FILE_PREVIEW_MODAL,
+ dialogType: FilePreviewModal,
+ dialogProps: {
+ post: {user_id: 'user_id_1', channel_id: 'channel_id'},
+ fileInfos: pdfFileInfos,
+ startIndex: 0,
+ },
+ });
+ });
+
+ /** Direct handler coverage: thumbnails for archived/deleted files are non-links, but guards must stay aligned. */
+ const thumbnailClickMouseEvent = () =>
+ ({
+ preventDefault: jest.fn(),
+ stopPropagation: jest.fn(),
+ blur: jest.fn(),
+ target: document.createElement('a'),
+ }) as unknown as React.MouseEvent
;
+
+ test('should not open preview modal via handler when attachment is archived', () => {
+ const openModalFn = jest.fn();
+ const archivedInfos = [{...fileInfos[0], archived: true}];
+ const instance = new FilePreview({
+ enableSVGs: false,
+ fileInfos: archivedInfos,
+ uploadsInProgress: [],
+ uploadsProgressPercent: {},
+ actions: {openModal: openModalFn},
+ });
+
+ instance.handleThumbnailPreviewClick(thumbnailClickMouseEvent(), 0);
+
+ expect(openModalFn).not.toHaveBeenCalled();
+ });
+
+ test('should not open preview modal via handler when attachment has delete_at set', () => {
+ const openModalFn = jest.fn();
+ const deletedInfos = [{...fileInfos[0], delete_at: 999}];
+ const instance = new FilePreview({
+ enableSVGs: false,
+ fileInfos: deletedInfos,
+ uploadsInProgress: [],
+ uploadsProgressPercent: {},
+ actions: {openModal: openModalFn},
+ });
+
+ instance.handleThumbnailPreviewClick(thumbnailClickMouseEvent(), 0);
+
+ expect(openModalFn).not.toHaveBeenCalled();
+ });
+
+ test('should render non-interactive thumbnail wrapper when attachment is archived or deleted', () => {
+ const {container, rerender} = renderWithContext(
+ ,
+ );
+
+ expect(container.querySelector('.post-image__thumbnail')).toBeTruthy();
+ expect(container.querySelector('a.post-image__thumbnail')).not.toBeInTheDocument();
+
+ rerender(
+ ,
+ );
+
+ expect(container.querySelector('a.post-image__thumbnail')).not.toBeInTheDocument();
+ expect(screen.queryAllByRole('link', {name: /file thumbnail/i})).toHaveLength(0);
+ });
+
test('should not render an SVG when SVGs are disabled', () => {
const props = {
...baseProps,
diff --git a/webapp/channels/src/components/file_preview/file_preview.tsx b/webapp/channels/src/components/file_preview/file_preview.tsx
index 43833fdca70..8f745fabbed 100644
--- a/webapp/channels/src/components/file_preview/file_preview.tsx
+++ b/webapp/channels/src/components/file_preview/file_preview.tsx
@@ -7,14 +7,18 @@ import type {ReactNode} from 'react';
import {WithTooltip} from '@mattermost/shared/components/tooltip';
import type {FileInfo} from '@mattermost/types/files';
+import type {Post} from '@mattermost/types/posts';
import {getFileThumbnailUrl, getFileUrl} from 'mattermost-redux/utils/file_utils';
import FilenameOverlay from 'components/file_attachment/filename_overlay';
+import FilePreviewModal from 'components/file_preview_modal';
-import Constants, {FileTypes} from 'utils/constants';
+import Constants, {FileTypes, ModalIdentifiers} from 'utils/constants';
import * as Utils from 'utils/utils';
+import type {ModalData} from 'types/actions';
+
import FileProgressPreview from './file_progress_preview';
type UploadInfo = {
@@ -32,6 +36,9 @@ type Props = {
uploadsProgressPercent?: {[clientID: string]: FilePreviewInfo};
compactMode?: boolean;
disabledRemoveTooltip?: string;
+ actions: {
+ openModal: (modalData: ModalData
) => void;
+ };
};
export default class FilePreview extends React.PureComponent {
@@ -45,14 +52,46 @@ export default class FilePreview extends React.PureComponent {
this.props.onRemove?.(id);
};
+ /**
+ * Opens the standard file preview modal for a draft attachment.
+ *
+ * @param e - Mouse event from the thumbnail link (default prevented; does not bubble).
+ * @param startIndex - Index of the clicked file in {@link Props.fileInfos} for modal navigation.
+ */
+ handleThumbnailPreviewClick = (e: React.MouseEvent, startIndex: number) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ const fileInfo = this.props.fileInfos[startIndex];
+ if (!fileInfo || fileInfo.archived || fileInfo.delete_at > 0) {
+ return;
+ }
+
+ if ('blur' in e.target) {
+ (e.target as HTMLElement).blur();
+ }
+
+ this.props.actions.openModal({
+ modalId: ModalIdentifiers.FILE_PREVIEW_MODAL,
+ dialogType: FilePreviewModal,
+ dialogProps: {
+ post: {user_id: fileInfo.user_id, channel_id: fileInfo.channel_id} as Post,
+ fileInfos: this.props.fileInfos,
+ startIndex,
+ },
+ });
+ };
+
render() {
const previews: ReactNode[] = [];
- this.props.fileInfos.forEach((info) => {
+ this.props.fileInfos.forEach((info, index) => {
const type = Utils.getFileType(info.extension);
let className = 'file-preview post-image__column';
let previewImage;
+ const canOpenPreviewModal = !info.archived && info.delete_at === 0;
+
if (type === FileTypes.SVG && this.props.enableSVGs) {
previewImage = (
{
className += ' compact';
}
+ const thumbnailLabel = `${Utils.localizeMessage({id: 'file_attachment.thumbnail', defaultMessage: 'file thumbnail'})} ${info.name}`.toLowerCase();
+
+ let thumbnailWrap: ReactNode;
+ if (canOpenPreviewModal) {
+ thumbnailWrap = (
+ this.handleThumbnailPreviewClick(e, index)}
+ >
+ {previewImage}
+
+ );
+ } else {
+ thumbnailWrap = (
+
+ {previewImage}
+
+ );
+ }
+
previews.push(
-
{previewImage}
+ {thumbnailWrap}