From 8e1af4298db800b609a34991019eaf2d872833cc Mon Sep 17 00:00:00 2001 From: Rajat Dabade Date: Fri, 20 Jun 2025 01:03:57 +0530 Subject: [PATCH] Viewing Files in Edit mode in mobile with ability to delete and save (#8918) * Viewing Files in Edit mode in mobile with ability to delete and save * Added upload attachment to keyboard tracker view * using state instread of use ref * Minor * Added tests * intl extract * new function getFiles ById, batch file deletion and tests * Files fetching in edit options and tests * Removed DeviceEventEmitter and used React context * Added support to check minimum required version to show edit file attachments * resolve forward ref issue * Minor * memotized props for context and observe config with value * Import fixes --- app/actions/remote/post.test.ts | 76 ++++++++++- app/actions/remote/post.ts | 18 ++- app/components/files/index.ts | 19 +-- .../post_draft/draft_input/draft_input.tsx | 1 + app/components/post_draft/uploads/index.tsx | 5 +- .../post_draft/uploads/upload_item/index.tsx | 6 +- .../uploads/upload_item/upload_remove.tsx | 10 ++ app/constants/events.ts | 1 + app/context/edit_post/index.tsx | 31 +++++ app/queries/servers/file.ts | 15 ++ app/screens/edit_post/edit_post.test.tsx | 84 ++++++++++++ app/screens/edit_post/edit_post.tsx | 68 +++++++-- .../edit_post_input/edit_post_input.test.tsx | 87 ++++++++++++ .../edit_post_input/edit_post_input.tsx | 129 ++++++++++++++++++ .../edit_post/edit_post_input/index.tsx | 116 ++-------------- .../options/{ => edit_option}/edit_option.tsx | 7 +- .../options/edit_option/index.test.tsx | 121 ++++++++++++++++ .../post_options/options/edit_option/index.ts | 30 ++++ app/utils/file/index.ts | 16 +++ assets/base/i18n/en.json | 4 + 20 files changed, 700 insertions(+), 144 deletions(-) create mode 100644 app/context/edit_post/index.tsx create mode 100644 app/screens/edit_post/edit_post.test.tsx create mode 100644 app/screens/edit_post/edit_post_input/edit_post_input.test.tsx create mode 100644 app/screens/edit_post/edit_post_input/edit_post_input.tsx rename app/screens/post_options/options/{ => edit_option}/edit_option.tsx (87%) create mode 100644 app/screens/post_options/options/edit_option/index.test.tsx create mode 100644 app/screens/post_options/options/edit_option/index.ts diff --git a/app/actions/remote/post.test.ts b/app/actions/remote/post.test.ts index 74120ac80..adc5bc045 100644 --- a/app/actions/remote/post.test.ts +++ b/app/actions/remote/post.test.ts @@ -411,7 +411,7 @@ describe('create, update & delete posts', () => { it('editPost - handle error', async () => { mockClient.deletePost.mockImplementationOnce(jest.fn(throwFunc)); - const result = await editPost('foo', '', ''); + const result = await editPost('foo', '', '', [], []); expect(result).toBeDefined(); expect(result.error).toBeTruthy(); }); @@ -424,12 +424,84 @@ describe('create, update & delete posts', () => { prepareRecordsOnly: false, }); - const result = await editPost(serverUrl, post1.id, 'new message'); + const result = await editPost(serverUrl, post1.id, 'new message', [], []); expect(result).toBeDefined(); expect(result.error).toBeUndefined(); expect(result.post).toBeDefined(); }); + it('editPost - delete files', async () => { + await operator.handlePosts({ + actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL, + order: [post1.id], + posts: [post1], + prepareRecordsOnly: false, + }); + + const testFiles: FileInfo[] = [ + TestHelper.fakeFileInfo({ + id: 'file-1', + post_id: post1.id, + name: 'test-file-1.jpg', + extension: 'jpg', + size: 1024, + mime_type: 'image/jpeg', + user_id: user1.id, + }), + TestHelper.fakeFileInfo({ + id: 'file-2', + post_id: post1.id, + name: 'test-file-2.png', + extension: 'png', + size: 2048, + mime_type: 'image/png', + user_id: user1.id, + }), + TestHelper.fakeFileInfo({ + id: 'file-3', + post_id: post1.id, + name: 'test-file-3.pdf', + extension: 'pdf', + size: 4096, + mime_type: 'application/pdf', + user_id: user1.id, + }), + ]; + + await operator.handleFiles({ + files: testFiles, + prepareRecordsOnly: false, + }); + + const {database} = operator; + const filesBefore = await database.get('File').query().fetch(); + expect(filesBefore).toHaveLength(3); + + const file1Before = filesBefore.find((f) => f.id === 'file-1'); + const file2Before = filesBefore.find((f) => f.id === 'file-2'); + const file3Before = filesBefore.find((f) => f.id === 'file-3'); + expect(file1Before).toBeDefined(); + expect(file2Before).toBeDefined(); + expect(file3Before).toBeDefined(); + + const result = await editPost(serverUrl, post1.id, 'new message', [], ['file-1', 'file-2']); + expect(result).toBeDefined(); + expect(result.error).toBeUndefined(); + expect(result.post).toBeDefined(); + + const filesAfter = await database.get('File').query().fetch(); + expect(filesAfter).toHaveLength(1); + + const remainingFile = filesAfter[0]; + expect(remainingFile.id).toBe('file-3'); + + const deletedFiles = await database.get('File').query().fetch(); + const file1After = deletedFiles.find((f) => f.id === 'file-1'); + const file2After = deletedFiles.find((f) => f.id === 'file-2'); + expect(file1After).toBeUndefined(); + expect(file2After).toBeUndefined(); + }); + it('acknowledgePost - handle error', async () => { mockClient.deletePost.mockImplementationOnce(jest.fn(throwFunc)); const result = await acknowledgePost('foo', ''); diff --git a/app/actions/remote/post.ts b/app/actions/remote/post.ts index 94193edd0..bdb2b812f 100644 --- a/app/actions/remote/post.ts +++ b/app/actions/remote/post.ts @@ -15,6 +15,7 @@ import {getNeededAtMentionedUsernames} from '@helpers/api/user'; import NetworkManager from '@managers/network_manager'; import {getMyChannel, prepareMissingChannelsForAllTeams, queryAllMyChannel} from '@queries/servers/channel'; import {queryAllCustomEmojis} from '@queries/servers/custom_emoji'; +import {getFilesByIds} from '@queries/servers/file'; import {getPostById, getRecentPostsInChannel} from '@queries/servers/post'; import {getCurrentUserId} from '@queries/servers/system'; import {getIsCRTEnabled, prepareThreadsFromReceivedPosts} from '@queries/servers/thread'; @@ -907,14 +908,14 @@ export const markPostAsUnread = async (serverUrl: string, postId: string) => { } }; -export const editPost = async (serverUrl: string, postId: string, postMessage: string) => { +export const editPost = async (serverUrl: string, postId: string, postMessage: string, file_ids: string[], removed_file_ids: string[]) => { try { const client = NetworkManager.getClient(serverUrl); - const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); const post = await getPostById(database, postId); if (post) { - const {update_at, edit_at, message: updatedMessage, message_source} = await client.patchPost({message: postMessage, id: postId}); + const {update_at, edit_at, message: updatedMessage, message_source} = await client.patchPost({message: postMessage, id: postId, file_ids}); await database.write(async () => { await post.update((p) => { p.updateAt = update_at; @@ -923,6 +924,17 @@ export const editPost = async (serverUrl: string, postId: string, postMessage: s p.messageSource = message_source || ''; }); }); + + if (removed_file_ids.length > 0) { + const filesToDelete = await getFilesByIds(database, removed_file_ids); + if (filesToDelete.length > 0) { + const fileDeleteModels = filesToDelete.map((file) => { + file.prepareDestroyPermanently(); + return file; + }); + await operator.batchRecords(fileDeleteModels, 'delete files'); + } + } } return {post}; } catch (error) { diff --git a/app/components/files/index.ts b/app/components/files/index.ts index c7984cec6..4fa12fa7e 100644 --- a/app/components/files/index.ts +++ b/app/components/files/index.ts @@ -8,34 +8,17 @@ import {switchMap} from 'rxjs/operators'; import {queryFilesForPost} from '@queries/servers/file'; import {observeCanDownloadFiles, observeEnableSecureFilePreview} from '@queries/servers/security'; import {observeConfigBooleanValue} from '@queries/servers/system'; -import {fileExists} from '@utils/file'; +import {filesLocalPathValidation} from '@utils/file'; import Files from './files'; import type {WithDatabaseArgs} from '@typings/database/database'; -import type FileModel from '@typings/database/models/servers/file'; import type PostModel from '@typings/database/models/servers/post'; type EnhanceProps = WithDatabaseArgs & { post: PostModel; } -const filesLocalPathValidation = async (files: FileModel[], authorId: string) => { - const filesInfo: FileInfo[] = []; - for await (const f of files) { - const info = f.toFileInfo(authorId); - if (info.localPath) { - const exists = await fileExists(info.localPath); - if (!exists) { - info.localPath = ''; - } - } - filesInfo.push(info); - } - - return filesInfo; -}; - const enhance = withObservables(['post'], ({database, post}: EnhanceProps) => { const publicLinkEnabled = observeConfigBooleanValue(database, 'EnablePublicLink'); diff --git a/app/components/post_draft/draft_input/draft_input.tsx b/app/components/post_draft/draft_input/draft_input.tsx index 9235edfef..0eb15c3e5 100644 --- a/app/components/post_draft/draft_input/draft_input.tsx +++ b/app/components/post_draft/draft_input/draft_input.tsx @@ -242,6 +242,7 @@ function DraftInput({ uploadFileError={uploadFileError} channelId={channelId} rootId={rootId} + isEditMode={false} /> { @@ -74,6 +75,7 @@ function Uploads({ uploadFileError, channelId, rootId, + isEditMode, }: Props) { const galleryIdentifier = `${channelId}-uploadedItems-${rootId}`; const theme = useTheme(); @@ -134,9 +136,10 @@ function Uploads({ galleryIdentifier={galleryIdentifier} index={index} file={file} - key={file.clientId} + key={file.clientId || file.id} openGallery={openGallery} rootId={rootId} + isEditMode={isEditMode} /> ); }); diff --git a/app/components/post_draft/uploads/upload_item/index.tsx b/app/components/post_draft/uploads/upload_item/index.tsx index 4d8550a06..f5c3a3281 100644 --- a/app/components/post_draft/uploads/upload_item/index.tsx +++ b/app/components/post_draft/uploads/upload_item/index.tsx @@ -27,6 +27,7 @@ type Props = { file: FileInfo; openGallery: (file: FileInfo) => void; rootId: string; + isEditMode: boolean; } const style = StyleSheet.create({ @@ -61,7 +62,7 @@ const style = StyleSheet.create({ export default function UploadItem({ channelId, galleryIdentifier, index, file, - rootId, openGallery, + rootId, openGallery, isEditMode, }: Props) { const theme = useTheme(); const serverUrl = useServerUrl(); @@ -124,6 +125,7 @@ export default function UploadItem({ backgroundColor={changeOpacity(theme.centerChannelColor, 0.08)} iconSize={60} file={file} + testID={file.id} /> ); }, [file, ref, theme.centerChannelColor]); @@ -158,6 +160,8 @@ export default function UploadItem({ clientId={file.clientId!} channelId={channelId} rootId={rootId} + isEditMode={isEditMode} + fileId={file.id!} /> ); 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 8b1b51877..9215b04ee 100644 --- a/app/components/post_draft/uploads/upload_item/upload_remove.tsx +++ b/app/components/post_draft/uploads/upload_item/upload_remove.tsx @@ -7,6 +7,7 @@ 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 {useEditPost} from '@context/edit_post'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; import DraftUploadManager from '@managers/draft_upload_manager'; @@ -16,6 +17,8 @@ type Props = { channelId: string; rootId: string; clientId: string; + isEditMode: boolean; + fileId: string; } const getStyleSheet = makeStyleSheetFromTheme((theme) => { @@ -46,12 +49,19 @@ export default function UploadRemove({ channelId, rootId, clientId, + isEditMode, + fileId, }: Props) { const theme = useTheme(); const style = getStyleSheet(theme); const serverUrl = useServerUrl(); + const {onFileRemove} = useEditPost(); const onPress = () => { + if (isEditMode) { + onFileRemove?.(fileId); + return; + } DraftUploadManager.cancel(clientId); removeDraftFile(serverUrl, channelId, rootId, clientId); }; diff --git a/app/constants/events.ts b/app/constants/events.ts index 44476c060..83844663c 100644 --- a/app/constants/events.ts +++ b/app/constants/events.ts @@ -34,4 +34,5 @@ export default keyMirror({ JOIN_CALL_BAR_VISIBLE: null, DRAFT_SWIPEABLE: null, ACTIVE_SCREEN: null, + FILE_ADD_REMOVED: null, }); diff --git a/app/context/edit_post/index.tsx b/app/context/edit_post/index.tsx new file mode 100644 index 000000000..f5ca4349e --- /dev/null +++ b/app/context/edit_post/index.tsx @@ -0,0 +1,31 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {createContext, useContext, useMemo, type ReactNode} from 'react'; + +type EditPostContextType = { + onFileRemove?: (fileId: string) => void; +}; + +const EditPostContext = createContext({}); + +type EditPostProviderProps = { + children: ReactNode; + onFileRemove?: (fileId: string) => void; +}; + +export const EditPostProvider = ({children, onFileRemove}: EditPostProviderProps) => { + const contextValue = useMemo(() => ({ + onFileRemove, + }), [onFileRemove]); + + return ( + + {children} + + ); +}; + +export const useEditPost = () => { + return useContext(EditPostContext); +}; diff --git a/app/queries/servers/file.ts b/app/queries/servers/file.ts index 501181954..c1d0fca06 100644 --- a/app/queries/servers/file.ts +++ b/app/queries/servers/file.ts @@ -35,3 +35,18 @@ export const queryFilesForPost = (database: Database, postId: string) => { export const observeFilesForPost = (database: Database, postId: string) => { return queryFilesForPost(database, postId).observe(); }; + +export const getFilesByIds = async (database: Database, fileIds: string[]) => { + if (!fileIds.length) { + return []; + } + + try { + const records = await database.get(FILE).query( + Q.where('id', Q.oneOf(fileIds)), + ).fetch(); + return records; + } catch { + return []; + } +}; diff --git a/app/screens/edit_post/edit_post.test.tsx b/app/screens/edit_post/edit_post.test.tsx new file mode 100644 index 000000000..e4fb323b4 --- /dev/null +++ b/app/screens/edit_post/edit_post.test.tsx @@ -0,0 +1,84 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import {renderWithEverything} from '@test/intl-test-helper'; +import TestHelper from '@test/test_helper'; + +import EditPost from './edit_post'; + +import type {Database} from '@nozbe/watermelondb'; +import type PostModel from '@typings/database/models/servers/post'; + +const serverUrl = 'baseHandler.test.com'; + +describe('Edit Post', () => { + + let database: Database; + + const baseProps: Parameters[0] = { + componentId: 'EditPost', + closeButtonId: 'edit-post', + post: { + id: '1', + channelId: '1', + message: 'test', + messageSource: 'test', + userId: '1', + rootId: '1', + metadata: {}, + } as PostModel, + maxPostSize: 1000, + hasFilesAttached: true, + canDelete: true, + files: [ + { + id: 'file-1', + name: 'test-1', + extension: 'png', + has_preview_image: true, + height: 100, + mime_type: 'test', + size: 100, + user_id: '1', + width: 100, + }, + { + id: 'file-2', + name: 'test-2', + extension: 'pdf', + has_preview_image: false, + height: 100, + mime_type: 'test', + size: 100, + user_id: '1', + width: 100, + }, + ], + }; + + beforeAll(async () => { + const server = await TestHelper.setupServerDatabase(serverUrl); + database = server.database; + const operator = server.operator; + operator.handleConfigs({ + configs: [ + {id: 'Version', value: '10.5.0'}, + ], + configsToDelete: [], + prepareRecordsOnly: false, + }); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render the attachments in edit mode', () => { + const {getByTestId} = renderWithEverything(, {database, serverUrl}); + expect(getByTestId('uploads')).toBeVisible(); + expect(getByTestId('file-1')).toBeVisible(); + expect(getByTestId('file-2')).toBeVisible(); + }); +}); diff --git a/app/screens/edit_post/edit_post.tsx b/app/screens/edit_post/edit_post.tsx index f2bc7141b..ee67669ed 100644 --- a/app/screens/edit_post/edit_post.tsx +++ b/app/screens/edit_post/edit_post.tsx @@ -8,6 +8,7 @@ import {Alert, Keyboard, type LayoutChangeEvent, Platform, SafeAreaView, View, S import {deletePost, editPost} from '@actions/remote/post'; import Autocomplete from '@components/autocomplete'; import Loading from '@components/loading'; +import {EditPostProvider} from '@context/edit_post'; import {ExtraKeyboardProvider} from '@context/extra_keyboard'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; @@ -22,8 +23,9 @@ import PostError from '@screens/edit_post/post_error'; import {buildNavigationButton, dismissModal, setButtons} from '@screens/navigation'; import {changeOpacity} from '@utils/theme'; -import EditPostInput, {type EditPostInputRef} from './edit_post_input'; +import EditPostInput from './edit_post_input'; +import type {PasteInputRef} from '@mattermost/react-native-paste-input'; import type PostModel from '@typings/database/models/servers/post'; import type {AvailableScreens} from '@typings/screens/navigation'; @@ -55,8 +57,9 @@ type EditPostProps = { maxPostSize: number; hasFilesAttached: boolean; canDelete: boolean; + files?: FileInfo[]; } -const EditPost = ({componentId, maxPostSize, post, closeButtonId, hasFilesAttached, canDelete}: EditPostProps) => { +const EditPost = ({componentId, maxPostSize, post, closeButtonId, hasFilesAttached, canDelete, files}: EditPostProps) => { const editingMessage = post.messageSource || post.message; const [postMessage, setPostMessage] = useState(editingMessage); const [cursorPosition, setCursorPosition] = useState(editingMessage.length); @@ -65,10 +68,11 @@ const EditPost = ({componentId, maxPostSize, post, closeButtonId, hasFilesAttach const [isUpdating, setIsUpdating] = useState(false); const [containerHeight, setContainerHeight] = useState(0); const [propagateValue, shouldProcessEvent] = useInputPropagation(); + const [postFiles, setPostFiles] = useState(files || []); const mainView = useRef(null); - const postInputRef = useRef(null); + const postInputRef = useRef(undefined); const theme = useTheme(); const intl = useIntl(); const serverUrl = useServerUrl(); @@ -117,6 +121,44 @@ const EditPost = ({componentId, maxPostSize, post, closeButtonId, hasFilesAttach }); }, [componentId, intl, theme]); + const handleFileRemoval = useCallback((id: string) => { + const filterFileById = (file: FileInfo) => { + return file.id !== id; + }; + Alert.alert( + intl.formatMessage({ + id: 'edit_post.delete_file.title', + defaultMessage: 'Delete attachment', + }), + intl.formatMessage({ + id: 'edit_post.delete_file.confirmation', + defaultMessage: 'Are you sure you want to remove {filename}?', + }, { + filename: postFiles?.find((file) => file.id === id)?.name || '', + }), + [ + { + text: intl.formatMessage({ + id: 'edit_post.delete_file.cancel', + defaultMessage: 'Cancel', + }), + style: 'cancel', + }, + { + text: intl.formatMessage({ + id: 'edit_post.delete_file.confirm', + defaultMessage: 'Delete', + }), + style: 'destructive', + onPress: () => { + setPostFiles((prevFiles) => prevFiles?.filter(filterFileById) || []); + toggleSaveButton(true); + }, + }, + ], + ); + }, [intl, toggleSaveButton, postFiles]); + const onChangeTextCommon = useCallback((message: string) => { const tooLong = message.trim().length > maxPostSize; setErrorLine(undefined); @@ -192,15 +234,21 @@ const EditPost = ({componentId, maxPostSize, post, closeButtonId, hasFilesAttach return; } - const res = await editPost(serverUrl, post.id, postMessage); + const currentFileIds = postFiles.map((file) => file.id).filter((id): id is string => Boolean(id)); + const originalFiles = post.metadata?.files || []; + const originalFileIds = originalFiles.map((file) => file.id).filter((id): id is string => Boolean(id)); + const currentFileIdSet = new Set(currentFileIds); + const removedFileIds = originalFileIds.filter((id) => !currentFileIdSet.has(id)); + + const res = await editPost(serverUrl, post.id, postMessage, currentFileIds, removedFileIds); handleUIUpdates(res); - }, [toggleSaveButton, shouldDeleteOnSave, serverUrl, post.id, postMessage, handleUIUpdates, handleDeletePost]); + }, [toggleSaveButton, shouldDeleteOnSave, post.metadata?.files, post.id, serverUrl, postMessage, handleUIUpdates, handleDeletePost, postFiles]); const onLayout = useCallback((e: LayoutChangeEvent) => { setContainerHeight(e.nativeEvent.layout.height); }, []); - useNavButtonPressed(RIGHT_BUTTON.id, componentId, onSavePostMessage, [postMessage]); + useNavButtonPressed(RIGHT_BUTTON.id, componentId, onSavePostMessage, [postMessage, postFiles]); useNavButtonPressed(closeButtonId, componentId, onClose, []); useAndroidHardwareBackHandler(componentId, onClose); @@ -222,7 +270,7 @@ const EditPost = ({componentId, maxPostSize, post, closeButtonId, hasFilesAttach } return ( - <> + @@ -265,7 +315,7 @@ const EditPost = ({componentId, maxPostSize, post, closeButtonId, hasFilesAttach inPost={false} serverUrl={serverUrl} /> - + ); }; diff --git a/app/screens/edit_post/edit_post_input/edit_post_input.test.tsx b/app/screens/edit_post/edit_post_input/edit_post_input.test.tsx new file mode 100644 index 000000000..60f98c512 --- /dev/null +++ b/app/screens/edit_post/edit_post_input/edit_post_input.test.tsx @@ -0,0 +1,87 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Database} from '@nozbe/watermelondb'; + +import {renderWithEverything} from '@test/intl-test-helper'; +import TestHelper from '@test/test_helper'; + +import EditPostInput from './edit_post_input'; + +import type PostModel from '@typings/database/models/servers/post'; + +const serverUrl = 'baseHandler.test.com'; + +describe('EditPostInput', () => { + let database: Database; + + const baseProps: Parameters[0] = { + message: 'test', + hasError: false, + post: { + id: '1', + channelId: '1', + message: 'test', + messageSource: 'test', + userId: '1', + rootId: '1', + metadata: {}, + } as PostModel, + postFiles: [ + { + id: 'file-1', + name: 'test-1', + extension: 'png', + has_preview_image: true, + height: 100, + mime_type: 'test', + size: 100, + user_id: '1', + width: 100, + }, + { + id: 'file-2', + name: 'test-2', + extension: 'pdf', + has_preview_image: false, + height: 100, + mime_type: 'test', + size: 100, + user_id: '1', + width: 100, + }, + ], + version: '10.5.0', + inputRef: {current: undefined}, + onTextSelectionChange: jest.fn(), + onChangeText: jest.fn(), + }; + + beforeAll(async () => { + const server = await TestHelper.setupServerDatabase(serverUrl); + database = server.database; + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render attachments in edit mode', () => { + const {getByTestId} = renderWithEverything(, {database, serverUrl}); + expect(getByTestId('uploads')).toBeVisible(); + expect(getByTestId('file-1')).toBeVisible(); + expect(getByTestId('file-2')).toBeVisible(); + }); + + it('should match the count of the attachments in edit mode', () => { + const {getByTestId} = renderWithEverything(, {database, serverUrl}); + expect(getByTestId('uploads')).toBeVisible(); + expect(getByTestId('uploads').children).toHaveLength(2); + }); + + it('should not render attachments if the server version is less than 10.5.0', () => { + const props = {...baseProps, version: '10.4.0'}; + const {queryByTestId} = renderWithEverything(, {database, serverUrl}); + expect(queryByTestId('uploads')).toBeNull(); + }); +}); diff --git a/app/screens/edit_post/edit_post_input/edit_post_input.tsx b/app/screens/edit_post/edit_post_input/edit_post_input.tsx new file mode 100644 index 000000000..2e7cda92e --- /dev/null +++ b/app/screens/edit_post/edit_post_input/edit_post_input.tsx @@ -0,0 +1,129 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {useManagedConfig} from '@mattermost/react-native-emm'; +import PasteInput, {type PasteInputRef} from '@mattermost/react-native-paste-input'; +import React, {useCallback, useMemo} from 'react'; +import {useIntl} from 'react-intl'; +import {type NativeSyntheticEvent, Platform, type TextInputSelectionChangeEventData, View} from 'react-native'; + +import Uploads from '@components/post_draft/uploads'; +import {ExtraKeyboard, useExtraKeyboardContext} from '@context/extra_keyboard'; +import {useTheme} from '@context/theme'; +import {emptyFunction} from '@utils/general'; +import {isMinimumServerVersion} from '@utils/helpers'; +import {changeOpacity, getKeyboardAppearanceFromTheme, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +import type PostModel from '@typings/database/models/servers/post'; + +const MAJOR_VERSION_TO_SHOW_ATTACHMENTS = 10; +const MINOR_VERSION_TO_SHOW_ATTACHMENTS = 5; + +const getStyleSheet = makeStyleSheetFromTheme((theme) => ({ + input: { + color: theme.centerChannelColor, + padding: 15, + textAlignVertical: 'top', + flex: 1, + ...typography('Body', 200), + }, + inputContainer: { + backgroundColor: theme.centerChannelBg, + marginTop: 2, + flex: 1, + }, +})); + +type PostInputProps = { + message: string; + hasError: boolean; + post: PostModel; + postFiles: FileInfo[]; + version?: string; + onTextSelectionChange: (curPos: number) => void; + onChangeText: (text: string) => void; + inputRef: React.MutableRefObject; +} + +const EditPostInput = ({ + message, + onChangeText, + onTextSelectionChange, + hasError, + post, + postFiles, + version, + inputRef, +}: PostInputProps) => { + const intl = useIntl(); + const theme = useTheme(); + const styles = getStyleSheet(theme); + const managedConfig = useManagedConfig(); + const disableCopyAndPaste = managedConfig.copyAndPasteProtection === 'true'; + + const keyboardContext = useExtraKeyboardContext(); + + const onFocus = useCallback(() => { + keyboardContext?.registerTextInputFocus(); + }, [keyboardContext]); + + const onBlur = useCallback(() => { + keyboardContext?.registerTextInputBlur(); + }, [keyboardContext]); + + const inputStyle = useMemo(() => { + return [styles.input]; + }, [styles]); + + const onSelectionChange = useCallback((event: NativeSyntheticEvent) => { + const curPos = event.nativeEvent.selection.end; + onTextSelectionChange(curPos); + }, [onTextSelectionChange]); + + const containerStyle = useMemo(() => [ + styles.inputContainer, + hasError && {marginTop: 0}, + ], [styles, hasError]); + + return ( + + + {isMinimumServerVersion(version, MAJOR_VERSION_TO_SHOW_ATTACHMENTS, MINOR_VERSION_TO_SHOW_ATTACHMENTS) && + + } + {Platform.select({ios: })} + + ); +}; + +EditPostInput.displayName = 'EditPostInput'; + +export default EditPostInput; diff --git a/app/screens/edit_post/edit_post_input/index.tsx b/app/screens/edit_post/edit_post_input/index.tsx index 2030b0c2f..62adf3d27 100644 --- a/app/screens/edit_post/edit_post_input/index.tsx +++ b/app/screens/edit_post/edit_post_input/index.tsx @@ -1,114 +1,16 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {useManagedConfig} from '@mattermost/react-native-emm'; -import PasteInput, {type PasteInputRef} from '@mattermost/react-native-paste-input'; -import React, {forwardRef, useCallback, useImperativeHandle, useMemo, useRef} from 'react'; -import {useIntl} from 'react-intl'; -import {type NativeSyntheticEvent, Platform, type TextInputSelectionChangeEventData, View} from 'react-native'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; -import {ExtraKeyboard, useExtraKeyboardContext} from '@context/extra_keyboard'; -import {useTheme} from '@context/theme'; -import {emptyFunction} from '@utils/general'; -import {changeOpacity, getKeyboardAppearanceFromTheme, makeStyleSheetFromTheme} from '@utils/theme'; -import {typography} from '@utils/typography'; +import {observeConfigValue} from '@queries/servers/system'; -const getStyleSheet = makeStyleSheetFromTheme((theme) => ({ - input: { - color: theme.centerChannelColor, - padding: 15, - textAlignVertical: 'top', - flex: 1, - ...typography('Body', 200), - }, - inputContainer: { - backgroundColor: theme.centerChannelBg, - marginTop: 2, - flex: 1, - }, +import EditPostInput from './edit_post_input'; + +import type {WithDatabaseArgs} from '@typings/database/database'; + +const enhanced = withObservables([], ({database}: WithDatabaseArgs) => ({ + version: observeConfigValue(database, 'Version'), })); -export type EditPostInputRef = { - focus: () => void; -} - -type PostInputProps = { - message: string; - hasError: boolean; - onTextSelectionChange: (curPos: number) => void; - onChangeText: (text: string) => void; -} - -const EditPostInput = forwardRef(({ - message, - onChangeText, - onTextSelectionChange, - hasError, -}: PostInputProps, ref) => { - const intl = useIntl(); - const theme = useTheme(); - const styles = getStyleSheet(theme); - const managedConfig = useManagedConfig(); - const disableCopyAndPaste = managedConfig.copyAndPasteProtection === 'true'; - - const inputRef = useRef(); - - const keyboardContext = useExtraKeyboardContext(); - - const onFocus = useCallback(() => { - keyboardContext?.registerTextInputFocus(); - }, [keyboardContext]); - - const onBlur = useCallback(() => { - keyboardContext?.registerTextInputBlur(); - }, [keyboardContext]); - - const inputStyle = useMemo(() => { - return [styles.input]; - }, [styles]); - - const onSelectionChange = useCallback((event: NativeSyntheticEvent) => { - const curPos = event.nativeEvent.selection.end; - onTextSelectionChange(curPos); - }, [onTextSelectionChange]); - - const containerStyle = useMemo(() => [ - styles.inputContainer, - hasError && {marginTop: 0}, - ], [styles, hasError]); - - useImperativeHandle(ref, () => ({ - focus: () => inputRef.current?.focus(), - }), []); - - return ( - - - {Platform.select({ios: })} - - ); -}); - -EditPostInput.displayName = 'EditPostInput'; - -export default EditPostInput; +export default withDatabase(enhanced(EditPostInput)); diff --git a/app/screens/post_options/options/edit_option.tsx b/app/screens/post_options/options/edit_option/edit_option.tsx similarity index 87% rename from app/screens/post_options/options/edit_option.tsx rename to app/screens/post_options/options/edit_option/edit_option.tsx index d7ff22c1c..e907b3712 100644 --- a/app/screens/post_options/options/edit_option.tsx +++ b/app/screens/post_options/options/edit_option/edit_option.tsx @@ -18,8 +18,9 @@ type Props = { bottomSheetId: AvailableScreens; post: PostModel; canDelete: boolean; + files?: FileInfo[]; } -const EditOption = ({bottomSheetId, post, canDelete}: Props) => { +const EditOption = ({bottomSheetId, post, canDelete, files}: Props) => { const intl = useIntl(); const theme = useTheme(); @@ -29,7 +30,7 @@ const EditOption = ({bottomSheetId, post, canDelete}: Props) => { const title = intl.formatMessage({id: 'mobile.edit_post.title', defaultMessage: 'Editing Message'}); const closeButton = CompassIcon.getImageSourceSync('close', 24, theme.sidebarHeaderTextColor); const closeButtonId = 'close-edit-post'; - const passProps = {post, closeButtonId, canDelete}; + const passProps = {post, closeButtonId, canDelete, files}; const options = { topBar: { leftButtons: [{ @@ -40,7 +41,7 @@ const EditOption = ({bottomSheetId, post, canDelete}: Props) => { }, }; showModal(Screens.EDIT_POST, title, passProps, options); - }, [bottomSheetId, post]); + }, [bottomSheetId, canDelete, files, intl, post, theme.sidebarHeaderTextColor]); return ( ({ + __esModule: true, + default: jest.fn(), +})); + +jest.mocked(EditOption).mockImplementation((props) => React.createElement('EditOption', {...props, testID: 'edit-option'})); + +describe('EditOption', () => { + const serverUrl = 'server-1'; + const teamId = 'team1'; + let database: Database; + let operator: ServerDataOperator; + let post: PostModel; + + beforeEach(async () => { + await DatabaseManager.init([serverUrl]); + const serverDatabaseAndOperator = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + database = serverDatabaseAndOperator.database; + operator = serverDatabaseAndOperator.operator; + + await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID, value: teamId}], prepareRecordsOnly: false}); + await operator.handleTeam({teams: [TestHelper.fakeTeam({id: teamId})], prepareRecordsOnly: false}); + await operator.handleConfigs({ + configs: [ + {id: 'Version', value: '7.6.0'}, + ], + configsToDelete: [], + prepareRecordsOnly: false, + }); + post = TestHelper.fakePostModel(); + const postRaw = TestHelper.fakePost({ + id: post.id, + message: post.message, + }); + await operator.handlePosts({ + actionType: ActionType.POSTS.RECEIVED_NEW, + order: [post.id], + posts: [postRaw], + prepareRecordsOnly: false, + }); + + const files = [ + TestHelper.fakeFileInfo( + { + post_id: post.id, + id: 'file-1', + name: 'file1', + create_at: 1000, + }), + TestHelper.fakeFileInfo({ + post_id: post.id, + id: 'file-2', + name: 'file2', + create_at: 1001, + }), + ]; + await operator.handleFiles({ + files, + prepareRecordsOnly: false, + }); + }); + + afterEach(async () => { + await DatabaseManager.destroyServerDatabase(serverUrl); + }); + + it('should fetch the files for the post', async () => { + const {getByTestId} = renderWithEverything( + , + {database}, + ); + await waitFor(() => { + const editPost = getByTestId('edit-option'); + expect(editPost).toBeDefined(); + const files = editPost.props.files; + expect(files).toHaveLength(2); + expect(files[0].name).toBe('file2'); + expect(files[1].name).toBe('file1'); + }); + }); + + it('should not fetch the files for the post if the post has no files', async () => { + const {getByTestId} = renderWithEverything( + , + {database}, + ); + await waitFor(() => { + const editPost = getByTestId('edit-option'); + expect(editPost).toBeDefined(); + const files = editPost.props.files; + expect(files).toHaveLength(0); + }); + }); +}); diff --git a/app/screens/post_options/options/edit_option/index.ts b/app/screens/post_options/options/edit_option/index.ts new file mode 100644 index 000000000..8457a10ed --- /dev/null +++ b/app/screens/post_options/options/edit_option/index.ts @@ -0,0 +1,30 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; +import {from as from$} from 'rxjs'; +import {switchMap} from 'rxjs/operators'; + +import {observeFilesForPost} from '@queries/servers/file'; +import {filesLocalPathValidation} from '@utils/file'; + +import EditOption from './edit_option'; + +import type {WithDatabaseArgs} from '@typings/database/database'; +import type PostModel from '@typings/database/models/servers/post'; + +type Props = WithDatabaseArgs & { + post: PostModel; +} + +const enhance = withObservables(['post'], ({post, database}: Props) => { + const files = observeFilesForPost(database, post.id).pipe( + switchMap((fs) => from$(filesLocalPathValidation(fs, post.userId))), + ); + + return { + files, + }; +}); + +export default withDatabase(enhance(EditOption)); diff --git a/app/utils/file/index.ts b/app/utils/file/index.ts index 2248c9244..65c74d9fa 100644 --- a/app/utils/file/index.ts +++ b/app/utils/file/index.ts @@ -597,3 +597,19 @@ export const pathWithPrefix = (prefix: string, path: string) => { export const deleteFile = async (path: string) => { await deleteAsync(pathWithPrefix('file://', path)); }; + +export const filesLocalPathValidation = async (files: FileModel[], authorId: string) => { + const filesInfo: FileInfo[] = []; + for await (const f of files) { + const info = f.toFileInfo(authorId); + if (info.localPath) { + const exists = await fileExists(info.localPath); + if (!exists) { + info.localPath = ''; + } + } + filesInfo.push(info); + } + + return filesInfo; +}; diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index b197a644b..ecfa761d6 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -328,6 +328,10 @@ "drafts_tab.title.scheduled": "Scheduled", "drafts.empty.subtitle": "Any message you have started will show here.", "drafts.empty.title": "No drafts at the moment", + "edit_post.delete_file.cancel": "Cancel", + "edit_post.delete_file.confirm": "Delete", + "edit_post.delete_file.confirmation": "Are you sure you want to remove {filename}?", + "edit_post.delete_file.title": "Delete attachment", "edit_post.editPost": "Edit the post...", "edit_post.save": "Save", "edit_server.description": "Specify a display name for this server",