Rocket.Chat.ReactNative/app/containers/MessageComposer/components/ComposerInput.tsx

362 lines
12 KiB
TypeScript

import React, { forwardRef, memo, useCallback, useEffect, useImperativeHandle } from 'react';
import { TextInput, StyleSheet, TextInputProps, InteractionManager } from 'react-native';
import { useDebouncedCallback } from 'use-debounce';
import { useDispatch } from 'react-redux';
import { RouteProp, useFocusEffect, useRoute } from '@react-navigation/native';
import I18n from '../../../i18n';
import { IAutocompleteItemProps, IComposerInput, IComposerInputProps, IInputSelection, TSetInput } from '../interfaces';
import { useAutocompleteParams, useFocused, useMessageComposerApi } from '../context';
import { loadDraftMessage, fetchIsAllOrHere, getMentionRegexp } from '../helpers';
import { useSubscription, useAutoSaveDraft } from '../hooks';
import sharedStyles from '../../../views/Styles';
import { useTheme } from '../../../theme';
import { userTyping } from '../../../actions/room';
import { getRoomTitle, parseJson } from '../../../lib/methods/helpers';
import { MAX_HEIGHT, MIN_HEIGHT, NO_CANNED_RESPONSES, MARKDOWN_STYLES } from '../constants';
import database from '../../../lib/database';
import Navigation from '../../../lib/navigation/appNavigation';
import { emitter } from '../../../lib/methods/helpers/emitter';
import { useRoomContext } from '../../../views/RoomView/context';
import { getMessageById } from '../../../lib/database/services/Message';
import { generateTriggerId } from '../../../lib/methods';
import { Services } from '../../../lib/services';
import log from '../../../lib/methods/helpers/log';
import { useAppSelector, usePrevious } from '../../../lib/hooks';
import { ChatsStackParamList } from '../../../stacks/types';
const defaultSelection: IInputSelection = { start: 0, end: 0 };
export const ComposerInput = memo(
forwardRef<IComposerInput, IComposerInputProps>(({ inputRef }, ref) => {
const { colors, theme } = useTheme();
const { rid, tmid, sharing, action, selectedMessages, setQuotesAndText } = useRoomContext();
const focused = useFocused();
const { setFocused, setMicOrSend, setAutocompleteParams } = useMessageComposerApi();
const autocompleteType = useAutocompleteParams()?.type;
const textRef = React.useRef('');
const firstRender = React.useRef(false);
const selectionRef = React.useRef<IInputSelection>(defaultSelection);
const dispatch = useDispatch();
const subscription = useSubscription(rid);
const isMasterDetail = useAppSelector(state => state.app.isMasterDetail);
let placeholder = tmid ? I18n.t('Add_thread_reply') : '';
if (subscription && !tmid) {
placeholder = I18n.t('Message_roomname', { roomName: (subscription.t === 'd' ? '@' : '#') + getRoomTitle(subscription) });
}
const route = useRoute<RouteProp<ChatsStackParamList, 'RoomView'>>();
const usedCannedResponse = route.params?.usedCannedResponse;
const prevAction = usePrevious(action);
useAutoSaveDraft(textRef.current);
// Draft/Canned Responses
useEffect(() => {
const setDraftMessage = async () => {
const draftMessage = await loadDraftMessage({ rid, tmid });
if (draftMessage) {
const parsedDraft = parseJson(draftMessage);
if (parsedDraft?.msg || parsedDraft?.quotes) {
setQuotesAndText?.(parsedDraft.msg, parsedDraft.quotes);
} else {
setInput(draftMessage);
}
}
};
if (sharing) return;
if (usedCannedResponse) setInput(usedCannedResponse);
if (action !== 'edit' && !firstRender.current) {
firstRender.current = true;
setDraftMessage();
}
}, [action, rid, tmid, usedCannedResponse, firstRender.current]);
// Edit/quote
useEffect(() => {
const fetchMessageAndSetInput = async () => {
const message = await getMessageById(selectedMessages[0]);
if (message) {
setInput(message?.msg || '');
}
};
if (sharing) return;
if (prevAction === 'edit' && action !== 'edit') {
setInput('');
return;
}
if (action === 'edit' && selectedMessages[0]) {
focus();
fetchMessageAndSetInput();
return;
}
if (action === 'quote' && selectedMessages.length) {
focus();
}
}, [action, selectedMessages]);
useFocusEffect(
useCallback(() => {
const task = InteractionManager.runAfterInteractions(() => {
emitter.on('addMarkdown', ({ style }) => {
const { start, end } = selectionRef.current;
const text = textRef.current;
const markdown = MARKDOWN_STYLES[style];
const newText = `${text.substr(0, start)}${markdown}${text.substr(start, end - start)}${markdown}${text.substr(end)}`;
setInput(newText, {
start: start + markdown.length,
end: start === end ? start + markdown.length : end + markdown.length
});
});
emitter.on('toolbarMention', () => {
if (autocompleteType) {
return;
}
const { start, end } = selectionRef.current;
const text = textRef.current;
const newText = `${text.substr(0, start)}@${text.substr(start, end - start)}${text.substr(end)}`;
setInput(newText, { start: start + 1, end: start === end ? start + 1 : end + 1 });
setAutocompleteParams({ text: '', type: '@' });
});
});
return () => {
emitter.off('addMarkdown');
emitter.off('toolbarMention');
task?.cancel();
};
}, [rid, tmid, autocompleteType])
);
useImperativeHandle(ref, () => ({
getTextAndClear: () => {
const text = textRef.current;
setInput('');
return text;
},
getText: () => textRef.current,
getSelection: () => selectionRef.current,
setInput,
onAutocompleteItemSelected
}));
const setInput: TSetInput = (text, selection) => {
textRef.current = text;
if (inputRef.current) {
inputRef.current.setNativeProps({ text });
}
if (selection) {
// setSelection won't trigger onSelectionChange, so we need it to be ran after new text is set
setTimeout(() => {
inputRef.current?.setSelection?.(selection.start, selection.end);
selectionRef.current = selection;
}, 50);
}
setMicOrSend(text.length === 0 ? 'mic' : 'send');
};
const focus = () => {
if (inputRef.current) {
inputRef.current.focus();
}
};
const onChangeText: TextInputProps['onChangeText'] = text => {
textRef.current = text;
debouncedOnChangeText(text);
setInput(text);
};
const onSelectionChange: TextInputProps['onSelectionChange'] = e => {
selectionRef.current = e.nativeEvent.selection;
};
const onFocus: TextInputProps['onFocus'] = () => {
setFocused(true);
};
const onBlur: TextInputProps['onBlur'] = () => {
setFocused(false);
stopAutocomplete();
};
const onAutocompleteItemSelected: IAutocompleteItemProps['onPress'] = async item => {
if (item.type === 'loading') {
return null;
}
// If it's slash command preview, we need to execute the command
if (item.type === '/preview') {
try {
if (!rid) return;
const db = database.active;
const commandsCollection = db.get('slash_commands');
const commandRecord = await commandsCollection.find(item.text);
const { appId } = commandRecord;
const triggerId = generateTriggerId(appId);
Services.executeCommandPreview(item.text, item.params, rid, item.preview, triggerId, tmid);
} catch (e) {
log(e);
}
requestAnimationFrame(() => {
stopAutocomplete();
setInput('', { start: 0, end: 0 });
});
return;
}
// If it's canned response, but there's no canned responses, we open the canned responses view
if (item.type === '!' && item.id === NO_CANNED_RESPONSES) {
const params = { rid };
if (isMasterDetail) {
Navigation.navigate('ModalStackNavigator', { screen: 'CannedResponsesListView', params });
} else {
Navigation.navigate('CannedResponsesListView', params);
}
stopAutocomplete();
return;
}
const text = textRef.current;
const { start, end } = selectionRef.current;
const cursor = Math.max(start, end);
const regexp = getMentionRegexp();
let result = text.substr(0, cursor).replace(regexp, '');
// Remove the ! after select the canned response
if (item.type === '!') {
const lastIndexOfExclamation = text.lastIndexOf('!', cursor);
result = text.substr(0, lastIndexOfExclamation).replace(regexp, '');
}
let mention = '';
switch (item.type) {
case '@':
mention = fetchIsAllOrHere(item) ? item.title : item.subtitle || item.title;
break;
case '#':
mention = item.subtitle ? item.subtitle : '';
break;
case ':':
mention = `${typeof item.emoji === 'string' ? item.emoji : item.emoji.name}:`;
break;
case '/':
mention = item.title;
break;
case '!':
mention = item.subtitle ? item.subtitle : '';
break;
default:
mention = '';
}
const newText = `${result}${mention} ${text.slice(cursor)}`;
const newCursor = result.length + mention.length + 1;
setInput(newText, { start: newCursor, end: newCursor });
focus();
requestAnimationFrame(() => {
stopAutocomplete();
});
};
const stopAutocomplete = () => {
setAutocompleteParams({ text: '', type: null, params: '' });
};
const debouncedOnChangeText = useDebouncedCallback(async (text: string) => {
const isTextEmpty = text.length === 0;
handleTyping(!isTextEmpty);
if (isTextEmpty || !focused) {
stopAutocomplete();
return;
}
const { start, end } = selectionRef.current;
const cursor = Math.max(start, end);
const whiteSpaceOrBreakLineRegex = /[\s\n]+/;
const txt =
cursor < text.length ? text.substr(0, cursor).split(whiteSpaceOrBreakLineRegex) : text.split(whiteSpaceOrBreakLineRegex);
const lastWord = txt[txt.length - 1];
const autocompleteText = lastWord.substring(1);
if (!lastWord) {
stopAutocomplete();
return;
}
if (!sharing && text.match(/^\//)) {
const commandParameter = text.match(/^\/([a-z0-9._-]+) (.+)/im);
if (commandParameter) {
const db = database.active;
const [, command, params] = commandParameter;
const commandsCollection = db.get('slash_commands');
try {
const commandRecord = await commandsCollection.find(command);
if (commandRecord.providesPreview) {
setAutocompleteParams({ params, text: command, type: '/preview' });
}
return;
} catch (e) {
// do nothing
}
}
setAutocompleteParams({ text: autocompleteText, type: '/' });
return;
}
if (lastWord.match(/^#/)) {
setAutocompleteParams({ text: autocompleteText, type: '#' });
return;
}
if (lastWord.match(/^@/)) {
setAutocompleteParams({ text: autocompleteText, type: '@' });
return;
}
if (lastWord.match(/^:/)) {
setAutocompleteParams({ text: autocompleteText, type: ':' });
return;
}
if (lastWord.match(/^!/) && subscription?.t === 'l') {
setAutocompleteParams({ text: autocompleteText, type: '!' });
return;
}
stopAutocomplete();
}, 300);
const handleTyping = (isTyping: boolean) => {
if (sharing || !rid) return;
dispatch(userTyping(rid, isTyping));
};
return (
<TextInput
style={[styles.textInput, { color: colors.fontDefault }]}
placeholder={placeholder}
placeholderTextColor={colors.fontAnnotation}
ref={component => (inputRef.current = component)}
blurOnSubmit={false}
onChangeText={onChangeText}
onSelectionChange={onSelectionChange}
onFocus={onFocus}
onBlur={onBlur}
underlineColorAndroid='transparent'
defaultValue=''
multiline
keyboardAppearance={theme === 'light' ? 'light' : 'dark'}
testID={`message-composer-input${tmid ? '-thread' : ''}`}
/>
);
})
);
const styles = StyleSheet.create({
textInput: {
flex: 1,
minHeight: MIN_HEIGHT,
maxHeight: MAX_HEIGHT,
paddingTop: 12,
paddingBottom: 12,
fontSize: 16,
textAlignVertical: 'center',
...sharedStyles.textRegular,
lineHeight: 22
}
});