(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:
Rajat Dabade 2025-09-11 12:54:54 +05:30 committed by GitHub
parent 113f9d942c
commit 71fed8aa50
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 1878 additions and 408 deletions

View file

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

View file

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

View file

@ -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';

View file

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

View file

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

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

View 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,
};
}

View 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;

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

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

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

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

View file

@ -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",

View file

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

View file

@ -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 (
<>

View file

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

View file

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

View file

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

View file

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

View file

@ -27,7 +27,7 @@ const getStyles = makeStyleSheetFromTheme((theme: Theme) => ({
flex: 1,
},
content: {
paddingTop: 20,
paddingTop: 0,
},
divider: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.08),