* Enable exhaustive deps in the repo * Add tests for the new hooks * Update claude.md * Remove pre-commit overwrite
541 lines
21 KiB
TypeScript
541 lines
21 KiB
TypeScript
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
import React, {useCallback, useEffect, useRef, useState} from 'react';
|
|
import {useIntl} from 'react-intl';
|
|
import {Alert, Keyboard, type LayoutChangeEvent, Platform, View, StyleSheet} from 'react-native';
|
|
import {KeyboardProvider} from 'react-native-keyboard-controller';
|
|
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';
|
|
import {useTheme} from '@context/theme';
|
|
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
|
|
import {useAutocompleteDefaultAnimatedValues} from '@hooks/autocomplete';
|
|
import {useKeyboardOverlap} from '@hooks/device';
|
|
import useDidMount from '@hooks/did_mount';
|
|
import useDidUpdate from '@hooks/did_update';
|
|
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';
|
|
|
|
const AUTOCOMPLETE_SEPARATION = 8;
|
|
|
|
const styles = StyleSheet.create({
|
|
body: {
|
|
flex: 1,
|
|
},
|
|
container: {
|
|
flex: 1,
|
|
},
|
|
loader: {
|
|
flex: 1,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
inputContainer: {
|
|
flex: 1,
|
|
},
|
|
});
|
|
|
|
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;
|
|
post: PostModel;
|
|
maxPostSize: number;
|
|
canDelete: boolean;
|
|
files?: FileInfo[];
|
|
maxFileCount: number;
|
|
maxFileSize: number;
|
|
canUploadFiles: boolean;
|
|
}
|
|
|
|
const EditPost = ({
|
|
componentId,
|
|
maxPostSize,
|
|
post,
|
|
closeButtonId,
|
|
canDelete,
|
|
files,
|
|
maxFileCount,
|
|
maxFileSize,
|
|
canUploadFiles,
|
|
}: EditPostProps) => {
|
|
const editingMessage = post.messageSource || post.message;
|
|
const [postMessage, setPostMessage] = useState(editingMessage);
|
|
const [cursorPosition, setCursorPosition] = useState(editingMessage.length);
|
|
const [errorLine, setErrorLine] = useState<string | undefined>();
|
|
const [errorExtra, setErrorExtra] = useState<string | undefined>();
|
|
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 uploadErrorHandlers = useRef<ErrorHandlers>({});
|
|
|
|
const postInputRef = useRef<PasteInputRef | undefined>(undefined);
|
|
const theme = useTheme();
|
|
const intl = useIntl();
|
|
const serverUrl = useServerUrl();
|
|
|
|
const hasNoCurrentFiles = postFiles.length === 0;
|
|
const shouldDeleteOnSave = !postMessage && canDelete && hasNoCurrentFiles;
|
|
|
|
const shouldEnableSaveButton = useCallback(() => {
|
|
const loadingFiles = postFiles.filter((v) => v.clientId && DraftEditPostUploadManager.isUploading(v.clientId));
|
|
const hasUploadingFiles = loadingFiles.length > 0;
|
|
const tooLong = postMessage.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]);
|
|
|
|
useDidMount(() => {
|
|
toggleSaveButton(false);
|
|
});
|
|
|
|
useEffect(() => {
|
|
const t = setTimeout(() => {
|
|
postInputRef.current?.focus();
|
|
}, 320);
|
|
|
|
return () => {
|
|
clearTimeout(t);
|
|
};
|
|
}, []);
|
|
|
|
useDidUpdate(() => {
|
|
// Workaround to avoid iOS emdash autocorrect in Code Blocks
|
|
if (Platform.OS === 'ios') {
|
|
onTextSelectionChange();
|
|
}
|
|
}, [postMessage]);
|
|
|
|
const onClose = useCallback(() => {
|
|
Keyboard.dismiss();
|
|
dismissModal({componentId});
|
|
}, [componentId]);
|
|
|
|
const onTextSelectionChange = useCallback((curPos: number = cursorPosition) => {
|
|
setCursorPosition(curPos);
|
|
}, [cursorPosition]);
|
|
|
|
const toggleSaveButton = useCallback((enabled = true) => {
|
|
setButtons(componentId, {
|
|
rightButtons: [{
|
|
...RIGHT_BUTTON,
|
|
color: theme.sidebarHeaderTextColor,
|
|
disabledColor: changeOpacity(theme.sidebarHeaderTextColor, 0.32),
|
|
text: intl.formatMessage({id: 'edit_post.save', defaultMessage: 'Save'}),
|
|
enabled,
|
|
}],
|
|
});
|
|
}, [componentId, intl, theme]);
|
|
|
|
const updateFileInPostFiles = useCallback((updatedFile: FileInfo) => {
|
|
const hasSameClientId = (file: FileInfo) => {
|
|
return file.clientId === updatedFile.clientId;
|
|
};
|
|
|
|
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) {
|
|
setErrorLine(uploadDisabledWarning(intl));
|
|
return;
|
|
}
|
|
|
|
const currentFileCount = postFiles?.length || 0;
|
|
const availableCount = maxFileCount - currentFileCount;
|
|
if (newFiles.length > availableCount) {
|
|
setErrorLine(fileMaxWarning(intl, maxFileCount));
|
|
return;
|
|
}
|
|
|
|
const largeFile = newFiles.find((file) => file.size > maxFileSize);
|
|
if (largeFile) {
|
|
setErrorLine(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!, setErrorLine);
|
|
}
|
|
|
|
const currentMessageTooLong = postMessage.length > maxPostSize;
|
|
if (!currentMessageTooLong) {
|
|
setErrorLine(undefined);
|
|
}
|
|
}, [canUploadFiles, postFiles?.length, maxFileCount, setErrorLine, intl, maxFileSize, serverUrl, post.channelId, post.rootId, updateFileInPostFiles, postMessage, maxPostSize]);
|
|
|
|
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',
|
|
},
|
|
{
|
|
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, setErrorLine);
|
|
}
|
|
}
|
|
}, [postFiles, postMessage, setErrorLine, shouldEnableSaveButton, toggleSaveButton]);
|
|
|
|
const onChangeTextCommon = useCallback((message: string) => {
|
|
const tooLong = message.length > maxPostSize;
|
|
setErrorExtra(undefined);
|
|
|
|
if (tooLong) {
|
|
const line = intl.formatMessage({id: 'mobile.message_length.message_split_left', defaultMessage: 'Message exceeds the character limit'});
|
|
const extra = `${message.length} / ${maxPostSize}`;
|
|
setErrorLine(line);
|
|
setErrorExtra(extra);
|
|
} else {
|
|
// Clear any error when message is valid
|
|
setErrorLine(undefined);
|
|
}
|
|
}, [intl, maxPostSize]);
|
|
|
|
const onAutocompleteChangeText = useCallback((message: string) => {
|
|
setPostMessage(message);
|
|
propagateValue(message);
|
|
onChangeTextCommon(message);
|
|
}, [onChangeTextCommon, propagateValue]);
|
|
|
|
const onInputChangeText = useCallback((message: string) => {
|
|
if (!shouldProcessEvent(message)) {
|
|
return;
|
|
}
|
|
setPostMessage(message);
|
|
onChangeTextCommon(message);
|
|
}, [onChangeTextCommon, shouldProcessEvent]);
|
|
|
|
const handleUIUpdates = useCallback((res: {error?: unknown}) => {
|
|
if (res.error) {
|
|
setIsUpdating(false);
|
|
const errorMessage = intl.formatMessage({id: 'mobile.edit_post.error', defaultMessage: 'There was a problem editing this message. Please try again.'});
|
|
setErrorLine(errorMessage);
|
|
postInputRef?.current?.focus();
|
|
} else {
|
|
setIsUpdating(false);
|
|
onClose();
|
|
}
|
|
}, [intl, onClose]);
|
|
|
|
const handleDeletePost = useCallback(async () => {
|
|
Alert.alert(
|
|
intl.formatMessage({id: 'mobile.edit_post.delete_title', defaultMessage: 'Confirm Post Delete'}),
|
|
intl.formatMessage({
|
|
id: 'mobile.edit_post.delete_question',
|
|
defaultMessage: 'Are you sure you want to delete this Post?',
|
|
}),
|
|
[{
|
|
text: intl.formatMessage({id: 'mobile.post.cancel', defaultMessage: 'Cancel'}),
|
|
style: 'cancel',
|
|
onPress: () => {
|
|
setIsUpdating(false);
|
|
toggleSaveButton();
|
|
setPostMessage(editingMessage);
|
|
},
|
|
}, {
|
|
text: intl.formatMessage({id: 'post_info.del', defaultMessage: 'Delete'}),
|
|
style: 'destructive',
|
|
onPress: async () => {
|
|
const res = await deletePost(serverUrl, post);
|
|
handleUIUpdates(res);
|
|
},
|
|
}],
|
|
);
|
|
}, [intl, toggleSaveButton, editingMessage, serverUrl, post, handleUIUpdates]);
|
|
|
|
const onSavePostMessage = useCallback(async () => {
|
|
setIsUpdating(true);
|
|
setErrorLine(undefined);
|
|
setErrorExtra(undefined);
|
|
toggleSaveButton(false);
|
|
if (shouldDeleteOnSave) {
|
|
handleDeletePost();
|
|
return;
|
|
}
|
|
|
|
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, 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, postFiles]);
|
|
useNavButtonPressed(closeButtonId, componentId, onClose, []);
|
|
useAndroidHardwareBackHandler(componentId, onClose);
|
|
|
|
const overlap = useKeyboardOverlap(mainView, containerHeight);
|
|
const autocompletePosition = overlap + AUTOCOMPLETE_SEPARATION + QUICK_ACTIONS_HEIGHT;
|
|
const autocompleteAvailableSpace = containerHeight - autocompletePosition;
|
|
|
|
const [animatedAutocompletePosition, animatedAutocompleteAvailableSpace] = useAutocompleteDefaultAnimatedValues(autocompletePosition, autocompleteAvailableSpace);
|
|
|
|
if (isUpdating) {
|
|
return (
|
|
<View
|
|
style={styles.loader}
|
|
nativeID={SecurityManager.getShieldScreenId(componentId)}
|
|
>
|
|
<Loading color={theme.buttonBg}/>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<EditPostProvider
|
|
onFileRemove={handleFileRemoval}
|
|
updateFileCallback={updateFileInPostFiles}
|
|
isEditMode={true}
|
|
>
|
|
<SafeAreaView
|
|
testID='edit_post.screen'
|
|
style={styles.container}
|
|
edges={safeAreaEdges}
|
|
onLayout={onLayout}
|
|
nativeID={SecurityManager.getShieldScreenId(componentId)}
|
|
>
|
|
{Platform.OS === 'ios' ? (
|
|
<KeyboardProvider>
|
|
<ExtraKeyboardProvider>
|
|
<View
|
|
style={styles.body}
|
|
ref={mainView}
|
|
>
|
|
{Boolean((errorLine || errorExtra)) &&
|
|
<PostError
|
|
errorExtra={errorExtra}
|
|
errorLine={errorLine}
|
|
/>
|
|
}
|
|
<View style={styles.inputContainer}>
|
|
<EditPostInput
|
|
hasError={Boolean(errorLine || errorExtra)}
|
|
message={postMessage}
|
|
onChangeText={onInputChangeText}
|
|
onTextSelectionChange={onTextSelectionChange}
|
|
inputRef={postInputRef}
|
|
post={post}
|
|
postFiles={postFiles}
|
|
addFiles={addFiles}
|
|
/>
|
|
</View>
|
|
</View>
|
|
</ExtraKeyboardProvider>
|
|
<Autocomplete
|
|
channelId={post.channelId}
|
|
shouldDirectlyReact={false}
|
|
nestedScrollEnabled={true}
|
|
rootId={post.rootId}
|
|
updateValue={onAutocompleteChangeText}
|
|
value={postMessage}
|
|
cursorPosition={cursorPosition}
|
|
position={animatedAutocompletePosition}
|
|
availableSpace={animatedAutocompleteAvailableSpace}
|
|
serverUrl={serverUrl}
|
|
/>
|
|
</KeyboardProvider>
|
|
) : (
|
|
<>
|
|
<ExtraKeyboardProvider>
|
|
<View
|
|
style={styles.body}
|
|
ref={mainView}
|
|
>
|
|
{Boolean((errorLine || errorExtra)) &&
|
|
<PostError
|
|
errorExtra={errorExtra}
|
|
errorLine={errorLine}
|
|
/>
|
|
}
|
|
<View style={styles.inputContainer}>
|
|
<EditPostInput
|
|
hasError={Boolean(errorLine || errorExtra)}
|
|
message={postMessage}
|
|
onChangeText={onInputChangeText}
|
|
onTextSelectionChange={onTextSelectionChange}
|
|
inputRef={postInputRef}
|
|
post={post}
|
|
postFiles={postFiles}
|
|
addFiles={addFiles}
|
|
/>
|
|
</View>
|
|
</View>
|
|
</ExtraKeyboardProvider>
|
|
<Autocomplete
|
|
channelId={post.channelId}
|
|
shouldDirectlyReact={false}
|
|
nestedScrollEnabled={true}
|
|
rootId={post.rootId}
|
|
updateValue={onAutocompleteChangeText}
|
|
value={postMessage}
|
|
cursorPosition={cursorPosition}
|
|
position={animatedAutocompletePosition}
|
|
availableSpace={animatedAutocompleteAvailableSpace}
|
|
serverUrl={serverUrl}
|
|
/>
|
|
</>
|
|
)}
|
|
</SafeAreaView>
|
|
</EditPostProvider>
|
|
);
|
|
};
|
|
|
|
export default EditPost;
|