From 80c58a9742870aba480854e00cba205aa75d6490 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20V=C3=A9lez?= Date: Thu, 1 May 2025 13:41:50 +0200 Subject: [PATCH] MM-63649 - fix preview embeded images (#30685) * MM-63649 - fix preview embeded images * enhance utils function and simplify validations in file preview modal component * add new test for proxied images without extension * simplify logic; apply DRY unifying it via helper function * if extension is available in file info, use it first * add extra validation for the extension lenght if fileInfo.extension is present --------- Co-authored-by: Mattermost Build --- .../pasted_image_url_preview_spec.js | 94 +++ .../file_preview_modal.test.tsx | 61 ++ .../file_preview_modal/file_preview_modal.tsx | 52 +- .../components/file_preview_modal/types.ts | 4 + webapp/channels/src/utils/utils.test.tsx | 604 ++---------------- webapp/channels/src/utils/utils.tsx | 37 ++ 6 files changed, 282 insertions(+), 570 deletions(-) create mode 100644 e2e-tests/cypress/tests/integration/channels/messaging/pasted_image_url_preview_spec.js diff --git a/e2e-tests/cypress/tests/integration/channels/messaging/pasted_image_url_preview_spec.js b/e2e-tests/cypress/tests/integration/channels/messaging/pasted_image_url_preview_spec.js new file mode 100644 index 0000000000..9a818afa9f --- /dev/null +++ b/e2e-tests/cypress/tests/integration/channels/messaging/pasted_image_url_preview_spec.js @@ -0,0 +1,94 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +// *************************************************************** +// - [#] indicates a test step (e.g. # Go to a page) +// - [*] indicates an assertion (e.g. * Check the title) +// - Use element ID when selecting an element. Create one if none. +// *************************************************************** + +// Stage: @prod +// Group: @channels @messaging + +describe('Image URL Preview', () => { + before(() => { + // # Login as test user and visit the newly created test channel + cy.apiInitSetup({loginAfter: true}).then(({team, channel}) => { + // # Visit a test channel + cy.visit(`/${team.name}/channels/${channel.name}`); + }); + }); + + it('MM-T1 - Image URL in markdown format opens correctly in preview modal', () => { + // # Post a message with text and an image URL in markdown format + const imageUrl = 'https://raw.githubusercontent.com/mattermost/mattermost/master/e2e-tests/cypress/tests/fixtures/image-small-height.png'; + cy.postMessage('This is a test message with an image.![test image](' + imageUrl + ') This text should appear after the image.'); + + // * Confirm the image is rendered in the post + cy.uiWaitUntilMessagePostedIncludes('This is a test message with an image'); + cy.get('.markdown-inline-img').should('be.visible'); + cy.getLastPostId().then((postId) => { + cy.get(`#postMessageText_${postId}`).should('contain', 'This text should appear after the image'); + }); + + // # Click on the image in the last post to open the preview modal + cy.getLastPostId().then((postId) => { + cy.get(`#post_${postId}`).find('.file-preview__button').click(); + }); + + // * Verify the preview modal opens and the image is displayed correctly + cy.findByTestId('imagePreview').should('be.visible').and('have.class', 'image_preview__image').and('have.attr', 'alt', 'preview url image').and('have.attr', 'src').and('include', 'image-small-height.png'); + + // # Close the modal + cy.uiCloseFilePreviewModal(); + }); + + it('MM-T2 - Image URL without file extension in markdown format opens correctly in preview modal', () => { + // # Post a message with text and an image URL without file extension in markdown format + const imageUrl = 'https://hub.mattermost.com/files/sdrkars9kfdrxmyj4gcz6xk9de/public?h=dM-SC6JuRu0DarFyFPEMG_-io9gi7VY2qNV4Z59TmsM'; + cy.postMessage('This is a test message with an image without extension.![test image](' + imageUrl + ') This text should appear after the image.'); + + // * Confirm the image is rendered in the post + cy.uiWaitUntilMessagePostedIncludes('This is a test message with an image without extension'); + cy.get('.markdown-inline-img').should('be.visible'); + cy.getLastPostId().then((postId) => { + cy.get(`#postMessageText_${postId}`).should('contain', 'This text should appear after the image'); + }); + + // # Click on the image in the last post to open the preview modal + cy.getLastPostId().then((postId) => { + cy.get(`#post_${postId}`).find('.file-preview__button').click(); + }); + + // * Verify the preview modal opens and the image is displayed correctly + cy.findByTestId('imagePreview').should('be.visible').and('have.class', 'image_preview__image').and('have.attr', 'alt', 'preview url image').and('have.attr', 'src').and('include', 'public'); + + // # Close the modal + cy.uiCloseFilePreviewModal(); + }); + + it('MM-T3 - Proxied image URL in markdown format opens correctly in preview modal', () => { + // # Post a message with text and a proxied image URL in markdown format + const originalImageUrl = 'https://raw.githubusercontent.com/mattermost/mattermost/master/e2e-tests/cypress/tests/fixtures/image-small-height.png'; + const proxiedImageUrl = Cypress.config('baseUrl') + '/api/v4/image?url=' + encodeURIComponent(originalImageUrl); + cy.postMessage('This is a test message with a proxied image URL.![test image](' + proxiedImageUrl + ') This text should appear after the image.'); + + // * Confirm the image is rendered in the post + cy.uiWaitUntilMessagePostedIncludes('This is a test message with a proxied image URL'); + cy.get('.markdown-inline-img').should('be.visible'); + cy.getLastPostId().then((postId) => { + cy.get(`#postMessageText_${postId}`).should('contain', 'This text should appear after the image'); + }); + + // # Click on the image in the last post to open the preview modal + cy.getLastPostId().then((postId) => { + cy.get(`#post_${postId}`).find('.file-preview__button').click(); + }); + + // * Verify the preview modal opens and the image is displayed correctly + cy.findByTestId('imagePreview').should('be.visible').and('have.class', 'image_preview__image').and('have.attr', 'alt', 'preview url image').and('have.attr', 'src').and('include', proxiedImageUrl); + + // # Close the modal + cy.uiCloseFilePreviewModal(); + }); +}); diff --git a/webapp/channels/src/components/file_preview_modal/file_preview_modal.test.tsx b/webapp/channels/src/components/file_preview_modal/file_preview_modal.test.tsx index 28c9be2b1c..e6c46d6178 100644 --- a/webapp/channels/src/components/file_preview_modal/file_preview_modal.test.tsx +++ b/webapp/channels/src/components/file_preview_modal/file_preview_modal.test.tsx @@ -8,6 +8,7 @@ import FilePreviewModal from 'components/file_preview_modal/file_preview_modal'; import Constants from 'utils/constants'; import {TestHelper} from 'utils/test_helper'; +import * as Utils from 'utils/utils'; import {generateId} from 'utils/utils'; describe('components/FilePreviewModal', () => { @@ -152,6 +153,66 @@ describe('components/FilePreviewModal', () => { expect(wrapper).toMatchSnapshot(); }); + test('should correctly identify image URLs with isImageUrl method', () => { + const wrapper = shallow(); + + // Test proxied image URLs + expect(wrapper.instance().isImageUrl('http://localhost:8065/api/v4/image?url=https%3A%2F%2Fexample.com%2Fimage.jpg')).toBe(true); + + // Test URLs with image extensions + expect(wrapper.instance().isImageUrl('https://example.com/image.jpg')).toBe(true); + expect(wrapper.instance().isImageUrl('https://example.com/image.png')).toBe(true); + expect(wrapper.instance().isImageUrl('https://example.com/image.gif')).toBe(true); + + // Test non-image URLs + expect(wrapper.instance().isImageUrl('https://example.com/document.pdf')).toBe(false); + expect(wrapper.instance().isImageUrl('https://example.com/file.txt')).toBe(false); + }); + + test('should handle external image URLs correctly', () => { + // Create a mock for Utils.loadImage + const loadImageSpy = jest.spyOn(Utils, 'loadImage').mockImplementation((url, onLoad) => { + // Create a mock ProgressEvent + const mockProgressEvent = new ProgressEvent('progress'); + + // Call onLoad with the mock event if it exists + if (onLoad) { + onLoad.call({} as XMLHttpRequest, mockProgressEvent); + } + }); + + // Create a LinkInfo object for an external image URL + const externalImageUrl = 'http://localhost:8065/api/v4/image?url=https%3A%2F%2Fexample.com%2Fimage.jpg'; + const fileInfos = [{ + has_preview_image: false, + link: externalImageUrl, + extension: '', + name: 'External Image', + }]; + + const props = {...baseProps, fileInfos}; + const wrapper = shallow(); + + // Spy on handleImageLoaded + const handleImageLoadedSpy = jest.spyOn(wrapper.instance(), 'handleImageLoaded'); + + // Call loadImage with the external image URL + wrapper.instance().loadImage(0); + + // Verify that Utils.loadImage was called with the correct URL + expect(loadImageSpy).toHaveBeenCalledWith( + externalImageUrl, + expect.any(Function), + expect.any(Function), + ); + + // Verify that handleImageLoaded was called + expect(handleImageLoadedSpy).toHaveBeenCalled(); + + // Restore the original loadImage function + loadImageSpy.mockRestore(); + }); + test('should have called loadImage', () => { const fileInfos = [ TestHelper.getFileInfoMock({id: 'file_id_1', extension: 'gif'}), diff --git a/webapp/channels/src/components/file_preview_modal/file_preview_modal.tsx b/webapp/channels/src/components/file_preview_modal/file_preview_modal.tsx index d177e9c448..3081269129 100644 --- a/webapp/channels/src/components/file_preview_modal/file_preview_modal.tsx +++ b/webapp/channels/src/components/file_preview_modal/file_preview_modal.tsx @@ -28,7 +28,7 @@ import FilePreviewModalFooter from './file_preview_modal_footer/file_preview_mod import FilePreviewModalHeader from './file_preview_modal_header/file_preview_modal_header'; import ImagePreview from './image_preview'; import PopoverBar from './popover_bar'; -import {isFileInfo} from './types'; +import {isFileInfo, isLinkInfo} from './types'; import type {LinkInfo} from './types'; import './file_preview_modal.scss'; @@ -164,21 +164,51 @@ export default class FilePreviewModal extends React.PureComponent } }; + isImageUrl = (url: string): boolean => { + const fileType = Utils.getFileType(url); + return fileType === FileTypes.IMAGE || fileType === FileTypes.SVG; + }; + + private getFileTypeFromFileInfo = (fileInfo: FileInfo | LinkInfo): typeof FileTypes[keyof typeof FileTypes] => { + if (isFileInfo(fileInfo)) { + return Utils.getFileType(fileInfo.extension); + } + + if (isLinkInfo(fileInfo)) { + // if extension is not available or is longer than 5 characters, use the link to determine the file type + const maxLenghtExtension = 11; // applescript is the longest extension + const extensionOrLink = fileInfo.extension && fileInfo.extension.length <= maxLenghtExtension ? fileInfo.extension : fileInfo.link; + return Utils.getFileType(extensionOrLink); + } + + return FileTypes.OTHER; + }; + loadImage = (index: number) => { const fileInfo = this.props.fileInfos[index]; if (isFileInfo(fileInfo) && fileInfo.archived) { this.handleImageLoaded(index); return; } - const fileType = Utils.getFileType(fileInfo.extension); - if (fileType === FileTypes.IMAGE && isFileInfo(fileInfo)) { - let previewUrl; - if (fileInfo.has_preview_image) { - previewUrl = getFilePreviewUrl(fileInfo.id); - } else { - // some images (eg animated gifs) just show the file itself and not a preview - previewUrl = getFileUrl(fileInfo.id); + // Determine file type using helper method + const fileType = this.getFileTypeFromFileInfo(fileInfo); + + // Check if this is an image + const isImage = fileType === FileTypes.IMAGE; + + if (isImage) { + let previewUrl = ''; + if (isFileInfo(fileInfo)) { + if (fileInfo.has_preview_image) { + previewUrl = getFilePreviewUrl(fileInfo.id); + } else { + // some images (eg animated gifs) just show the file itself and not a preview + previewUrl = getFileUrl(fileInfo.id); + } + } else if (isLinkInfo(fileInfo)) { + // For LinkInfo, use the link directly + previewUrl = fileInfo.link; } Utils.loadImage( @@ -269,7 +299,9 @@ export default class FilePreviewModal extends React.PureComponent } const fileInfo = this.props.fileInfos[this.state.imageIndex]; - const fileType = Utils.getFileType(fileInfo.extension); + + // Determine file type using helper method + const fileType = this.getFileTypeFromFileInfo(fileInfo); let showPublicLink; let fileName; diff --git a/webapp/channels/src/components/file_preview_modal/types.ts b/webapp/channels/src/components/file_preview_modal/types.ts index 5d79ff65d4..f6af49a6b4 100644 --- a/webapp/channels/src/components/file_preview_modal/types.ts +++ b/webapp/channels/src/components/file_preview_modal/types.ts @@ -13,3 +13,7 @@ export type LinkInfo = { export function isFileInfo(info: FileInfo | LinkInfo): info is FileInfo { return Boolean((info as FileInfo).id); } + +export function isLinkInfo(info: FileInfo | LinkInfo): info is LinkInfo { + return Boolean((info as LinkInfo).link) && !isFileInfo(info); +} diff --git a/webapp/channels/src/utils/utils.test.tsx b/webapp/channels/src/utils/utils.test.tsx index 011638bf8b..7eae9a5d90 100644 --- a/webapp/channels/src/utils/utils.test.tsx +++ b/webapp/channels/src/utils/utils.test.tsx @@ -1,574 +1,58 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import type {UserProfile} from '@mattermost/types/users'; +import {FileTypes} from './constants'; +import {getFileType} from './utils'; -import {GeneralTypes} from 'mattermost-redux/action_types'; - -import store from 'stores/redux_store'; - -import * as lineBreakHelpers from 'tests/helpers/line_break_helpers'; -import * as ua from 'tests/helpers/user_agent_mocks'; -import Constants, {ValidationErrors, AdvancedTextEditorTextboxIds} from 'utils/constants'; -import * as Utils from 'utils/utils'; - -describe('Utils.getDisplayNameByUser', () => { - afterEach(() => { - store.dispatch({ - type: GeneralTypes.CLIENT_CONFIG_RESET, - data: {}, - }); +describe('Utils.getFileType', () => { + test('should identify image files by extension', () => { + expect(getFileType('jpg')).toBe(FileTypes.IMAGE); + expect(getFileType('png')).toBe(FileTypes.IMAGE); + expect(getFileType('gif')).toBe(FileTypes.IMAGE); + expect(getFileType('bmp')).toBe(FileTypes.IMAGE); + expect(getFileType('tiff')).toBe(FileTypes.IMAGE); }); - const userA = {username: 'a_user', nickname: 'a_nickname', first_name: 'a_first_name', last_name: ''}; - const userB = {username: 'b_user', nickname: 'b_nickname', first_name: '', last_name: 'b_last_name'}; - const userC = {username: 'c_user', nickname: '', first_name: 'c_first_name', last_name: 'c_last_name'}; - const userD = {username: 'd_user', nickname: 'd_nickname', first_name: 'd_first_name', last_name: 'd_last_name'}; - const userE = {username: 'e_user', nickname: '', first_name: 'e_first_name', last_name: 'e_last_name'}; - const userF = {username: 'f_user', nickname: 'f_nickname', first_name: 'f_first_name', last_name: 'f_last_name'}; - const userG = {username: 'g_user', nickname: '', first_name: 'g_first_name', last_name: 'g_last_name'}; - const userH = {username: 'h_user', nickname: 'h_nickname', first_name: '', last_name: 'h_last_name'}; - const userI = {username: 'i_user', nickname: 'i_nickname', first_name: 'i_first_name', last_name: ''}; - const userJ = {username: 'j_user', nickname: '', first_name: 'j_first_name', last_name: ''}; - - test('Show display name of user with TeammateNameDisplay set to username', () => { - store.dispatch({ - type: GeneralTypes.CLIENT_CONFIG_RECEIVED, - data: { - TeammateNameDisplay: 'username', - }, - }); - - [userA, userB, userC, userD, userE, userF, userG, userH, userI, userJ].forEach((user) => { - expect(Utils.getDisplayNameByUser(store.getState(), user as UserProfile)).toEqual(user.username); - }); + test('should identify image files from URLs with extensions', () => { + expect(getFileType('https://example.com/image.jpg')).toBe(FileTypes.IMAGE); + expect(getFileType('https://example.com/path/to/image.png')).toBe(FileTypes.IMAGE); + expect(getFileType('https://example.com/image.gif?query=param')).toBe(FileTypes.IMAGE); + expect(getFileType('http://example.com/image.bmp#fragment')).toBe(FileTypes.IMAGE); }); - test('Show display name of user with TeammateNameDisplay set to nickname_full_name', () => { - store.dispatch({ - type: GeneralTypes.CLIENT_CONFIG_RECEIVED, - data: { - TeammateNameDisplay: 'nickname_full_name', - }, - }); + test('should identify image files from URLs without extensions', () => { + // Test URLs with /api/v4/image and ?url= parameter + expect(getFileType('/api/v4/image?url=https://example.com/image-without-extension')).toBe(FileTypes.IMAGE); + expect(getFileType('https://mattermost.com/api/v4/image?url=https://example.com/another-image')).toBe(FileTypes.IMAGE); - for (const data of [ - {user: userA, result: userA.nickname}, - {user: userB, result: userB.nickname}, - {user: userC, result: `${userC.first_name} ${userC.last_name}`}, - {user: userD, result: userD.nickname}, - {user: userE, result: `${userE.first_name} ${userE.last_name}`}, - {user: userF, result: userF.nickname}, - {user: userG, result: `${userG.first_name} ${userG.last_name}`}, - {user: userH, result: userH.nickname}, - {user: userI, result: userI.nickname}, - {user: userJ, result: userJ.first_name}, - ]) { - expect(Utils.getDisplayNameByUser(store.getState(), data.user as UserProfile)).toEqual(data.result); - } + // Test URLs with /api/v4/image and &url= parameter (in case it's not the first parameter) + expect(getFileType('/api/v4/image?param=value&url=https://example.com/image')).toBe(FileTypes.IMAGE); + expect(getFileType('https://mattermost.com/api/v4/image?param=value&url=https://example.com/image')).toBe(FileTypes.IMAGE); }); - test('Show display name of user with TeammateNameDisplay set to username', () => { - store.dispatch({ - type: GeneralTypes.CLIENT_CONFIG_RECEIVED, - data: { - TeammateNameDisplay: 'full_name', - }, - }); + test('should identify image files from proxied URLs', () => { + expect(getFileType('/api/v4/image?url=https://example.com/image.jpg')).toBe(FileTypes.IMAGE); + expect(getFileType('https://mattermost.com/api/v4/image?url=https://example.com/image.png')).toBe(FileTypes.IMAGE); + }); - for (const data of [ - {user: userA, result: userA.first_name}, - {user: userB, result: userB.last_name}, - {user: userC, result: `${userC.first_name} ${userC.last_name}`}, - {user: userD, result: `${userD.first_name} ${userD.last_name}`}, - {user: userE, result: `${userE.first_name} ${userE.last_name}`}, - {user: userF, result: `${userF.first_name} ${userF.last_name}`}, - {user: userG, result: `${userG.first_name} ${userG.last_name}`}, - {user: userH, result: userH.last_name}, - {user: userI, result: userI.first_name}, - {user: userJ, result: userJ.first_name}, - ]) { - expect(Utils.getDisplayNameByUser(store.getState(), data.user as UserProfile)).toEqual(data.result); - } - }); -}); - -describe('Utils.isValidUsername', () => { - const tests = [ - { - testUserName: 'sonic.the.hedgehog', - expectedError: undefined, - }, { - testUserName: 'sanic.the.speedy.errored.hedgehog@10_10-10', - expectedError: ValidationErrors.INVALID_LENGTH, - }, { - testUserName: 'sanicâ­‘', - expectedError: ValidationErrors.INVALID_CHARACTERS, - }, { - testUserName: '.sanic', - expectedError: ValidationErrors.INVALID_FIRST_CHARACTER, - }, { - testUserName: 'valet', - expectedError: ValidationErrors.RESERVED_NAME, - }, - ]; - test('Validate username', () => { - for (const test of tests) { - const testError = Utils.isValidUsername(test.testUserName); - if (testError) { - expect(testError.id).toEqual(test.expectedError); - } else { - expect(testError).toBe(undefined); - } - } - }); - test('Validate bot username', () => { - tests.push({ - testUserName: 'sanic.the.hedgehog.', - expectedError: ValidationErrors.INVALID_LAST_CHARACTER, - }); - for (const test of tests) { - const testError = Utils.isValidUsername(test.testUserName); - if (testError) { - expect(testError.id).toEqual(test.expectedError); - } else { - expect(testError).toBe(undefined); - } - } - }); -}); - -describe('Utils.localizeMessage', () => { - const originalGetState = store.getState; - - afterAll(() => { - store.getState = originalGetState; - }); - - const entities = { - general: { - config: {}, - }, - users: { - currentUserId: 'abcd', - profiles: { - abcd: { - locale: 'fr', - }, - }, - }, - }; - - describe('with translations', () => { - beforeAll(() => { - store.getState = () => ({ - entities, - views: { - i18n: { - translations: { - fr: { - 'test.hello_world': 'Bonjour tout le monde!', - }, - }, - }, - }, - } as any); - }); - - test('with translations', () => { - const messageId = 'test.hello_world'; - expect(Utils.localizeMessage({id: messageId, defaultMessage: 'Hello, World!'})).toEqual('Bonjour tout le monde!'); - }); - - test('with missing string in translations', () => { - const messageId = 'test.hello_world2'; - expect(Utils.localizeMessage({id: messageId, defaultMessage: 'Hello, World 2!'})).toEqual('Hello, World 2!'); - }); - - test('with missing string in translations and no default', () => { - const messageId = 'test.hello_world2'; - expect(Utils.localizeMessage({id: messageId})).toEqual('test.hello_world2'); - }); - }); - - describe('without translations', () => { - beforeAll(() => { - store.getState = () => ({ - entities, - views: { - i18n: { - translations: {}, - }, - }, - } as any); - }); - - test('without translations', () => { - const messageId = 'test.hello_world'; - expect(Utils.localizeMessage({id: messageId, defaultMessage: 'Hello, World!'})).toEqual('Hello, World!'); - }); - - test('without translations and no default', () => { - const messageId = 'test.hello_world'; - expect(Utils.localizeMessage({id: messageId})).toEqual('test.hello_world'); - }); - }); -}); - -describe('Utils.imageURLForUser', () => { - test('should return url when user id and last_picture_update is given', () => { - const imageUrl = Utils.imageURLForUser('foobar-123', 123456); - expect(imageUrl).toEqual('/api/v4/users/foobar-123/image?_=123456'); - }); - - test('should return url when user id is given without last_picture_update', () => { - const imageUrl = Utils.imageURLForUser('foobar-123'); - expect(imageUrl).toEqual('/api/v4/users/foobar-123/image?_=0'); - }); -}); - -describe('Utils.isUnhandledLineBreakKeyCombo', () => { - test('isUnhandledLineBreakKeyCombo returns true for alt + enter for Chrome UA', () => { - ua.mockChrome(); - expect(Utils.isUnhandledLineBreakKeyCombo(lineBreakHelpers.getAltKeyEvent())).toBe(true); - }); - - test('isUnhandledLineBreakKeyCombo returns false for alt + enter for Safari UA', () => { - ua.mockSafari(); - expect(Utils.isUnhandledLineBreakKeyCombo(lineBreakHelpers.getAltKeyEvent())).toBe(false); - }); - - test('isUnhandledLineBreakKeyCombo returns false for shift + enter', () => { - expect(Utils.isUnhandledLineBreakKeyCombo(lineBreakHelpers.getShiftKeyEvent())).toBe(false); - }); - - test('isUnhandledLineBreakKeyCombo returns false for ctrl/command + enter', () => { - expect(Utils.isUnhandledLineBreakKeyCombo(lineBreakHelpers.getCtrlKeyEvent())).toBe(false); - expect(Utils.isUnhandledLineBreakKeyCombo(lineBreakHelpers.getMetaKeyEvent())).toBe(false); - }); - - test('isUnhandledLineBreakKeyCombo returns false for just enter', () => { - expect(Utils.isUnhandledLineBreakKeyCombo(lineBreakHelpers.BASE_EVENT)).toBe(false); - }); - - test('isUnhandledLineBreakKeyCombo returns false for f (random key)', () => { - const e = { - ...lineBreakHelpers.BASE_EVENT, - key: Constants.KeyCodes.F[0], - keyCode: Constants.KeyCodes.F[1], - }; - expect(Utils.isUnhandledLineBreakKeyCombo(e)).toBe(false); - }); - - // restore initial user agent - afterEach(ua.reset); -}); - -describe('Utils.insertLineBreakFromKeyEvent', () => { - test('insertLineBreakFromKeyEvent returns with line break appending (no selection range)', () => { - expect(Utils.insertLineBreakFromKeyEvent(lineBreakHelpers.getAppendEvent())).toBe(lineBreakHelpers.OUTPUT_APPEND); - }); - test('insertLineBreakFromKeyEvent returns with line break replacing (with selection range)', () => { - expect(Utils.insertLineBreakFromKeyEvent(lineBreakHelpers.getReplaceEvent())).toBe(lineBreakHelpers.OUTPUT_REPLACE); - }); -}); - -describe('Utils.copyTextAreaToDiv', () => { - const textArea = document.createElement('textarea'); - - test('copyTextAreaToDiv actually creates a div element', () => { - const copy = Utils.copyTextAreaToDiv(textArea); - - expect(copy!.nodeName).toEqual('DIV'); - }); - - test('copyTextAreaToDiv copies the content into the div element', () => { - textArea.value = 'the content'; - - const copy = Utils.copyTextAreaToDiv(textArea); - - expect(copy!.innerHTML).toEqual('the content'); - }); - - test('copyTextAreaToDiv correctly copies the styles of the textArea element', () => { - textArea.style.fontFamily = 'Sans-serif'; - - const copy = Utils.copyTextAreaToDiv(textArea); - - expect(copy!.style.fontFamily).toEqual('Sans-serif'); - }); -}); - -describe('Utils.getCaretXYCoordinate', () => { - const tmpCreateRange = document.createRange; - const cleanUp = () => { - document.createRange = tmpCreateRange; - }; - - afterAll(cleanUp); - - const textArea = document.createElement('textarea'); - document.createRange = () => { - const range = new Range(); - - range.getClientRects = () => { - return [{ - top: 10, - left: 15, - }] as unknown as DOMRectList; - }; - - return range; - }; - textArea.value = 'm'.repeat(10); - - test('getCaretXYCoordinate returns the coordinates of the caret', () => { - const coordinates = Utils.getCaretXYCoordinate(textArea); - - expect(coordinates.x).toEqual(15); - expect(coordinates.y).toEqual(10); - }); - - test('getCaretXYCoordinate returns the coordinates of the caret with a left scroll', () => { - textArea.scrollLeft = 5; - - const coordinates = Utils.getCaretXYCoordinate(textArea); - - expect(coordinates.x).toEqual(10); - }); -}); - -describe('Utils.getViewportSize', () => { - test('getViewportSize returns the right viewport using default jsDom window', () => { - // the default values of the jsDom window are w: 1024, h: 768 - const viewportDimensions = Utils.getViewportSize(); - - expect(viewportDimensions.w).toEqual(1024); - expect(viewportDimensions.h).toEqual(768); - }); - - test('getViewportSize returns the right viewport width with custom parameter', () => { - const mockWindow = {document: {body: {}, compatMode: undefined}}; - (mockWindow.document.body as any).clientWidth = 1025; - (mockWindow.document.body as any).clientHeight = 860; - - const viewportDimensions = Utils.getViewportSize(mockWindow as unknown as Window); - - expect(viewportDimensions.w).toEqual(1025); - expect(viewportDimensions.h).toEqual(860); - }); - - test('getViewportSize returns the right viewport width with custom parameter - innerWidth', () => { - const mockWindow = {innerWidth: 1027, innerHeight: 767}; - - const viewportDimensions = Utils.getViewportSize(mockWindow as unknown as Window); - - expect(viewportDimensions.w).toEqual(1027); - expect(viewportDimensions.h).toEqual(767); - }); -}); - -describe('Utils.offsetTopLeft', () => { - test('offsetTopLeft returns the right offset values', () => { - const textArea = document.createElement('textArea'); - - textArea.getBoundingClientRect = jest.fn(() => ({ - top: 967, - left: 851, - } as DOMRect)); - - const offsetTopLeft = Utils.offsetTopLeft(textArea); - expect(offsetTopLeft.top).toEqual(967); - expect(offsetTopLeft.left).toEqual(851); - }); -}); - -describe('Utils.getSuggestionBoxAlgn', () => { - const tmpCreateRange = document.createRange; - const cleanUp = () => { - document.createRange = tmpCreateRange; - }; - - afterAll(cleanUp); - - const textArea: HTMLTextAreaElement = document.createElement('textArea') as HTMLTextAreaElement; - - textArea.value = 'a'.repeat(30); - - jest.spyOn(textArea, 'offsetWidth', 'get'). - mockImplementation(() => 950); - - textArea.getBoundingClientRect = jest.fn(() => ({ - left: 50, - } as DOMRect)); - - const createRange = (size: number) => { - document.createRange = () => { - const range = new Range(); - range.getClientRects = () => { - return [{ - top: 100, - left: size, - }] as unknown as DOMRectList; - }; - return range; - }; - }; - - const fixedToTheRight = textArea.offsetWidth - Constants.SUGGESTION_LIST_MODAL_WIDTH; - - test('getSuggestionBoxAlgn returns 0 (box stuck to left) when the length of the text is small', () => { - const smallSizeText = 15; - createRange(smallSizeText); - const suggestionBoxAlgn = Utils.getSuggestionBoxAlgn(textArea, Utils.getPxToSubstract()); - expect(suggestionBoxAlgn.pixelsToMoveX).toEqual(0); - }); - - test('getSuggestionBoxAlgn returns pixels to move when text is medium size', () => { - const mediumSizeText = 155; - createRange(mediumSizeText); - const suggestionBoxAlgn = Utils.getSuggestionBoxAlgn(textArea, Utils.getPxToSubstract()); - expect(suggestionBoxAlgn.pixelsToMoveX).toBeGreaterThan(0); - expect(suggestionBoxAlgn.pixelsToMoveX).not.toBe(fixedToTheRight); - }); - - test('getSuggestionBoxAlgn align box to the righ when text is large size', () => { - const largeSizeText = 700; - createRange(largeSizeText); - const suggestionBoxAlgn = Utils.getSuggestionBoxAlgn(textArea, Utils.getPxToSubstract()); - expect(fixedToTheRight).toEqual(suggestionBoxAlgn.pixelsToMoveX); - }); -}); - -describe('Utils.numberToFixedDynamic', () => { - const tests = [ - { - label: 'Removes period when no decimals needed', - num: 123.001, - places: 2, - expected: '123', - }, - { - label: 'Extra places are ignored', - num: 123.45, - places: 3, - expected: '123.45', - }, - { - label: 'rounds positives', - num: 123.45, - places: 1, - expected: '123.5', - }, - { - label: 'rounds negatives', - num: -123.45, - places: 1, - expected: '-123.5', - }, - { - label: 'negative places interpreted as 0 places', - num: 123, - places: -1, - expected: '123', - }, - { - label: 'handles integers', - num: 123, - places: 4, - expected: '123', - }, - { - label: 'handles integers with 0 places', - num: 123, - places: 4, - expected: '123', - }, - { - label: 'correctly excludes decimal when rounding exlcudes number', - num: 0.004, - places: 2, - expected: '0', - }, - ]; - tests.forEach((testCase) => { - test(testCase.label, () => { - const actual = Utils.numberToFixedDynamic(testCase.num, testCase.places); - expect(actual).toBe(testCase.expected); - }); - }); -}); - -describe('isTextSelectedInPostOrReply', () => { - function createKeyboardEvent(target: Partial) { - return { - target: { - selectionStart: 0, - selectionEnd: 0, - id: AdvancedTextEditorTextboxIds.Default, - ...target, - }, - } as unknown as KeyboardEvent; - } - - test('returns false when not typing in a textbox', () => { - const event = createKeyboardEvent({ - id: 'not_a_textbox', - }); - expect(Utils.isTextSelectedInPostOrReply(event)).toBe(false); - }); - - test('returns false when no text is selected in center textbox', () => { - const event = createKeyboardEvent({ - id: AdvancedTextEditorTextboxIds.InCenter, - selectionStart: 5, - selectionEnd: 5, - }); - expect(Utils.isTextSelectedInPostOrReply(event)).toBe(false); - }); - - test('returns true when text is selected in center textbox', () => { - const event = createKeyboardEvent({ - id: AdvancedTextEditorTextboxIds.InCenter, - selectionStart: 0, - selectionEnd: 5, - }); - expect(Utils.isTextSelectedInPostOrReply(event)).toBe(true); - }); - - test('returns false when no text is selected in RHS comment textbox', () => { - const event = createKeyboardEvent({ - id: AdvancedTextEditorTextboxIds.InRHSComment, - selectionStart: 3, - selectionEnd: 3, - }); - expect(Utils.isTextSelectedInPostOrReply(event)).toBe(false); - }); - - test('returns true when text is selected in RHS comment textbox', () => { - const event = createKeyboardEvent({ - id: AdvancedTextEditorTextboxIds.InRHSComment, - selectionStart: 0, - selectionEnd: 3, - }); - expect(Utils.isTextSelectedInPostOrReply(event)).toBe(true); - }); - - test('returns false when no text is selected in edit mode textbox', () => { - const event = createKeyboardEvent({ - id: AdvancedTextEditorTextboxIds.InEditMode, - selectionStart: 7, - selectionEnd: 7, - }); - expect(Utils.isTextSelectedInPostOrReply(event)).toBe(false); - }); - - test('returns true when text is selected in edit mode textbox', () => { - const event = createKeyboardEvent({ - id: AdvancedTextEditorTextboxIds.InEditMode, - selectionStart: 0, - selectionEnd: 7, - }); - expect(Utils.isTextSelectedInPostOrReply(event)).toBe(true); + test('should handle invalid image URLs gracefully', () => { + // These are not valid URLs but should still be processed correctly + expect(getFileType('path/to/image.jpg')).toBe(FileTypes.IMAGE); + expect(getFileType('image.png')).toBe(FileTypes.IMAGE); + }); + + test('should identify other file types correctly', () => { + expect(getFileType('doc')).toBe(FileTypes.WORD); + expect(getFileType('pdf')).toBe(FileTypes.PDF); + expect(getFileType('mp3')).toBe(FileTypes.AUDIO); + expect(getFileType('mp4')).toBe(FileTypes.VIDEO); + expect(getFileType('js')).toBe(FileTypes.CODE); + expect(getFileType('txt')).toBe(FileTypes.TEXT); + }); + + test('should handle null or undefined input', () => { + expect(getFileType(null as any)).toBe(FileTypes.OTHER); + expect(getFileType(undefined as any)).toBe(FileTypes.OTHER); + expect(getFileType('')).toBe(FileTypes.OTHER); }); }); diff --git a/webapp/channels/src/utils/utils.tsx b/webapp/channels/src/utils/utils.tsx index 3d1062ebe8..0986298192 100644 --- a/webapp/channels/src/utils/utils.tsx +++ b/webapp/channels/src/utils/utils.tsx @@ -212,6 +212,43 @@ const removeQuerystringOrHash = (extin: string): string => { }; export const getFileType = (extin: string): typeof FileTypes[keyof typeof FileTypes] => { + // Handle null or undefined input + if (!extin) { + return FileTypes.OTHER; + } + + // Special handling for image proxy URLs + // Check for various forms of image proxy URLs + if (extin.includes('/api/v4/image') && + (extin.includes('?url=') || extin.includes('&url='))) { + return FileTypes.IMAGE; + } + + // Check for image file extensions in the URL path + try { + // Try to parse as a URL - this will validate if it's a proper URL + const url = new URL(extin); + const pathname = url.pathname; + const pathParts = pathname.split('/'); + const lastPathPart = pathParts[pathParts.length - 1]; + + if (lastPathPart && lastPathPart.includes('.')) { + const urlExtension = lastPathPart.split('.').pop()?.toLowerCase(); + if (urlExtension && Constants.IMAGE_TYPES.indexOf(urlExtension) > -1) { + return FileTypes.IMAGE; + } + } + } catch (e) { + // Not a valid URL, just check if the string itself has an extension + if (extin.includes('.')) { + const extension = extin.split('.').pop()?.toLowerCase(); + if (extension && Constants.IMAGE_TYPES.indexOf(extension) > -1) { + return FileTypes.IMAGE; + } + } + } + + // Standard extension-based detection const ext = removeQuerystringOrHash(extin.toLowerCase()); if (Constants.TEXT_TYPES.indexOf(ext) > -1) {