Fix edit attachments (#8468)

* Fix edit attachments

* Address feedback
This commit is contained in:
Daniel Espino García 2025-01-17 12:41:14 +01:00 committed by GitHub
parent 592b3805f1
commit 438edd96f6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 1108 additions and 39 deletions

View file

@ -94,7 +94,7 @@ const File = ({
} else {
onPress(index);
}
}, [index]);
}, [index, onPress]);
const {styles, onGestureEvent, ref} = useGalleryItem(galleryIdentifier, index, handlePreviewPress);

View file

@ -0,0 +1,616 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {render, fireEvent} from '@testing-library/react-native';
import React, {useMemo, type ComponentProps} from 'react';
import {DeviceEventEmitter, Text, TouchableOpacity, View} from 'react-native';
import {Events} from '@constants';
import {useIsTablet} from '@hooks/device';
import {useImageAttachments} from '@hooks/files';
import {mockFileInfo} from '@test/api_mocks/file';
import {isImage, isVideo} from '@utils/file';
import {fileToGalleryItem, openGalleryAtIndex} from '@utils/gallery';
import {getViewPortWidth} from '@utils/images';
import File from './file';
import Files from './files';
import type FileModel from '@typings/database/models/servers/file';
jest.mock('@hooks/device', () => ({
useIsTablet: jest.fn().mockReturnValue(false),
}));
jest.mock('@hooks/files', () => ({
useImageAttachments: jest.fn().mockReturnValue({
images: [],
nonImages: [],
}),
}));
jest.mock('@hooks/utils', () => ({
usePreventDoubleTap: (fn: () => void) => fn,
}));
jest.mock('@utils/file', () => ({
isImage: jest.fn().mockReturnValue(true),
isVideo: jest.fn().mockReturnValue(false),
}));
jest.mock('@utils/gallery', () => ({
fileToGalleryItem: jest.fn(),
openGalleryAtIndex: jest.fn(),
}));
jest.mock('@utils/images', () => ({
getViewPortWidth: jest.fn().mockReturnValue(300),
}));
jest.mock('./file', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mocked(File).mockImplementation((props) => (
<View testID={props.file.id}>
<Text testID={`${props.file.id}-galleryIdentifier`}>{props.galleryIdentifier}</Text>
<Text testID={`${props.file.id}-canDownloadFiles`}>{String(props.canDownloadFiles)}</Text>
<Text testID={`${props.file.id}-index`}>{props.index}</Text>
<Text testID={`${props.file.id}-isSingleImage`}>{String(props.isSingleImage)}</Text>
<Text testID={`${props.file.id}-nonVisibleImagesCount`}>{String(props.nonVisibleImagesCount)}</Text>
<Text testID={`${props.file.id}-publicLinkEnabled`}>{String(props.publicLinkEnabled)}</Text>
<Text testID={`${props.file.id}-wrapperWidth`}>{props.wrapperWidth}</Text>
<Text testID={`${props.file.id}-inViewPort`}>{String(props.inViewPort)}</Text>
<TouchableOpacity
testID={`${props.file.id}-onPress`}
onPress={() => props.onPress(props.index)}
/>
<TouchableOpacity
testID={`${props.file.id}-updateFileForGallery`}
onPress={() => props.updateFileForGallery(props.index, {...props.file, uri: 'updated'})}
/>
</View>
));
function getBaseProps(): ComponentProps<typeof Files> {
return {
canDownloadFiles: true,
failed: false,
filesInfo: [],
isReplyPost: false,
layoutWidth: 300,
location: 'test-location',
postId: 'test-post-id',
postProps: {},
publicLinkEnabled: true,
};
}
describe('Files', () => {
it('should render attachments, with images in the image row', () => {
const filesInfo = [
mockFileInfo({id: '1'}),
mockFileInfo({id: '2'}),
mockFileInfo({id: '3'}),
mockFileInfo({id: '4'}),
];
jest.mocked(useImageAttachments).mockImplementation((fi) => {
return useMemo(() => ({
images: fi.filter((f) => f.id === '1' || f.id === '2'),
nonImages: fi.filter((f) => f.id === '3' || f.id === '4'),
}), [fi]);
});
const {getByTestId} = render(
<Files
{...getBaseProps()}
filesInfo={filesInfo}
/>,
);
expect(getByTestId('1')).toBeVisible();
expect(getByTestId('2')).toBeVisible();
expect(getByTestId('3')).toBeVisible();
expect(getByTestId('4')).toBeVisible();
expect(getByTestId('image-row')).toContainElement(getByTestId('1'));
expect(getByTestId('image-row')).toContainElement(getByTestId('2'));
expect(getByTestId('image-row')).not.toContainElement(getByTestId('3'));
expect(getByTestId('image-row')).not.toContainElement(getByTestId('4'));
});
it('should not show the image row if no images', () => {
const filesInfo = [
mockFileInfo({id: '3'}),
mockFileInfo({id: '4'}),
];
jest.mocked(useImageAttachments).mockImplementation((fi) => {
return useMemo(() => ({
images: fi.filter((f) => f.id === '1' || f.id === '2'),
nonImages: fi.filter((f) => f.id === '3' || f.id === '4'),
}), [fi]);
});
const {getByTestId, queryByTestId} = render(
<Files
{...getBaseProps()}
filesInfo={filesInfo}
/>,
);
expect(getByTestId('3')).toBeVisible();
expect(getByTestId('4')).toBeVisible();
expect(queryByTestId('image-row')).not.toBeVisible();
});
it('should have different opacity if failed', () => {
const baseProps = getBaseProps();
baseProps.failed = true;
const {getByTestId, rerender} = render(
<Files {...baseProps}/>,
);
expect(getByTestId('files-container')).toHaveStyle({opacity: 0.5});
baseProps.failed = false;
rerender(<Files {...baseProps}/>);
expect(getByTestId('files-container')).not.toHaveStyle({opacity: 0.5});
});
it('should drill all relevant props', () => {
const filesInfo = [
mockFileInfo({id: '1'}),
mockFileInfo({id: '2'}),
];
jest.mocked(useImageAttachments).mockImplementation((fi) => {
return useMemo(() => ({
images: fi.filter((f) => f.id === '1'),
nonImages: fi.filter((f) => f.id === '2'),
}), [fi]);
});
const baseProps = getBaseProps();
baseProps.filesInfo = filesInfo;
baseProps.canDownloadFiles = false;
baseProps.publicLinkEnabled = false;
const {getByTestId, rerender} = render(<Files {...baseProps}/>);
expect(getByTestId('1-canDownloadFiles')).toHaveTextContent('false');
expect(getByTestId('1-publicLinkEnabled')).toHaveTextContent('false');
expect(getByTestId('2-canDownloadFiles')).toHaveTextContent('false');
expect(getByTestId('2-publicLinkEnabled')).toHaveTextContent('false');
baseProps.canDownloadFiles = true;
baseProps.publicLinkEnabled = true;
rerender(<Files {...baseProps}/>);
expect(getByTestId('1-canDownloadFiles')).toHaveTextContent('true');
expect(getByTestId('1-publicLinkEnabled')).toHaveTextContent('true');
expect(getByTestId('2-canDownloadFiles')).toHaveTextContent('true');
expect(getByTestId('2-publicLinkEnabled')).toHaveTextContent('true');
baseProps.canDownloadFiles = true;
baseProps.publicLinkEnabled = false;
rerender(<Files {...baseProps}/>);
expect(getByTestId('1-canDownloadFiles')).toHaveTextContent('true');
expect(getByTestId('1-publicLinkEnabled')).toHaveTextContent('false');
expect(getByTestId('2-canDownloadFiles')).toHaveTextContent('true');
expect(getByTestId('2-publicLinkEnabled')).toHaveTextContent('false');
baseProps.canDownloadFiles = false;
baseProps.publicLinkEnabled = true;
rerender(<Files {...baseProps}/>);
expect(getByTestId('1-canDownloadFiles')).toHaveTextContent('false');
expect(getByTestId('1-publicLinkEnabled')).toHaveTextContent('true');
expect(getByTestId('2-canDownloadFiles')).toHaveTextContent('false');
expect(getByTestId('2-publicLinkEnabled')).toHaveTextContent('true');
});
it('should set layoutWidth if provided', () => {
const filesInfo = [
mockFileInfo({id: '1'}),
mockFileInfo({id: '2'}),
];
jest.mocked(useImageAttachments).mockImplementation((fi) => {
return useMemo(() => ({
images: fi.filter((f) => f.id === '1'),
nonImages: fi.filter((f) => f.id === '2'),
}), [fi]);
});
const baseProps = getBaseProps();
baseProps.filesInfo = filesInfo;
baseProps.layoutWidth = 400;
const {getByTestId, rerender} = render(<Files {...baseProps}/>);
expect(getByTestId('1-wrapperWidth')).toHaveTextContent('400');
expect(getByTestId('2-wrapperWidth')).toHaveTextContent('400');
expect(getByTestId('image-row')).toHaveStyle({width: 400});
baseProps.layoutWidth = 500;
rerender(<Files {...baseProps}/>);
expect(getByTestId('1-wrapperWidth')).toHaveTextContent('500');
expect(getByTestId('2-wrapperWidth')).toHaveTextContent('500');
expect(getByTestId('image-row')).toHaveStyle({width: 500});
});
it('should use ((getViewportWidth result) - 6) if layoutWidth is not provided', () => {
const filesInfo = [
mockFileInfo({id: '1'}),
mockFileInfo({id: '2'}),
];
jest.mocked(useImageAttachments).mockImplementation((fi) => {
return useMemo(() => ({
images: fi.filter((f) => f.id === '1'),
nonImages: fi.filter((f) => f.id === '2'),
}), [fi]);
});
jest.mocked(getViewPortWidth).mockReturnValue(300);
jest.mocked(useIsTablet).mockReturnValue(false);
const baseProps = getBaseProps();
baseProps.filesInfo = filesInfo;
baseProps.layoutWidth = undefined;
baseProps.isReplyPost = false;
const {getByTestId, rerender} = render(<Files {...baseProps}/>);
expect(getByTestId('1-wrapperWidth')).toHaveTextContent('294');
expect(getByTestId('2-wrapperWidth')).toHaveTextContent('294');
expect(getByTestId('image-row')).toHaveStyle({width: 294});
expect(getViewPortWidth).toHaveBeenCalledWith(false, false);
jest.mocked(getViewPortWidth).mockReturnValue(400);
jest.mocked(useIsTablet).mockReturnValue(true);
baseProps.isReplyPost = true;
rerender(<Files {...baseProps}/>);
expect(getByTestId('1-wrapperWidth')).toHaveTextContent('394');
expect(getByTestId('2-wrapperWidth')).toHaveTextContent('394');
expect(getByTestId('image-row')).toHaveStyle({width: 394});
expect(getViewPortWidth).toHaveBeenCalledWith(true, true);
});
it('calling onPress on the child should open gallery', () => {
const filesInfo = [
mockFileInfo({id: '1'}),
mockFileInfo({id: '2'}),
];
jest.mocked(useImageAttachments).mockImplementation((fi) => {
return useMemo(() => ({
images: fi.filter((f) => f.id === '1'),
nonImages: fi.filter((f) => f.id === '2'),
}), [fi]);
});
jest.mocked(fileToGalleryItem).mockImplementation((file) => ({
height: 100,
id: file.id || '',
lastPictureUpdate: 0,
mime_type: file.mime_type,
name: file.name,
type: 'image',
uri: file.uri || '',
width: file.width,
}));
const {getByTestId} = render(
<Files
{...getBaseProps()}
filesInfo={filesInfo}
/>,
);
fireEvent.press(getByTestId('1-onPress'));
expect(openGalleryAtIndex).toHaveBeenCalledWith(
'test-post-id-fileAttachments-test-location',
0,
expect.arrayContaining([expect.objectContaining({id: '1'}), expect.objectContaining({id: '2'})]),
);
fireEvent.press(getByTestId('2-onPress'));
expect(openGalleryAtIndex).toHaveBeenCalledWith(
'test-post-id-fileAttachments-test-location',
1,
expect.arrayContaining([expect.objectContaining({id: '1'}), expect.objectContaining({id: '2'})]),
);
});
it('calling updateFileForGallery updates the file', () => {
const filesInfo = [
mockFileInfo({id: '1', uri: 'original'}),
mockFileInfo({id: '2', uri: 'original'}),
];
jest.mocked(useImageAttachments).mockImplementation((fi) => {
return useMemo(() => ({
images: fi.filter((f) => f.id === '1'),
nonImages: fi.filter((f) => f.id === '2'),
}), [fi]);
});
jest.mocked(fileToGalleryItem).mockImplementation((file) => ({
height: 100,
id: file.id || '',
lastPictureUpdate: 0,
mime_type: file.mime_type,
name: file.name,
type: 'image',
uri: file.uri || '',
width: file.width,
}));
const {getByTestId} = render(
<Files
{...getBaseProps()}
filesInfo={filesInfo}
/>,
);
fireEvent.press(getByTestId('1-updateFileForGallery'));
fireEvent.press(getByTestId('1-onPress'));
expect(openGalleryAtIndex).toHaveBeenCalledWith(
'test-post-id-fileAttachments-test-location',
0,
expect.arrayContaining([expect.objectContaining({id: '1', uri: 'updated'}), expect.objectContaining({id: '2', uri: 'original'})]),
);
fireEvent.press(getByTestId('2-onPress'));
expect(openGalleryAtIndex).toHaveBeenCalledWith(
'test-post-id-fileAttachments-test-location',
1,
expect.arrayContaining([expect.objectContaining({id: '1', uri: 'updated'}), expect.objectContaining({id: '2', uri: 'original'})]),
);
});
it('should update files for gallery on new props', () => {
const {rerender, getByTestId} = render(
<Files
{...getBaseProps()}
filesInfo={[]}
/>,
);
const newFilesInfo = [
mockFileInfo({id: '1', name: 'image1.png', user_id: 'user1'}),
];
rerender(
<Files
{...getBaseProps()}
filesInfo={newFilesInfo}
/>,
);
expect(getByTestId('1')).toBeVisible();
});
it('should set inViewPort to true on ITEM_IN_VIEWPORT event', () => {
const filesInfo = [
mockFileInfo({id: '1', name: 'image1.png', user_id: 'user1'}),
mockFileInfo({id: '2', name: 'image2.png', user_id: 'user2'}),
];
jest.mocked(useImageAttachments).mockImplementation((fi) => {
return useMemo(() => ({
images: fi.filter((f) => f.id === '1'),
nonImages: fi.filter((f) => f.id === '2'),
}), [fi]);
});
const {getByTestId} = render(
<Files
{...getBaseProps()}
filesInfo={filesInfo}
/>,
);
expect(getByTestId('1-inViewPort')).toHaveTextContent('false');
expect(getByTestId('2-inViewPort')).toHaveTextContent('false');
DeviceEventEmitter.emit(Events.ITEM_IN_VIEWPORT, {'unrelated-event': true});
expect(getByTestId('1-inViewPort')).toHaveTextContent('false');
expect(getByTestId('2-inViewPort')).toHaveTextContent('false');
DeviceEventEmitter.emit(Events.ITEM_IN_VIEWPORT, {'test-location-test-post-id': true});
expect(getByTestId('1-inViewPort')).toHaveTextContent('true');
expect(getByTestId('2-inViewPort')).toHaveTextContent('true');
});
it('should ignore ITEM_IN_VIEWPORT event if not for the current post or location', () => {
const filesInfo = [
mockFileInfo({id: '1', name: 'image1.png', user_id: 'user1'}),
mockFileInfo({id: '2', name: 'image2.png', user_id: 'user2'}),
];
jest.mocked(useImageAttachments).mockImplementation((fi) => {
return useMemo(() => ({
images: fi.filter((f) => f.id === '1'),
nonImages: fi.filter((f) => f.id === '2'),
}), [fi]);
});
const baseProps = getBaseProps();
baseProps.filesInfo = filesInfo;
baseProps.location = 'location1';
baseProps.postId = 'post1';
const {getByTestId, rerender} = render(<Files {...baseProps}/>);
expect(getByTestId('1-inViewPort')).toHaveTextContent('false');
expect(getByTestId('2-inViewPort')).toHaveTextContent('false');
baseProps.location = 'location2';
rerender(<Files {...baseProps}/>);
expect(getByTestId('1-inViewPort')).toHaveTextContent('false');
expect(getByTestId('2-inViewPort')).toHaveTextContent('false');
DeviceEventEmitter.emit(Events.ITEM_IN_VIEWPORT, {'location1-post1': true});
expect(getByTestId('1-inViewPort')).toHaveTextContent('false');
expect(getByTestId('2-inViewPort')).toHaveTextContent('false');
baseProps.postId = 'post2';
rerender(<Files {...baseProps}/>);
expect(getByTestId('1-inViewPort')).toHaveTextContent('false');
expect(getByTestId('2-inViewPort')).toHaveTextContent('false');
DeviceEventEmitter.emit(Events.ITEM_IN_VIEWPORT, {'location2-post1': true});
expect(getByTestId('1-inViewPort')).toHaveTextContent('false');
expect(getByTestId('2-inViewPort')).toHaveTextContent('false');
DeviceEventEmitter.emit(Events.ITEM_IN_VIEWPORT, {'location2-post2': true});
expect(getByTestId('1-inViewPort')).toHaveTextContent('true');
expect(getByTestId('2-inViewPort')).toHaveTextContent('true');
});
it('should pass isSingleImage to the children', () => {
let filesInfo = [
mockFileInfo({id: '1'}),
mockFileInfo({id: '3'}),
mockFileInfo({id: '4'}),
];
const selectImages = (f: FileInfo | FileModel | undefined) => f?.id === '1' || f?.id === '2';
jest.mocked(useImageAttachments).mockImplementation((fi) => {
return useMemo(() => ({
images: fi.filter((f) => selectImages(f)),
nonImages: fi.filter((f) => !selectImages(f)),
}), [fi]);
});
jest.mocked(isImage).mockImplementation((f) => selectImages(f));
jest.mocked(isVideo).mockReturnValue(false);
const {rerender, getByTestId} = render(
<Files
{...getBaseProps()}
filesInfo={filesInfo}
/>,
);
expect(getByTestId('1-isSingleImage')).toHaveTextContent('true');
expect(getByTestId('3-isSingleImage')).toHaveTextContent('true');
expect(getByTestId('4-isSingleImage')).toHaveTextContent('true');
jest.mocked(isImage).mockReturnValue(false);
jest.mocked(isVideo).mockImplementation((f) => selectImages(f));
rerender(
<Files
{...getBaseProps()}
filesInfo={filesInfo}
/>,
);
expect(getByTestId('1-isSingleImage')).toHaveTextContent('true');
expect(getByTestId('3-isSingleImage')).toHaveTextContent('true');
expect(getByTestId('4-isSingleImage')).toHaveTextContent('true');
filesInfo = [
mockFileInfo({id: '1'}),
mockFileInfo({id: '2'}),
mockFileInfo({id: '3'}),
mockFileInfo({id: '4'}),
];
jest.mocked(isImage).mockImplementation((f) => selectImages(f));
jest.mocked(isVideo).mockReturnValue(false);
rerender(
<Files
{...getBaseProps()}
filesInfo={filesInfo}
/>,
);
expect(getByTestId('1-isSingleImage')).toHaveTextContent('false');
expect(getByTestId('2-isSingleImage')).toHaveTextContent('false');
expect(getByTestId('3-isSingleImage')).toHaveTextContent('false');
expect(getByTestId('4-isSingleImage')).toHaveTextContent('false');
jest.mocked(isImage).mockReturnValue(false);
jest.mocked(isVideo).mockImplementation((f) => selectImages(f));
rerender(
<Files
{...getBaseProps()}
filesInfo={filesInfo}
/>,
);
expect(getByTestId('1-isSingleImage')).toHaveTextContent('false');
expect(getByTestId('2-isSingleImage')).toHaveTextContent('false');
expect(getByTestId('3-isSingleImage')).toHaveTextContent('false');
expect(getByTestId('4-isSingleImage')).toHaveTextContent('false');
});
it('should trim more than 4 images and properly add the non visible images count to the last image', () => {
const filesInfo = [
mockFileInfo({id: '1'}),
mockFileInfo({id: '2'}),
mockFileInfo({id: '3'}),
mockFileInfo({id: '4'}),
mockFileInfo({id: '5'}),
mockFileInfo({id: '6'}),
mockFileInfo({id: '7'}),
mockFileInfo({id: '8'}),
mockFileInfo({id: '9'}),
mockFileInfo({id: '10'}),
];
jest.mocked(useImageAttachments).mockImplementation((fi) => {
return useMemo(() => ({
images: fi.filter((f) => f.id === '1' || f.id === '2' || f.id === '3' || f.id === '4' || f.id === '5'),
nonImages: fi.filter((f) => f.id === '6' || f.id === '7' || f.id === '8' || f.id === '9' || f.id === '10'),
}), [fi]);
});
const {getByTestId, queryByTestId} = render(
<Files
{...getBaseProps()}
filesInfo={filesInfo}
/>,
);
expect(getByTestId('1-nonVisibleImagesCount')).toHaveTextContent('undefined');
expect(getByTestId('2-nonVisibleImagesCount')).toHaveTextContent('undefined');
expect(getByTestId('3-nonVisibleImagesCount')).toHaveTextContent('undefined');
expect(getByTestId('4-nonVisibleImagesCount')).toHaveTextContent('1');
expect(queryByTestId('5-nonVisibleImagesCount')).not.toBeVisible();
expect(getByTestId('6-nonVisibleImagesCount')).toHaveTextContent('undefined');
expect(getByTestId('7-nonVisibleImagesCount')).toHaveTextContent('undefined');
expect(getByTestId('8-nonVisibleImagesCount')).toHaveTextContent('undefined');
expect(getByTestId('9-nonVisibleImagesCount')).toHaveTextContent('undefined');
expect(getByTestId('10-nonVisibleImagesCount')).toHaveTextContent('undefined');
});
it('should add gutter to the container of to all elements but the first only on image row', () => {
const filesInfo = [
mockFileInfo({id: '1'}),
mockFileInfo({id: '2'}),
mockFileInfo({id: '3'}),
mockFileInfo({id: '4'}),
mockFileInfo({id: '5'}),
mockFileInfo({id: '6'}),
];
jest.mocked(useImageAttachments).mockImplementation((fi) => {
return useMemo(() => ({
images: fi.filter((f) => f.id === '1' || f.id === '2' || f.id === '3'),
nonImages: fi.filter((f) => f.id === '4' || f.id === '5' || f.id === '6'),
}), [fi]);
});
const {getByTestId} = render(
<Files
{...getBaseProps()}
filesInfo={filesInfo}
/>,
);
expect(getByTestId('1-file-container')).not.toHaveStyle({marginLeft: 8});
expect(getByTestId('2-file-container')).toHaveStyle({marginLeft: 8});
expect(getByTestId('3-file-container')).toHaveStyle({marginLeft: 8});
expect(getByTestId('4-file-container')).not.toHaveStyle({marginLeft: 8});
expect(getByTestId('5-file-container')).not.toHaveStyle({marginLeft: 8});
expect(getByTestId('6-file-container')).not.toHaveStyle({marginLeft: 8});
});
});

View file

@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useEffect, useMemo, useState} from 'react';
import React, {useCallback, useEffect, useMemo, useState} from 'react';
import {DeviceEventEmitter, type StyleProp, StyleSheet, View, type ViewStyle} from 'react-native';
import Animated from 'react-native-reanimated';
@ -9,10 +9,10 @@ import {Events} from '@constants';
import {GalleryInit} from '@context/gallery';
import {useIsTablet} from '@hooks/device';
import {useImageAttachments} from '@hooks/files';
import {usePreventDoubleTap} from '@hooks/utils';
import {isImage, isVideo} from '@utils/file';
import {fileToGalleryItem, openGalleryAtIndex} from '@utils/gallery';
import {getViewPortWidth} from '@utils/images';
import {preventDoubleTap} from '@utils/tap';
import File from './file';
@ -49,29 +49,38 @@ const styles = StyleSheet.create({
},
});
const Files = ({canDownloadFiles, failed, filesInfo, isReplyPost, layoutWidth, location, postId, postProps, publicLinkEnabled}: FilesProps) => {
const Files = ({
canDownloadFiles,
failed,
filesInfo,
isReplyPost,
layoutWidth,
location,
postId,
postProps,
publicLinkEnabled,
}: FilesProps) => {
const galleryIdentifier = `${postId}-fileAttachments-${location}`;
const [inViewPort, setInViewPort] = useState(false);
const isTablet = useIsTablet();
const {images: imageAttachments, nonImages: nonImageAttachments} = useImageAttachments(filesInfo, publicLinkEnabled);
const filesForGallery = useMemo(() => imageAttachments.concat(nonImageAttachments),
[imageAttachments, nonImageAttachments]);
const {images: imageAttachments, nonImages: nonImageAttachments} = useImageAttachments(filesInfo);
const [filesForGallery, setFilesForGallery] = useState(() => [...imageAttachments, ...nonImageAttachments]);
const attachmentIndex = (fileId: string) => {
return filesForGallery.findIndex((file) => file.id === fileId) || 0;
};
const handlePreviewPress = preventDoubleTap((idx: number) => {
const handlePreviewPress = usePreventDoubleTap(useCallback((idx: number) => {
const items = filesForGallery.map((f) => fileToGalleryItem(f, f.user_id, postProps));
openGalleryAtIndex(galleryIdentifier, idx, items);
});
}, [filesForGallery, galleryIdentifier, postProps]));
const updateFileForGallery = (idx: number, file: FileInfo) => {
'worklet';
filesForGallery[idx] = file;
};
const updateFileForGallery = useCallback((idx: number, file: FileInfo) => {
const newFilesForGallery = [...filesForGallery];
newFilesForGallery[idx] = file;
setFilesForGallery(newFilesForGallery);
}, [filesForGallery]);
const isSingleImage = useMemo(() => filesInfo.filter((f) => isImage(f) || isVideo(f)).length === 1, [filesInfo]);
@ -79,6 +88,7 @@ const Files = ({canDownloadFiles, failed, filesInfo, isReplyPost, layoutWidth, l
let nonVisibleImagesCount: number;
let container: StyleProp<ViewStyle> = items.length > 1 ? styles.container : undefined;
const containerWithGutter = [container, styles.gutter];
const wrapperWidth = getViewPortWidth(isReplyPost, isTablet) - 6;
return items.map((file, idx) => {
if (moreImagesCount && idx === MAX_VISIBLE_ROW_IMAGES - 1) {
@ -91,6 +101,7 @@ const Files = ({canDownloadFiles, failed, filesInfo, isReplyPost, layoutWidth, l
return (
<View
style={[container, styles.marginTop]}
testID={`${file.id}-file-container`}
key={file.id}
>
<File
@ -104,7 +115,7 @@ const Files = ({canDownloadFiles, failed, filesInfo, isReplyPost, layoutWidth, l
nonVisibleImagesCount={nonVisibleImagesCount}
publicLinkEnabled={publicLinkEnabled}
updateFileForGallery={updateFileForGallery}
wrapperWidth={layoutWidth || (getViewPortWidth(isReplyPost, isTablet) - 6)}
wrapperWidth={layoutWidth || wrapperWidth}
inViewPort={inViewPort}
/>
</View>
@ -126,7 +137,10 @@ const Files = ({canDownloadFiles, failed, filesInfo, isReplyPost, layoutWidth, l
}
return (
<View style={[styles.row, {width: portraitPostWidth}]}>
<View
style={[styles.row, {width: portraitPostWidth}]}
testID='image-row'
>
{ renderItems(visibleImages, nonVisibleImagesCount, true) }
</View>
);
@ -140,11 +154,18 @@ const Files = ({canDownloadFiles, failed, filesInfo, isReplyPost, layoutWidth, l
});
return () => onScrollEnd.remove();
}, []);
}, [location, postId]);
useEffect(() => {
setFilesForGallery([...imageAttachments, ...nonImageAttachments]);
}, [imageAttachments, nonImageAttachments]);
return (
<GalleryInit galleryIdentifier={galleryIdentifier}>
<Animated.View style={[failed && styles.failed]}>
<Animated.View
testID='files-container'
style={failed ? styles.failed : undefined}
>
{renderImageRow()}
{renderItems(nonImageAttachments)}
</Animated.View>

View file

@ -76,7 +76,7 @@ const FileResults = ({
const containerStyle = useMemo(() => ([paddingTop, {flexGrow: 1}]), [paddingTop]);
const numOptions = getNumberFileMenuOptions(canDownloadFiles, publicLinkEnabled);
const {images: imageAttachments, nonImages: nonImageAttachments} = useImageAttachments(fileInfos, publicLinkEnabled);
const {images: imageAttachments, nonImages: nonImageAttachments} = useImageAttachments(fileInfos);
const filesForGallery = useMemo(() => imageAttachments.concat(nonImageAttachments), [imageAttachments, nonImageAttachments]);
const channelNames = useMemo(() => getChannelNamesWithID(fileChannels), [fileChannels]);

View file

@ -22,7 +22,7 @@ describe('*** Operator: Post Handlers tests ***', () => {
let operator: ServerDataOperator;
let posts: Post[] = [];
beforeEach(() => {
beforeEach(async () => {
posts = [
{
id: '8swgtrrdiff89jnsiwiip3y1eoe',
@ -101,8 +101,8 @@ describe('*** Operator: Post Handlers tests ***', () => {
files: [
{
id: 'f1oxe5rtepfs7n3zifb4sso7po',
user_id: '89ertha8xpfsumpucqppy5knao',
post_id: 'a7ebyw883trm884p1qcgt8yw4a',
user_id: 'q3mzxua9zjfczqakxdkowc6u6yy',
post_id: '8swgtrrdiff89jnsiwiip3y1eoe',
create_at: 1608270920357,
update_at: 1608270920357,
delete_at: 0,
@ -169,13 +169,15 @@ describe('*** Operator: Post Handlers tests ***', () => {
metadata: {},
},
];
});
beforeAll(async () => {
await DatabaseManager.init(['baseHandler.test.com']);
operator = DatabaseManager.serverDatabases['baseHandler.test.com']!.operator;
});
afterEach(async () => {
DatabaseManager.destroyServerDatabase('baseHandler.test.com');
});
it('=> HandleDraft: should write to the the Draft table', async () => {
expect.assertions(1);
@ -264,8 +266,8 @@ describe('*** Operator: Post Handlers tests ***', () => {
files: [
{
id: 'f1oxe5rtepfs7n3zifb4sso7po',
user_id: '89ertha8xpfsumpucqppy5knao',
post_id: 'a7ebyw883trm884p1qcgt8yw4a',
user_id: 'q3mzxua9zjfczqakxdkowc6u6yy',
post_id: '8swgtrrdiff89jnsiwiip3y1eoe',
create_at: 1608270920357,
update_at: 1608270920357,
delete_at: 0,
@ -367,6 +369,174 @@ describe('*** Operator: Post Handlers tests ***', () => {
prepareRecordsOnly: true,
});
});
it('=> HandlePosts: should remove files no longer present in the post', async () => {
const postWithMetadata = posts[0];
const uploadedFiles = postWithMetadata.metadata.files!;
const updatedPosts: Post[] = [
{
...postWithMetadata,
update_at: Date.now(),
metadata: {
...postWithMetadata.metadata,
files: [],
},
},
];
const order = [
'8swgtrrdiff89jnsiwiip3y1eoe',
'8fcnk3p1jt8mmkaprgajoxz115a',
'3y3w3a6gkbg73bnj3xund9o5ic',
];
const actionType = ActionType.POSTS.RECEIVED_IN_CHANNEL;
await operator.handlePosts({
actionType,
order,
posts,
prepareRecordsOnly: false,
});
let files = await operator.database.get('File').query(Q.where('post_id', postWithMetadata.id)).fetch();
expect(files).toHaveLength(1);
expect(files[0].id).toBe(uploadedFiles[0].id);
await operator.handlePosts({
actionType,
order: [uploadedFiles[0].id!],
posts: updatedPosts,
});
files = await operator.database.get('File').query(Q.where('post_id', postWithMetadata.id)).fetch();
expect(files).toHaveLength(0);
});
it('=> HandlePosts: should add new files if new files are added', async () => {
const postWithMetadata = posts[0];
const uploadedFiles = postWithMetadata.metadata.files!;
const updatedPosts: Post[] = [
{
...postWithMetadata,
update_at: Date.now(),
metadata: {
...postWithMetadata.metadata,
files: [
...postWithMetadata.metadata.files!,
{
id: 'another-file-id',
user_id: 'q3mzxua9zjfczqakxdkowc6u6yy',
post_id: '8swgtrrdiff89jnsiwiip3y1eoe',
create_at: 1608270920357,
update_at: 1608270920357,
delete_at: 0,
name: '4qtwrg.jpg',
extension: 'jpg',
size: 89208,
mime_type: 'image/jpeg',
width: 500,
height: 656,
has_preview_image: true,
mini_preview:
'/9j/2wCEAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRQBAwQEBQQFCQUFCRQNCw0UFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFP/AABEIABAAEAMBIgACEQEDEQH/xAGiAAABBQEBAQEBAQAAAAAAAAAAAQIDBAUGBwgJCgsQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+gEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoLEQACAQIEBAMEBwUEBAABAncAAQIDEQQFITEGEkFRB2FxEyIygQgUQpGhscEJIzNS8BVictEKFiQ04SXxFxgZGiYnKCkqNTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqCg4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2dri4+Tl5ufo6ery8/T19vf4+fr/2gAMAwEAAhEDEQA/AN/T/iZp+pX15FpUmnwLbXtpJpyy2sQLw8CcBXA+bksCDnHGOaf4W+P3xIshbQ6loB8RrbK11f3FpbBFW3ZwiFGHB2kr25BIOeCPPbX4S3407T7rTdDfxFNIpDyRaw9lsB4OECHGR15yO4GK6fRPhR4sGmSnxAs8NgchNOjvDPsjz8qSHA37cDk5JPPFdlOpTdPlcVt/Ku1lrvr17b67EPnjrH8/626H/9k=',
},
],
},
},
];
const order = [
'8swgtrrdiff89jnsiwiip3y1eoe',
'8fcnk3p1jt8mmkaprgajoxz115a',
'3y3w3a6gkbg73bnj3xund9o5ic',
];
const actionType = ActionType.POSTS.RECEIVED_IN_CHANNEL;
await operator.handlePosts({
actionType,
order,
posts,
prepareRecordsOnly: false,
});
let files = await operator.database.get('File').query(Q.where('post_id', postWithMetadata.id)).fetch();
expect(files).toHaveLength(1);
expect(files[0].id).toBe(uploadedFiles[0].id);
await operator.handlePosts({
actionType,
order: [uploadedFiles[0].id!],
posts: updatedPosts,
});
files = await operator.database.get('File').query(Q.where('post_id', postWithMetadata.id)).fetch();
expect(files).toHaveLength(2);
expect(files.map((file) => file.id)).toEqual(expect.arrayContaining(['f1oxe5rtepfs7n3zifb4sso7po', 'another-file-id']));
});
it('=> HandlePosts: should substitute files new files are added and old files are removed', async () => {
const postWithMetadata = posts[0];
const uploadedFiles = postWithMetadata.metadata.files!;
const updatedPosts: Post[] = [
{
...postWithMetadata,
update_at: Date.now(),
metadata: {
...postWithMetadata.metadata,
files: [
{
id: 'another-file-id',
user_id: 'q3mzxua9zjfczqakxdkowc6u6yy',
post_id: '8swgtrrdiff89jnsiwiip3y1eoe',
create_at: 1608270920357,
update_at: 1608270920357,
delete_at: 0,
name: '4qtwrg.jpg',
extension: 'jpg',
size: 89208,
mime_type: 'image/jpeg',
width: 500,
height: 656,
has_preview_image: true,
mini_preview:
'/9j/2wCEAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRQBAwQEBQQFCQUFCRQNCw0UFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFP/AABEIABAAEAMBIgACEQEDEQH/xAGiAAABBQEBAQEBAQAAAAAAAAAAAQIDBAUGBwgJCgsQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+gEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoLEQACAQIEBAMEBwUEBAABAncAAQIDEQQFITEGEkFRB2FxEyIygQgUQpGhscEJIzNS8BVictEKFiQ04SXxFxgZGiYnKCkqNTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqCg4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2dri4+Tl5ufo6ery8/T19vf4+fr/2gAMAwEAAhEDEQA/AN/T/iZp+pX15FpUmnwLbXtpJpyy2sQLw8CcBXA+bksCDnHGOaf4W+P3xIshbQ6loB8RrbK11f3FpbBFW3ZwiFGHB2kr25BIOeCPPbX4S3407T7rTdDfxFNIpDyRaw9lsB4OECHGR15yO4GK6fRPhR4sGmSnxAs8NgchNOjvDPsjz8qSHA37cDk5JPPFdlOpTdPlcVt/Ku1lrvr17b67EPnjrH8/626H/9k=',
},
],
},
},
];
const order = [
'8swgtrrdiff89jnsiwiip3y1eoe',
'8fcnk3p1jt8mmkaprgajoxz115a',
'3y3w3a6gkbg73bnj3xund9o5ic',
];
const actionType = ActionType.POSTS.RECEIVED_IN_CHANNEL;
await operator.handlePosts({
actionType,
order,
posts,
prepareRecordsOnly: false,
});
let files = await operator.database.get('File').query(Q.where('post_id', postWithMetadata.id)).fetch();
expect(files).toHaveLength(1);
expect(files[0].id).toBe(uploadedFiles[0].id);
await operator.handlePosts({
actionType,
order: [uploadedFiles[0].id!],
posts: updatedPosts,
});
files = await operator.database.get('File').query(Q.where('post_id', postWithMetadata.id)).fetch();
expect(files).toHaveLength(1);
expect(files[0].id).toBe('another-file-id');
});
});
describe('*** Operator: merge chunks ***', () => {

View file

@ -217,8 +217,8 @@ const PostHandler = <TBase extends Constructor<ServerDataOperatorBase>>(supercla
return result;
}, new Set<string>());
const database: Database = this.database;
if (deletedPostIds.size) {
const database: Database = this.database;
const postsToDelete = await database.get<PostModel>(POST).query(Q.where('id', Q.oneOf(Array.from(deletedPostIds)))).fetch();
if (postsToDelete.length) {
await database.write(async () => {
@ -260,6 +260,14 @@ const PostHandler = <TBase extends Constructor<ServerDataOperatorBase>>(supercla
batch.push(...postFiles);
}
const allFiles = await database.get<FileModel>(MM_TABLES.SERVER.FILE).query(Q.where('post_id', Q.oneOf(uniquePosts.map((p) => p.id)))).fetch();
const receivedFilesSet = new Set(files.map((f) => f.id));
allFiles.forEach((f) => {
if (!receivedFilesSet.has(f.id)) {
batch.push(f.prepareDestroyPermanently());
}
});
if (emojis.length) {
const postEmojis = await this.handleCustomEmojis({emojis, prepareRecordsOnly: true});
batch.push(...postEmojis);

145
app/hooks/files.test.ts Normal file
View file

@ -0,0 +1,145 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {renderHook} from '@testing-library/react-hooks';
import {buildFilePreviewUrl, buildFileUrl} from '@actions/remote/file';
import {useServerUrl} from '@context/server';
import {mockFileInfo} from '@test/api_mocks/file';
import {isGif, isImage, isVideo} from '@utils/file';
import {useImageAttachments} from './files';
jest.mock('@actions/remote/file', () => ({
buildFilePreviewUrl: jest.fn(),
buildFileUrl: jest.fn(),
}));
jest.mock('@utils/file', () => ({
isGif: jest.fn(),
isImage: jest.fn(),
isVideo: jest.fn(),
}));
jest.mock('@context/server', () => ({
useServerUrl: jest.fn(),
}));
describe('useImageAttachments', () => {
const serverUrl = 'https://example.com';
beforeEach(() => {
jest.mocked(useServerUrl).mockReturnValue(serverUrl);
});
it('should separate images and non-images correctly', () => {
const filesInfo = [
mockFileInfo({id: '1', localPath: 'path/to/image1', uri: `${serverUrl}/files/image1`}),
mockFileInfo({id: '2', localPath: 'path/to/video1', uri: `${serverUrl}/files/video1`}),
mockFileInfo({id: '3', localPath: 'path/to/file1', uri: `${serverUrl}/files/file1`}),
];
jest.mocked(isImage).mockImplementation((file) => file?.id === '1');
jest.mocked(isVideo).mockImplementation((file) => file?.id === '2');
jest.mocked(isGif).mockReturnValue(false);
jest.mocked(buildFilePreviewUrl).mockImplementation((url, id) => `${url}/preview/${id}`);
jest.mocked(buildFileUrl).mockImplementation((url, id) => `${url}/file/${id}`);
const {result} = renderHook(() => useImageAttachments(filesInfo));
expect(result.current.images).toEqual([
mockFileInfo({id: '1', localPath: 'path/to/image1', uri: 'path/to/image1'}),
mockFileInfo({id: '2', localPath: 'path/to/video1', uri: 'path/to/video1'}),
]);
expect(result.current.nonImages).toEqual([
mockFileInfo({id: '3', localPath: 'path/to/file1', uri: `${serverUrl}/files/file1`}),
]);
});
it('should use preview URL for images without localPath', () => {
const filesInfo = [
mockFileInfo({id: '1', localPath: '', uri: `${serverUrl}/files/image1`}),
];
jest.mocked(isImage).mockReturnValue(true);
jest.mocked(isVideo).mockReturnValue(false);
jest.mocked(isGif).mockReturnValue(false);
jest.mocked(buildFilePreviewUrl).mockImplementation((url, id) => `${url}/preview/${id}`);
const {result} = renderHook(() => useImageAttachments(filesInfo));
expect(result.current.images).toEqual([
mockFileInfo({id: '1', localPath: '', uri: 'https://example.com/preview/1'}),
]);
});
it('should use file URL for gifs and videos without local path', () => {
const filesInfo = [
mockFileInfo({id: '1', localPath: '', uri: `${serverUrl}/files/image1`}),
mockFileInfo({id: '2', localPath: '', uri: `${serverUrl}/files/video1`}),
];
jest.mocked(isImage).mockImplementation((file) => file?.id === '1' || file?.id === '3');
jest.mocked(isVideo).mockImplementation((file) => file?.id === '2');
jest.mocked(isGif).mockImplementation((file) => file?.id === '1');
jest.mocked(buildFileUrl).mockImplementation((url, id) => `${url}/file/${id}`);
const {result} = renderHook(() => useImageAttachments(filesInfo));
expect(result.current.images).toEqual([
mockFileInfo({id: '1', localPath: '', uri: 'https://example.com/file/1'}),
mockFileInfo({id: '2', localPath: '', uri: 'https://example.com/file/2'}),
]);
});
it('should return the same values if the same arguments are passed', () => {
const filesInfo = [
mockFileInfo({id: '1', localPath: 'path/to/image1', uri: `${serverUrl}/files/image1`}),
mockFileInfo({id: '2', localPath: 'path/to/video1', uri: `${serverUrl}/files/video1`}),
];
jest.mocked(isImage).mockImplementation((file) => file?.id === '1');
jest.mocked(isVideo).mockImplementation((file) => file?.id === '2');
jest.mocked(isGif).mockReturnValue(false);
jest.mocked(buildFilePreviewUrl).mockImplementation((url, id) => `${url}/preview/${id}`);
jest.mocked(buildFileUrl).mockImplementation((url, id) => `${url}/file/${id}`);
const {result, rerender} = renderHook(() => useImageAttachments(filesInfo));
const firstResult = result.current;
rerender();
const secondResult = result.current;
expect(firstResult).toBe(secondResult);
});
it('should handle empty filesInfo array', () => {
const filesInfo: FileInfo[] = [];
const {result} = renderHook(() => useImageAttachments(filesInfo));
expect(result.current.images).toEqual([]);
expect(result.current.nonImages).toEqual([]);
});
it('should handle files with no id', () => {
const filesInfo = [
mockFileInfo({id: '', localPath: 'path/to/image1', uri: `${serverUrl}/files/image1`}),
mockFileInfo({id: '', localPath: '', uri: `${serverUrl}/files/image2`}),
];
jest.mocked(isImage).mockReturnValue(true);
jest.mocked(isVideo).mockReturnValue(false);
jest.mocked(isGif).mockReturnValue(false);
jest.mocked(buildFilePreviewUrl).mockImplementation((url, id) => `${url}/preview/${id}`);
const {result} = renderHook(() => useImageAttachments(filesInfo));
expect(result.current.images).toEqual([
mockFileInfo({id: '', localPath: 'path/to/image1', uri: 'path/to/image1'}),
]);
});
});

View file

@ -49,32 +49,30 @@ const getFileInfo = async (serverUrl: string, bookmarks: ChannelBookmarkModel[],
}
};
export const useImageAttachments = (filesInfo: FileInfo[], publicLinkEnabled: boolean) => {
export const useImageAttachments = (filesInfo: FileInfo[]) => {
const serverUrl = useServerUrl();
return useMemo(() => {
return filesInfo.reduce(({images, nonImages}: {images: FileInfo[]; nonImages: FileInfo[]}, file) => {
const imageFile = isImage(file);
const videoFile = isVideo(file);
if (imageFile || (videoFile && publicLinkEnabled)) {
if (imageFile || videoFile) {
let uri;
if (file.localPath) {
uri = file.localPath;
} else {
uri = (isGif(file) || videoFile) ? buildFileUrl(serverUrl, file.id!) : buildFilePreviewUrl(serverUrl, file.id!);
// If no local path and no id, we skip the image
if (!file.id) {
return {images, nonImages};
}
uri = (isGif(file) || videoFile) ? buildFileUrl(serverUrl, file.id) : buildFilePreviewUrl(serverUrl, file.id);
}
images.push({...file, uri});
} else {
let uri = file.uri;
if (videoFile) {
// fallback if public links are not enabled
uri = buildFileUrl(serverUrl, file.id!);
}
nonImages.push({...file, uri});
nonImages.push({...file});
}
return {images, nonImages};
}, {images: [], nonImages: []});
}, [filesInfo, publicLinkEnabled]);
}, [filesInfo, serverUrl]);
};
export const useChannelBookmarkFiles = (bookmarks: ChannelBookmarkModel[], publicLinkEnabled: boolean) => {

View file

@ -212,7 +212,7 @@ export function useGalleryItem(
) {
const gallery = useGallery(identifier);
const ref = useAnimatedRef<any>();
const {opacity, activeIndex} = gallery!.sharedValues;
const {opacity, activeIndex} = gallery.sharedValues;
const styles = useAnimatedStyle(() => {
return {

73
app/hooks/utils.test.tsx Normal file
View file

@ -0,0 +1,73 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {renderHook, act} from '@testing-library/react-hooks';
import {usePreventDoubleTap} from './utils';
describe('usePreventDoubleTap', () => {
const callback = jest.fn();
it('should allow the first tap', () => {
const {result} = renderHook(() => usePreventDoubleTap(callback));
act(() => {
result.current();
});
expect(callback).toHaveBeenCalled();
});
it('should prevent the second tap within the delay', () => {
const {result} = renderHook(() => usePreventDoubleTap(callback));
act(() => {
result.current();
});
expect(callback).toHaveBeenCalledTimes(1);
act(() => {
result.current();
});
expect(callback).toHaveBeenCalledTimes(1);
});
it('should allow the tap after the delay', () => {
jest.useFakeTimers();
const {result} = renderHook(() => usePreventDoubleTap(callback));
act(() => {
result.current();
});
expect(callback).toHaveBeenCalledTimes(1);
act(() => {
result.current();
});
expect(callback).toHaveBeenCalledTimes(1);
act(() => {
jest.advanceTimersByTime(750);
result.current();
});
expect(callback).toHaveBeenCalledTimes(2);
jest.useRealTimers();
});
it('should return the same result if the same callback is passed', () => {
const {result, rerender} = renderHook(() => usePreventDoubleTap(callback));
const firstResult = result.current;
rerender();
const secondResult = result.current;
expect(firstResult).toBe(secondResult);
});
});

19
app/hooks/utils.ts Normal file
View file

@ -0,0 +1,19 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useCallback, useRef} from 'react';
const DELAY = 750;
export const usePreventDoubleTap = <T extends Function>(callback: T) => {
const lastTapRef = useRef<number | null>(null);
return useCallback((...args: unknown[]) => {
const now = Date.now();
if (lastTapRef.current && now - lastTapRef.current < DELAY) {
return;
}
lastTapRef.current = now;
callback(...args);
}, [callback]);
};

19
test/api_mocks/file.ts Normal file
View file

@ -0,0 +1,19 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export function mockFileInfo(overwrite: Partial<FileInfo> = {}): FileInfo {
return {
id: '1',
localPath: 'path/to/image1',
uri: '',
has_preview_image: true,
extension: 'png',
height: 100,
width: 100,
mime_type: 'image/png',
name: 'image1',
size: 100,
user_id: '1',
...overwrite,
};
}