From 71fed8aa50ecda1e3b3574d2b44c8f793031489b Mon Sep 17 00:00:00 2001 From: Rajat Dabade Date: Thu, 11 Sep 2025 12:54:54 +0530 Subject: [PATCH] (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 --- app/components/post_draft/uploads/index.tsx | 2 +- .../post_draft/uploads/upload_item/index.tsx | 295 ---------- ....test.tsx => upload_item_wrapper.test.tsx} | 2 +- .../upload_item/upload_item_wrapper.tsx | 119 ++++ .../uploads/upload_item/upload_remove.tsx | 46 +- .../upload_item_shared/adapters.test.ts | 376 ++++++++++++ app/components/upload_item_shared/adapters.ts | 42 ++ .../upload_item_shared/constants.ts | 14 + .../upload_item_shared/index.test.tsx | 545 ++++++++++++++++++ app/components/upload_item_shared/index.tsx | 311 ++++++++++ .../upload_item_shared/remove_button.test.tsx | 111 ++++ .../upload_item_shared/remove_button.tsx | 69 +++ assets/base/i18n/en.json | 1 + .../content_view/attachments/attachments.tsx | 2 +- .../content_view/attachments/multiple.tsx | 9 +- .../attachments/shared_upload_item.test.tsx | 132 +++++ .../attachments/shared_upload_item.tsx | 31 + .../content_view/attachments/single.test.tsx | 84 +++ .../content_view/attachments/single.tsx | 93 +-- .../components/content_view/content_view.tsx | 2 +- 20 files changed, 1878 insertions(+), 408 deletions(-) delete mode 100644 app/components/post_draft/uploads/upload_item/index.tsx rename app/components/post_draft/uploads/upload_item/{index.test.tsx => upload_item_wrapper.test.tsx} (99%) create mode 100644 app/components/post_draft/uploads/upload_item/upload_item_wrapper.tsx create mode 100644 app/components/upload_item_shared/adapters.test.ts create mode 100644 app/components/upload_item_shared/adapters.ts create mode 100644 app/components/upload_item_shared/constants.ts create mode 100644 app/components/upload_item_shared/index.test.tsx create mode 100644 app/components/upload_item_shared/index.tsx create mode 100644 app/components/upload_item_shared/remove_button.test.tsx create mode 100644 app/components/upload_item_shared/remove_button.tsx create mode 100644 share_extension/components/content_view/attachments/shared_upload_item.test.tsx create mode 100644 share_extension/components/content_view/attachments/shared_upload_item.tsx create mode 100644 share_extension/components/content_view/attachments/single.test.tsx 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),