Ability to show quick action and add files to edit post (#8926)

* 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

* Ability to show quick action and add files to edit post

* type safety for EditPostContext

* Reverted back the post priority props

* constant shift

* Added test for QuickAction to show slashcomand

* Added test for edit_post, upload_item and upload_remove

* Added test for Edit_post_input and edit_post index

* fix the height issue between attachment and keyboard due to safeArea

* Minor: removed debugging border color

* Addressed dev review comments

* Import fixes

* Address UX comments

* Fixed props for UploadItem and remove effective Edit mode

* handled save button disabled state when uploading attachments

* handled newly added and retry file removal without alert message

* Test updated

* Added test for input_quick_action index.tsx

* Added test for not in edit mode for upload_item index

* Added test for upload_remove component when not in edit mode

* added tests for file_upload_error hook

* Added test for calling callback when in edit mode

* Test for edit post input for server version check
This commit is contained in:
Rajat Dabade 2025-06-25 16:04:08 +05:30 committed by GitHub
parent 65c0f418b0
commit dae4b75720
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 1183 additions and 180 deletions

View file

@ -1,16 +1,19 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useRef, useState} from 'react';
import React, {useCallback, useEffect, useRef} from 'react';
import {useIntl} from 'react-intl';
import {addFilesToDraft, removeDraft} from '@actions/local/draft';
import {useServerUrl} from '@context/server';
import DraftUploadManager from '@managers/draft_upload_manager';
import useFileUploadError from '@hooks/file_upload_error';
import DraftEditPostUploadManager from '@managers/draft_upload_manager';
import {fileMaxWarning, fileSizeWarning, uploadDisabledWarning} from '@utils/file';
import SendHandler from '../send_handler';
import type {ErrorHandlers} from '@typings/components/upload_error_handlers';
type Props = {
testID?: string;
channelId: string;
@ -29,11 +32,6 @@ type Props = {
}
const emptyFileList: FileInfo[] = [];
const UPLOAD_ERROR_SHOW_INTERVAL = 5000;
type ErrorHandlers = {
[clientId: string]: (() => void) | null;
}
export default function DraftHandler(props: Props) {
const {
@ -56,27 +54,14 @@ export default function DraftHandler(props: Props) {
const serverUrl = useServerUrl();
const intl = useIntl();
const [uploadError, setUploadError] = useState<React.ReactNode>(null);
const uploadErrorTimeout = useRef<NodeJS.Timeout>();
const uploadErrorHandlers = useRef<ErrorHandlers>({});
const {uploadError, newUploadError} = useFileUploadError();
const clearDraft = useCallback(() => {
removeDraft(serverUrl, channelId, rootId);
updateValue('');
}, [serverUrl, channelId, rootId]);
const newUploadError = useCallback((error: React.ReactNode) => {
if (uploadErrorTimeout.current) {
clearTimeout(uploadErrorTimeout.current);
}
setUploadError(error);
uploadErrorTimeout.current = setTimeout(() => {
setUploadError(null);
}, UPLOAD_ERROR_SHOW_INTERVAL);
}, []);
const addFiles = useCallback((newFiles: FileInfo[]) => {
if (!newFiles.length) {
return;
@ -103,8 +88,8 @@ export default function DraftHandler(props: Props) {
addFilesToDraft(serverUrl, channelId, rootId, newFiles);
for (const file of newFiles) {
DraftUploadManager.prepareUpload(serverUrl, file, channelId, rootId);
uploadErrorHandlers.current[file.clientId!] = DraftUploadManager.registerErrorHandler(file.clientId!, newUploadError);
DraftEditPostUploadManager.prepareUpload(serverUrl, file, channelId, rootId);
uploadErrorHandlers.current[file.clientId!] = DraftEditPostUploadManager.registerErrorHandler(file.clientId!, newUploadError);
}
newUploadError(null);
@ -115,7 +100,7 @@ export default function DraftHandler(props: Props) {
useEffect(() => {
let loadingFiles: FileInfo[] = [];
if (files) {
loadingFiles = files.filter((v) => v.clientId && DraftUploadManager.isUploading(v.clientId));
loadingFiles = files.filter((v) => v.clientId && DraftEditPostUploadManager.isUploading(v.clientId));
}
for (const key of Object.keys(uploadErrorHandlers.current)) {
@ -127,7 +112,7 @@ export default function DraftHandler(props: Props) {
for (const file of loadingFiles) {
if (!uploadErrorHandlers.current[file.clientId!]) {
uploadErrorHandlers.current[file.clientId!] = DraftUploadManager.registerErrorHandler(file.clientId!, newUploadError);
uploadErrorHandlers.current[file.clientId!] = DraftEditPostUploadManager.registerErrorHandler(file.clientId!, newUploadError);
}
}
}, [files]);

View file

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

View file

@ -0,0 +1,55 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {fireEvent, renderWithIntlAndTheme} from '@test/intl-test-helper';
import InputQuickAction from '.';
describe('InputQuickAction', () => {
it('should add If theres existing text and it doesnt end with a space, add a space before @', () => {
const updateValue = jest.fn();
const testID = 'test-id';
const inputType = 'at';
const focus = jest.fn();
const {getByTestId} = renderWithIntlAndTheme(
<InputQuickAction
testID={testID}
updateValue={updateValue}
inputType={inputType}
focus={focus}
/>);
const icon = getByTestId('test-id');
fireEvent.press(icon);
expect(updateValue).toHaveBeenCalledWith(expect.any(Function));
const updateFunction = updateValue.mock.calls[0][0];
expect(updateFunction('')).toBe('@');
expect(focus).toHaveBeenCalled();
});
it('should add space before @ if there is existing text and it doesnt end with a space', () => {
const updateValue = jest.fn();
const testID = 'test-id';
const inputType = 'at';
const focus = jest.fn();
const {getByTestId} = renderWithIntlAndTheme(
<InputQuickAction
testID={testID}
updateValue={updateValue}
inputType={inputType}
focus={focus}
/>);
const icon = getByTestId('test-id');
fireEvent.press(icon);
expect(updateValue).toHaveBeenCalledWith(expect.any(Function));
const updateFunction = updateValue.mock.calls[0][0];
expect(updateFunction('Hello')).toBe('Hello @');
expect(focus).toHaveBeenCalled();
});
});

View file

@ -41,12 +41,16 @@ export default function InputQuickAction({
const onPress = useCallback(() => {
updateValue((v) => {
if (inputType === 'at') {
// If there's existing text and it doesn't end with a space, add a space before @
if (v.length > 0 && !v.endsWith(' ')) {
return `${v} @`;
}
return `${v}@`;
}
return '/';
});
focus();
}, [inputType]);
}, [inputType, updateValue, focus]);
const actionTestID = disabled ?
`${testID}.disabled` :

View file

@ -0,0 +1,44 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {PostPriorityType} from '@constants/post';
import {renderWithIntlAndTheme} from '@test/intl-test-helper';
import QuickActions from './quick_actions';
describe('Quick Actions', () => {
const baseProps: Parameters<typeof QuickActions>[0] = {
canUploadFiles: true,
fileCount: 0,
isPostPriorityEnabled: true,
canShowPostPriority: true,
canShowSlashCommands: true,
maxFileCount: 10,
value: '',
updateValue: jest.fn(),
addFiles: jest.fn(),
postPriority: {
priority: PostPriorityType.STANDARD,
},
updatePostPriority: jest.fn(),
focus: jest.fn(),
};
it('Should render slash command if canShowSlashCommands is true', () => {
const props = {
...baseProps,
canShowSlashCommands: true,
};
const {queryByTestId} = renderWithIntlAndTheme(<QuickActions {...props}/>);
expect(queryByTestId('slash-input-action')).toBeDefined();
});
it('Should not render slash command if canShowSlashCommands is false', () => {
const props = {
...baseProps,
canShowSlashCommands: false,
};
const {queryByTestId} = renderWithIntlAndTheme(<QuickActions {...props}/>);
expect(queryByTestId('slash-input-action')).toBeNull();
});
});

View file

@ -16,6 +16,7 @@ type Props = {
fileCount: number;
isPostPriorityEnabled: boolean;
canShowPostPriority?: boolean;
canShowSlashCommands?: boolean;
maxFileCount: number;
// Draft Handler
@ -27,11 +28,13 @@ type Props = {
focus: () => void;
}
export const QUICK_ACTIONS_HEIGHT = 44;
const style = StyleSheet.create({
quickActionsContainer: {
display: 'flex',
flexDirection: 'row',
height: 44,
height: QUICK_ACTIONS_HEIGHT,
},
});
@ -41,6 +44,7 @@ export default function QuickActions({
value,
fileCount,
isPostPriorityEnabled,
canShowSlashCommands = true,
canShowPostPriority,
maxFileCount,
updateValue,
@ -79,13 +83,15 @@ export default function QuickActions({
updateValue={updateValue}
focus={focus}
/>
<InputAction
testID={slashInputActionTestID}
disabled={slashDisabled}
inputType='slash'
updateValue={updateValue}
focus={focus}
/>
{canShowSlashCommands && (
<InputAction
testID={slashInputActionTestID}
disabled={slashDisabled}
inputType='slash'
updateValue={updateValue}
focus={focus}
/>
)}
<FileAction
testID={fileActionTestID}
{...uploadProps}

View file

@ -12,7 +12,7 @@ import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-nati
import {GalleryInit} from '@context/gallery';
import {useTheme} from '@context/theme';
import DraftUploadManager from '@managers/draft_upload_manager';
import DraftEditPostUploadManager from '@managers/draft_upload_manager';
import {fileToGalleryItem, openGalleryAtIndex} from '@utils/gallery';
import {makeStyleSheetFromTheme} from '@utils/theme';
@ -29,7 +29,6 @@ type Props = {
uploadFileError: React.ReactNode;
channelId: string;
rootId: string;
isEditMode: boolean;
}
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
@ -75,7 +74,6 @@ function Uploads({
uploadFileError,
channelId,
rootId,
isEditMode,
}: Props) {
const galleryIdentifier = `${channelId}-uploadedItems-${rootId}`;
const theme = useTheme();
@ -83,7 +81,7 @@ function Uploads({
const errorHeight = useSharedValue(ERROR_HEIGHT_MIN);
const containerHeight = useSharedValue(files.length ? CONTAINER_HEIGHT_MAX : CONTAINER_HEIGHT_MIN);
const filesForGallery = useRef(files.filter((f) => !f.failed && !DraftUploadManager.isUploading(f.clientId!)));
const filesForGallery = useRef(files.filter((f) => !f.failed && !DraftEditPostUploadManager.isUploading(f.clientId!)));
const hasFiles = files.length > 0;
const errorAnimatedStyle = useAnimatedStyle(() => {
@ -103,7 +101,7 @@ function Uploads({
}), [files.length]);
useEffect(() => {
filesForGallery.current = files.filter((f) => !f.failed && !DraftUploadManager.isUploading(f.clientId!));
filesForGallery.current = files.filter((f) => !f.failed && !DraftEditPostUploadManager.isUploading(f.clientId!));
}, [files]);
useEffect(() => {
@ -139,7 +137,6 @@ function Uploads({
key={file.clientId || file.id}
openGallery={openGallery}
rootId={rootId}
isEditMode={isEditMode}
/>
);
});

View file

@ -0,0 +1,130 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {updateDraftFile} from '@actions/local/draft';
import {EditPostProvider} from '@context/edit_post';
import DraftEditPostUploadManager from '@managers/draft_upload_manager';
import {fireEvent, renderWithEverything} from '@test/intl-test-helper';
import TestHelper from '@test/test_helper';
import UploadItem from '.';
import type {Database} from '@nozbe/watermelondb';
jest.mock('@actions/local/draft', () => ({
updateDraftFile: jest.fn(),
}));
jest.mock('@managers/draft_upload_manager', () => ({
prepareUpload: jest.fn(),
isUploading: jest.fn(() => false),
registerProgressHandler: jest.fn(() => jest.fn()),
registerErrorHandler: jest.fn(() => jest.fn()),
cancel: jest.fn(),
}));
jest.mock('@context/server', () => ({
useServerUrl: () => 'serverUrl',
}));
describe('UploadItem', () => {
const serverUrl = 'serverUrl';
let database: Database;
beforeEach(async () => {
jest.clearAllMocks();
database = (await TestHelper.setupServerDatabase(serverUrl)).database;
});
const baseProps: Parameters<typeof UploadItem>[0] = {
channelId: 'channelId',
galleryIdentifier: 'galleryIdentifier',
index: 0,
file: {
id: 'fileId',
name: 'fileName',
extension: 'extension',
has_preview_image: false,
height: 0,
mime_type: 'mime_type',
size: 0,
width: 0,
bytesRead: 0,
clientId: 'clientId',
failed: false,
} as FileInfo,
openGallery: jest.fn(),
rootId: 'rootId' as string,
};
it('When file is failed, onclick of retry button, it should call prepareUpload with correct arguments in edit mode', () => {
const updateFileCallback = jest.fn();
const failedFile = {
...baseProps.file,
failed: true,
} as FileInfo;
const props = {
...baseProps,
file: failedFile,
};
const {getByTestId} = renderWithEverything(
<EditPostProvider
isEditMode={true}
updateFileCallback={updateFileCallback}
>
<UploadItem {...props}/>
</EditPostProvider>,
{database},
);
fireEvent.press(getByTestId('retry-button'));
const expectedResetFile = {...failedFile, failed: false};
expect(updateFileCallback).toHaveBeenCalledWith(expectedResetFile);
expect(DraftEditPostUploadManager.prepareUpload).toHaveBeenCalledWith(
serverUrl,
expectedResetFile,
props.channelId,
props.rootId,
expectedResetFile.bytesRead,
true,
updateFileCallback,
);
expect(updateDraftFile).not.toHaveBeenCalled();
});
it('When file is failed, onclick of retry button, it should call prepareUpload with correct arguments in draft mode', () => {
const updateFileCallback = jest.fn();
const failedFile = {
...baseProps.file,
failed: true,
} as FileInfo;
const props = {
...baseProps,
file: failedFile,
};
const {getByTestId} = renderWithEverything(
<EditPostProvider
isEditMode={false}
updateFileCallback={updateFileCallback}
>
<UploadItem {...props}/>
</EditPostProvider>,
{database},
);
fireEvent.press(getByTestId('retry-button'));
const expectedResetFile = {...failedFile, failed: false};
expect(updateDraftFile).toHaveBeenCalledWith(serverUrl, props.channelId, props.rootId, expectedResetFile);
expect(DraftEditPostUploadManager.prepareUpload).toHaveBeenCalledWith(serverUrl, expectedResetFile, props.channelId, props.rootId, expectedResetFile.bytesRead);
});
});

View file

@ -9,11 +9,12 @@ import {updateDraftFile} from '@actions/local/draft';
import FileIcon from '@components/files/file_icon';
import ImageFile from '@components/files/image_file';
import ProgressBar from '@components/progress_bar';
import {useEditPost} from '@context/edit_post';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import useDidUpdate from '@hooks/did_update';
import {useGalleryItem} from '@hooks/gallery';
import DraftUploadManager from '@managers/draft_upload_manager';
import DraftEditPostUploadManager from '@managers/draft_upload_manager';
import {isImage} from '@utils/file';
import {changeOpacity} from '@utils/theme';
@ -27,7 +28,6 @@ type Props = {
file: FileInfo;
openGallery: (file: FileInfo) => void;
rootId: string;
isEditMode: boolean;
}
const style = StyleSheet.create({
@ -62,14 +62,15 @@ const style = StyleSheet.create({
export default function UploadItem({
channelId, galleryIdentifier, index, file,
rootId, openGallery, isEditMode,
rootId, openGallery,
}: Props) {
const theme = useTheme();
const serverUrl = useServerUrl();
const removeCallback = useRef<(() => void)|null>(null);
const removeCallback = useRef<(() => void) | undefined>(undefined);
const [progress, setProgress] = useState(0);
const {updateFileCallback, isEditMode} = useEditPost();
const loading = DraftUploadManager.isUploading(file.clientId!);
const loading = DraftEditPostUploadManager.isUploading(file.clientId!);
const handlePress = useCallback(() => {
openGallery(file);
@ -77,21 +78,21 @@ export default function UploadItem({
useEffect(() => {
if (file.clientId) {
removeCallback.current = DraftUploadManager.registerProgressHandler(file.clientId, setProgress);
removeCallback.current = DraftEditPostUploadManager.registerProgressHandler(file.clientId, setProgress);
}
return () => {
removeCallback.current?.();
removeCallback.current = null;
removeCallback.current = undefined;
};
}, []);
useDidUpdate(() => {
if (loading && file.clientId) {
removeCallback.current = DraftUploadManager.registerProgressHandler(file.clientId, setProgress);
removeCallback.current = DraftEditPostUploadManager.registerProgressHandler(file.clientId, setProgress);
}
return () => {
removeCallback.current?.();
removeCallback.current = null;
removeCallback.current = undefined;
};
}, [file.failed, file.id]);
@ -103,10 +104,26 @@ export default function UploadItem({
const newFile = {...file};
newFile.failed = false;
updateDraftFile(serverUrl, channelId, rootId, newFile);
DraftUploadManager.prepareUpload(serverUrl, newFile, channelId, rootId, newFile.bytesRead);
DraftUploadManager.registerProgressHandler(newFile.clientId!, setProgress);
}, [serverUrl, channelId, rootId, file]);
if (isEditMode && updateFileCallback) {
// In edit mode, use the context callback to update the file
updateFileCallback(newFile);
DraftEditPostUploadManager.prepareUpload(
serverUrl,
newFile,
channelId,
rootId,
newFile.bytesRead,
true, // isEditPost = true
updateFileCallback,
);
} else {
// In draft mode, use the draft file system
updateDraftFile(serverUrl, channelId, rootId, newFile);
DraftEditPostUploadManager.prepareUpload(serverUrl, newFile, channelId, rootId, newFile.bytesRead);
}
DraftEditPostUploadManager.registerProgressHandler(newFile.clientId!, setProgress);
}, [serverUrl, channelId, rootId, file, isEditMode, updateFileCallback]);
const {styles, onGestureEvent, ref} = useGalleryItem(galleryIdentifier, index, handlePress);
@ -160,7 +177,6 @@ export default function UploadItem({
clientId={file.clientId!}
channelId={channelId}
rootId={rootId}
isEditMode={isEditMode}
fileId={file.id!}
/>
</View>

View file

@ -0,0 +1,67 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {removeDraftFile} from '@actions/local/draft';
import {fireEvent, renderWithEverything} from '@test/intl-test-helper';
import TestHelper from '@test/test_helper';
import UploadRemove from './upload_remove';
import type {Database} from '@nozbe/watermelondb';
jest.mock('@context/edit_post', () => ({
useEditPost: jest.fn(),
}));
jest.mock('@actions/local/draft', () => ({
removeDraftFile: jest.fn(),
}));
const {useEditPost} = require('@context/edit_post');
describe('UploadRemove', () => {
const serverUrl = 'serverUrl';
let database: Database;
beforeEach(async () => {
database = (await TestHelper.setupServerDatabase(serverUrl)!).database;
});
it('should call onFileRemove when isEditMode is true', () => {
const onFileRemove = jest.fn();
jest.mocked(useEditPost).mockReturnValue({
onFileRemove,
isEditMode: true,
});
const {getByTestId} = renderWithEverything(
<UploadRemove
channelId='channelId'
rootId='rootId'
clientId='clientId'
fileId='fileId'
/>,
{database},
);
fireEvent.press(getByTestId('remove-button-fileId'));
expect(onFileRemove).toHaveBeenCalledWith('fileId');
});
it('should call removeDraftFile when isEditMode is false', () => {
jest.mocked(useEditPost).mockReturnValue({
isEditMode: false,
});
const {getByTestId} = renderWithEverything(
<UploadRemove
channelId='channelId'
rootId='rootId'
clientId='clientId'
fileId='fileId'
/>,
{database, serverUrl},
);
fireEvent.press(getByTestId('remove-button-fileId'));
expect(removeDraftFile).toHaveBeenCalledWith(serverUrl, 'channelId', 'rootId', 'clientId');
});
});

View file

@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import React, {useCallback} from 'react';
import {View, Platform} from 'react-native';
import {removeDraftFile} from '@actions/local/draft';
@ -10,14 +10,13 @@ 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';
import DraftEditPostUploadManager from '@managers/draft_upload_manager';
import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme';
type Props = {
channelId: string;
rootId: string;
clientId: string;
isEditMode: boolean;
fileId: string;
}
@ -49,28 +48,28 @@ export default function UploadRemove({
channelId,
rootId,
clientId,
isEditMode,
fileId,
}: Props) {
const theme = useTheme();
const style = getStyleSheet(theme);
const serverUrl = useServerUrl();
const {onFileRemove} = useEditPost();
const {onFileRemove, isEditMode} = useEditPost();
const onPress = () => {
const onPress = useCallback(() => {
if (isEditMode) {
onFileRemove?.(fileId);
onFileRemove?.(fileId || clientId);
return;
}
DraftUploadManager.cancel(clientId);
DraftEditPostUploadManager.cancel(clientId);
removeDraftFile(serverUrl, channelId, rootId, clientId);
};
}, [onFileRemove, isEditMode, fileId, clientId, serverUrl, channelId, rootId]);
return (
<TouchableWithFeedback
style={style.tappableContainer}
onPress={onPress}
type={'opacity'}
testID={`remove-button-${fileId}`}
>
<View style={style.removeButton}>
<CompassIcon

View file

@ -31,6 +31,7 @@ export default function UploadRetry({
style={style.failed}
onPress={onPress}
type='opacity'
testID='retry-button'
>
<CompassIcon
name='refresh'

View file

@ -47,4 +47,6 @@ Files.DOCUMENT_TYPES = Files.WORD_TYPES.concat(Files.PDF_TYPES, Files.TEXT_TYPES
export const PROGRESS_TIME_TO_STORE = toMilliseconds({seconds: 60});
export const UPLOAD_ERROR_SHOW_INTERVAL = 5000;
export default Files;

View file

@ -5,19 +5,29 @@ import React, {createContext, useContext, useMemo, type ReactNode} from 'react';
type EditPostContextType = {
onFileRemove?: (fileId: string) => void;
updateFileCallback?: (fileInfo: FileInfo) => void;
isEditMode: boolean;
};
const EditPostContext = createContext<EditPostContextType>({});
const EditPostContext = createContext<EditPostContextType>({
onFileRemove: undefined,
updateFileCallback: undefined,
isEditMode: false,
});
type EditPostProviderProps = {
children: ReactNode;
onFileRemove?: (fileId: string) => void;
updateFileCallback?: (fileInfo: FileInfo) => void;
isEditMode: boolean;
};
export const EditPostProvider = ({children, onFileRemove}: EditPostProviderProps) => {
export const EditPostProvider = ({children, onFileRemove, updateFileCallback, isEditMode}: EditPostProviderProps) => {
const contextValue = useMemo(() => ({
onFileRemove,
}), [onFileRemove]);
updateFileCallback,
isEditMode,
}), [onFileRemove, updateFileCallback, isEditMode]);
return (
<EditPostContext.Provider value={contextValue}>

View file

@ -0,0 +1,138 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {renderHook, act} from '@testing-library/react-hooks';
import {UPLOAD_ERROR_SHOW_INTERVAL} from '@constants/files';
import useFileUploadError from './file_upload_error';
jest.useFakeTimers();
describe('useFileUploadError', () => {
afterEach(() => {
jest.clearAllTimers();
});
it('should initialize with no upload error', () => {
const {result} = renderHook(() => useFileUploadError());
expect(result.current.uploadError).toBeNull();
expect(typeof result.current.newUploadError).toBe('function');
});
it('should set upload error when newUploadError is called', () => {
const {result} = renderHook(() => useFileUploadError());
const errorMessage = 'Upload failed';
act(() => {
result.current.newUploadError(errorMessage);
});
expect(result.current.uploadError).toBe(errorMessage);
});
it('should clear upload error after timeout', () => {
const {result} = renderHook(() => useFileUploadError());
const errorMessage = 'Upload failed';
act(() => {
result.current.newUploadError(errorMessage);
});
expect(result.current.uploadError).toBe(errorMessage);
act(() => {
jest.advanceTimersByTime(UPLOAD_ERROR_SHOW_INTERVAL);
});
expect(result.current.uploadError).toBeNull();
});
it('should handle different types of error messages', () => {
const {result} = renderHook(() => useFileUploadError());
const complexErrorMessage = 'Complex error: File size too large (5MB), maximum allowed is 2MB';
act(() => {
result.current.newUploadError(complexErrorMessage);
});
expect(result.current.uploadError).toBe(complexErrorMessage);
});
it('should clear previous timeout when new error is set', () => {
const {result} = renderHook(() => useFileUploadError());
const firstError = 'First error';
const secondError = 'Second error';
// Set first error
act(() => {
result.current.newUploadError(firstError);
});
expect(result.current.uploadError).toBe(firstError);
// Set second error before first timeout completes
act(() => {
jest.advanceTimersByTime(2000); // Advance partway through first timeout
result.current.newUploadError(secondError);
});
expect(result.current.uploadError).toBe(secondError);
// Advance to where first timeout would have completed
act(() => {
jest.advanceTimersByTime(3000); // Total 5000ms from first error
});
// Error should still be second error (first timeout was cleared)
expect(result.current.uploadError).toBe(secondError);
// Complete second timeout
act(() => {
jest.advanceTimersByTime(2000); // Complete 5000ms from second error
});
expect(result.current.uploadError).toBeNull();
});
it('should not clear error before timeout completes', () => {
const {result} = renderHook(() => useFileUploadError());
const errorMessage = 'Upload failed';
act(() => {
result.current.newUploadError(errorMessage);
});
expect(result.current.uploadError).toBe(errorMessage);
// Advance time but not to full timeout
act(() => {
jest.advanceTimersByTime(UPLOAD_ERROR_SHOW_INTERVAL - 1000);
});
// Error should still be present
expect(result.current.uploadError).toBe(errorMessage);
});
it('should handle multiple consecutive errors correctly', () => {
const {result} = renderHook(() => useFileUploadError());
const error1 = 'Error 1';
const error2 = 'Error 2';
const error3 = 'Error 3';
// Set first error
act(() => {
result.current.newUploadError(error1);
});
expect(result.current.uploadError).toBe(error1);
// Quickly set second error
act(() => {
result.current.newUploadError(error2);
});
expect(result.current.uploadError).toBe(error2);
// Quickly set third error
act(() => {
result.current.newUploadError(error3);
});
expect(result.current.uploadError).toBe(error3);
// Only the last timeout should be active
act(() => {
jest.advanceTimersByTime(UPLOAD_ERROR_SHOW_INTERVAL);
});
expect(result.current.uploadError).toBeNull();
});
});

View file

@ -0,0 +1,29 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useCallback, useRef, useState} from 'react';
import {UPLOAD_ERROR_SHOW_INTERVAL} from '@constants/files';
const useFileUploadError = () => {
const [uploadError, setUploadError] = useState<React.ReactNode>(null);
const uploadErrorTimeout = useRef<NodeJS.Timeout>();
const newUploadError = useCallback((error: React.ReactNode) => {
if (uploadErrorTimeout.current) {
clearTimeout(uploadErrorTimeout.current);
}
setUploadError(error);
uploadErrorTimeout.current = setTimeout(() => {
setUploadError(null);
}, UPLOAD_ERROR_SHOW_INTERVAL);
}, []);
return {
uploadError,
newUploadError,
};
};
export default useFileUploadError;

View file

@ -14,7 +14,7 @@ import {exportedForTesting} from '.';
import type ServerDataOperator from '@database/operator/server_data_operator';
import type {ClientResponse, ProgressPromise} from '@mattermost/react-native-network-client';
const {DraftUploadManagerSingleton} = exportedForTesting;
const {DraftEditPostUploadManagerSingleton} = exportedForTesting;
const url = 'baseHandler.test.com';
const mockClient = TestHelper.createClient();
@ -79,7 +79,7 @@ describe('draft upload manager', () => {
});
it('File is uploaded and stored', async () => {
const manager = new DraftUploadManagerSingleton();
const manager = new DraftEditPostUploadManagerSingleton();
const uploadMocks = mockUpload();
const fileClientId = 'clientId';
@ -103,7 +103,7 @@ describe('draft upload manager', () => {
});
it('Progress is not stored on progress, but stored on fail', async () => {
const manager = new DraftUploadManagerSingleton();
const manager = new DraftEditPostUploadManagerSingleton();
const uploadMocks = mockUpload();
const fileClientId = 'clientId';
@ -158,7 +158,7 @@ describe('draft upload manager', () => {
const spyNow = jest.spyOn(Date, 'now');
spyNow.mockImplementation(() => now);
AppState.currentState = 'active';
const manager = new DraftUploadManagerSingleton();
const manager = new DraftEditPostUploadManagerSingleton();
const progressFunc: {[fileUrl: string] : ((fractionCompleted: number, bytesRead?: number | null | undefined) => void)} = {};
const cancel = jest.fn();
@ -255,7 +255,7 @@ describe('draft upload manager', () => {
});
it('Error on complete: Received wrong response code', async () => {
const manager = new DraftUploadManagerSingleton();
const manager = new DraftEditPostUploadManagerSingleton();
const uploadMocks = mockUpload();
const fileClientId = 'clientId';
@ -280,7 +280,7 @@ describe('draft upload manager', () => {
});
it('Error on complete: Received no data', async () => {
const manager = new DraftUploadManagerSingleton();
const manager = new DraftEditPostUploadManagerSingleton();
const uploadMocks = mockUpload();
const clientId = 'clientId';
@ -304,7 +304,7 @@ describe('draft upload manager', () => {
});
it('Error on complete: Received no file info', async () => {
const manager = new DraftUploadManagerSingleton();
const manager = new DraftEditPostUploadManagerSingleton();
const uploadMocks = mockUpload();
const clientId = 'clientId';
@ -328,7 +328,7 @@ describe('draft upload manager', () => {
});
it('Progress handler', async () => {
const manager = new DraftUploadManagerSingleton();
const manager = new DraftEditPostUploadManagerSingleton();
const uploadMocks = mockUpload();
const clientId = 'clientId';
@ -336,14 +336,14 @@ describe('draft upload manager', () => {
const nullProgressHandler = jest.fn();
let cancelProgressHandler = manager.registerProgressHandler(clientId, nullProgressHandler);
expect(cancelProgressHandler).toBeNull();
expect(cancelProgressHandler).toBeUndefined();
manager.prepareUpload(url, {clientId, localPath: 'path1'} as FileInfo, channelId, rootId, 0);
expect(manager.isUploading(clientId)).toBe(true);
const progressHandler = jest.fn();
cancelProgressHandler = manager.registerProgressHandler(clientId, progressHandler);
expect(cancelProgressHandler).not.toBeNull();
expect(cancelProgressHandler).not.toBeUndefined();
let bytesRead = 200;
uploadMocks.progressFunc!(0.1, bytesRead);
@ -369,7 +369,7 @@ describe('draft upload manager', () => {
});
it('Error handler: normal error', async () => {
const manager = new DraftUploadManagerSingleton();
const manager = new DraftEditPostUploadManagerSingleton();
const uploadMocks = mockUpload();
const clientId = 'clientId';
@ -377,14 +377,14 @@ describe('draft upload manager', () => {
const nullErrorHandler = jest.fn();
let cancelErrorHandler = manager.registerProgressHandler(clientId, nullErrorHandler);
expect(cancelErrorHandler).toBeNull();
expect(cancelErrorHandler).toBeUndefined();
manager.prepareUpload(url, {clientId, localPath: 'path1'} as FileInfo, channelId, rootId, 0);
expect(manager.isUploading(clientId)).toBe(true);
const errorHandler = jest.fn();
cancelErrorHandler = manager.registerErrorHandler(clientId, errorHandler);
expect(cancelErrorHandler).not.toBeNull();
expect(cancelErrorHandler).not.toBeUndefined();
uploadMocks.rejectPromise!({message: 'error'});
@ -405,7 +405,7 @@ describe('draft upload manager', () => {
});
it('Error handler: complete error', async () => {
const manager = new DraftUploadManagerSingleton();
const manager = new DraftEditPostUploadManagerSingleton();
const uploadMocks = mockUpload();
const clientId = 'clientId';
@ -413,14 +413,14 @@ describe('draft upload manager', () => {
const nullErrorHandler = jest.fn();
let cancelErrorHandler = manager.registerProgressHandler(clientId, nullErrorHandler);
expect(cancelErrorHandler).toBeNull();
expect(cancelErrorHandler).toBeUndefined();
manager.prepareUpload(url, {clientId, localPath: 'path1'} as FileInfo, channelId, rootId, 0);
expect(manager.isUploading(clientId)).toBe(true);
const errorHandler = jest.fn();
cancelErrorHandler = manager.registerErrorHandler(clientId, errorHandler);
expect(cancelErrorHandler).not.toBeNull();
expect(cancelErrorHandler).not.toBeUndefined();
uploadMocks.resolvePromise!({ok: true, code: 500});
@ -437,4 +437,23 @@ describe('draft upload manager', () => {
expect(errorHandler).toHaveBeenCalledTimes(1);
expect(nullErrorHandler).not.toHaveBeenCalled();
});
it('should call updateFileCallback when isEditPost is true', async () => {
const manager = new DraftEditPostUploadManagerSingleton();
const uploadMocks = mockUpload();
const updateFileCallback = jest.fn();
const updateDraftFileSpy = jest.spyOn(require('@actions/local/draft'), 'updateDraftFile');
const clientId = 'clientId';
manager.prepareUpload(url, {clientId, localPath: 'path1'} as FileInfo, channelId, rootId, 0, true, updateFileCallback);
expect(manager.isUploading(clientId)).toBe(true);
uploadMocks.rejectPromise!('Upload failed');
await new Promise(process.nextTick);
expect(updateFileCallback).toHaveBeenCalledWith({clientId, localPath: 'path1', failed: true});
expect(updateDraftFileSpy).not.toHaveBeenCalled();
expect(manager.isUploading(clientId)).toBe(false);
updateDraftFileSpy.mockRestore();
});
});

View file

@ -20,10 +20,12 @@ type FileHandler = {
lastTimeStored: number;
onError: Array<(msg: string) => void>;
onProgress: Array<(p: number, b: number) => void>;
isEditPost?: boolean;
updateFileCallback?: (fileInfo: FileInfo) => void;
};
}
class DraftUploadManagerSingleton {
class DraftEditPostUploadManagerSingleton {
private handlers: FileHandler = {};
private previousAppState: AppStateStatus;
@ -38,6 +40,8 @@ class DraftUploadManagerSingleton {
channelId: string,
rootId: string,
skipBytes = 0,
isEditPost = false,
updateFileCallback?: (fileInfo: FileInfo) => void,
) => {
this.handlers[file.clientId!] = {
fileInfo: file,
@ -47,6 +51,8 @@ class DraftUploadManagerSingleton {
lastTimeStored: 0,
onError: [],
onProgress: [],
isEditPost,
updateFileCallback,
};
const onProgress = (progress: number, bytesRead?: number | null | undefined) => {
@ -83,7 +89,7 @@ class DraftUploadManagerSingleton {
public registerProgressHandler = (clientId: string, callback: (progress: number, bytes: number) => void) => {
if (!this.handlers[clientId]) {
return null;
return undefined;
}
this.handlers[clientId].onProgress.push(callback);
@ -98,7 +104,7 @@ class DraftUploadManagerSingleton {
public registerErrorHandler = (clientId: string, callback: (errMessage: string) => void) => {
if (!this.handlers[clientId]) {
return null;
return undefined;
}
this.handlers[clientId].onError.push(callback);
@ -121,12 +127,12 @@ class DraftUploadManagerSingleton {
h.onProgress.forEach((c) => c(progress, bytes));
if (AppState.currentState !== 'active' && h.lastTimeStored + PROGRESS_TIME_TO_STORE < Date.now()) {
updateDraftFile(h.serverUrl, h.channelId, h.rootId, this.handlers[clientId].fileInfo);
this.handleUpdateDraftFile(h, this.handlers[clientId].fileInfo, h.isEditPost || false);
h.lastTimeStored = Date.now();
}
};
private handleComplete = (response: ClientResponse, clientId: string) => {
private handleComplete = async (response: ClientResponse, clientId: string) => {
const h = this.handlers[clientId];
if (!h) {
return;
@ -151,10 +157,10 @@ class DraftUploadManagerSingleton {
fileInfo.clientId = h.fileInfo.clientId;
fileInfo.localPath = h.fileInfo.localPath;
updateDraftFile(h.serverUrl, h.channelId, h.rootId, fileInfo);
await this.handleUpdateDraftFile(h, fileInfo, h.isEditPost || false);
};
private handleError = (errorMessage: string, clientId: string) => {
private handleError = async (errorMessage: string, clientId: string) => {
const h = this.handlers[clientId];
if (!h) {
return;
@ -166,7 +172,15 @@ class DraftUploadManagerSingleton {
const fileInfo = {...h.fileInfo};
fileInfo.failed = true;
updateDraftFile(h.serverUrl, h.channelId, h.rootId, fileInfo);
await this.handleUpdateDraftFile(h, fileInfo, h.isEditPost || false);
};
private handleUpdateDraftFile = async (handler: FileHandler[string], fileInfo: FileInfo, isEditPost: boolean) => {
if (isEditPost && handler.updateFileCallback) {
handler.updateFileCallback(fileInfo);
} else {
await updateDraftFile(handler.serverUrl, handler.channelId, handler.rootId, fileInfo);
}
};
private onAppStateChange = async (appState: AppStateStatus) => {
@ -180,15 +194,15 @@ class DraftUploadManagerSingleton {
private storeProgress = async () => {
for (const h of Object.values(this.handlers)) {
// eslint-disable-next-line no-await-in-loop
await updateDraftFile(h.serverUrl, h.channelId, h.rootId, h.fileInfo);
await this.handleUpdateDraftFile(h, h.fileInfo, h.isEditPost || false);
h.lastTimeStored = Date.now();
}
};
}
const DraftUploadManager = new DraftUploadManagerSingleton();
export default DraftUploadManager;
const DraftEditPostUploadManager = new DraftEditPostUploadManagerSingleton();
export default DraftEditPostUploadManager;
export const exportedForTesting = {
DraftUploadManagerSingleton,
DraftEditPostUploadManagerSingleton,
};

View file

@ -2,20 +2,89 @@
// See LICENSE.txt for license information.
import React from 'react';
import {Alert} from 'react-native';
import {renderWithEverything} from '@test/intl-test-helper';
import useFileUploadError from '@hooks/file_upload_error';
import DraftEditPostUploadManager from '@managers/draft_upload_manager';
import {fireEvent, renderWithEverything} from '@test/intl-test-helper';
import TestHelper from '@test/test_helper';
import PickerUtil from '@utils/file/file_picker';
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';
jest.mock('@hooks/file_upload_error');
jest.mock('@utils/file/file_picker');
jest.mock('@managers/draft_upload_manager', () => ({
prepareUpload: jest.fn(),
isUploading: jest.fn(() => false),
registerProgressHandler: jest.fn(() => jest.fn()),
registerErrorHandler: jest.fn(() => jest.fn()),
cancel: jest.fn(),
}));
const TEST_CONFIG = {
serverUrl: 'baseHandler.test.com',
maxPostSize: 1000,
maxFileCount: 10,
maxFileSize: 1000,
} as const;
const TEST_FILES = {
existingFile1: {
id: 'file-1',
name: 'test-1',
extension: 'png',
has_preview_image: true,
height: 100,
mime_type: 'test',
size: 100,
user_id: '1',
width: 100,
},
existingFile2: {
id: 'file-2',
name: 'test-2',
extension: 'pdf',
has_preview_image: false,
height: 100,
mime_type: 'test',
size: 100,
user_id: '1',
width: 100,
},
newFile: {
clientId: 'file-3',
id: 'file-3',
name: 'test-3',
extension: 'txt',
mime_type: 'text/plain',
size: 999,
},
smallFile: {
name: 'test.txt',
mime_type: 'text/plain',
},
largeFile: {
name: 'test.txt',
mime_type: 'text/plain',
size: 1001,
},
} as const satisfies Record<string, Partial<ExtractedFileInfo>>;
const ERROR_MESSAGES = {
uploadsDisabled: 'File uploads from mobile are disabled.',
maxFilesReached: 'Uploads limited to 1 files maximum.',
fileTooLarge: 'Files must be less than 1000 B',
confirmDelete: 'Delete attachment',
confirmDeleteMessage: 'Are you sure you want to remove test-3?',
} as const;
describe('Edit Post', () => {
let database: Database;
let mockNewUploadError: jest.Mock;
const baseProps: Parameters<typeof EditPost>[0] = {
componentId: 'EditPost',
@ -29,42 +98,57 @@ describe('Edit Post', () => {
rootId: '1',
metadata: {},
} as PostModel,
maxPostSize: 1000,
maxPostSize: TEST_CONFIG.maxPostSize,
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,
},
],
files: [TEST_FILES.existingFile1, TEST_FILES.existingFile2],
maxFileCount: TEST_CONFIG.maxFileCount,
maxFileSize: TEST_CONFIG.maxFileSize,
canUploadFiles: true,
};
const setupMocks = () => {
mockNewUploadError = jest.fn();
jest.mocked(useFileUploadError).mockReturnValue({
uploadError: null,
newUploadError: mockNewUploadError,
});
};
const setupPickerMock = (file: Partial<ExtractedFileInfo>) => {
jest.mocked(PickerUtil).mockImplementation((intl, onUploadFiles) => ({
attachFileFromFiles: jest.fn(() => {
onUploadFiles?.([file as ExtractedFileInfo]);
return Promise.resolve({error: undefined});
}),
}) as unknown as PickerUtil);
};
const renderEditPost = (props = baseProps) => {
return renderWithEverything(<EditPost {...props}/>, {
database,
serverUrl: TEST_CONFIG.serverUrl,
});
};
const triggerFileUpload = (screen: ReturnType<typeof renderEditPost>) => {
fireEvent.press(screen.getByTestId('edit_post.quick_actions.file_action'));
};
const triggerFileRemoval = (screen: ReturnType<typeof renderEditPost>, fileId: string) => {
fireEvent.press(screen.getByTestId(`remove-button-${fileId}`));
};
beforeAll(async () => {
const server = await TestHelper.setupServerDatabase(serverUrl);
const server = await TestHelper.setupServerDatabase(TEST_CONFIG.serverUrl);
database = server.database;
const operator = server.operator;
operator.handleConfigs({
configs: [
{id: 'Version', value: '10.5.0'},
{id: 'EnableFileAttachments', value: 'true'},
{id: 'EnableMobileFileUpload', value: 'true'},
],
configsToDelete: [],
prepareRecordsOnly: false,
@ -73,12 +157,93 @@ describe('Edit Post', () => {
beforeEach(() => {
jest.clearAllMocks();
setupMocks();
});
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();
describe('Rendering', () => {
it('should render existing attachments in edit mode', () => {
const screen = renderEditPost();
expect(screen.getByTestId('uploads')).toBeVisible();
expect(screen.getByTestId('file-1')).toBeVisible();
expect(screen.getByTestId('file-2')).toBeVisible();
});
});
describe('File Upload Validation', () => {
it('should show error when file uploads are disabled', () => {
setupPickerMock(TEST_FILES.smallFile);
const props = {...baseProps, canUploadFiles: false};
const screen = renderEditPost(props);
triggerFileUpload(screen);
expect(mockNewUploadError).toHaveBeenCalledWith(ERROR_MESSAGES.uploadsDisabled);
});
it('should show error when maximum file count is reached', () => {
setupPickerMock(TEST_FILES.smallFile);
const props = {...baseProps, maxFileCount: 1};
const screen = renderEditPost(props);
triggerFileUpload(screen);
expect(mockNewUploadError).toHaveBeenCalledWith(ERROR_MESSAGES.maxFilesReached);
});
it('should show error when file size exceeds limit', () => {
setupPickerMock(TEST_FILES.largeFile);
const props = {...baseProps, maxFileSize: 1000};
const screen = renderEditPost(props);
triggerFileUpload(screen);
expect(mockNewUploadError).toHaveBeenCalledWith(ERROR_MESSAGES.fileTooLarge);
});
});
describe('File Upload Integration', () => {
it('should integrate with DraftEditPostUploadManager for successful uploads', () => {
setupPickerMock(TEST_FILES.newFile);
const screen = renderEditPost();
triggerFileUpload(screen);
expect(DraftEditPostUploadManager.prepareUpload).toHaveBeenCalledWith(
TEST_CONFIG.serverUrl,
TEST_FILES.newFile,
baseProps.post.channelId,
baseProps.post.rootId,
0,
true,
expect.any(Function),
);
expect(DraftEditPostUploadManager.registerErrorHandler).toHaveBeenCalledWith(
TEST_FILES.newFile.clientId,
mockNewUploadError,
);
expect(mockNewUploadError).toHaveBeenCalledWith(null);
});
});
describe('File Removal', () => {
beforeEach(() => {
jest.mocked(DraftEditPostUploadManager.isUploading).mockReturnValue(true);
setupPickerMock(TEST_FILES.newFile);
});
it('should remove newly uploaded files without confirmation', () => {
const screen = renderEditPost();
triggerFileUpload(screen);
triggerFileRemoval(screen, TEST_FILES.newFile.id);
expect(Alert.alert).not.toHaveBeenCalled();
expect(DraftEditPostUploadManager.cancel).toHaveBeenCalledWith(TEST_FILES.newFile.clientId);
});
it('should show confirmation dialog for existing files', () => {
const screen = renderEditPost();
triggerFileRemoval(screen, TEST_FILES.existingFile1.id);
expect(Alert.alert).toHaveBeenCalledWith(
ERROR_MESSAGES.confirmDelete,
'Are you sure you want to remove test-1?',
expect.any(Array),
);
});
});
});

View file

@ -3,11 +3,13 @@
import React, {useCallback, useEffect, useRef, useState} from 'react';
import {useIntl} from 'react-intl';
import {Alert, Keyboard, type LayoutChangeEvent, Platform, SafeAreaView, View, StyleSheet} from 'react-native';
import {Alert, Keyboard, type LayoutChangeEvent, Platform, View, StyleSheet} from 'react-native';
import {SafeAreaView, type Edge} from 'react-native-safe-area-context';
import {deletePost, editPost} from '@actions/remote/post';
import Autocomplete from '@components/autocomplete';
import Loading from '@components/loading';
import {QUICK_ACTIONS_HEIGHT} from '@components/post_draft/quick_actions/quick_actions';
import {EditPostProvider} from '@context/edit_post';
import {ExtraKeyboardProvider} from '@context/extra_keyboard';
import {useServerUrl} from '@context/server';
@ -16,16 +18,20 @@ import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import {useAutocompleteDefaultAnimatedValues} from '@hooks/autocomplete';
import {useKeyboardOverlap} from '@hooks/device';
import useDidUpdate from '@hooks/did_update';
import useFileUploadError from '@hooks/file_upload_error';
import {useInputPropagation} from '@hooks/input';
import useNavButtonPressed from '@hooks/navigation_button_pressed';
import DraftEditPostUploadManager from '@managers/draft_upload_manager';
import SecurityManager from '@managers/security_manager';
import PostError from '@screens/edit_post/post_error';
import {buildNavigationButton, dismissModal, setButtons} from '@screens/navigation';
import {fileMaxWarning, fileSizeWarning, uploadDisabledWarning} from '@utils/file';
import {changeOpacity} from '@utils/theme';
import EditPostInput from './edit_post_input';
import type {PasteInputRef} from '@mattermost/react-native-paste-input';
import type {ErrorHandlers} from '@typings/components/upload_error_handlers';
import type PostModel from '@typings/database/models/servers/post';
import type {AvailableScreens} from '@typings/screens/navigation';
@ -50,6 +56,9 @@ const styles = StyleSheet.create({
const RIGHT_BUTTON = buildNavigationButton('edit-post', 'edit_post.save.button');
// Exclude bottom edge from SafeAreaView to prevent gap between attachments and keyboard.
const safeAreaEdges: Edge[] = ['top', 'left', 'right'];
type EditPostProps = {
componentId: AvailableScreens;
closeButtonId: string;
@ -58,8 +67,23 @@ type EditPostProps = {
hasFilesAttached: boolean;
canDelete: boolean;
files?: FileInfo[];
maxFileCount: number;
maxFileSize: number;
canUploadFiles: boolean;
}
const EditPost = ({componentId, maxPostSize, post, closeButtonId, hasFilesAttached, canDelete, files}: EditPostProps) => {
const EditPost = ({
componentId,
maxPostSize,
post,
closeButtonId,
hasFilesAttached,
canDelete,
files,
maxFileCount,
maxFileSize,
canUploadFiles,
}: EditPostProps) => {
const editingMessage = post.messageSource || post.message;
const [postMessage, setPostMessage] = useState(editingMessage);
const [cursorPosition, setCursorPosition] = useState(editingMessage.length);
@ -71,6 +95,7 @@ const EditPost = ({componentId, maxPostSize, post, closeButtonId, hasFilesAttach
const [postFiles, setPostFiles] = useState<FileInfo[]>(files || []);
const mainView = useRef<View>(null);
const uploadErrorHandlers = useRef<ErrorHandlers>({});
const postInputRef = useRef<PasteInputRef | undefined>(undefined);
const theme = useTheme();
@ -78,6 +103,27 @@ const EditPost = ({componentId, maxPostSize, post, closeButtonId, hasFilesAttach
const serverUrl = useServerUrl();
const shouldDeleteOnSave = !postMessage && canDelete && !hasFilesAttached;
const {uploadError, newUploadError} = useFileUploadError();
const shouldEnableSaveButton = useCallback(() => {
const loadingFiles = postFiles.filter((v) => v.clientId && DraftEditPostUploadManager.isUploading(v.clientId));
const hasUploadingFiles = loadingFiles.length > 0;
const tooLong = postMessage.trim().length > maxPostSize;
const messageChanged = editingMessage !== postMessage;
const originalFiles = files || [];
const originalFileIds = originalFiles.map((f) => f.id).sort();
const currentFileIds = postFiles.map((f) => f.id).filter((id) => id).sort();
const filesChanged = JSON.stringify(originalFileIds) !== JSON.stringify(currentFileIds);
// Enable save button if:
// 1. No files are uploading AND
// 2. No message length error AND
// 3. (Message changed OR files changed)
return !hasUploadingFiles && !tooLong && (messageChanged || filesChanged);
}, [postMessage, postFiles, editingMessage, maxPostSize, files]);
useEffect(() => {
toggleSaveButton(false);
@ -121,43 +167,164 @@ const EditPost = ({componentId, maxPostSize, post, closeButtonId, hasFilesAttach
});
}, [componentId, intl, theme]);
const handleFileRemoval = useCallback((id: string) => {
const filterFileById = (file: FileInfo) => {
return file.id !== id;
const updateFileInPostFiles = useCallback((updatedFile: FileInfo) => {
const hasSameClientId = (file: FileInfo) => {
return file.clientId === updatedFile.clientId;
};
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);
setPostFiles((prevFiles) => {
const fileIndex = prevFiles.findIndex(hasSameClientId);
if (fileIndex >= 0) {
const newFiles = [...prevFiles];
newFiles[fileIndex] = updatedFile;
return newFiles;
}
return prevFiles;
});
}, []);
const addFiles = useCallback((newFiles: FileInfo[]) => {
if (!newFiles.length) {
return;
}
if (!canUploadFiles) {
newUploadError(uploadDisabledWarning(intl));
return;
}
const currentFileCount = postFiles?.length || 0;
const availableCount = maxFileCount - currentFileCount;
if (newFiles.length > availableCount) {
newUploadError(fileMaxWarning(intl, maxFileCount));
return;
}
const largeFile = newFiles.find((file) => file.size > maxFileSize);
if (largeFile) {
newUploadError(fileSizeWarning(intl, maxFileSize));
return;
}
setPostFiles((prevFiles) => [...prevFiles, ...newFiles]);
// Start uploads for new files using the upload manager
for (const file of newFiles) {
DraftEditPostUploadManager.prepareUpload(
serverUrl,
file,
post.channelId,
post.rootId,
0,
true, // isEditPost = true
updateFileInPostFiles,
);
uploadErrorHandlers.current[file.clientId!] = DraftEditPostUploadManager.registerErrorHandler(file.clientId!, newUploadError);
}
newUploadError(null);
}, [canUploadFiles, postFiles?.length, maxFileCount, newUploadError, intl, maxFileSize, serverUrl, post.channelId, post.rootId, updateFileInPostFiles]);
const handleFileRemoval = useCallback((id: string) => {
const fileToRemove = postFiles?.find((file) => {
if (file.id && id) {
return file.id === id;
}
if (file.clientId && id) {
return file.clientId === id;
}
return false;
});
if (!fileToRemove) {
return;
}
const shouldKeepFile = (file: FileInfo) => {
if (fileToRemove.id && file.id) {
return file.id !== fileToRemove.id;
}
if (fileToRemove.clientId && file.clientId) {
return file.clientId !== fileToRemove.clientId;
}
return file !== fileToRemove;
};
const removeFileAction = () => {
if (fileToRemove.clientId && DraftEditPostUploadManager.isUploading(fileToRemove.clientId)) {
DraftEditPostUploadManager.cancel(fileToRemove.clientId);
if (uploadErrorHandlers.current[fileToRemove.clientId]) {
uploadErrorHandlers.current[fileToRemove.clientId]?.();
delete uploadErrorHandlers.current[fileToRemove.clientId];
}
}
// Remove the specific file by using a unique identifier combination
setPostFiles((prevFiles) => prevFiles?.filter(shouldKeepFile) || []);
};
const originalFiles = files || [];
const isNewlyUploadedFile = !originalFiles.some((originalFile) => {
return originalFile.id === fileToRemove.id;
});
if (isNewlyUploadedFile) {
removeFileAction();
} else {
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: fileToRemove.name || '',
}),
[
{
text: intl.formatMessage({
id: 'edit_post.delete_file.cancel',
defaultMessage: 'Cancel',
}),
style: 'cancel',
},
},
],
);
}, [intl, toggleSaveButton, postFiles]);
{
text: intl.formatMessage({
id: 'edit_post.delete_file.confirm',
defaultMessage: 'Delete',
}),
style: 'destructive',
onPress: removeFileAction,
},
],
);
}
}, [intl, postFiles, files]);
useEffect(() => {
let loadingFiles: FileInfo[] = [];
if (postFiles) {
loadingFiles = postFiles.filter((v) => v.clientId && DraftEditPostUploadManager.isUploading(v.clientId));
}
toggleSaveButton(shouldEnableSaveButton());
for (const key of Object.keys(uploadErrorHandlers.current)) {
if (!loadingFiles.find((v) => v.clientId === key)) {
uploadErrorHandlers.current[key]?.();
delete uploadErrorHandlers.current[key];
}
}
for (const file of loadingFiles) {
if (file.clientId && !uploadErrorHandlers.current[file.clientId]) {
uploadErrorHandlers.current[file.clientId] = DraftEditPostUploadManager.registerErrorHandler(file.clientId, newUploadError);
}
}
}, [postFiles, postMessage, newUploadError, shouldEnableSaveButton, toggleSaveButton]);
const onChangeTextCommon = useCallback((message: string) => {
const tooLong = message.trim().length > maxPostSize;
@ -169,8 +336,7 @@ const EditPost = ({componentId, maxPostSize, post, closeButtonId, hasFilesAttach
setErrorLine(line);
setErrorExtra(extra);
}
toggleSaveButton(editingMessage !== message && !tooLong);
}, [intl, maxPostSize, editingMessage, toggleSaveButton]);
}, [intl, maxPostSize]);
const onAutocompleteChangeText = useCallback((message: string) => {
setPostMessage(message);
@ -253,7 +419,7 @@ const EditPost = ({componentId, maxPostSize, post, closeButtonId, hasFilesAttach
useAndroidHardwareBackHandler(componentId, onClose);
const overlap = useKeyboardOverlap(mainView, containerHeight);
const autocompletePosition = overlap + AUTOCOMPLETE_SEPARATION;
const autocompletePosition = overlap + AUTOCOMPLETE_SEPARATION + QUICK_ACTIONS_HEIGHT;
const autocompleteAvailableSpace = containerHeight - autocompletePosition;
const [animatedAutocompletePosition, animatedAutocompleteAvailableSpace] = useAutocompleteDefaultAnimatedValues(autocompletePosition, autocompleteAvailableSpace);
@ -270,10 +436,15 @@ const EditPost = ({componentId, maxPostSize, post, closeButtonId, hasFilesAttach
}
return (
<EditPostProvider onFileRemove={handleFileRemoval}>
<EditPostProvider
onFileRemove={handleFileRemoval}
updateFileCallback={updateFileInPostFiles}
isEditMode={true}
>
<SafeAreaView
testID='edit_post.screen'
style={styles.container}
edges={safeAreaEdges}
onLayout={onLayout}
nativeID={SecurityManager.getShieldScreenId(componentId)}
>
@ -297,6 +468,8 @@ const EditPost = ({componentId, maxPostSize, post, closeButtonId, hasFilesAttach
inputRef={postInputRef}
post={post}
postFiles={postFiles}
addFiles={addFiles}
uploadFileError={uploadError}
/>
</View>
</View>

View file

@ -55,6 +55,8 @@ describe('EditPostInput', () => {
inputRef: {current: undefined},
onTextSelectionChange: jest.fn(),
onChangeText: jest.fn(),
addFiles: jest.fn(),
uploadFileError: undefined,
};
beforeAll(async () => {
@ -84,4 +86,32 @@ describe('EditPostInput', () => {
const {queryByTestId} = renderWithEverything(<EditPostInput {...props}/>, {database, serverUrl});
expect(queryByTestId('uploads')).toBeNull();
});
it('should not render quick actions 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('edit_post.quick_actions')).toBeNull();
expect(queryByTestId('edit_post.quick_actions.at_input_action')).toBeNull();
expect(queryByTestId('edit_post.quick_actions.file_action')).toBeNull();
expect(queryByTestId('edit_post.quick_actions.image_action')).toBeNull();
expect(queryByTestId('edit_post.quick_actions.camera_action')).toBeNull();
});
it('should not render both uploads and quick actions when server version is unsupported', () => {
const props = {...baseProps, version: '9.8.0'};
const {queryByTestId} = renderWithEverything(<EditPostInput {...props}/>, {database, serverUrl});
expect(queryByTestId('uploads')).toBeNull();
expect(queryByTestId('edit_post.quick_actions')).toBeNull();
});
it('should render quick actions in edit mode', () => {
const {getByTestId, queryByTestId} = renderWithEverything(<EditPostInput {...baseProps}/>, {database, serverUrl});
expect(getByTestId('edit_post.quick_actions')).toBeVisible();
expect(getByTestId('edit_post.quick_actions.at_input_action')).toBeVisible();
expect(getByTestId('edit_post.quick_actions.file_action')).toBeVisible();
expect(getByTestId('edit_post.quick_actions.image_action')).toBeVisible();
expect(getByTestId('edit_post.quick_actions.camera_action')).toBeVisible();
expect(queryByTestId('edit_post.quick_actions.slash_input_action')).toBeNull();
expect(queryByTestId('edit_post.quick_actions.post_priority_action')).toBeNull();
});
});

View file

@ -7,6 +7,8 @@ import React, {useCallback, useMemo} from 'react';
import {useIntl} from 'react-intl';
import {type NativeSyntheticEvent, Platform, type TextInputSelectionChangeEventData, View} from 'react-native';
import QuickActions from '@components/post_draft/quick_actions/';
import {INITIAL_PRIORITY} from '@components/post_draft/send_handler/send_handler';
import Uploads from '@components/post_draft/uploads';
import {ExtraKeyboard, useExtraKeyboardContext} from '@context/extra_keyboard';
import {useTheme} from '@context/theme';
@ -44,6 +46,8 @@ type PostInputProps = {
onTextSelectionChange: (curPos: number) => void;
onChangeText: (text: string) => void;
inputRef: React.MutableRefObject<PasteInputRef | undefined>;
addFiles: (file: FileInfo[]) => void;
uploadFileError: React.ReactNode;
}
const EditPostInput = ({
@ -55,12 +59,26 @@ const EditPostInput = ({
postFiles,
version,
inputRef,
addFiles,
uploadFileError,
}: PostInputProps) => {
const intl = useIntl();
const theme = useTheme();
const styles = getStyleSheet(theme);
const managedConfig = useManagedConfig<ManagedConfig>();
const disableCopyAndPaste = managedConfig.copyAndPasteProtection === 'true';
const focus = useCallback(() => {
inputRef.current?.focus();
}, []);
const updateValue = useCallback((valueOrUpdater: string | ((prevValue: string) => string)) => {
if (typeof valueOrUpdater === 'function') {
const newValue = valueOrUpdater(message);
onChangeText(newValue);
} else {
onChangeText(valueOrUpdater);
}
}, [message, onChangeText]);
const keyboardContext = useExtraKeyboardContext();
@ -110,14 +128,27 @@ const EditPostInput = ({
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}
/>
<>
<Uploads
channelId={post.channelId}
currentUserId={post.userId}
files={postFiles}
uploadFileError={uploadFileError}
rootId={post.rootId}
/>
<QuickActions
testID='edit_post.quick_actions'
fileCount={postFiles.length}
addFiles={addFiles}
updateValue={updateValue}
value={message}
updatePostPriority={emptyFunction}
canShowPostPriority={false}
postPriority={INITIAL_PRIORITY}
canShowSlashCommands={false}
focus={focus}
/>
</>
}
{Platform.select({ios: <ExtraKeyboard/>})}
</View>

View file

@ -0,0 +1,76 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {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 EditPost from './edit_post';
import EnhancedEditPost from './index';
import type ServerDataOperator from '@database/operator/server_data_operator';
import type {Database} from '@nozbe/watermelondb';
jest.mock('./edit_post', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mocked(EditPost).mockImplementation((props) => React.createElement('EditPost', {...props, testID: 'edit-post'}));
describe('EditPost', () => {
const serverUrl = 'server-1';
const teamId = 'team1';
let database: Database;
let operator: ServerDataOperator;
const baseProps: Parameters<typeof EnhancedEditPost>[0] = {
componentId: Screens.EDIT_POST,
closeButtonId: 'close-button',
post: TestHelper.fakePostModel({id: 'post-1'}),
canDelete: true,
files: [],
};
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: '10.6.0'},
{id: 'MaxFileCount', value: '10'},
{id: 'MaxFileSize', value: '1000'},
{id: 'CanUploadFiles', value: 'true'},
],
configsToDelete: [],
prepareRecordsOnly: false,
});
});
afterEach(async () => {
await DatabaseManager.destroyServerDatabase(serverUrl);
});
it('should pass props to the edit post component', async () => {
const {getByTestId} = renderWithEverything(<EnhancedEditPost {...baseProps}/>, {database, serverUrl});
await waitFor(() => {
const editPost = getByTestId('edit-post');
expect(editPost).toBeDefined();
expect(editPost.props.maxFileCount).toBe(10);
expect(editPost.props.maxFileSize).toBe(1000);
expect(editPost.props.canUploadFiles).toBe(true);
expect(editPost.props.hasFilesAttached).toBe(false);
});
});
});

View file

@ -5,9 +5,10 @@ import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {MAX_MESSAGE_LENGTH_FALLBACK} from '@constants/post_draft';
import {DEFAULT_SERVER_MAX_FILE_SIZE, MAX_MESSAGE_LENGTH_FALLBACK} from '@constants/post_draft';
import {observeFilesForPost} from '@queries/servers/file';
import {observeConfigIntValue} from '@queries/servers/system';
import {observeCanUploadFiles} from '@queries/servers/security';
import {observeConfigIntValue, observeMaxFileCount} from '@queries/servers/system';
import EditPost from './edit_post';
@ -16,12 +17,18 @@ import type PostModel from '@typings/database/models/servers/post';
const enhance = withObservables([], ({database, post}: WithDatabaseArgs & { post: PostModel}) => {
const maxPostSize = observeConfigIntValue(database, 'MaxPostSize', MAX_MESSAGE_LENGTH_FALLBACK);
const canUploadFiles = observeCanUploadFiles(database);
const maxFileSize = observeConfigIntValue(database, 'MaxFileSize', DEFAULT_SERVER_MAX_FILE_SIZE);
const maxFileCount = observeMaxFileCount(database);
const hasFilesAttached = observeFilesForPost(database, post.id).pipe(switchMap((files) => of$(files?.length > 0)));
return {
maxPostSize,
hasFilesAttached,
canUploadFiles,
maxFileSize,
maxFileCount,
};
});

View file

@ -0,0 +1,6 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export type ErrorHandlers = {
[clientId: string]: (() => void) | undefined;
}