mattermost-mobile/app/screens/edit_post/edit_post.tsx
Daniel Espino García cff12f7182
Enable exhaustive deps in the repo (#9508)
* Enable exhaustive deps in the repo

* Add tests for the new hooks

* Update claude.md

* Remove pre-commit overwrite
2026-02-18 11:56:16 +01:00

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;