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