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 <build@mattermost.com>
Этот коммит содержится в:
Pablo Vélez
2025-05-01 13:41:50 +02:00
коммит произвёл GitHub
родитель 20f9f58e4c
Коммит 80c58a9742
6 изменённых файлов: 282 добавлений и 570 удалений

Просмотреть файл

@@ -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();
});
});

Просмотреть файл

@@ -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<FilePreviewModal>(<FilePreviewModal {...baseProps}/>);
// 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<FilePreviewModal>(<FilePreviewModal {...props}/>);
// 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'}),

Просмотреть файл

@@ -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<Props, State>
}
};
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<Props, State>
}
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;

Просмотреть файл

@@ -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);
}

Просмотреть файл

@@ -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<HTMLTextAreaElement>) {
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);
});
});

Просмотреть файл

@@ -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) {