(Android) Common component for upload item for main application and share extension (#9028)
* Common component for upload item for main application and share extension * Addressed review comments * intl fixes --------- Co-authored-by: Mattermost Build <build@mattermost.com>
This commit is contained in:
parent
113f9d942c
commit
71fed8aa50
20 changed files with 1878 additions and 408 deletions
|
|
@ -16,7 +16,7 @@ import DraftEditPostUploadManager from '@managers/draft_upload_manager';
|
|||
import {fileToGalleryItem, openGalleryAtIndex} from '@utils/gallery';
|
||||
import {makeStyleSheetFromTheme} from '@utils/theme';
|
||||
|
||||
import UploadItem from './upload_item';
|
||||
import UploadItem from './upload_item/upload_item_wrapper';
|
||||
|
||||
const CONTAINER_HEIGHT_MAX = 80;
|
||||
const CONTAINER_HEIGHT_MIN = 0;
|
||||
|
|
|
|||
|
|
@ -1,295 +0,0 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
||||
import {TouchableWithoutFeedback, View, Text} from 'react-native';
|
||||
import Animated from 'react-native-reanimated';
|
||||
|
||||
import {updateDraftFile} from '@actions/local/draft';
|
||||
import FileIcon from '@components/files/file_icon';
|
||||
import ImageFile from '@components/files/image_file';
|
||||
import ProgressBar from '@components/progress_bar';
|
||||
import {useEditPost} from '@context/edit_post';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import {useTheme} from '@context/theme';
|
||||
import useDidUpdate from '@hooks/did_update';
|
||||
import {useGalleryItem} from '@hooks/gallery';
|
||||
import DraftEditPostUploadManager from '@managers/draft_upload_manager';
|
||||
import {isImage, getFormattedFileSize} from '@utils/file';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
import {typography} from '@utils/typography';
|
||||
|
||||
import UploadRemove from './upload_remove';
|
||||
import UploadRetry from './upload_retry';
|
||||
|
||||
type Props = {
|
||||
channelId: string;
|
||||
galleryIdentifier: string;
|
||||
index: number;
|
||||
file: FileInfo;
|
||||
openGallery: (file: FileInfo) => void;
|
||||
rootId: string;
|
||||
}
|
||||
|
||||
// Constants for dimensions
|
||||
const DIMENSIONS = {
|
||||
THUMBNAIL_SIZE: 64,
|
||||
ICON_SIZE: 48,
|
||||
FILE_CONTAINER_WIDTH: 264,
|
||||
FILE_CONTAINER_HEIGHT: 64,
|
||||
} as const;
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
|
||||
return {
|
||||
preview: {
|
||||
paddingTop: 5,
|
||||
marginLeft: 12,
|
||||
},
|
||||
previewContainer: {
|
||||
borderRadius: 4,
|
||||
borderWidth: 1,
|
||||
borderColor: changeOpacity(theme.centerChannelColor, 0.16),
|
||||
backgroundColor: theme.centerChannelBg,
|
||||
alignItems: 'center',
|
||||
position: 'relative',
|
||||
},
|
||||
imageOnlyContainer: {
|
||||
width: DIMENSIONS.THUMBNAIL_SIZE,
|
||||
height: DIMENSIONS.THUMBNAIL_SIZE,
|
||||
padding: 0,
|
||||
},
|
||||
fileWithInfoContainer: {
|
||||
width: DIMENSIONS.FILE_CONTAINER_WIDTH,
|
||||
height: DIMENSIONS.FILE_CONTAINER_HEIGHT,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flexShrink: 0,
|
||||
gap: 8,
|
||||
paddingVertical: 12,
|
||||
paddingLeft: 8,
|
||||
paddingRight: 16,
|
||||
},
|
||||
iconContainer: {
|
||||
width: DIMENSIONS.ICON_SIZE,
|
||||
height: DIMENSIONS.ICON_SIZE,
|
||||
borderRadius: 4,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
imageContainer: {
|
||||
width: DIMENSIONS.THUMBNAIL_SIZE,
|
||||
height: DIMENSIONS.THUMBNAIL_SIZE,
|
||||
borderRadius: 4,
|
||||
marginRight: 8,
|
||||
overflow: 'hidden',
|
||||
borderWidth: 1,
|
||||
borderColor: changeOpacity(theme.centerChannelColor, 0.16),
|
||||
shadowColor: '#000000',
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 2,
|
||||
},
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 3,
|
||||
elevation: 1,
|
||||
},
|
||||
imageOnlyThumbnail: {
|
||||
width: DIMENSIONS.THUMBNAIL_SIZE,
|
||||
height: DIMENSIONS.THUMBNAIL_SIZE,
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
shadowColor: '#000000',
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 2,
|
||||
},
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 3,
|
||||
elevation: 1,
|
||||
},
|
||||
fileInfo: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
fileName: {
|
||||
color: theme.centerChannelColor,
|
||||
...typography('Body', 200, 'SemiBold'),
|
||||
marginBottom: 2,
|
||||
},
|
||||
fileSize: {
|
||||
color: changeOpacity(theme.centerChannelColor, 0.64),
|
||||
...typography('Body', 75, 'Regular'),
|
||||
},
|
||||
progress: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.1)',
|
||||
borderRadius: 4,
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
progressContainer: {
|
||||
paddingVertical: undefined,
|
||||
position: undefined,
|
||||
justifyContent: undefined,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export default function UploadItem({
|
||||
channelId, galleryIdentifier, index, file,
|
||||
rootId, openGallery,
|
||||
}: Props) {
|
||||
const theme = useTheme();
|
||||
const style = getStyleSheet(theme);
|
||||
const serverUrl = useServerUrl();
|
||||
const removeCallback = useRef<(() => void) | undefined>(undefined);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const {updateFileCallback, isEditMode} = useEditPost();
|
||||
|
||||
const loading = DraftEditPostUploadManager.isUploading(file.clientId!);
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
openGallery(file);
|
||||
}, [openGallery, file]);
|
||||
|
||||
useEffect(() => {
|
||||
if (file.clientId) {
|
||||
removeCallback.current = DraftEditPostUploadManager.registerProgressHandler(file.clientId, setProgress);
|
||||
}
|
||||
return () => {
|
||||
removeCallback.current?.();
|
||||
removeCallback.current = undefined;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useDidUpdate(() => {
|
||||
if (loading && file.clientId) {
|
||||
removeCallback.current = DraftEditPostUploadManager.registerProgressHandler(file.clientId, setProgress);
|
||||
}
|
||||
return () => {
|
||||
removeCallback.current?.();
|
||||
removeCallback.current = undefined;
|
||||
};
|
||||
}, [file.failed, file.id]);
|
||||
|
||||
const retryFileUpload = useCallback(() => {
|
||||
if (!file.failed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newFile = {...file};
|
||||
newFile.failed = false;
|
||||
|
||||
if (isEditMode && updateFileCallback) {
|
||||
// In edit mode, use the context callback to update the file
|
||||
updateFileCallback(newFile);
|
||||
DraftEditPostUploadManager.prepareUpload(
|
||||
serverUrl,
|
||||
newFile,
|
||||
channelId,
|
||||
rootId,
|
||||
newFile.bytesRead,
|
||||
true, // isEditPost = true
|
||||
updateFileCallback,
|
||||
);
|
||||
} else {
|
||||
// In draft mode, use the draft file system
|
||||
updateDraftFile(serverUrl, channelId, rootId, newFile);
|
||||
DraftEditPostUploadManager.prepareUpload(serverUrl, newFile, channelId, rootId, newFile.bytesRead);
|
||||
}
|
||||
|
||||
DraftEditPostUploadManager.registerProgressHandler(newFile.clientId!, setProgress);
|
||||
}, [serverUrl, channelId, rootId, file, isEditMode, updateFileCallback]);
|
||||
|
||||
const {styles, onGestureEvent, ref} = useGalleryItem(galleryIdentifier, index, handlePress);
|
||||
|
||||
const fileDisplayComponent = () => {
|
||||
if (isImage(file)) {
|
||||
return (
|
||||
<View style={style.imageOnlyThumbnail}>
|
||||
<ImageFile
|
||||
file={file}
|
||||
forwardRef={ref}
|
||||
contentFit='cover'
|
||||
inViewPort={true}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<View style={style.iconContainer}>
|
||||
<FileIcon
|
||||
iconSize={48}
|
||||
file={file}
|
||||
testID={file.id}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const fileExtension = file.extension?.toUpperCase() || file.name?.split('.').pop()?.toUpperCase() || '';
|
||||
|
||||
const formattedSize = getFormattedFileSize(file.size || 0);
|
||||
|
||||
const isImageFile = isImage(file);
|
||||
const containerStyle = useMemo(() => [
|
||||
style.previewContainer,
|
||||
isImageFile ? style.imageOnlyContainer : style.fileWithInfoContainer,
|
||||
], [isImageFile, style.fileWithInfoContainer, style.imageOnlyContainer, style.previewContainer]);
|
||||
|
||||
return (
|
||||
<View
|
||||
key={file.clientId}
|
||||
style={style.preview}
|
||||
>
|
||||
<View style={containerStyle}>
|
||||
<TouchableWithoutFeedback onPress={onGestureEvent}>
|
||||
<Animated.View style={styles}>
|
||||
{fileDisplayComponent()}
|
||||
</Animated.View>
|
||||
</TouchableWithoutFeedback>
|
||||
|
||||
{!isImageFile && (
|
||||
<View style={style.fileInfo}>
|
||||
<Text
|
||||
style={style.fileName}
|
||||
numberOfLines={1}
|
||||
ellipsizeMode='tail'
|
||||
>
|
||||
{file.name || 'Unknown file'}
|
||||
</Text>
|
||||
<Text style={style.fileSize}>
|
||||
{fileExtension && `${fileExtension} `}{formattedSize}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{file.failed &&
|
||||
<UploadRetry
|
||||
onPress={retryFileUpload}
|
||||
/>
|
||||
}
|
||||
{loading && !file.failed &&
|
||||
<View style={style.progress}>
|
||||
<ProgressBar
|
||||
progress={progress || 0}
|
||||
color={theme.buttonBg}
|
||||
containerStyle={style.progressContainer}
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
</View>
|
||||
<UploadRemove
|
||||
clientId={file.clientId!}
|
||||
channelId={channelId}
|
||||
rootId={rootId}
|
||||
fileId={file.id!}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -11,7 +11,7 @@ import DraftEditPostUploadManager from '@managers/draft_upload_manager';
|
|||
import {fireEvent, renderWithEverything} from '@test/intl-test-helper';
|
||||
import TestHelper from '@test/test_helper';
|
||||
|
||||
import UploadItem from '.';
|
||||
import UploadItem from './upload_item_wrapper';
|
||||
|
||||
import type {Database} from '@nozbe/watermelondb';
|
||||
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback, useEffect, useRef, useState} from 'react';
|
||||
import {View} from 'react-native';
|
||||
|
||||
import {updateDraftFile} from '@actions/local/draft';
|
||||
import UploadItemShared from '@components/upload_item_shared';
|
||||
import {fileInfoToUploadItemFile} from '@components/upload_item_shared/adapters';
|
||||
import {useEditPost} from '@context/edit_post';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import useDidUpdate from '@hooks/did_update';
|
||||
import {useGalleryItem} from '@hooks/gallery';
|
||||
import DraftEditPostUploadManager from '@managers/draft_upload_manager';
|
||||
|
||||
import UploadRemove from './upload_remove';
|
||||
|
||||
type Props = {
|
||||
channelId: string;
|
||||
galleryIdentifier: string;
|
||||
index: number;
|
||||
file: FileInfo;
|
||||
openGallery: (file: FileInfo) => void;
|
||||
rootId: string;
|
||||
inViewPort?: boolean;
|
||||
}
|
||||
|
||||
export default function UploadItemWrapper({
|
||||
channelId, galleryIdentifier, index, file,
|
||||
rootId, openGallery, inViewPort = true,
|
||||
}: Props) {
|
||||
const serverUrl = useServerUrl();
|
||||
const removeCallback = useRef<(() => void) | undefined>(undefined);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const {updateFileCallback, isEditMode} = useEditPost();
|
||||
|
||||
const loading = DraftEditPostUploadManager.isUploading(file.clientId!);
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
openGallery(file);
|
||||
}, [openGallery, file]);
|
||||
|
||||
useEffect(() => {
|
||||
if (file.clientId) {
|
||||
removeCallback.current = DraftEditPostUploadManager.registerProgressHandler(file.clientId, setProgress);
|
||||
}
|
||||
return () => {
|
||||
removeCallback.current?.();
|
||||
removeCallback.current = undefined;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useDidUpdate(() => {
|
||||
if (loading && file.clientId) {
|
||||
removeCallback.current = DraftEditPostUploadManager.registerProgressHandler(file.clientId, setProgress);
|
||||
}
|
||||
return () => {
|
||||
removeCallback.current?.();
|
||||
removeCallback.current = undefined;
|
||||
};
|
||||
}, [file.failed, file.id]);
|
||||
|
||||
const retryFileUpload = useCallback(() => {
|
||||
if (!file.failed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newFile = {...file};
|
||||
newFile.failed = false;
|
||||
|
||||
if (isEditMode && updateFileCallback) {
|
||||
updateFileCallback(newFile);
|
||||
DraftEditPostUploadManager.prepareUpload(
|
||||
serverUrl,
|
||||
newFile,
|
||||
channelId,
|
||||
rootId,
|
||||
newFile.bytesRead,
|
||||
true, // isEditPost = true
|
||||
updateFileCallback,
|
||||
);
|
||||
} else {
|
||||
updateDraftFile(serverUrl, channelId, rootId, newFile);
|
||||
DraftEditPostUploadManager.prepareUpload(serverUrl, newFile, channelId, rootId, newFile.bytesRead);
|
||||
}
|
||||
|
||||
DraftEditPostUploadManager.registerProgressHandler(newFile.clientId!, setProgress);
|
||||
}, [serverUrl, channelId, rootId, file, isEditMode, updateFileCallback]);
|
||||
|
||||
const {styles, onGestureEvent, ref} = useGalleryItem(galleryIdentifier, index, handlePress);
|
||||
|
||||
const uploadItemFile = fileInfoToUploadItemFile(file);
|
||||
|
||||
return (
|
||||
<View
|
||||
key={file.clientId}
|
||||
style={{paddingTop: 5, marginLeft: 12}}
|
||||
>
|
||||
<UploadItemShared
|
||||
file={uploadItemFile}
|
||||
onPress={onGestureEvent}
|
||||
onRetry={retryFileUpload}
|
||||
loading={loading}
|
||||
progress={progress}
|
||||
showRetryButton={Boolean(file.failed)}
|
||||
galleryStyles={styles}
|
||||
testID={file.id}
|
||||
forwardRef={ref}
|
||||
inViewPort={inViewPort}
|
||||
/>
|
||||
<UploadRemove
|
||||
clientId={file.clientId!}
|
||||
channelId={channelId}
|
||||
rootId={rootId}
|
||||
fileId={file.id!}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,16 +2,12 @@
|
|||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback} from 'react';
|
||||
import {View, Platform} from 'react-native';
|
||||
|
||||
import {removeDraftFile} from '@actions/local/draft';
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||
import RemoveButton from '@components/upload_item_shared/remove_button';
|
||||
import {useEditPost} from '@context/edit_post';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import {useTheme} from '@context/theme';
|
||||
import DraftEditPostUploadManager from '@managers/draft_upload_manager';
|
||||
import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme';
|
||||
|
||||
type Props = {
|
||||
channelId: string;
|
||||
|
|
@ -20,38 +16,12 @@ type Props = {
|
|||
fileId: string;
|
||||
}
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
|
||||
return {
|
||||
tappableContainer: {
|
||||
position: 'absolute',
|
||||
elevation: 11,
|
||||
top: -12,
|
||||
right: -10,
|
||||
width: 24,
|
||||
height: 24,
|
||||
},
|
||||
removeButton: {
|
||||
borderRadius: 12,
|
||||
alignSelf: 'center',
|
||||
marginTop: Platform.select({
|
||||
ios: 5.4,
|
||||
android: 4.75,
|
||||
}),
|
||||
backgroundColor: theme.centerChannelBg,
|
||||
width: 24,
|
||||
height: 25,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export default function UploadRemove({
|
||||
channelId,
|
||||
rootId,
|
||||
clientId,
|
||||
fileId,
|
||||
}: Props) {
|
||||
const theme = useTheme();
|
||||
const style = getStyleSheet(theme);
|
||||
const serverUrl = useServerUrl();
|
||||
const {onFileRemove, isEditMode} = useEditPost();
|
||||
|
||||
|
|
@ -65,19 +35,9 @@ export default function UploadRemove({
|
|||
}, [onFileRemove, isEditMode, fileId, clientId, serverUrl, channelId, rootId]);
|
||||
|
||||
return (
|
||||
<TouchableWithFeedback
|
||||
style={style.tappableContainer}
|
||||
<RemoveButton
|
||||
onPress={onPress}
|
||||
type={'opacity'}
|
||||
testID={`remove-button-${fileId}`}
|
||||
>
|
||||
<View style={style.removeButton}>
|
||||
<CompassIcon
|
||||
name='close-circle'
|
||||
color={changeOpacity(theme.centerChannelColor, 0.64)}
|
||||
size={24}
|
||||
/>
|
||||
</View>
|
||||
</TouchableWithFeedback>
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
376
app/components/upload_item_shared/adapters.test.ts
Normal file
376
app/components/upload_item_shared/adapters.test.ts
Normal file
|
|
@ -0,0 +1,376 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {fileInfoToUploadItemFile, sharedItemToUploadItemFile} from './adapters';
|
||||
|
||||
import type {SharedItem} from '@mattermost/rnshare';
|
||||
|
||||
describe('Adapters', () => {
|
||||
describe('fileInfoToUploadItemFile', () => {
|
||||
it('should convert complete FileInfo to UploadItemFile', () => {
|
||||
const fileInfo: FileInfo = {
|
||||
id: 'file-123',
|
||||
clientId: 'client-456',
|
||||
name: 'test-document.pdf',
|
||||
extension: 'pdf',
|
||||
size: 2048,
|
||||
uri: '/local/path/to/file.pdf',
|
||||
localPath: '/local/path/to/file.pdf',
|
||||
failed: false,
|
||||
width: 800,
|
||||
height: 600,
|
||||
mime_type: 'application/pdf',
|
||||
has_preview_image: false,
|
||||
bytesRead: 1024,
|
||||
user_id: 'user-789',
|
||||
};
|
||||
|
||||
const result = fileInfoToUploadItemFile(fileInfo);
|
||||
|
||||
expect(result).toEqual({
|
||||
id: 'file-123',
|
||||
clientId: 'client-456',
|
||||
name: 'test-document.pdf',
|
||||
extension: 'pdf',
|
||||
size: 2048,
|
||||
uri: '/local/path/to/file.pdf',
|
||||
failed: false,
|
||||
width: 800,
|
||||
height: 600,
|
||||
mime_type: 'application/pdf',
|
||||
});
|
||||
});
|
||||
|
||||
it('should prefer uri over localPath when both are present', () => {
|
||||
const fileInfo: FileInfo = {
|
||||
id: 'file-123',
|
||||
name: 'test.jpg',
|
||||
uri: '/uri/path/test.jpg',
|
||||
localPath: '/local/path/test.jpg',
|
||||
extension: 'jpg',
|
||||
size: 1024,
|
||||
failed: false,
|
||||
width: 1024,
|
||||
height: 768,
|
||||
mime_type: 'image/jpeg',
|
||||
has_preview_image: true,
|
||||
bytesRead: 0,
|
||||
user_id: 'user-123',
|
||||
};
|
||||
|
||||
const result = fileInfoToUploadItemFile(fileInfo);
|
||||
|
||||
expect(result.uri).toBe('/uri/path/test.jpg');
|
||||
});
|
||||
|
||||
it('should use localPath when uri is not present', () => {
|
||||
const fileInfo: FileInfo = {
|
||||
id: 'file-123',
|
||||
name: 'test.jpg',
|
||||
localPath: '/local/path/test.jpg',
|
||||
extension: 'jpg',
|
||||
size: 1024,
|
||||
failed: false,
|
||||
width: 1024,
|
||||
height: 768,
|
||||
mime_type: 'image/jpeg',
|
||||
has_preview_image: true,
|
||||
bytesRead: 0,
|
||||
user_id: 'user-123',
|
||||
};
|
||||
|
||||
const result = fileInfoToUploadItemFile(fileInfo);
|
||||
|
||||
expect(result.uri).toBe('/local/path/test.jpg');
|
||||
});
|
||||
|
||||
it('should handle FileInfo with missing optional fields', () => {
|
||||
const minimalFileInfo: FileInfo = {
|
||||
id: 'file-123',
|
||||
name: 'test.txt',
|
||||
extension: 'txt',
|
||||
size: 512,
|
||||
failed: false,
|
||||
width: 0,
|
||||
height: 0,
|
||||
mime_type: 'text/plain',
|
||||
has_preview_image: false,
|
||||
bytesRead: 0,
|
||||
user_id: 'user-123',
|
||||
};
|
||||
|
||||
const result = fileInfoToUploadItemFile(minimalFileInfo);
|
||||
|
||||
expect(result).toEqual({
|
||||
id: 'file-123',
|
||||
clientId: undefined,
|
||||
name: 'test.txt',
|
||||
extension: 'txt',
|
||||
size: 512,
|
||||
uri: undefined,
|
||||
failed: false,
|
||||
width: 0,
|
||||
height: 0,
|
||||
mime_type: 'text/plain',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle failed upload state correctly', () => {
|
||||
const failedFileInfo: FileInfo = {
|
||||
id: 'file-123',
|
||||
name: 'failed-upload.jpg',
|
||||
extension: 'jpg',
|
||||
size: 1024,
|
||||
failed: true,
|
||||
width: 1024,
|
||||
height: 768,
|
||||
mime_type: 'image/jpeg',
|
||||
has_preview_image: false,
|
||||
bytesRead: 0,
|
||||
user_id: 'user-123',
|
||||
};
|
||||
|
||||
const result = fileInfoToUploadItemFile(failedFileInfo);
|
||||
|
||||
expect(result.failed).toBe(true);
|
||||
});
|
||||
|
||||
it('should preserve image dimensions', () => {
|
||||
const imageFileInfo: FileInfo = {
|
||||
id: 'image-123',
|
||||
name: 'photo.jpg',
|
||||
extension: 'jpg',
|
||||
size: 2048,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
failed: false,
|
||||
mime_type: 'image/jpeg',
|
||||
has_preview_image: true,
|
||||
bytesRead: 0,
|
||||
user_id: 'user-123',
|
||||
};
|
||||
|
||||
const result = fileInfoToUploadItemFile(imageFileInfo);
|
||||
|
||||
expect(result.width).toBe(1920);
|
||||
expect(result.height).toBe(1080);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sharedItemToUploadItemFile', () => {
|
||||
it('should convert complete SharedItem to UploadItemFile', () => {
|
||||
const sharedItem: SharedItem = {
|
||||
filename: 'shared-photo.jpg',
|
||||
extension: 'jpg',
|
||||
size: 1536,
|
||||
value: 'file:///shared/path/photo.jpg',
|
||||
width: 1024,
|
||||
height: 768,
|
||||
type: 'image/jpeg',
|
||||
isString: false,
|
||||
videoThumb: undefined,
|
||||
};
|
||||
|
||||
const result = sharedItemToUploadItemFile(sharedItem);
|
||||
|
||||
expect(result).toEqual({
|
||||
id: undefined,
|
||||
clientId: undefined,
|
||||
name: 'shared-photo.jpg',
|
||||
extension: 'jpg',
|
||||
size: 1536,
|
||||
uri: 'file:///shared/path/photo.jpg',
|
||||
failed: false,
|
||||
width: 1024,
|
||||
height: 768,
|
||||
mime_type: 'image/jpeg',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle SharedItem without optional fields', () => {
|
||||
const minimalSharedItem: SharedItem = {
|
||||
extension: 'txt',
|
||||
type: 'text/plain',
|
||||
value: 'file:///shared/document.txt',
|
||||
isString: false,
|
||||
};
|
||||
|
||||
const result = sharedItemToUploadItemFile(minimalSharedItem);
|
||||
|
||||
expect(result).toEqual({
|
||||
id: undefined,
|
||||
clientId: undefined,
|
||||
name: undefined,
|
||||
extension: 'txt',
|
||||
size: undefined,
|
||||
uri: 'file:///shared/document.txt',
|
||||
failed: false,
|
||||
width: undefined,
|
||||
height: undefined,
|
||||
mime_type: 'text/plain',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle video files with thumbnail', () => {
|
||||
const videoSharedItem: SharedItem = {
|
||||
filename: 'shared-video.mp4',
|
||||
extension: 'mp4',
|
||||
size: 5120,
|
||||
value: 'file:///shared/path/video.mp4',
|
||||
width: 1280,
|
||||
height: 720,
|
||||
type: 'video/mp4',
|
||||
isString: false,
|
||||
videoThumb: 'data:image/jpeg;base64,thumbnaildata',
|
||||
};
|
||||
|
||||
const result = sharedItemToUploadItemFile(videoSharedItem);
|
||||
|
||||
expect(result).toEqual({
|
||||
id: undefined,
|
||||
clientId: undefined,
|
||||
name: 'shared-video.mp4',
|
||||
extension: 'mp4',
|
||||
size: 5120,
|
||||
uri: 'file:///shared/path/video.mp4',
|
||||
failed: false,
|
||||
width: 1280,
|
||||
height: 720,
|
||||
mime_type: 'video/mp4',
|
||||
});
|
||||
});
|
||||
|
||||
it('should always set failed to false for SharedItem', () => {
|
||||
const sharedItem: SharedItem = {
|
||||
filename: 'document.pdf',
|
||||
extension: 'pdf',
|
||||
type: 'application/pdf',
|
||||
value: 'file:///shared/document.pdf',
|
||||
isString: false,
|
||||
size: 1024,
|
||||
};
|
||||
|
||||
const result = sharedItemToUploadItemFile(sharedItem);
|
||||
|
||||
expect(result.failed).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle large file sizes', () => {
|
||||
const largeFileSharedItem: SharedItem = {
|
||||
filename: 'large-video.mp4',
|
||||
extension: 'mp4',
|
||||
size: 104857600, // 100MB
|
||||
value: 'file:///shared/large-video.mp4',
|
||||
type: 'video/mp4',
|
||||
isString: false,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
};
|
||||
|
||||
const result = sharedItemToUploadItemFile(largeFileSharedItem);
|
||||
|
||||
expect(result.size).toBe(104857600);
|
||||
});
|
||||
|
||||
it('should handle string-based shared items', () => {
|
||||
const stringSharedItem: SharedItem = {
|
||||
value: 'Some text content',
|
||||
type: 'text/plain',
|
||||
extension: 'txt',
|
||||
isString: true,
|
||||
};
|
||||
|
||||
const result = sharedItemToUploadItemFile(stringSharedItem);
|
||||
|
||||
expect(result).toEqual({
|
||||
id: undefined,
|
||||
clientId: undefined,
|
||||
name: undefined,
|
||||
extension: 'txt',
|
||||
size: undefined,
|
||||
uri: 'Some text content',
|
||||
failed: false,
|
||||
width: undefined,
|
||||
height: undefined,
|
||||
mime_type: 'text/plain',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Data Type Consistency', () => {
|
||||
it('should produce UploadItemFile objects with consistent structure from both adapters', () => {
|
||||
const fileInfo: FileInfo = {
|
||||
id: 'file-123',
|
||||
name: 'test.jpg',
|
||||
extension: 'jpg',
|
||||
size: 1024,
|
||||
failed: false,
|
||||
width: 1024,
|
||||
height: 768,
|
||||
mime_type: 'image/jpeg',
|
||||
has_preview_image: true,
|
||||
bytesRead: 0,
|
||||
user_id: 'user-123',
|
||||
};
|
||||
|
||||
const sharedItem: SharedItem = {
|
||||
filename: 'test.jpg',
|
||||
extension: 'jpg',
|
||||
size: 1024,
|
||||
value: 'file:///path/test.jpg',
|
||||
type: 'image/jpeg',
|
||||
isString: false,
|
||||
};
|
||||
|
||||
const fromFileInfo = fileInfoToUploadItemFile(fileInfo);
|
||||
const fromSharedItem = sharedItemToUploadItemFile(sharedItem);
|
||||
|
||||
// Both should have the same properties
|
||||
expect(Object.keys(fromFileInfo).sort()).toEqual(Object.keys(fromSharedItem).sort());
|
||||
|
||||
// Both should have the same essential file information
|
||||
expect(fromFileInfo.name).toBe('test.jpg');
|
||||
expect(fromSharedItem.name).toBe('test.jpg');
|
||||
expect(fromFileInfo.extension).toBe('jpg');
|
||||
expect(fromSharedItem.extension).toBe('jpg');
|
||||
expect(fromFileInfo.size).toBe(1024);
|
||||
expect(fromSharedItem.size).toBe(1024);
|
||||
expect(fromFileInfo.mime_type).toBe('image/jpeg');
|
||||
expect(fromSharedItem.mime_type).toBe('image/jpeg');
|
||||
});
|
||||
|
||||
it('should handle edge cases gracefully', () => {
|
||||
// Empty/null values
|
||||
const emptyFileInfo: FileInfo = {
|
||||
id: '',
|
||||
name: '',
|
||||
extension: '',
|
||||
size: 0,
|
||||
failed: false,
|
||||
width: 0,
|
||||
height: 0,
|
||||
mime_type: '',
|
||||
has_preview_image: false,
|
||||
bytesRead: 0,
|
||||
user_id: '',
|
||||
};
|
||||
|
||||
const emptySharedItem: SharedItem = {
|
||||
filename: '',
|
||||
extension: '',
|
||||
size: 0,
|
||||
value: '',
|
||||
type: '',
|
||||
isString: false,
|
||||
};
|
||||
|
||||
const fromEmptyFileInfo = fileInfoToUploadItemFile(emptyFileInfo);
|
||||
const fromEmptySharedItem = sharedItemToUploadItemFile(emptySharedItem);
|
||||
|
||||
expect(fromEmptyFileInfo).toBeDefined();
|
||||
expect(fromEmptySharedItem).toBeDefined();
|
||||
expect(fromEmptyFileInfo.failed).toBe(false);
|
||||
expect(fromEmptySharedItem.failed).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
42
app/components/upload_item_shared/adapters.ts
Normal file
42
app/components/upload_item_shared/adapters.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {UploadItemFile} from './index';
|
||||
export {SHARED_UPLOAD_STYLES} from './constants';
|
||||
import type {SharedItem} from '@mattermost/rnshare';
|
||||
|
||||
/**
|
||||
* Convert FileInfo to UploadItemFile for the shared component
|
||||
*/
|
||||
export function fileInfoToUploadItemFile(file: FileInfo): UploadItemFile {
|
||||
return {
|
||||
id: file.id,
|
||||
clientId: file.clientId,
|
||||
name: file.name,
|
||||
extension: file.extension,
|
||||
size: file.size,
|
||||
uri: file.uri || file.localPath,
|
||||
failed: file.failed,
|
||||
width: file.width,
|
||||
height: file.height,
|
||||
mime_type: file.mime_type,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert SharedItem to UploadItemFile for the shared component
|
||||
*/
|
||||
export function sharedItemToUploadItemFile(item: SharedItem): UploadItemFile {
|
||||
return {
|
||||
id: undefined,
|
||||
clientId: undefined,
|
||||
name: item.filename,
|
||||
extension: item.extension,
|
||||
size: item.size,
|
||||
uri: item.value,
|
||||
failed: false,
|
||||
width: item.width,
|
||||
height: item.height,
|
||||
mime_type: item.type,
|
||||
};
|
||||
}
|
||||
14
app/components/upload_item_shared/constants.ts
Normal file
14
app/components/upload_item_shared/constants.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
export const SHARED_UPLOAD_STYLES = {
|
||||
THUMBNAIL_SIZE: 64,
|
||||
ICON_SIZE: 48,
|
||||
FILE_CONTAINER_WIDTH: 264,
|
||||
FILE_CONTAINER_HEIGHT: 64,
|
||||
BORDER_RADIUS: 4,
|
||||
SHADOW_OFFSET: {width: 0, height: 2},
|
||||
SHADOW_OPACITY: 0.08,
|
||||
SHADOW_RADIUS: 3,
|
||||
ELEVATION: 1,
|
||||
} as const;
|
||||
545
app/components/upload_item_shared/index.test.tsx
Normal file
545
app/components/upload_item_shared/index.test.tsx
Normal file
|
|
@ -0,0 +1,545 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import DatabaseManager from '@database/manager';
|
||||
import {fireEvent, renderWithEverything} from '@test/intl-test-helper';
|
||||
import TestHelper from '@test/test_helper';
|
||||
|
||||
import UploadItemShared, {type UploadItemFile} from './index';
|
||||
|
||||
import type {Database} from '@nozbe/watermelondb';
|
||||
|
||||
// Mock the dependencies
|
||||
jest.mock('@components/files/file_icon', () => 'FileIcon');
|
||||
jest.mock('@components/files/image_file', () => 'ImageFile');
|
||||
jest.mock('@components/progress_bar', () => 'ProgressBar');
|
||||
jest.mock('@utils/file', () => ({
|
||||
isImage: jest.fn(),
|
||||
getFormattedFileSize: jest.fn(),
|
||||
}));
|
||||
|
||||
const {isImage, getFormattedFileSize} = require('@utils/file');
|
||||
|
||||
describe('UploadItemShared', () => {
|
||||
const serverUrl = 'serverUrl';
|
||||
let database: Database;
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
database = (await TestHelper.setupServerDatabase(serverUrl)).database;
|
||||
getFormattedFileSize.mockReturnValue('1024 KB');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await DatabaseManager.destroyServerDatabase(serverUrl);
|
||||
});
|
||||
|
||||
const createMockFile = (overrides: Partial<UploadItemFile> = {}): UploadItemFile => ({
|
||||
id: 'test-id',
|
||||
clientId: 'test-client-id',
|
||||
name: 'test-file.jpg',
|
||||
extension: 'jpg',
|
||||
size: 1024,
|
||||
uri: 'file://test-uri',
|
||||
failed: false,
|
||||
width: 800,
|
||||
height: 600,
|
||||
mime_type: 'image/jpeg',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('File Type Display Behavior', () => {
|
||||
it('should display image thumbnail for image files', () => {
|
||||
isImage.mockReturnValue(true);
|
||||
const imageFile = createMockFile({
|
||||
name: 'vacation.jpg',
|
||||
mime_type: 'image/jpeg',
|
||||
});
|
||||
|
||||
const {getByTestId, queryByText} = renderWithEverything(
|
||||
<UploadItemShared
|
||||
file={imageFile}
|
||||
testID='test-upload'
|
||||
/>,
|
||||
{database},
|
||||
);
|
||||
|
||||
// Should show image component
|
||||
expect(getByTestId('test-upload')).toBeTruthy();
|
||||
|
||||
// Should NOT show file info text for images
|
||||
expect(queryByText('vacation.jpg')).toBeNull();
|
||||
expect(queryByText('JPG')).toBeNull();
|
||||
});
|
||||
|
||||
it('should display file info for document files', () => {
|
||||
isImage.mockReturnValue(false);
|
||||
const docFile = createMockFile({
|
||||
name: 'report.pdf',
|
||||
extension: 'pdf',
|
||||
mime_type: 'application/pdf',
|
||||
});
|
||||
|
||||
const {getByText} = renderWithEverything(
|
||||
<UploadItemShared
|
||||
file={docFile}
|
||||
testID='test-upload'
|
||||
/>,
|
||||
{database},
|
||||
);
|
||||
|
||||
// Should show file info
|
||||
expect(getByText('report.pdf')).toBeTruthy();
|
||||
expect(getByText('PDF 1024 KB')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should handle files without extension gracefully', () => {
|
||||
isImage.mockReturnValue(false);
|
||||
const fileWithoutExt = createMockFile({
|
||||
name: 'document_no_extension',
|
||||
extension: undefined,
|
||||
mime_type: 'application/octet-stream',
|
||||
});
|
||||
|
||||
const {getByText} = renderWithEverything(
|
||||
<UploadItemShared
|
||||
file={fileWithoutExt}
|
||||
testID='test-upload'
|
||||
/>,
|
||||
{database},
|
||||
);
|
||||
|
||||
expect(getByText('document_no_extension')).toBeTruthy();
|
||||
expect(getByText('DOCUMENT_NO_EXTENSION 1024 KB')).toBeTruthy(); // Shows extracted extension and size
|
||||
});
|
||||
|
||||
it('should extract extension from filename when extension field is missing', () => {
|
||||
isImage.mockReturnValue(false);
|
||||
const fileWithNameExt = createMockFile({
|
||||
name: 'document.docx',
|
||||
extension: '',
|
||||
mime_type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
});
|
||||
|
||||
const {getByText} = renderWithEverything(
|
||||
<UploadItemShared
|
||||
file={fileWithNameExt}
|
||||
testID='test-upload'
|
||||
/>,
|
||||
{database},
|
||||
);
|
||||
|
||||
expect(getByText('document.docx')).toBeTruthy();
|
||||
expect(getByText('DOCX 1024 KB')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onPress when user taps the file', () => {
|
||||
isImage.mockReturnValue(false);
|
||||
const onPress = jest.fn();
|
||||
|
||||
const {getByTestId} = renderWithEverything(
|
||||
<UploadItemShared
|
||||
file={createMockFile()}
|
||||
onPress={onPress}
|
||||
testID='test-upload'
|
||||
/>,
|
||||
{database},
|
||||
);
|
||||
|
||||
fireEvent.press(getByTestId('test-upload'));
|
||||
expect(onPress).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should call onRetry when user taps retry button on failed upload', () => {
|
||||
isImage.mockReturnValue(false);
|
||||
const onRetry = jest.fn();
|
||||
const failedFile = createMockFile({
|
||||
failed: true,
|
||||
});
|
||||
|
||||
const {getByTestId} = renderWithEverything(
|
||||
<UploadItemShared
|
||||
file={failedFile}
|
||||
onRetry={onRetry}
|
||||
showRetryButton={true}
|
||||
testID='test-upload'
|
||||
/>,
|
||||
{database},
|
||||
);
|
||||
|
||||
const component = getByTestId('test-upload');
|
||||
expect(component).toBeTruthy();
|
||||
|
||||
const retryButton = getByTestId('retry-button');
|
||||
expect(retryButton).toBeTruthy();
|
||||
|
||||
fireEvent.press(retryButton);
|
||||
expect(onRetry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not crash when tapped without onPress handler', () => {
|
||||
isImage.mockReturnValue(false);
|
||||
|
||||
const {getByTestId} = renderWithEverything(
|
||||
<UploadItemShared
|
||||
file={createMockFile()}
|
||||
testID='test-upload'
|
||||
/>,
|
||||
{database},
|
||||
);
|
||||
|
||||
// Should not crash when pressed without onPress
|
||||
expect(() => {
|
||||
fireEvent.press(getByTestId('test-upload'));
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Upload Progress and States', () => {
|
||||
it('should show progress bar during active upload', () => {
|
||||
isImage.mockReturnValue(false);
|
||||
|
||||
const {getByTestId} = renderWithEverything(
|
||||
<UploadItemShared
|
||||
file={createMockFile()}
|
||||
loading={true}
|
||||
progress={0.5}
|
||||
testID='test-upload'
|
||||
/>,
|
||||
{database},
|
||||
);
|
||||
|
||||
// Should show progress bar component
|
||||
expect(getByTestId('test-upload')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should not show progress bar for completed uploads', () => {
|
||||
isImage.mockReturnValue(false);
|
||||
|
||||
const {getByTestId} = renderWithEverything(
|
||||
<UploadItemShared
|
||||
file={createMockFile()}
|
||||
loading={false}
|
||||
testID='test-upload'
|
||||
/>,
|
||||
{database},
|
||||
);
|
||||
|
||||
// Should render without progress bar
|
||||
expect(getByTestId('test-upload')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should not show progress bar for failed uploads', () => {
|
||||
isImage.mockReturnValue(false);
|
||||
const failedFile = createMockFile({
|
||||
failed: true,
|
||||
});
|
||||
|
||||
const {getByTestId} = renderWithEverything(
|
||||
<UploadItemShared
|
||||
file={failedFile}
|
||||
loading={true}
|
||||
testID='test-upload'
|
||||
/>,
|
||||
{database},
|
||||
);
|
||||
|
||||
// Should render without progress bar when failed (even if loading=true)
|
||||
expect(getByTestId('test-upload')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should show retry button only when file is failed and showRetryButton is true', () => {
|
||||
isImage.mockReturnValue(false);
|
||||
const failedFile = createMockFile({
|
||||
failed: true,
|
||||
});
|
||||
|
||||
const {getByTestId} = renderWithEverything(
|
||||
<UploadItemShared
|
||||
file={failedFile}
|
||||
showRetryButton={true}
|
||||
onRetry={jest.fn()}
|
||||
testID='test-upload'
|
||||
/>,
|
||||
{database},
|
||||
);
|
||||
|
||||
expect(getByTestId('test-upload')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should not show retry button for successful uploads', () => {
|
||||
isImage.mockReturnValue(false);
|
||||
|
||||
const {getByTestId} = renderWithEverything(
|
||||
<UploadItemShared
|
||||
file={createMockFile()}
|
||||
showRetryButton={true}
|
||||
testID='test-upload'
|
||||
/>,
|
||||
{database},
|
||||
);
|
||||
|
||||
expect(getByTestId('test-upload')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Share Extension vs Main App Context', () => {
|
||||
it('should handle share extension context for images', () => {
|
||||
isImage.mockReturnValue(true);
|
||||
const imageFile = createMockFile({
|
||||
name: 'shared-photo.jpg',
|
||||
uri: 'file://local-path/photo.jpg',
|
||||
});
|
||||
|
||||
const {getByTestId} = renderWithEverything(
|
||||
<UploadItemShared
|
||||
file={imageFile}
|
||||
isShareExtension={true}
|
||||
testID='test-upload'
|
||||
/>,
|
||||
{database},
|
||||
);
|
||||
|
||||
// Should render in share extension mode
|
||||
expect(getByTestId('test-upload')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should handle main app context for images', () => {
|
||||
isImage.mockReturnValue(true);
|
||||
const imageFile = createMockFile({
|
||||
name: 'main-app-photo.jpg',
|
||||
});
|
||||
|
||||
const {getByTestId} = renderWithEverything(
|
||||
<UploadItemShared
|
||||
file={imageFile}
|
||||
isShareExtension={false}
|
||||
forwardRef={React.createRef()}
|
||||
inViewPort={true}
|
||||
testID='test-upload'
|
||||
/>,
|
||||
{database},
|
||||
);
|
||||
|
||||
// Should render in main app mode with gallery integration
|
||||
expect(getByTestId('test-upload')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Layout Modes', () => {
|
||||
it('should use full width layout for non-image files when specified', () => {
|
||||
isImage.mockReturnValue(false);
|
||||
|
||||
const {getByTestId} = renderWithEverything(
|
||||
<UploadItemShared
|
||||
file={createMockFile()}
|
||||
fullWidth={true}
|
||||
testID='test-upload'
|
||||
/>,
|
||||
{database},
|
||||
);
|
||||
|
||||
expect(getByTestId('test-upload')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should not apply full width to image files even when specified', () => {
|
||||
isImage.mockReturnValue(true);
|
||||
|
||||
const {getByTestId} = renderWithEverything(
|
||||
<UploadItemShared
|
||||
file={createMockFile()}
|
||||
fullWidth={true}
|
||||
testID='test-upload'
|
||||
/>,
|
||||
{database},
|
||||
);
|
||||
|
||||
// Images should maintain their thumbnail size regardless of fullWidth
|
||||
expect(getByTestId('test-upload')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error States and Visual Feedback', () => {
|
||||
it('should apply error styling when hasError is true', () => {
|
||||
isImage.mockReturnValue(false);
|
||||
|
||||
const {getByTestId} = renderWithEverything(
|
||||
<UploadItemShared
|
||||
file={createMockFile()}
|
||||
hasError={true}
|
||||
testID='test-upload'
|
||||
/>,
|
||||
{database},
|
||||
);
|
||||
|
||||
// Should render with error styling
|
||||
expect(getByTestId('test-upload')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should not apply error styling when hasError is false', () => {
|
||||
isImage.mockReturnValue(false);
|
||||
|
||||
const {getByTestId} = renderWithEverything(
|
||||
<UploadItemShared
|
||||
file={createMockFile()}
|
||||
hasError={false}
|
||||
testID='test-upload'
|
||||
/>,
|
||||
{database},
|
||||
);
|
||||
|
||||
// Should render without error styling
|
||||
expect(getByTestId('test-upload')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Data Resilience', () => {
|
||||
it('should handle files with missing name gracefully', () => {
|
||||
isImage.mockReturnValue(false);
|
||||
const fileWithoutName = createMockFile({
|
||||
name: undefined,
|
||||
});
|
||||
|
||||
const {getByText} = renderWithEverything(
|
||||
<UploadItemShared
|
||||
file={fileWithoutName}
|
||||
testID='test-upload'
|
||||
/>,
|
||||
{database},
|
||||
);
|
||||
|
||||
// Should show fallback text
|
||||
expect(getByText('Unknown file')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should handle files with zero size', () => {
|
||||
isImage.mockReturnValue(false);
|
||||
getFormattedFileSize.mockReturnValue('0 KB');
|
||||
const emptyFile = createMockFile({
|
||||
size: 0,
|
||||
});
|
||||
|
||||
const {getByText} = renderWithEverything(
|
||||
<UploadItemShared
|
||||
file={emptyFile}
|
||||
testID='test-upload'
|
||||
/>,
|
||||
{database},
|
||||
);
|
||||
|
||||
expect(getByText('JPG 0 KB')).toBeTruthy(); // Extension + size format
|
||||
});
|
||||
|
||||
it('should handle files with no URI', () => {
|
||||
isImage.mockReturnValue(false);
|
||||
const fileWithoutUri = createMockFile({
|
||||
uri: undefined,
|
||||
});
|
||||
|
||||
// Should not crash with missing URI
|
||||
expect(() => {
|
||||
renderWithEverything(
|
||||
<UploadItemShared
|
||||
file={fileWithoutUri}
|
||||
testID='test-upload'
|
||||
/>,
|
||||
{database},
|
||||
);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle files with no mime type', () => {
|
||||
isImage.mockReturnValue(false);
|
||||
const fileWithoutMimeType = createMockFile({
|
||||
mime_type: undefined,
|
||||
});
|
||||
|
||||
expect(() => {
|
||||
renderWithEverything(
|
||||
<UploadItemShared
|
||||
file={fileWithoutMimeType}
|
||||
testID='test-upload'
|
||||
/>,
|
||||
{database},
|
||||
);
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Complex Interaction Scenarios', () => {
|
||||
it('should handle retry flow for failed uploads', () => {
|
||||
isImage.mockReturnValue(false);
|
||||
const onRetry = jest.fn();
|
||||
const failedFile = createMockFile({
|
||||
failed: true,
|
||||
name: 'failed-upload.pdf',
|
||||
extension: 'pdf',
|
||||
});
|
||||
|
||||
const {getByText} = renderWithEverything(
|
||||
<UploadItemShared
|
||||
file={failedFile}
|
||||
onRetry={onRetry}
|
||||
showRetryButton={true}
|
||||
testID='test-upload'
|
||||
/>,
|
||||
{database},
|
||||
);
|
||||
|
||||
// Should show file info even when failed
|
||||
expect(getByText('failed-upload.pdf')).toBeTruthy();
|
||||
expect(getByText('PDF 1024 KB')).toBeTruthy(); // Correct extension + size format
|
||||
});
|
||||
|
||||
it('should handle image files in error state', () => {
|
||||
isImage.mockReturnValue(true);
|
||||
const errorImageFile = createMockFile({
|
||||
name: 'corrupted.jpg',
|
||||
failed: true,
|
||||
});
|
||||
|
||||
const {getByTestId, queryByText} = renderWithEverything(
|
||||
<UploadItemShared
|
||||
file={errorImageFile}
|
||||
hasError={true}
|
||||
showRetryButton={true}
|
||||
onRetry={jest.fn()}
|
||||
testID='test-upload'
|
||||
/>,
|
||||
{database},
|
||||
);
|
||||
|
||||
// Should show image thumbnail even in error state
|
||||
expect(getByTestId('test-upload')).toBeTruthy();
|
||||
|
||||
// Should not show file info for images (even in error state)
|
||||
expect(queryByText('corrupted.jpg')).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle simultaneous loading and error states correctly', () => {
|
||||
isImage.mockReturnValue(false);
|
||||
const conflictedFile = createMockFile({
|
||||
failed: true,
|
||||
});
|
||||
|
||||
const {getByTestId} = renderWithEverything(
|
||||
<UploadItemShared
|
||||
file={conflictedFile}
|
||||
loading={true}
|
||||
hasError={true}
|
||||
showRetryButton={true}
|
||||
onRetry={jest.fn()}
|
||||
testID='test-upload'
|
||||
/>,
|
||||
{database},
|
||||
);
|
||||
|
||||
// Should prioritize failed state over loading state
|
||||
expect(getByTestId('test-upload')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
311
app/components/upload_item_shared/index.tsx
Normal file
311
app/components/upload_item_shared/index.tsx
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {Image} from 'expo-image';
|
||||
import React, {useMemo} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {TouchableWithoutFeedback, View, Text, type ViewStyle} from 'react-native';
|
||||
import Animated from 'react-native-reanimated';
|
||||
|
||||
import FileIcon from '@components/files/file_icon';
|
||||
import ImageFile from '@components/files/image_file';
|
||||
import UploadRetry from '@components/post_draft/uploads/upload_item/upload_retry';
|
||||
import ProgressBar from '@components/progress_bar';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {isImage, getFormattedFileSize} from '@utils/file';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
import {typography} from '@utils/typography';
|
||||
|
||||
import {SHARED_UPLOAD_STYLES} from './constants';
|
||||
|
||||
export interface UploadItemFile {
|
||||
id?: string;
|
||||
clientId?: string;
|
||||
name?: string;
|
||||
extension?: string;
|
||||
size?: number;
|
||||
uri?: string;
|
||||
failed?: boolean;
|
||||
width?: number;
|
||||
height?: number;
|
||||
mime_type?: string;
|
||||
}
|
||||
|
||||
export interface UploadItemProps {
|
||||
file: UploadItemFile;
|
||||
onPress?: () => void;
|
||||
onRetry?: () => void;
|
||||
loading?: boolean;
|
||||
progress?: number;
|
||||
showRetryButton?: boolean;
|
||||
galleryStyles?: Animated.AnimateStyle<ViewStyle>;
|
||||
testID?: string;
|
||||
fullWidth?: boolean;
|
||||
isShareExtension?: boolean;
|
||||
forwardRef?: React.RefObject<unknown>;
|
||||
inViewPort?: boolean;
|
||||
hasError?: boolean;
|
||||
}
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
|
||||
return {
|
||||
previewContainer: {
|
||||
borderRadius: 4,
|
||||
borderWidth: 1,
|
||||
borderColor: changeOpacity(theme.centerChannelColor, 0.16),
|
||||
backgroundColor: theme.centerChannelBg,
|
||||
alignItems: 'center',
|
||||
position: 'relative',
|
||||
},
|
||||
imageOnlyContainer: {
|
||||
width: SHARED_UPLOAD_STYLES.THUMBNAIL_SIZE,
|
||||
height: SHARED_UPLOAD_STYLES.THUMBNAIL_SIZE,
|
||||
padding: 0,
|
||||
},
|
||||
fileWithInfoContainer: {
|
||||
width: SHARED_UPLOAD_STYLES.FILE_CONTAINER_WIDTH,
|
||||
height: SHARED_UPLOAD_STYLES.FILE_CONTAINER_HEIGHT,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flexShrink: 0,
|
||||
gap: 8,
|
||||
paddingVertical: 12,
|
||||
paddingLeft: 8,
|
||||
paddingRight: 16,
|
||||
},
|
||||
fullWidthContainer: {
|
||||
width: '100%',
|
||||
height: SHARED_UPLOAD_STYLES.FILE_CONTAINER_HEIGHT,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flexShrink: 0,
|
||||
gap: 8,
|
||||
paddingVertical: 12,
|
||||
paddingLeft: 20,
|
||||
paddingRight: 20,
|
||||
},
|
||||
iconContainer: {
|
||||
width: SHARED_UPLOAD_STYLES.ICON_SIZE,
|
||||
height: SHARED_UPLOAD_STYLES.ICON_SIZE,
|
||||
borderRadius: 4,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
imageContainer: {
|
||||
width: SHARED_UPLOAD_STYLES.THUMBNAIL_SIZE,
|
||||
height: SHARED_UPLOAD_STYLES.THUMBNAIL_SIZE,
|
||||
borderRadius: 4,
|
||||
marginRight: 8,
|
||||
overflow: 'hidden',
|
||||
borderWidth: 1,
|
||||
borderColor: changeOpacity(theme.centerChannelColor, 0.16),
|
||||
shadowColor: '#000000',
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 2,
|
||||
},
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 3,
|
||||
elevation: 1,
|
||||
},
|
||||
imageOnlyThumbnail: {
|
||||
width: SHARED_UPLOAD_STYLES.THUMBNAIL_SIZE,
|
||||
height: SHARED_UPLOAD_STYLES.THUMBNAIL_SIZE,
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
shadowColor: '#000000',
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 2,
|
||||
},
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 3,
|
||||
elevation: 1,
|
||||
},
|
||||
imageOnlyImage: {
|
||||
width: SHARED_UPLOAD_STYLES.THUMBNAIL_SIZE,
|
||||
height: SHARED_UPLOAD_STYLES.THUMBNAIL_SIZE,
|
||||
borderRadius: 4,
|
||||
},
|
||||
fileInfo: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'flex-start',
|
||||
minWidth: 0,
|
||||
},
|
||||
fileName: {
|
||||
color: theme.centerChannelColor,
|
||||
...typography('Body', 200, 'SemiBold'),
|
||||
marginBottom: 2,
|
||||
width: '100%',
|
||||
},
|
||||
fileSize: {
|
||||
color: changeOpacity(theme.centerChannelColor, 0.64),
|
||||
...typography('Body', 75, 'Regular'),
|
||||
width: '100%',
|
||||
},
|
||||
progress: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.1)',
|
||||
borderRadius: 4,
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
progressContainer: {
|
||||
paddingVertical: undefined,
|
||||
position: undefined,
|
||||
justifyContent: undefined,
|
||||
},
|
||||
errorBorder: {
|
||||
borderColor: theme.errorTextColor,
|
||||
borderWidth: 2,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export default function UploadItemShared({
|
||||
file,
|
||||
onPress,
|
||||
onRetry,
|
||||
loading = false,
|
||||
progress = 0,
|
||||
showRetryButton = false,
|
||||
galleryStyles,
|
||||
testID,
|
||||
fullWidth = false,
|
||||
isShareExtension = false,
|
||||
forwardRef,
|
||||
inViewPort = false,
|
||||
hasError = false,
|
||||
}: UploadItemProps) {
|
||||
const theme = useTheme();
|
||||
const intl = useIntl();
|
||||
const style = getStyleSheet(theme);
|
||||
|
||||
const fileForCheck = useMemo(() => ({
|
||||
name: file.name,
|
||||
extension: file.extension,
|
||||
mime_type: file.mime_type,
|
||||
} as FileInfo), [file.name, file.extension, file.mime_type]);
|
||||
|
||||
const isImageFile = useMemo(() => isImage(fileForCheck), [fileForCheck]);
|
||||
|
||||
const imageFileData = useMemo(() => ({
|
||||
...fileForCheck,
|
||||
id: file.id,
|
||||
clientId: file.clientId,
|
||||
size: file.size,
|
||||
uri: file.uri,
|
||||
localPath: file.uri,
|
||||
width: file.width,
|
||||
height: file.height,
|
||||
failed: file.failed,
|
||||
} as FileInfo), [fileForCheck, file.id, file.clientId, file.size, file.uri, file.width, file.height, file.failed]);
|
||||
|
||||
const fileDisplay = useMemo(() => {
|
||||
if (isImageFile) {
|
||||
if (isShareExtension) {
|
||||
return (
|
||||
<View style={style.imageOnlyThumbnail}>
|
||||
<Image
|
||||
source={{uri: file.uri}}
|
||||
style={style.imageOnlyImage}
|
||||
contentFit='cover'
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<View style={style.imageOnlyThumbnail}>
|
||||
<ImageFile
|
||||
file={imageFileData}
|
||||
forwardRef={forwardRef}
|
||||
inViewPort={inViewPort}
|
||||
contentFit='cover'
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<View style={style.iconContainer}>
|
||||
<FileIcon
|
||||
iconSize={48}
|
||||
file={fileForCheck}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}, [file.uri, imageFileData, forwardRef, inViewPort, isImageFile, isShareExtension, style.imageOnlyThumbnail, style.imageOnlyImage, style.iconContainer, fileForCheck]);
|
||||
|
||||
const fileExtension = file.extension?.toUpperCase() || file.name?.split('.').pop()?.toUpperCase() || '';
|
||||
const formattedSize = getFormattedFileSize(file.size || 0);
|
||||
const unknownFileLabel = intl.formatMessage({id: 'upload_item.unknown_file', defaultMessage: 'Unknown file'});
|
||||
|
||||
const containerStyle = useMemo(() => {
|
||||
let containerStyleType;
|
||||
if (fullWidth && !isImageFile) {
|
||||
containerStyleType = style.fullWidthContainer;
|
||||
} else if (isImageFile) {
|
||||
containerStyleType = style.imageOnlyContainer;
|
||||
} else {
|
||||
containerStyleType = style.fileWithInfoContainer;
|
||||
}
|
||||
|
||||
const baseStyles = [style.previewContainer, containerStyleType];
|
||||
|
||||
if (hasError) {
|
||||
return [
|
||||
...baseStyles,
|
||||
style.errorBorder,
|
||||
];
|
||||
}
|
||||
|
||||
return baseStyles;
|
||||
}, [fullWidth, isImageFile, hasError, style.fileWithInfoContainer, style.imageOnlyContainer, style.fullWidthContainer, style.previewContainer, style.errorBorder]);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={containerStyle}
|
||||
testID={testID}
|
||||
>
|
||||
<TouchableWithoutFeedback onPress={onPress}>
|
||||
<Animated.View style={galleryStyles}>
|
||||
{fileDisplay}
|
||||
</Animated.View>
|
||||
</TouchableWithoutFeedback>
|
||||
|
||||
{!isImageFile && (
|
||||
<View style={style.fileInfo}>
|
||||
<Text
|
||||
style={style.fileName}
|
||||
numberOfLines={1}
|
||||
ellipsizeMode='tail'
|
||||
>
|
||||
{file.name || unknownFileLabel}
|
||||
</Text>
|
||||
<Text style={style.fileSize}>
|
||||
{fileExtension && `${fileExtension} `}{formattedSize}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{file.failed && showRetryButton && onRetry && (
|
||||
<UploadRetry
|
||||
onPress={onRetry}
|
||||
/>
|
||||
)}
|
||||
|
||||
{loading && !file.failed && (
|
||||
<View style={style.progress}>
|
||||
<ProgressBar
|
||||
progress={progress || 0}
|
||||
color={theme.buttonBg}
|
||||
containerStyle={style.progressContainer}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
111
app/components/upload_item_shared/remove_button.test.tsx
Normal file
111
app/components/upload_item_shared/remove_button.test.tsx
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {fireEvent} from '@testing-library/react-native';
|
||||
import React from 'react';
|
||||
|
||||
import DatabaseManager from '@database/manager';
|
||||
import {renderWithEverything} from '@test/intl-test-helper';
|
||||
import TestHelper from '@test/test_helper';
|
||||
|
||||
import RemoveButton from './remove_button';
|
||||
|
||||
import type {Database} from '@nozbe/watermelondb';
|
||||
|
||||
describe('RemoveButton', () => {
|
||||
const serverUrl = 'serverUrl';
|
||||
let database: Database;
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
database = (await TestHelper.setupServerDatabase(serverUrl)).database;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await DatabaseManager.destroyServerDatabase(serverUrl);
|
||||
});
|
||||
|
||||
describe('User Interaction', () => {
|
||||
it('should call onPress when user taps the button', () => {
|
||||
const onPress = jest.fn();
|
||||
|
||||
const {getByTestId} = renderWithEverything(
|
||||
<RemoveButton onPress={onPress}/>,
|
||||
{database},
|
||||
);
|
||||
|
||||
const button = getByTestId('remove-button');
|
||||
fireEvent.press(button);
|
||||
|
||||
expect(onPress).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should render real close-circle icon and be interactive', () => {
|
||||
const onPress = jest.fn();
|
||||
|
||||
const {getByTestId} = renderWithEverything(
|
||||
<RemoveButton onPress={onPress}/>,
|
||||
{database},
|
||||
);
|
||||
|
||||
const button = getByTestId('remove-button');
|
||||
expect(button).toBeTruthy();
|
||||
|
||||
fireEvent.press(button);
|
||||
expect(onPress).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Rapid presses are prevented (double-tap protection)
|
||||
fireEvent.press(button);
|
||||
fireEvent.press(button);
|
||||
expect(onPress).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should work with custom testID for user identification', () => {
|
||||
const onPress = jest.fn();
|
||||
|
||||
const {getByTestId} = renderWithEverything(
|
||||
<RemoveButton
|
||||
onPress={onPress}
|
||||
testID='custom-remove-btn'
|
||||
/>,
|
||||
{database},
|
||||
);
|
||||
|
||||
const button = getByTestId('custom-remove-btn');
|
||||
fireEvent.press(button);
|
||||
|
||||
expect(onPress).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should handle missing onPress gracefully', () => {
|
||||
// This should not crash even without onPress
|
||||
expect(() => {
|
||||
renderWithEverything(
|
||||
<RemoveButton onPress={undefined as unknown as () => void}/>,
|
||||
{database},
|
||||
);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should prevent rapid successive taps (double-tap protection)', () => {
|
||||
const onPress = jest.fn();
|
||||
|
||||
const {getByTestId} = renderWithEverything(
|
||||
<RemoveButton onPress={onPress}/>,
|
||||
{database},
|
||||
);
|
||||
|
||||
const button = getByTestId('remove-button');
|
||||
|
||||
// Rapid fire presses should be coalesced into a single call
|
||||
fireEvent.press(button);
|
||||
fireEvent.press(button);
|
||||
fireEvent.press(button);
|
||||
fireEvent.press(button);
|
||||
|
||||
expect(onPress).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
69
app/components/upload_item_shared/remove_button.tsx
Normal file
69
app/components/upload_item_shared/remove_button.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback} from 'react';
|
||||
import {Platform, View, Pressable} from 'react-native';
|
||||
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {usePreventDoubleTap} from '@hooks/utils';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
|
||||
const hitSlop = {top: 10, bottom: 10, left: 10, right: 10};
|
||||
|
||||
type Props = {
|
||||
onPress: () => void;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
|
||||
return {
|
||||
tappableContainer: {
|
||||
position: 'absolute',
|
||||
elevation: 11,
|
||||
width: 24,
|
||||
height: 24,
|
||||
top: -12,
|
||||
right: -10,
|
||||
},
|
||||
removeButton: {
|
||||
borderRadius: 12,
|
||||
alignSelf: 'center',
|
||||
marginTop: Platform.select({
|
||||
ios: 5.4,
|
||||
android: 4.75,
|
||||
}),
|
||||
backgroundColor: theme.centerChannelBg,
|
||||
width: 24,
|
||||
height: 25,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export default function RemoveButton({
|
||||
onPress,
|
||||
testID = 'remove-button',
|
||||
}: Props) {
|
||||
const theme = useTheme();
|
||||
const style = getStyleSheet(theme);
|
||||
const handlePress = usePreventDoubleTap(useCallback(() => {
|
||||
onPress();
|
||||
}, [onPress]));
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
style={({pressed}) => [style.tappableContainer, pressed && {opacity: 0.72}]}
|
||||
onPress={handlePress}
|
||||
testID={testID}
|
||||
hitSlop={hitSlop}
|
||||
>
|
||||
<View style={style.removeButton}>
|
||||
<CompassIcon
|
||||
name='close-circle'
|
||||
color={changeOpacity(theme.centerChannelColor, 0.64)}
|
||||
size={24}
|
||||
/>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
|
@ -1345,6 +1345,7 @@
|
|||
"unreads.empty.paragraph": "Turn off the unread filter to show all your channels.",
|
||||
"unreads.empty.show_all": "Show all",
|
||||
"unreads.empty.title": "No more unreads",
|
||||
"upload_item.unknown_file": "Unknown file",
|
||||
"user_profile.channel_admin": "Channel Admin",
|
||||
"user_profile.custom_status": "Custom Status",
|
||||
"user_profile.system_admin": "System Admin",
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ type Props = {
|
|||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
alignItems: 'center',
|
||||
marginTop: 12,
|
||||
},
|
||||
margin: {
|
||||
marginHorizontal: 20,
|
||||
|
|
@ -95,7 +96,6 @@ const Attachments = ({canUploadFiles, maxFileCount, maxFileSize, theme}: Props)
|
|||
<Single
|
||||
file={files[0]}
|
||||
maxFileSize={maxFileSize}
|
||||
theme={theme}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -31,8 +31,8 @@ const getStyles = makeStyleSheetFromTheme((theme: Theme) => ({
|
|||
marginRight: 20,
|
||||
},
|
||||
list: {
|
||||
height: 114,
|
||||
top: -8,
|
||||
height: 80,
|
||||
top: 0,
|
||||
width: '100%',
|
||||
},
|
||||
container: {
|
||||
|
|
@ -40,7 +40,7 @@ const getStyles = makeStyleSheetFromTheme((theme: Theme) => ({
|
|||
},
|
||||
labelContainer: {
|
||||
alignItems: 'flex-start',
|
||||
marginTop: 8,
|
||||
marginTop: 0,
|
||||
paddingHorizontal: 20,
|
||||
width: '100%',
|
||||
},
|
||||
|
|
@ -67,11 +67,10 @@ const Multiple = ({files, maxFileSize, theme}: Props) => {
|
|||
file={item}
|
||||
isSmall={true}
|
||||
maxFileSize={maxFileSize}
|
||||
theme={theme}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}, [maxFileSize, theme, files]);
|
||||
}, [styles.item, styles.first, styles.last, files.length, maxFileSize]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,132 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import {sharedItemToUploadItemFile} from '@components/upload_item_shared/adapters';
|
||||
import {renderWithEverything} from '@test/intl-test-helper';
|
||||
import TestHelper from '@test/test_helper';
|
||||
|
||||
import SharedUploadItem from './shared_upload_item';
|
||||
|
||||
import type {SharedItem} from '@mattermost/rnshare';
|
||||
import type {Database} from '@nozbe/watermelondb';
|
||||
|
||||
jest.mock('@components/upload_item_shared/adapters', () => ({
|
||||
sharedItemToUploadItemFile: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockSharedItemToUploadItemFile = jest.mocked(sharedItemToUploadItemFile);
|
||||
|
||||
describe('SharedUploadItem', () => {
|
||||
const serverUrl = 'serverUrl';
|
||||
let database: Database;
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
database = (await TestHelper.setupServerDatabase(serverUrl)).database;
|
||||
});
|
||||
|
||||
const createMockSharedItem = (overrides: Partial<SharedItem> = {}): SharedItem => ({
|
||||
filename: 'test-file.jpg',
|
||||
extension: 'jpg',
|
||||
size: 1024,
|
||||
value: 'file:///path/to/file.jpg',
|
||||
width: 800,
|
||||
height: 600,
|
||||
type: 'image/jpeg',
|
||||
isString: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('should display document file with name and formatted size', () => {
|
||||
const mockSharedItem = createMockSharedItem({
|
||||
filename: 'test-document.pdf',
|
||||
type: 'application/pdf',
|
||||
});
|
||||
|
||||
mockSharedItemToUploadItemFile.mockReturnValue({
|
||||
name: 'test-document.pdf',
|
||||
uri: 'file:///path/to/file.jpg',
|
||||
size: 1024,
|
||||
extension: 'pdf',
|
||||
mime_type: 'application/pdf',
|
||||
});
|
||||
|
||||
const {getByTestId, getByText} = renderWithEverything(
|
||||
<SharedUploadItem file={mockSharedItem}/>,
|
||||
{database},
|
||||
);
|
||||
|
||||
// Should render the actual UploadItemShared component
|
||||
expect(getByTestId('shared_upload_item_file:///path/to/file.jpg')).toBeTruthy();
|
||||
|
||||
// We can now test REAL behavior - file name display!
|
||||
expect(getByText('test-document.pdf')).toBeTruthy();
|
||||
|
||||
// Real component shows "1024 B" not "1024 KB" (smarter formatting!)
|
||||
expect(getByText('PDF 1024 B')).toBeTruthy();
|
||||
|
||||
expect(mockSharedItemToUploadItemFile).toHaveBeenCalledWith(mockSharedItem);
|
||||
});
|
||||
|
||||
it('should display image file as thumbnail without text labels', () => {
|
||||
const imageFile = createMockSharedItem({
|
||||
filename: 'photo.jpg',
|
||||
extension: 'jpg',
|
||||
type: 'image/jpeg',
|
||||
value: 'file:///photos/vacation.jpg',
|
||||
});
|
||||
|
||||
mockSharedItemToUploadItemFile.mockReturnValue({
|
||||
name: 'photo.jpg',
|
||||
extension: 'jpg',
|
||||
mime_type: 'image/jpeg',
|
||||
uri: 'file:///photos/vacation.jpg',
|
||||
size: 2048,
|
||||
});
|
||||
|
||||
const {getByTestId} = renderWithEverything(
|
||||
<SharedUploadItem file={imageFile}/>,
|
||||
{database},
|
||||
);
|
||||
|
||||
expect(getByTestId('shared_upload_item_file:///photos/vacation.jpg')).toBeTruthy();
|
||||
|
||||
// Real component for images just shows the thumbnail, no text labels!
|
||||
// This is actually the correct behavior for image previews
|
||||
|
||||
expect(mockSharedItemToUploadItemFile).toHaveBeenCalledWith(imageFile);
|
||||
});
|
||||
|
||||
it('should display video file with smart size formatting and handle props', () => {
|
||||
const mockSharedItem = createMockSharedItem({
|
||||
filename: 'large-file.mov',
|
||||
type: 'video/quicktime',
|
||||
});
|
||||
|
||||
mockSharedItemToUploadItemFile.mockReturnValue({
|
||||
name: 'large-file.mov',
|
||||
uri: 'file:///videos/large-file.mov',
|
||||
size: 50 * 1024 * 1024, // 50MB
|
||||
extension: 'mov',
|
||||
mime_type: 'video/quicktime',
|
||||
});
|
||||
|
||||
const {getByTestId, getByText} = renderWithEverything(
|
||||
<SharedUploadItem
|
||||
file={mockSharedItem}
|
||||
fullWidth={true}
|
||||
hasError={true}
|
||||
/>,
|
||||
{database},
|
||||
);
|
||||
|
||||
// Verify it renders with real content
|
||||
expect(getByTestId('shared_upload_item_file:///path/to/file.jpg')).toBeTruthy();
|
||||
expect(getByText('large-file.mov')).toBeTruthy();
|
||||
expect(getByText('MOV 50 MB')).toBeTruthy(); // Real smart formatting: 50 MB not 51200 KB!
|
||||
|
||||
expect(mockSharedItemToUploadItemFile).toHaveBeenCalledWith(mockSharedItem);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import UploadItemShared from '@components/upload_item_shared';
|
||||
import {sharedItemToUploadItemFile} from '@components/upload_item_shared/adapters';
|
||||
|
||||
import type {SharedItem} from '@mattermost/rnshare';
|
||||
|
||||
type Props = {
|
||||
file: SharedItem;
|
||||
fullWidth?: boolean;
|
||||
hasError?: boolean;
|
||||
};
|
||||
|
||||
const SharedUploadItem = ({file, fullWidth = false, hasError = false}: Props) => {
|
||||
const uploadItemFile = sharedItemToUploadItemFile(file);
|
||||
|
||||
return (
|
||||
<UploadItemShared
|
||||
file={uploadItemFile}
|
||||
testID={`shared_upload_item_${file.value}`}
|
||||
fullWidth={fullWidth}
|
||||
isShareExtension={true}
|
||||
hasError={hasError}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default SharedUploadItem;
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {fireEvent} from '@testing-library/react-native';
|
||||
import React from 'react';
|
||||
|
||||
import DatabaseManager from '@database/manager';
|
||||
import {removeShareExtensionFile} from '@share/state';
|
||||
import {renderWithEverything} from '@test/intl-test-helper';
|
||||
import TestHelper from '@test/test_helper';
|
||||
|
||||
import Single from './single';
|
||||
|
||||
import type {SharedItem} from '@mattermost/rnshare';
|
||||
import type {Database} from '@nozbe/watermelondb';
|
||||
|
||||
jest.mock('@share/state', () => ({
|
||||
removeShareExtensionFile: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('Share Extension - Single', () => {
|
||||
const serverUrl = 'serverUrl';
|
||||
let database: Database;
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
database = (await TestHelper.setupServerDatabase(serverUrl)).database;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await DatabaseManager.destroyServerDatabase(serverUrl);
|
||||
});
|
||||
|
||||
const createMockSharedItem = (overrides: Partial<SharedItem> = {}): SharedItem => ({
|
||||
filename: 'photo.jpg',
|
||||
extension: 'jpg',
|
||||
size: 2048,
|
||||
value: 'file:///photos/vacation.jpg',
|
||||
width: 800,
|
||||
height: 600,
|
||||
type: 'image/jpeg',
|
||||
isString: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('renders remove button in small variant and calls removal on press', () => {
|
||||
const file = createMockSharedItem();
|
||||
|
||||
const {getByTestId} = renderWithEverything(
|
||||
<Single
|
||||
file={file}
|
||||
isSmall={true}
|
||||
maxFileSize={Number.MAX_SAFE_INTEGER}
|
||||
/>,
|
||||
{database},
|
||||
);
|
||||
|
||||
// Ensure container exists
|
||||
expect(getByTestId('single-file-container')).toBeTruthy();
|
||||
|
||||
// Remove button should be present with default testID
|
||||
const button = getByTestId('remove-button');
|
||||
fireEvent.press(button);
|
||||
|
||||
expect(removeShareExtensionFile).toHaveBeenCalledTimes(1);
|
||||
expect(removeShareExtensionFile).toHaveBeenCalledWith(file);
|
||||
});
|
||||
|
||||
it('does not render remove button for non-small variant', () => {
|
||||
const file = createMockSharedItem();
|
||||
|
||||
const {queryByTestId} = renderWithEverything(
|
||||
<Single
|
||||
file={file}
|
||||
isSmall={false}
|
||||
maxFileSize={Number.MAX_SAFE_INTEGER}
|
||||
/>,
|
||||
{database},
|
||||
);
|
||||
|
||||
expect(queryByTestId('remove-button')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -2,17 +2,15 @@
|
|||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback, useMemo} from 'react';
|
||||
import {LayoutAnimation, TouchableOpacity, View} from 'react-native';
|
||||
import {LayoutAnimation, View, StyleSheet} from 'react-native';
|
||||
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import RemoveButton from '@components/upload_item_shared/remove_button';
|
||||
import {MAX_RESOLUTION} from '@constants/image';
|
||||
import {removeShareExtensionFile} from '@share/state';
|
||||
import {toFileInfo} from '@share/utils';
|
||||
import {isImage, isVideo} from '@utils/file';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
|
||||
import Info from './info';
|
||||
import Thumbnail from './thumbnail';
|
||||
import SharedUploadItem from './shared_upload_item';
|
||||
|
||||
import type {SharedItem} from '@mattermost/rnshare';
|
||||
|
||||
|
|
@ -20,11 +18,8 @@ type Props = {
|
|||
file: SharedItem;
|
||||
maxFileSize: number;
|
||||
isSmall?: boolean;
|
||||
theme: Theme;
|
||||
};
|
||||
|
||||
const hitSlop = {top: 10, left: 10, right: 10, bottom: 10};
|
||||
|
||||
const layoutAnimConfig = {
|
||||
duration: 300,
|
||||
update: {
|
||||
|
|
@ -37,31 +32,24 @@ const layoutAnimConfig = {
|
|||
},
|
||||
};
|
||||
|
||||
const getStyles = makeStyleSheetFromTheme((theme: Theme) => ({
|
||||
remove: {
|
||||
backgroundColor: theme.centerChannelBg,
|
||||
borderRadius: 12,
|
||||
height: 24,
|
||||
position: 'absolute',
|
||||
right: -5,
|
||||
top: -7,
|
||||
width: 24,
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
position: 'relative',
|
||||
},
|
||||
}));
|
||||
smallContainer: {
|
||||
marginBottom: 8,
|
||||
},
|
||||
});
|
||||
|
||||
const Single = ({file, isSmall, maxFileSize, theme}: Props) => {
|
||||
const styles = getStyles(theme);
|
||||
const Single = ({file, isSmall, maxFileSize}: Props) => {
|
||||
const fileInfo = useMemo(() => toFileInfo(file), [file]);
|
||||
const contentMode = isSmall ? 'small' : 'large';
|
||||
const type = useMemo(() => {
|
||||
if (isImage(fileInfo)) {
|
||||
return 'image';
|
||||
}
|
||||
|
||||
if (isVideo(fileInfo)) {
|
||||
return 'video';
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, [fileInfo]);
|
||||
|
||||
|
|
@ -83,50 +71,33 @@ const Single = ({file, isSmall, maxFileSize, theme}: Props) => {
|
|||
removeShareExtensionFile(file);
|
||||
}, [file]);
|
||||
|
||||
let attachment;
|
||||
|
||||
if (type) {
|
||||
attachment = (
|
||||
<Thumbnail
|
||||
contentMode={contentMode}
|
||||
file={file}
|
||||
hasError={hasError}
|
||||
theme={theme}
|
||||
type={type}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
attachment = (
|
||||
<Info
|
||||
contentMode={contentMode}
|
||||
file={fileInfo}
|
||||
hasError={hasError}
|
||||
theme={theme}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isSmall) {
|
||||
return (
|
||||
<View>
|
||||
{attachment}
|
||||
<View style={styles.remove}>
|
||||
<TouchableOpacity
|
||||
hitSlop={hitSlop}
|
||||
onPress={onPress}
|
||||
>
|
||||
<CompassIcon
|
||||
name='close-circle'
|
||||
size={24}
|
||||
color={changeOpacity(theme.centerChannelColor, 0.56)}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View
|
||||
style={[styles.container, styles.smallContainer]}
|
||||
testID='single-file-container'
|
||||
>
|
||||
<SharedUploadItem
|
||||
file={file}
|
||||
fullWidth={false}
|
||||
hasError={hasError}
|
||||
/>
|
||||
<RemoveButton
|
||||
onPress={onPress}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return attachment;
|
||||
return (
|
||||
<View testID='single-file-container'>
|
||||
<SharedUploadItem
|
||||
file={file}
|
||||
fullWidth={true}
|
||||
hasError={hasError}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default Single;
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ const getStyles = makeStyleSheetFromTheme((theme: Theme) => ({
|
|||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
paddingTop: 20,
|
||||
paddingTop: 0,
|
||||
},
|
||||
divider: {
|
||||
backgroundColor: changeOpacity(theme.centerChannelColor, 0.08),
|
||||
|
|
|
|||
Loading…
Reference in a new issue