diff --git a/app/components/post_draft/uploads/index.tsx b/app/components/post_draft/uploads/index.tsx
index 83c5de377..1dc151648 100644
--- a/app/components/post_draft/uploads/index.tsx
+++ b/app/components/post_draft/uploads/index.tsx
@@ -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;
diff --git a/app/components/post_draft/uploads/upload_item/index.tsx b/app/components/post_draft/uploads/upload_item/index.tsx
deleted file mode 100644
index 802f20ca5..000000000
--- a/app/components/post_draft/uploads/upload_item/index.tsx
+++ /dev/null
@@ -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 (
-
-
-
- );
- }
- return (
-
-
-
- );
- };
-
- 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 (
-
-
-
-
- {fileDisplayComponent()}
-
-
-
- {!isImageFile && (
-
-
- {file.name || 'Unknown file'}
-
-
- {fileExtension && `${fileExtension} `}{formattedSize}
-
-
- )}
-
- {file.failed &&
-
- }
- {loading && !file.failed &&
-
-
-
- }
-
-
-
- );
-}
-
diff --git a/app/components/post_draft/uploads/upload_item/index.test.tsx b/app/components/post_draft/uploads/upload_item/upload_item_wrapper.test.tsx
similarity index 99%
rename from app/components/post_draft/uploads/upload_item/index.test.tsx
rename to app/components/post_draft/uploads/upload_item/upload_item_wrapper.test.tsx
index bd5f2bffb..6e0d8499f 100644
--- a/app/components/post_draft/uploads/upload_item/index.test.tsx
+++ b/app/components/post_draft/uploads/upload_item/upload_item_wrapper.test.tsx
@@ -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';
diff --git a/app/components/post_draft/uploads/upload_item/upload_item_wrapper.tsx b/app/components/post_draft/uploads/upload_item/upload_item_wrapper.tsx
new file mode 100644
index 000000000..2584b8cb5
--- /dev/null
+++ b/app/components/post_draft/uploads/upload_item/upload_item_wrapper.tsx
@@ -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 (
+
+
+
+
+ );
+}
diff --git a/app/components/post_draft/uploads/upload_item/upload_remove.tsx b/app/components/post_draft/uploads/upload_item/upload_remove.tsx
index fac57c7d5..6c3a0c8b8 100644
--- a/app/components/post_draft/uploads/upload_item/upload_remove.tsx
+++ b/app/components/post_draft/uploads/upload_item/upload_remove.tsx
@@ -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 (
-
-
-
-
-
+ />
);
}
diff --git a/app/components/upload_item_shared/adapters.test.ts b/app/components/upload_item_shared/adapters.test.ts
new file mode 100644
index 000000000..f318a1c84
--- /dev/null
+++ b/app/components/upload_item_shared/adapters.test.ts
@@ -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);
+ });
+ });
+});
diff --git a/app/components/upload_item_shared/adapters.ts b/app/components/upload_item_shared/adapters.ts
new file mode 100644
index 000000000..f19406f71
--- /dev/null
+++ b/app/components/upload_item_shared/adapters.ts
@@ -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,
+ };
+}
diff --git a/app/components/upload_item_shared/constants.ts b/app/components/upload_item_shared/constants.ts
new file mode 100644
index 000000000..b52be3ec5
--- /dev/null
+++ b/app/components/upload_item_shared/constants.ts
@@ -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;
diff --git a/app/components/upload_item_shared/index.test.tsx b/app/components/upload_item_shared/index.test.tsx
new file mode 100644
index 000000000..318833dd5
--- /dev/null
+++ b/app/components/upload_item_shared/index.test.tsx
@@ -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 => ({
+ 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(
+ ,
+ {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(
+ ,
+ {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(
+ ,
+ {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(
+ ,
+ {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(
+ ,
+ {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(
+ ,
+ {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(
+ ,
+ {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(
+ ,
+ {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(
+ ,
+ {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(
+ ,
+ {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(
+ ,
+ {database},
+ );
+
+ expect(getByTestId('test-upload')).toBeTruthy();
+ });
+
+ it('should not show retry button for successful uploads', () => {
+ isImage.mockReturnValue(false);
+
+ const {getByTestId} = renderWithEverything(
+ ,
+ {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(
+ ,
+ {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(
+ ,
+ {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(
+ ,
+ {database},
+ );
+
+ expect(getByTestId('test-upload')).toBeTruthy();
+ });
+
+ it('should not apply full width to image files even when specified', () => {
+ isImage.mockReturnValue(true);
+
+ const {getByTestId} = renderWithEverything(
+ ,
+ {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(
+ ,
+ {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(
+ ,
+ {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(
+ ,
+ {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(
+ ,
+ {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(
+ ,
+ {database},
+ );
+ }).not.toThrow();
+ });
+
+ it('should handle files with no mime type', () => {
+ isImage.mockReturnValue(false);
+ const fileWithoutMimeType = createMockFile({
+ mime_type: undefined,
+ });
+
+ expect(() => {
+ renderWithEverything(
+ ,
+ {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(
+ ,
+ {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(
+ ,
+ {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(
+ ,
+ {database},
+ );
+
+ // Should prioritize failed state over loading state
+ expect(getByTestId('test-upload')).toBeTruthy();
+ });
+ });
+});
diff --git a/app/components/upload_item_shared/index.tsx b/app/components/upload_item_shared/index.tsx
new file mode 100644
index 000000000..23499e9d4
--- /dev/null
+++ b/app/components/upload_item_shared/index.tsx
@@ -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;
+ testID?: string;
+ fullWidth?: boolean;
+ isShareExtension?: boolean;
+ forwardRef?: React.RefObject;
+ 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 (
+
+
+
+ );
+ }
+ return (
+
+
+
+ );
+ }
+ return (
+
+
+
+ );
+ }, [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 (
+
+
+
+ {fileDisplay}
+
+
+
+ {!isImageFile && (
+
+
+ {file.name || unknownFileLabel}
+
+
+ {fileExtension && `${fileExtension} `}{formattedSize}
+
+
+ )}
+
+ {file.failed && showRetryButton && onRetry && (
+
+ )}
+
+ {loading && !file.failed && (
+
+
+
+ )}
+
+ );
+}
diff --git a/app/components/upload_item_shared/remove_button.test.tsx b/app/components/upload_item_shared/remove_button.test.tsx
new file mode 100644
index 000000000..095717ee3
--- /dev/null
+++ b/app/components/upload_item_shared/remove_button.test.tsx
@@ -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(
+ ,
+ {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(
+ ,
+ {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(
+ ,
+ {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(
+ void}/>,
+ {database},
+ );
+ }).not.toThrow();
+ });
+
+ it('should prevent rapid successive taps (double-tap protection)', () => {
+ const onPress = jest.fn();
+
+ const {getByTestId} = renderWithEverything(
+ ,
+ {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);
+ });
+ });
+});
diff --git a/app/components/upload_item_shared/remove_button.tsx b/app/components/upload_item_shared/remove_button.tsx
new file mode 100644
index 000000000..a62969ef6
--- /dev/null
+++ b/app/components/upload_item_shared/remove_button.tsx
@@ -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 (
+ [style.tappableContainer, pressed && {opacity: 0.72}]}
+ onPress={handlePress}
+ testID={testID}
+ hitSlop={hitSlop}
+ >
+
+
+
+
+ );
+}
diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json
index 28129b474..23c93bd24 100644
--- a/assets/base/i18n/en.json
+++ b/assets/base/i18n/en.json
@@ -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",
diff --git a/share_extension/components/content_view/attachments/attachments.tsx b/share_extension/components/content_view/attachments/attachments.tsx
index de0c96b48..1527051fb 100644
--- a/share_extension/components/content_view/attachments/attachments.tsx
+++ b/share_extension/components/content_view/attachments/attachments.tsx
@@ -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)
);
} else {
diff --git a/share_extension/components/content_view/attachments/multiple.tsx b/share_extension/components/content_view/attachments/multiple.tsx
index 41f43afc5..ca42e9223 100644
--- a/share_extension/components/content_view/attachments/multiple.tsx
+++ b/share_extension/components/content_view/attachments/multiple.tsx
@@ -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}
/>
);
- }, [maxFileSize, theme, files]);
+ }, [styles.item, styles.first, styles.last, files.length, maxFileSize]);
return (
<>
diff --git a/share_extension/components/content_view/attachments/shared_upload_item.test.tsx b/share_extension/components/content_view/attachments/shared_upload_item.test.tsx
new file mode 100644
index 000000000..80a0f0b9b
--- /dev/null
+++ b/share_extension/components/content_view/attachments/shared_upload_item.test.tsx
@@ -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 => ({
+ 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(
+ ,
+ {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(
+ ,
+ {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(
+ ,
+ {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);
+ });
+});
diff --git a/share_extension/components/content_view/attachments/shared_upload_item.tsx b/share_extension/components/content_view/attachments/shared_upload_item.tsx
new file mode 100644
index 000000000..e2d7f64dc
--- /dev/null
+++ b/share_extension/components/content_view/attachments/shared_upload_item.tsx
@@ -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 (
+
+ );
+};
+
+export default SharedUploadItem;
diff --git a/share_extension/components/content_view/attachments/single.test.tsx b/share_extension/components/content_view/attachments/single.test.tsx
new file mode 100644
index 000000000..3625099e0
--- /dev/null
+++ b/share_extension/components/content_view/attachments/single.test.tsx
@@ -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 => ({
+ 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(
+ ,
+ {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(
+ ,
+ {database},
+ );
+
+ expect(queryByTestId('remove-button')).toBeNull();
+ });
+});
+
diff --git a/share_extension/components/content_view/attachments/single.tsx b/share_extension/components/content_view/attachments/single.tsx
index c31aa54a9..82d0fb7ae 100644
--- a/share_extension/components/content_view/attachments/single.tsx
+++ b/share_extension/components/content_view/attachments/single.tsx
@@ -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 = (
-
- );
- } else {
- attachment = (
-
- );
- }
-
if (isSmall) {
return (
-
- {attachment}
-
-
-
-
-
+
+
+
);
}
- return attachment;
+ return (
+
+
+
+ );
};
export default Single;
diff --git a/share_extension/components/content_view/content_view.tsx b/share_extension/components/content_view/content_view.tsx
index 6ae346866..9a448c75b 100644
--- a/share_extension/components/content_view/content_view.tsx
+++ b/share_extension/components/content_view/content_view.tsx
@@ -27,7 +27,7 @@ const getStyles = makeStyleSheetFromTheme((theme: Theme) => ({
flex: 1,
},
content: {
- paddingTop: 20,
+ paddingTop: 0,
},
divider: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.08),