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
This commit is contained in:
Rajat Dabade 2025-06-20 01:03:57 +05:30 committed by GitHub
parent a207be38ce
commit 8e1af4298d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 700 additions and 144 deletions

View file

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

View file

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

View file

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

View file

@ -242,6 +242,7 @@ function DraftInput({
uploadFileError={uploadFileError}
channelId={channelId}
rootId={rootId}
isEditMode={false}
/>
<View style={style.actionsContainer}>
<QuickActions

View file

@ -29,6 +29,7 @@ type Props = {
uploadFileError: React.ReactNode;
channelId: string;
rootId: string;
isEditMode: boolean;
}
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
@ -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}
/>
);
});

View file

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

View file

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

View file

@ -34,4 +34,5 @@ export default keyMirror({
JOIN_CALL_BAR_VISIBLE: null,
DRAFT_SWIPEABLE: null,
ACTIVE_SCREEN: null,
FILE_ADD_REMOVED: null,
});

View file

@ -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<EditPostContextType>({});
type EditPostProviderProps = {
children: ReactNode;
onFileRemove?: (fileId: string) => void;
};
export const EditPostProvider = ({children, onFileRemove}: EditPostProviderProps) => {
const contextValue = useMemo(() => ({
onFileRemove,
}), [onFileRemove]);
return (
<EditPostContext.Provider value={contextValue}>
{children}
</EditPostContext.Provider>
);
};
export const useEditPost = () => {
return useContext(EditPostContext);
};

View file

@ -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<FileModel>(FILE).query(
Q.where('id', Q.oneOf(fileIds)),
).fetch();
return records;
} catch {
return [];
}
};

View file

@ -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<typeof EditPost>[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(<EditPost {...baseProps}/>, {database, serverUrl});
expect(getByTestId('uploads')).toBeVisible();
expect(getByTestId('file-1')).toBeVisible();
expect(getByTestId('file-2')).toBeVisible();
});
});

View file

@ -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<FileInfo[]>(files || []);
const mainView = useRef<View>(null);
const postInputRef = useRef<EditPostInputRef>(null);
const postInputRef = useRef<PasteInputRef | undefined>(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 (
<>
<EditPostProvider onFileRemove={handleFileRemoval}>
<SafeAreaView
testID='edit_post.screen'
style={styles.container}
@ -246,7 +294,9 @@ const EditPost = ({componentId, maxPostSize, post, closeButtonId, hasFilesAttach
message={postMessage}
onChangeText={onInputChangeText}
onTextSelectionChange={onTextSelectionChange}
ref={postInputRef}
inputRef={postInputRef}
post={post}
postFiles={postFiles}
/>
</View>
</View>
@ -265,7 +315,7 @@ const EditPost = ({componentId, maxPostSize, post, closeButtonId, hasFilesAttach
inPost={false}
serverUrl={serverUrl}
/>
</>
</EditPostProvider>
);
};

View file

@ -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<typeof EditPostInput>[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(<EditPostInput {...baseProps}/>, {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(<EditPostInput {...baseProps}/>, {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(<EditPostInput {...props}/>, {database, serverUrl});
expect(queryByTestId('uploads')).toBeNull();
});
});

View file

@ -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<PasteInputRef | undefined>;
}
const EditPostInput = ({
message,
onChangeText,
onTextSelectionChange,
hasError,
post,
postFiles,
version,
inputRef,
}: PostInputProps) => {
const intl = useIntl();
const theme = useTheme();
const styles = getStyleSheet(theme);
const managedConfig = useManagedConfig<ManagedConfig>();
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<TextInputSelectionChangeEventData>) => {
const curPos = event.nativeEvent.selection.end;
onTextSelectionChange(curPos);
}, [onTextSelectionChange]);
const containerStyle = useMemo(() => [
styles.inputContainer,
hasError && {marginTop: 0},
], [styles, hasError]);
return (
<View style={containerStyle}>
<PasteInput
allowFontScaling={true}
disableCopyPaste={disableCopyAndPaste}
disableFullscreenUI={true}
keyboardAppearance={getKeyboardAppearanceFromTheme(theme)}
multiline={true}
onChangeText={onChangeText}
onPaste={emptyFunction}
onSelectionChange={onSelectionChange}
placeholder={intl.formatMessage({id: 'edit_post.editPost', defaultMessage: 'Edit the post...'})}
placeholderTextColor={changeOpacity(theme.centerChannelColor, 0.5)}
ref={inputRef}
smartPunctuation='disable'
submitBehavior='newline'
style={inputStyle}
testID='edit_post.message.input'
underlineColorAndroid='transparent'
value={message}
onFocus={onFocus}
onBlur={onBlur}
/>
{isMinimumServerVersion(version, MAJOR_VERSION_TO_SHOW_ATTACHMENTS, MINOR_VERSION_TO_SHOW_ATTACHMENTS) &&
<Uploads
channelId={post.channelId}
currentUserId={post.userId}
files={postFiles}
uploadFileError={undefined} // TODO: Add upload file error
rootId={post.rootId}
isEditMode={true}
/>
}
{Platform.select({ios: <ExtraKeyboard/>})}
</View>
);
};
EditPostInput.displayName = 'EditPostInput';
export default EditPostInput;

View file

@ -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<EditPostInputRef, PostInputProps>(({
message,
onChangeText,
onTextSelectionChange,
hasError,
}: PostInputProps, ref) => {
const intl = useIntl();
const theme = useTheme();
const styles = getStyleSheet(theme);
const managedConfig = useManagedConfig<ManagedConfig>();
const disableCopyAndPaste = managedConfig.copyAndPasteProtection === 'true';
const inputRef = useRef<PasteInputRef>();
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<TextInputSelectionChangeEventData>) => {
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 (
<View style={containerStyle}>
<PasteInput
allowFontScaling={true}
disableCopyPaste={disableCopyAndPaste}
disableFullscreenUI={true}
keyboardAppearance={getKeyboardAppearanceFromTheme(theme)}
multiline={true}
onChangeText={onChangeText}
onPaste={emptyFunction}
onSelectionChange={onSelectionChange}
placeholder={intl.formatMessage({id: 'edit_post.editPost', defaultMessage: 'Edit the post...'})}
placeholderTextColor={changeOpacity(theme.centerChannelColor, 0.5)}
ref={inputRef}
smartPunctuation='disable'
submitBehavior='newline'
style={inputStyle}
testID='edit_post.message.input'
underlineColorAndroid='transparent'
value={message}
onFocus={onFocus}
onBlur={onBlur}
/>
{Platform.select({ios: <ExtraKeyboard/>})}
</View>
);
});
EditPostInput.displayName = 'EditPostInput';
export default EditPostInput;
export default withDatabase(enhanced(EditPostInput));

View file

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

View file

@ -0,0 +1,121 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {ActionType, Screens} from '@constants';
import {SYSTEM_IDENTIFIERS} from '@constants/database';
import DatabaseManager from '@database/manager';
import {renderWithEverything, waitFor} from '@test/intl-test-helper';
import TestHelper from '@test/test_helper';
import EditOption from './edit_option';
import EnhancedEditOption from './index';
import type ServerDataOperator from '@database/operator/server_data_operator';
import type {Database} from '@nozbe/watermelondb';
import type PostModel from '@typings/database/models/servers/post';
jest.mock('./edit_option', () => ({
__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(
<EnhancedEditOption
post={post}
canDelete={true}
bottomSheetId={Screens.EDIT_POST}
/>,
{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(
<EnhancedEditOption
post={{id: 'post-2', message: 'test'} as PostModel}
canDelete={true}
bottomSheetId={Screens.EDIT_POST}
/>,
{database},
);
await waitFor(() => {
const editPost = getByTestId('edit-option');
expect(editPost).toBeDefined();
const files = editPost.props.files;
expect(files).toHaveLength(0);
});
});
});

View file

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

View file

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

View file

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