import React, { useEffect, useRef, useState } from 'react'; import { StyleSheet, TextInput } from 'react-native'; import throttle from 'lodash/throttle'; import { CommandsHeader } from './CommandsHeader'; import { EmojisHeader } from './EmojisHeader'; import { ChannelContextValue, useChannelContext, } from '../../contexts/channelContext/ChannelContext'; import { MessageInputContextValue, useMessageInputContext, } from '../../contexts/messageInputContext/MessageInputContext'; import { isSuggestionCommand, isSuggestionEmoji, isSuggestionUser, Suggestion, SuggestionCommand, SuggestionsContextValue, SuggestionUser, useSuggestionsContext, } from '../../contexts/suggestionsContext/SuggestionsContext'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { TranslationContextValue, useTranslationContext, } from '../../contexts/translationContext/TranslationContext'; import { isCommandTrigger, isEmojiTrigger, isMentionTrigger } from '../../utils/utils'; import type { TextInputProps } from 'react-native'; import type { Emoji } from '../../emoji-data/compiled'; import type { DefaultAttachmentType, DefaultChannelType, DefaultCommandType, DefaultEventType, DefaultMessageType, DefaultReactionType, DefaultUserType, UnknownType, } from '../../types/types'; import type { Trigger } from '../../utils/utils'; const styles = StyleSheet.create({ inputBox: { flex: 1, fontSize: 14, includeFontPadding: false, // for android vertical text centering padding: 0, // removal of default text input padding on android paddingTop: 0, // removal of iOS top padding for weird centering textAlignVertical: 'center', // for android vertical text centering }, }); const computeCaretPosition = (token: string, startOfTokenPosition: number) => startOfTokenPosition + token.length; const isCommand = (text: string) => text[0] === '/' && text.split(' ').length <= 1; type AutoCompleteInputPropsWithContext< At extends UnknownType = DefaultAttachmentType, Ch extends UnknownType = DefaultChannelType, Co extends string = DefaultCommandType, Ev extends UnknownType = DefaultEventType, Me extends UnknownType = DefaultMessageType, Re extends UnknownType = DefaultReactionType, Us extends UnknownType = DefaultUserType, > = Pick, 'giphyEnabled'> & Pick< MessageInputContextValue, | 'additionalTextInputProps' | 'autoCompleteSuggestionsLimit' | 'giphyActive' | 'maxMessageLength' | 'mentionAllAppUsersEnabled' | 'mentionAllAppUsersQuery' | 'numberOfLines' | 'onChange' | 'setGiphyActive' | 'setInputBoxRef' | 'setShowMoreOptions' | 'text' | 'triggerSettings' > & Pick< SuggestionsContextValue, 'closeSuggestions' | 'openSuggestions' | 'updateSuggestions' > & Pick; export type AutoCompleteInputProps< At extends UnknownType = DefaultAttachmentType, Ch extends UnknownType = DefaultChannelType, Co extends string = DefaultCommandType, Ev extends UnknownType = DefaultEventType, Me extends UnknownType = DefaultMessageType, Re extends UnknownType = DefaultReactionType, Us extends UnknownType = DefaultUserType, > = Partial>; const AutoCompleteInputWithContext = < At extends UnknownType = DefaultAttachmentType, Ch extends UnknownType = DefaultChannelType, Co extends string = DefaultCommandType, Ev extends UnknownType = DefaultEventType, Me extends UnknownType = DefaultMessageType, Re extends UnknownType = DefaultReactionType, Us extends UnknownType = DefaultUserType, >( props: AutoCompleteInputPropsWithContext, ) => { const { additionalTextInputProps, autoCompleteSuggestionsLimit, closeSuggestions, giphyActive, giphyEnabled, maxMessageLength, mentionAllAppUsersEnabled, mentionAllAppUsersQuery, numberOfLines, onChange, openSuggestions, setGiphyActive, setInputBoxRef, setShowMoreOptions, t, text, triggerSettings, updateSuggestions: updateSuggestionsContext, } = props; const isTrackingStarted = useRef(false); const selectionEnd = useRef(0); const [textHeight, setTextHeight] = useState(0); const { theme: { colors: { black, grey }, messageInput: { inputBox }, }, } = useTheme(); const handleChange = (newText: string, fromUpdate = false) => { if (!fromUpdate) { onChange(newText); } else { handleSuggestionsThrottled(newText); } }; useEffect(() => { handleChange(text, true); }, [text]); const startTracking = (trigger: Trigger) => { const triggerSetting = triggerSettings[trigger]; if (triggerSetting) { isTrackingStarted.current = true; const { component: Component } = triggerSetting; openSuggestions( typeof Component === 'string' ? Component : , trigger === ':' ? ( ) : trigger === '/' ? ( ) : undefined, ); } }; const stopTracking = () => { isTrackingStarted.current = false; closeSuggestions(); }; const updateSuggestions = async ({ query, trigger, }: { query: Suggestion['name']; trigger: Trigger; }) => { if (isMentionTrigger(trigger)) { const triggerSetting = triggerSettings[trigger]; if (triggerSetting) { await triggerSetting.dataProvider( query as SuggestionUser['name'], text, (data, queryCallback) => { if (query === queryCallback) { updateSuggestionsContext({ data, onSelect: (item) => onSelectSuggestion({ item, trigger }), }); } }, { limit: autoCompleteSuggestionsLimit, mentionAllAppUsersEnabled, mentionAllAppUsersQuery, }, ); } } else if (isCommandTrigger(trigger)) { const triggerSetting = triggerSettings[trigger]; if (triggerSetting) { await triggerSetting.dataProvider( query as SuggestionCommand['name'], text, (data, queryCallback) => { if (query !== queryCallback) { return; } updateSuggestionsContext({ data, onSelect: (item) => onSelectSuggestion({ item, trigger }), }); }, { limit: autoCompleteSuggestionsLimit, }, ); } } else { const triggerSetting = triggerSettings[trigger]; if (triggerSetting) { await triggerSetting.dataProvider(query as Emoji['name'], text, (data, queryCallback) => { if (query !== queryCallback) { return; } updateSuggestionsContext( { data, onSelect: (item) => onSelectSuggestion({ item, trigger }), }, , ); }); } } }; const handleSelectionChange: TextInputProps['onSelectionChange'] = ({ nativeEvent: { selection: { end }, }, }) => { selectionEnd.current = end; }; const onSelectSuggestion = ({ item, trigger, }: { item: Suggestion; trigger: Trigger; }) => { if (!trigger || !triggerSettings[trigger]) { return; } let newTokenString = ''; if (isCommandTrigger(trigger) && isSuggestionCommand(item)) { const triggerSetting = triggerSettings[trigger]; if (triggerSetting) { newTokenString = `${triggerSetting.output(item).text} `; } } if (isEmojiTrigger(trigger) && isSuggestionEmoji(item)) { const triggerSetting = triggerSettings[trigger]; if (triggerSetting) { newTokenString = `${triggerSetting.output(item).text} `; } } if (isMentionTrigger(trigger) && isSuggestionUser(item)) { const triggerSetting = triggerSettings[trigger]; if (triggerSetting) { newTokenString = `${triggerSetting.output(item).text} `; } } const textToModify = text.slice(0, selectionEnd.current); const startOfTokenPosition = textToModify.lastIndexOf(trigger, selectionEnd.current); const newCaretPosition = computeCaretPosition(newTokenString, startOfTokenPosition); const modifiedText = `${textToModify.substring(0, startOfTokenPosition)}${newTokenString}`; stopTracking(); const newText = text.replace(textToModify, modifiedText); if (giphyEnabled && newText.startsWith('/giphy ')) { onChange(newText.slice(7)); // 7 because of '/giphy ' length setGiphyActive(true); } else { onChange(newText); } selectionEnd.current = newCaretPosition || 0; if (isMentionTrigger(trigger) && isSuggestionUser(item)) { const triggerSetting = triggerSettings[trigger]; if (triggerSetting) { triggerSetting.callback(item); } } }; const handleCommand = async (text: string) => { if (!isCommand(text)) { return false; } if (!isTrackingStarted.current) { startTracking('/'); } const actualToken = text.trim().slice(1); await updateSuggestions({ query: actualToken, trigger: '/' }); return true; }; const handleMentions = ({ tokenMatch }: { tokenMatch: RegExpMatchArray | null }) => { const lastToken = tokenMatch?.[tokenMatch.length - 1]; const handleMentionsTrigger = (lastToken && Object.keys(triggerSettings).find((trigger) => trigger === lastToken[0])) || null; /* if we lost the trigger token or there is no following character we want to close the autocomplete */ if (!lastToken || lastToken.length <= 0) { stopTracking(); return; } const actualToken = lastToken.slice(1); // if trigger is not configured step out from the function, otherwise proceed if (!handleMentionsTrigger) { return; } if (!isTrackingStarted.current) { startTracking('@'); } updateSuggestions({ query: actualToken, trigger: '@' }); }; const handleEmojis = ({ tokenMatch }: { tokenMatch: RegExpMatchArray | null }) => { const lastToken = tokenMatch?.[tokenMatch.length - 1].trim(); const handleEmojisTrigger = (lastToken && Object.keys(triggerSettings).find((trigger) => trigger === lastToken[0])) || null; /* if we lost the trigger token or there is no following character we want to close the autocomplete */ if (!lastToken || lastToken.length <= 0) { stopTracking(); return; } const actualToken = lastToken.slice(1); // if trigger is not configured step out from the function, otherwise proceed if (!handleEmojisTrigger) { return; } if (!isTrackingStarted.current) { startTracking(':'); } updateSuggestions({ query: actualToken, trigger: ':' }); }; const handleSuggestions = async (text: string) => { if ( /\s/.test(text.slice(selectionEnd.current - 1, selectionEnd.current)) && isTrackingStarted.current ) { stopTracking(); } else if (giphyEnabled && !(await handleCommand(text))) { const mentionTokenMatch = text .slice(0, selectionEnd.current) .match(/(?!^|\W)?@[^\s@]*\s?[^\s@]*$/g); if (mentionTokenMatch) { handleMentions({ tokenMatch: mentionTokenMatch }); } else { const emojiTokenMatch = text .slice(0, selectionEnd.current) .match(/(?!^|\W)?:\w{2,}[^\s]*\s?[^\s]*$/g); handleEmojis({ tokenMatch: emojiTokenMatch }); } } }; const handleSuggestionsThrottled = throttle(handleSuggestions, 100, { leading: false, }); return ( { if (giphyEnabled && newText.startsWith('/giphy ')) { handleChange(newText.slice(7)); // 7 because of '/giphy' length setGiphyActive(true); } else { handleChange(newText); } }} onContentSizeChange={({ nativeEvent: { contentSize: { height }, }, }) => { if (!textHeight) { setTextHeight(height); } }} onSelectionChange={handleSelectionChange} placeholder={giphyActive ? t('Search GIFs') : t('Send a message')} placeholderTextColor={grey} ref={setInputBoxRef} style={[ styles.inputBox, { color: black, maxHeight: (textHeight || 17) * numberOfLines, }, inputBox, ]} testID='auto-complete-text-input' value={text} {...additionalTextInputProps} onBlur={(event) => { if (additionalTextInputProps?.onBlur) { additionalTextInputProps?.onBlur(event); } setShowMoreOptions(true); }} /> ); }; const areEqual = < At extends UnknownType = DefaultAttachmentType, Ch extends UnknownType = DefaultChannelType, Co extends string = DefaultCommandType, Ev extends UnknownType = DefaultEventType, Me extends UnknownType = DefaultMessageType, Re extends UnknownType = DefaultReactionType, Us extends UnknownType = DefaultUserType, >( prevProps: AutoCompleteInputPropsWithContext, nextProps: AutoCompleteInputPropsWithContext, ) => { const { giphyActive: prevGiphyActive, t: prevT, text: prevText } = prevProps; const { giphyActive: nextGiphyActive, t: nextT, text: nextText } = nextProps; const giphyActiveEqual = prevGiphyActive === nextGiphyActive; if (!giphyActiveEqual) return false; const tEqual = prevT === nextT; if (!tEqual) return false; const textEqual = prevText === nextText; if (!textEqual) return false; return true; }; const MemoizedAutoCompleteInput = React.memo( AutoCompleteInputWithContext, areEqual, ) as typeof AutoCompleteInputWithContext; export const AutoCompleteInput = < At extends UnknownType = DefaultAttachmentType, Ch extends UnknownType = DefaultChannelType, Co extends string = DefaultCommandType, Ev extends UnknownType = DefaultEventType, Me extends UnknownType = DefaultMessageType, Re extends UnknownType = DefaultReactionType, Us extends UnknownType = DefaultUserType, >( props: AutoCompleteInputProps, ) => { const { giphyEnabled } = useChannelContext(); const { additionalTextInputProps, autoCompleteSuggestionsLimit, giphyActive, maxMessageLength, mentionAllAppUsersEnabled, mentionAllAppUsersQuery, numberOfLines, onChange, setGiphyActive, setInputBoxRef, setShowMoreOptions, text, triggerSettings, } = useMessageInputContext(); const { closeSuggestions, openSuggestions, updateSuggestions } = useSuggestionsContext(); const { t } = useTranslationContext(); return ( ); }; AutoCompleteInput.displayName = 'AutoCompleteInput{messageInput{inputBox}}';