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

245 lines
8.3 KiB
TypeScript

import React, { ReactElement, useRef, useImperativeHandle, useCallback } from 'react';
import { View, StyleSheet, NativeModules, NativeSyntheticEvent, TextInputProps } from 'react-native';
import { KeyboardAccessoryView } from 'react-native-ui-lib/keyboard';
import { useBackHandler } from '@react-native-community/hooks';
import { Q } from '@nozbe/watermelondb';
import { useFocusEffect } from '@react-navigation/native';
import { useRoomContext } from '../../views/RoomView/context';
import { Autocomplete, Toolbar, EmojiSearchbar, ComposerInput, Left, Right, Quotes, SendThreadToChannel } from './components';
import { MIN_HEIGHT, TIMEOUT_CLOSE_EMOJI_KEYBOARD } from './constants';
import {
MessageInnerContext,
useAlsoSendThreadToChannel,
useMessageComposerApi,
useRecordingAudio,
useShowEmojiKeyboard,
useShowEmojiSearchbar
} from './context';
import { IComposerInput, ITrackingView, ITrackingViewHeightEvent } from './interfaces';
import { isAndroid, isIOS } from '../../lib/methods/helpers';
import shortnameToUnicode from '../../lib/methods/helpers/shortnameToUnicode';
import { useTheme } from '../../theme';
import { EventTypes } from '../EmojiPicker/interfaces';
import { IEmoji } from '../../definitions';
import database from '../../lib/database';
import { sanitizeLikeString } from '../../lib/database/utils';
import { generateTriggerId } from '../../lib/methods';
import { Services } from '../../lib/services';
import log from '../../lib/methods/helpers/log';
import { prepareQuoteMessage } from './helpers';
import { RecordAudio } from './components/RecordAudio';
const styles = StyleSheet.create({
container: {
borderTopWidth: 1,
paddingHorizontal: 16,
minHeight: MIN_HEIGHT
},
input: {
flexDirection: 'row'
}
});
require('./components/EmojiKeyboard');
export const MessageComposer = ({
forwardedRef,
children
}: {
forwardedRef: any;
children?: ReactElement;
}): ReactElement | null => {
const composerInputRef = useRef(null);
const composerInputComponentRef = useRef<IComposerInput>({
getTextAndClear: () => '',
getText: () => '',
getSelection: () => ({ start: 0, end: 0 }),
setInput: () => {},
onAutocompleteItemSelected: () => {}
});
const trackingViewRef = useRef<ITrackingView>({ resetTracking: () => {} });
const { colors, theme } = useTheme();
const { rid, tmid, action, selectedMessages, sharing, editRequest, onSendMessage } = useRoomContext();
const showEmojiKeyboard = useShowEmojiKeyboard();
const showEmojiSearchbar = useShowEmojiSearchbar();
const alsoSendThreadToChannel = useAlsoSendThreadToChannel();
const { openSearchEmojiKeyboard, closeEmojiKeyboard, closeSearchEmojiKeyboard, setHeight } = useMessageComposerApi();
const recordingAudio = useRecordingAudio();
useFocusEffect(
useCallback(() => {
trackingViewRef.current?.resetTracking();
}, [recordingAudio])
);
useImperativeHandle(forwardedRef, () => ({
closeEmojiKeyboardAndAction,
getText: composerInputComponentRef.current?.getText,
setInput: composerInputComponentRef.current?.setInput
}));
useBackHandler(() => {
if (showEmojiSearchbar) {
closeSearchEmojiKeyboard();
return true;
}
return false;
});
const closeEmojiKeyboardAndAction = (action?: Function, params?: any) => {
if (showEmojiKeyboard) {
closeEmojiKeyboard();
}
setTimeout(() => action && action(params), showEmojiKeyboard && isIOS ? TIMEOUT_CLOSE_EMOJI_KEYBOARD : undefined);
};
const handleSendMessage = async () => {
if (!rid) return;
if (sharing) {
onSendMessage?.();
return;
}
const textFromInput = composerInputComponentRef.current.getTextAndClear();
if (action === 'edit') {
return editRequest?.({ id: selectedMessages[0], msg: textFromInput, rid });
}
if (action === 'quote') {
const quoteMessage = await prepareQuoteMessage(textFromInput, selectedMessages);
onSendMessage?.(quoteMessage);
return;
}
// Slash command
if (textFromInput[0] === '/') {
const db = database.active;
const commandsCollection = db.get('slash_commands');
const command = textFromInput.replace(/ .*/, '').slice(1);
const likeString = sanitizeLikeString(command);
const slashCommand = await commandsCollection.query(Q.where('id', Q.like(`${likeString}%`))).fetch();
if (slashCommand.length > 0) {
try {
const messageWithoutCommand = textFromInput.replace(/([^\s]+)/, '').trim();
const [{ appId }] = slashCommand;
const triggerId = generateTriggerId(appId);
await Services.runSlashCommand(command, rid, messageWithoutCommand, triggerId, tmid);
} catch (e) {
log(e);
}
return;
}
}
// Text message
onSendMessage?.(textFromInput, alsoSendThreadToChannel);
};
const onKeyboardItemSelected = (_keyboardId: string, params: { eventType: EventTypes; emoji: IEmoji }) => {
const { eventType, emoji } = params;
const text = composerInputComponentRef.current.getText();
let newText = '';
// if input has an active cursor
const { start, end } = composerInputComponentRef.current.getSelection();
const cursor = Math.max(start, end);
let newCursor;
switch (eventType) {
case EventTypes.BACKSPACE_PRESSED:
const emojiRegex = /\u00a9|\u00ae|[\u2000-\u3300]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff]/;
let charsToRemove = 1;
const lastEmoji = text.substr(cursor > 0 ? cursor - 2 : text.length - 2, cursor > 0 ? cursor : text.length);
// Check if last character is an emoji
if (emojiRegex.test(lastEmoji)) charsToRemove = 2;
newText =
text.substr(0, (cursor > 0 ? cursor : text.length) - charsToRemove) + text.substr(cursor > 0 ? cursor : text.length);
newCursor = cursor - charsToRemove;
composerInputComponentRef.current.setInput(newText, { start: newCursor, end: newCursor });
break;
case EventTypes.EMOJI_PRESSED:
let emojiText = '';
if (typeof emoji === 'string') {
emojiText = shortnameToUnicode(`:${emoji}:`);
} else {
emojiText = `:${emoji.name}:`;
}
newText = `${text.substr(0, cursor)}${emojiText}${text.substr(cursor)}`;
newCursor = cursor + emojiText.length;
composerInputComponentRef.current.setInput(newText, { start: newCursor, end: newCursor });
break;
case EventTypes.SEARCH_PRESSED:
openSearchEmojiKeyboard();
break;
default:
// Do nothing
}
};
const onEmojiSelected = (emoji: IEmoji) => {
onKeyboardItemSelected('EmojiKeyboard', { eventType: EventTypes.EMOJI_PRESSED, emoji });
};
const onKeyboardResigned = () => {
if (!showEmojiSearchbar) {
closeEmojiKeyboard();
}
};
const onHeightChange = (event: NativeSyntheticEvent<ITrackingViewHeightEvent>) => {
setHeight(event.nativeEvent.keyboardHeight, event.nativeEvent.height);
};
const handleLayout: TextInputProps['onLayout'] = e => {
setHeight(0, e.nativeEvent.layout.height);
};
const backgroundColor = action === 'edit' ? colors.statusBackgroundWarning2 : colors.surfaceLight;
const renderContent = () => {
console.count('[MessageComposer] renderContent');
if (recordingAudio) {
return <RecordAudio />;
}
return (
<View style={[styles.container, { backgroundColor, borderTopColor: colors.strokeLight }]} testID='message-composer'>
<View style={styles.input}>
<Left />
<ComposerInput ref={composerInputComponentRef} inputRef={composerInputRef} />
<Right />
</View>
<Quotes />
<Toolbar />
<EmojiSearchbar />
<SendThreadToChannel />
{children}
</View>
);
};
return (
<View onLayout={isAndroid ? handleLayout : undefined}>
<MessageInnerContext.Provider value={{ sendMessage: handleSendMessage, onEmojiSelected, closeEmojiKeyboardAndAction }}>
<KeyboardAccessoryView
ref={(ref: ITrackingView) => (trackingViewRef.current = ref)}
renderContent={renderContent}
kbInputRef={composerInputRef}
kbComponent={showEmojiKeyboard ? 'EmojiKeyboard' : null}
kbInitialProps={{ theme }}
onKeyboardResigned={onKeyboardResigned}
onItemSelected={onKeyboardItemSelected}
trackInteractive
requiresSameParentToManageScrollView
addBottomView
bottomViewColor={backgroundColor}
iOSScrollBehavior={NativeModules.KeyboardTrackingViewTempManager?.KeyboardTrackingScrollBehaviorFixedOffset}
onHeightChange={onHeightChange}
/>
<Autocomplete onPress={item => composerInputComponentRef.current.onAutocompleteItemSelected(item)} />
</MessageInnerContext.Provider>
</View>
);
};