lbry-desktop/ui/component/textareaWithSuggestions/view.jsx

397 lines
15 KiB
React
Raw Normal View History

// @flow
import { EMOTES_48px as EMOTES } from 'constants/emotes';
import { matchSorter } from 'match-sorter';
import { SEARCH_OPTIONS } from 'constants/search';
import * as KEYCODES from 'constants/keycodes';
import Autocomplete from '@mui/material/Autocomplete';
2021-12-07 15:51:55 +01:00
import BusyIndicator from 'component/common/busy-indicator';
2021-12-06 21:38:13 +01:00
import EMOJIS from 'emoji-dictionary';
2021-12-07 15:51:55 +01:00
import Popper from '@mui/material/Popper';
import React from 'react';
import TextareaSuggestionsItem from 'component/textareaSuggestionsItem';
import TextField from '@mui/material/TextField';
import useLighthouse from 'effects/use-lighthouse';
import useThrottle from 'effects/use-throttle';
const SUGGESTION_REGEX = new RegExp(
2021-12-06 21:38:13 +01:00
'(?<Mention>(?:^| |\n)@[^\\s=&#$@%?:;/\\"<>%{}|^~[]*(?::[\\w]+)?)|(?<Emote>(?:^| |\n):[\\w+-]*:?)',
'gm'
);
/** Regex Explained step-by-step:
*
* 1) (?<Name>....) = naming a match into a possible group (either Mention or Emote)
* 2) (?:^| |\n) = only allow for: sentence beginning, space or newline before the match (no words or symbols)
* 3) [^\s=&#$@%?:;/\\"<>%{}|^~[]* = anything, except the characters inside
* 4) (?::[\w]+)? = A mention can be matched with a ':' as a claim modifier with words or digits after as ID digits,
* or else it's everything before the ':' (will then match the winning uri for the mention behind since has no canonical ID)
* 5) :\w*:? = the emote Regex, possible to be matched with a ':' at the end to consider previously typed emotes
*
*/
const SEARCH_SIZE = 10;
const LIGHTHOUSE_MIN_CHARACTERS = 3;
const INPUT_DEBOUNCE_MS = 1000;
2021-12-06 21:38:13 +01:00
const EMOJI_MIN_CHARACTERS = 2;
type Props = {
canonicalCommentors?: Array<string>,
canonicalCreatorUri?: string,
canonicalSearch?: Array<string>,
canonicalSubscriptions?: Array<string>,
className?: string,
commentorUris?: Array<string>,
disabled?: boolean,
2021-12-07 15:51:55 +01:00
hasNewResolvedResults?: boolean,
id: string,
inputRef: any,
isLivestream?: boolean,
maxLength?: number,
placeholder?: string,
showMature: boolean,
type?: string,
uri?: string,
value: any,
doResolveUris: (Array<string>) => void,
2021-12-07 15:51:55 +01:00
doSetMentionSearchResults: (Array<string>) => void,
onBlur: (any) => any,
onChange: (any) => any,
onFocus: (any) => any,
};
export default function TextareaWithSuggestions(props: Props) {
const {
canonicalCommentors,
canonicalCreatorUri,
canonicalSearch,
canonicalSubscriptions: canonicalSubs,
className,
commentorUris,
disabled,
2021-12-07 15:51:55 +01:00
hasNewResolvedResults,
id,
inputRef,
isLivestream,
maxLength,
placeholder,
showMature,
type,
value: messageValue,
doResolveUris,
2021-12-07 15:51:55 +01:00
doSetMentionSearchResults,
onBlur,
onChange,
onFocus,
} = props;
const inputDefaultProps = { className, placeholder, maxLength, type, disabled };
const [suggestionValue, setSuggestionValue] = React.useState(undefined);
const [selectedValue, setSelectedValue] = React.useState(undefined);
const [highlightedSuggestion, setHighlightedSuggestion] = React.useState('');
const [shouldClose, setClose] = React.useState();
const [debouncedTerm, setDebouncedTerm] = React.useState('');
// const [mostSupported, setMostSupported] = React.useState('');
const suggestionTerm = suggestionValue && suggestionValue.term;
const isEmote = suggestionValue && suggestionValue.isEmote;
const isMention = suggestionValue && !suggestionValue.isEmote;
const additionalOptions = { isBackgroundSearch: false, [SEARCH_OPTIONS.CLAIM_TYPE]: SEARCH_OPTIONS.INCLUDE_CHANNELS };
const { results, loading } = useLighthouse(debouncedTerm, showMature, SEARCH_SIZE, additionalOptions, 0);
const stringifiedResults = JSON.stringify(results);
const hasMinLength = suggestionTerm && isMention && suggestionTerm.length >= LIGHTHOUSE_MIN_CHARACTERS;
const isTyping = isMention && debouncedTerm !== suggestionTerm;
2021-12-07 15:51:55 +01:00
const showPlaceholder =
isMention && (isTyping || loading || (results && results.length > 0 && !hasNewResolvedResults));
const shouldFilter = (uri, previous) => uri !== canonicalCreatorUri && (!previous || !previous.includes(uri));
const filteredCommentors = canonicalCommentors && canonicalCommentors.filter((uri) => shouldFilter(uri));
const filteredSubs = canonicalSubs && canonicalSubs.filter((uri) => shouldFilter(uri, filteredCommentors));
const filteredSearch =
canonicalSearch &&
canonicalSearch.filter((uri) => shouldFilter(uri, filteredSubs) && shouldFilter(uri, filteredCommentors));
2021-12-06 21:38:13 +01:00
let emoteNames;
let emojiNames;
const allOptions = [];
if (isEmote) {
2021-12-06 21:38:13 +01:00
emoteNames = EMOTES.map(({ name }) => name.toLowerCase());
const hasMinEmojiLength = suggestionTerm && suggestionTerm.length > EMOJI_MIN_CHARACTERS;
emojiNames = hasMinEmojiLength ? EMOJIS.names : [];
const emotesAndEmojis = [...emoteNames, ...emojiNames];
allOptions.push(...emotesAndEmojis);
} else {
if (canonicalCreatorUri) allOptions.push(canonicalCreatorUri);
if (filteredSubs) allOptions.push(...filteredSubs);
if (filteredCommentors) allOptions.push(...filteredCommentors);
if (filteredSearch) allOptions.push(...filteredSearch);
}
const allOptionsGrouped =
allOptions.length > 0
? allOptions.map((option) => {
const groupName = isEmote
2021-12-06 21:38:13 +01:00
? (emoteNames.includes(option) && __('Emotes')) || (emojiNames.includes(option) && __('Emojis'))
: (canonicalCreatorUri === option && __('Creator')) ||
(filteredSubs && filteredSubs.includes(option) && __('Following')) ||
(filteredCommentors && filteredCommentors.includes(option) && __('From Comments')) ||
(filteredSearch && filteredSearch.includes(option) && __('From Search'));
2021-12-06 21:38:13 +01:00
let emoteLabel;
if (isEmote) {
// $FlowFixMe
emoteLabel = `:${option.replaceAll(':', '')}:`;
}
return {
2021-12-06 21:38:13 +01:00
label: emoteLabel || option.replace('lbry://', '').replace('#', ':'),
group: groupName,
};
})
: [];
const allMatches = useSuggestionMatch(
suggestionTerm || '',
allOptionsGrouped.map(({ label }) => label)
);
/** --------- **/
/** Functions **/
/** --------- **/
function handleInputChange(value: string) {
onChange({ target: { value } });
const cursorIndex = inputRef && inputRef.current && inputRef.current.selectionStart;
const suggestionMatches = value.match(SUGGESTION_REGEX);
if (!suggestionMatches) {
if (suggestionValue) setSuggestionValue(undefined);
return; // Exit here and avoid unnecessary behavior
}
const exec = SUGGESTION_REGEX.exec(value);
const groups = exec && exec.groups;
const groupValue = groups && Object.keys(groups).find((group) => groups[group]);
const previousLastIndexes = [];
let isEmote = groupValue && groupValue === 'Emote';
let currentSuggestionIndex = exec && exec.index;
let currentLastIndex = exec && SUGGESTION_REGEX.lastIndex;
let currentSuggestionValue =
cursorIndex >= currentSuggestionIndex &&
cursorIndex <= currentLastIndex &&
suggestionMatches &&
suggestionMatches[0];
if (suggestionMatches && suggestionMatches.length > 1) {
currentSuggestionValue = suggestionMatches.find((match, index) => {
const previousLastIndex = previousLastIndexes[index - 1] || 0;
const valueWithoutPrevious = value.substring(previousLastIndex);
const tempRe = new RegExp(SUGGESTION_REGEX);
const tempExec = tempRe.exec(valueWithoutPrevious);
const groups = tempExec && tempExec.groups;
const groupValue = groups && Object.keys(groups).find((group) => groups[group]);
if (tempExec) {
isEmote = groupValue && groupValue === 'Emote';
currentSuggestionIndex = previousLastIndex + tempExec.index;
currentLastIndex = previousLastIndex + tempRe.lastIndex;
previousLastIndexes.push(currentLastIndex);
}
// the current mention term will be the one on the text cursor's range,
// in case of there being more in the same comment message
if (previousLastIndexes) {
return cursorIndex >= currentSuggestionIndex && cursorIndex <= currentLastIndex;
}
});
}
if (currentSuggestionValue) {
const token = isEmote ? ':' : '@';
const tokenIndex = currentSuggestionValue.indexOf(token);
// $FlowFixMe
setSuggestionValue({
beforeTerm: currentSuggestionValue.substring(0, tokenIndex), // in case of a space or newline
term: currentSuggestionValue.substring(tokenIndex),
index: currentSuggestionIndex,
lastIndex: currentLastIndex,
isEmote,
});
}
}
const handleSelect = React.useCallback(
(selectedValue: string) => {
setSelectedValue(selectedValue);
if (!suggestionValue) return;
const elem = inputRef && inputRef.current;
const newCursorPos = suggestionValue.beforeTerm.length + suggestionValue.index + selectedValue.length + 1;
const contentBegin = messageValue.substring(0, suggestionValue.index);
const replaceValue = suggestionValue.beforeTerm + selectedValue;
const contentEnd =
messageValue.length > suggestionValue.lastIndex
? messageValue.substring(suggestionValue.lastIndex, messageValue.length)
: ' ';
const newValue = contentBegin + replaceValue + contentEnd;
onChange({ target: { value: newValue } });
setSuggestionValue(undefined);
elem.focus();
elem.setSelectionRange(newCursorPos, newCursorPos);
},
[messageValue, inputRef, onChange, suggestionValue]
);
/** ------- **/
/** Effects **/
/** ------- **/
React.useEffect(() => {
if (!isMention) return;
const timer = setTimeout(() => {
if (isTyping && suggestionTerm) setDebouncedTerm(!hasMinLength ? '' : suggestionTerm);
}, INPUT_DEBOUNCE_MS);
return () => clearTimeout(timer);
}, [hasMinLength, isMention, isTyping, suggestionTerm]);
React.useEffect(() => {
if (!stringifiedResults) return;
const arrayResults = JSON.parse(stringifiedResults);
if (arrayResults && arrayResults.length > 0) {
doResolveUris(arrayResults);
2021-12-07 15:51:55 +01:00
doSetMentionSearchResults(arrayResults);
}
2021-12-07 15:51:55 +01:00
}, [doResolveUris, doSetMentionSearchResults, stringifiedResults]);
// Disable sending on Enter on Livestream chat
React.useEffect(() => {
if (!isLivestream) return;
if (suggestionTerm && inputRef) {
inputRef.current.setAttribute('term', suggestionTerm);
} else {
inputRef.current.removeAttribute('term');
}
}, [inputRef, isLivestream, suggestionTerm]);
// Only resolve commentors on Livestreams when first trying to mention/looking for it
React.useEffect(() => {
if (isLivestream && commentorUris && suggestionTerm) doResolveUris(commentorUris);
}, [commentorUris, doResolveUris, isLivestream, suggestionTerm]);
// Allow selecting with TAB key
React.useEffect(() => {
if (!suggestionTerm) return; // only if there is a term, or else can't tab to navigate page
function handleKeyDown(e: SyntheticKeyboardEvent<*>) {
const { keyCode } = e;
if (highlightedSuggestion && keyCode === KEYCODES.TAB) {
e.preventDefault();
handleSelect(highlightedSuggestion.label);
}
}
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [handleSelect, highlightedSuggestion, suggestionTerm]);
/** ------ **/
/** Render **/
/** ------ **/
2021-12-07 12:57:31 +01:00
const renderGroup = (groupName: string, children: any) => (
2021-12-07 13:48:28 +01:00
<div key={groupName} className="textareaSuggestions__group">
2021-12-06 18:25:27 +01:00
<label className="textareaSuggestions__label">
{suggestionTerm && suggestionTerm.length > 1
2021-12-07 12:57:31 +01:00
? __('%group_name% matching %matching_term%', { group_name: groupName, matching_term: suggestionTerm })
: groupName}
2021-12-06 18:25:27 +01:00
</label>
{children}
<hr className="textareaSuggestions__topSeparator" />
</div>
);
const renderInput = (params: any) => {
const { InputProps, disabled, fullWidth, id, inputProps: autocompleteInputProps } = params;
const inputProps = { ...autocompleteInputProps, ...inputDefaultProps };
const autocompleteProps = { InputProps, disabled, fullWidth, id, inputProps };
return <TextField inputRef={inputRef} multiline select={false} {...autocompleteProps} />;
};
const renderOption = (optionProps: any, label: string) => {
const emoteFound = isEmote && EMOTES.find(({ name }) => name.toLowerCase() === label);
const emoteValue = emoteFound ? { name: label, url: emoteFound.url } : undefined;
2021-12-06 21:38:13 +01:00
const emojiFound = isEmote && EMOJIS.getUnicode(label);
const emojiValue = emojiFound ? { name: label, unicode: emojiFound } : undefined;
2021-12-07 13:48:28 +01:00
return <TextareaSuggestionsItem key={label} uri={label} emote={emoteValue || emojiValue} {...optionProps} />;
};
return (
<Autocomplete
PopperComponent={AutocompletePopper}
autoHighlight
disableClearable
filterOptions={(options) => options.filter(({ label }) => allMatches && allMatches.includes(label))}
freeSolo
fullWidth
getOptionLabel={(option) => option.label}
groupBy={(option) => option.group}
id={id}
inputValue={messageValue}
loading={!allMatches || allMatches.length === 0 || showPlaceholder}
2021-12-07 15:51:55 +01:00
loadingText={showPlaceholder ? <BusyIndicator message={__('Searching...')} /> : __('Nothing found')}
onBlur={() => onBlur && onBlur()}
/* Different from onInputChange, onChange is only used for the selected value,
so here it is acting simply as a selection handler (see it as onSelect) */
onChange={(event, value) => handleSelect(value.label)}
onClose={(event, reason) => reason !== 'selectOption' && setClose(true)}
onFocus={() => onFocus && onFocus()}
onHighlightChange={(event, option) => setHighlightedSuggestion(option)}
onInputChange={(event, value, reason) => reason === 'input' && handleInputChange(value)}
onOpen={() => suggestionTerm && setClose(false)}
/* 'open' is for the popper box component, set to check for a valid term
2021-12-07 15:51:55 +01:00
or else it will be displayed all the time as empty (no options) */
open={!!suggestionTerm && !shouldClose}
options={allOptionsGrouped}
renderGroup={({ group, children }) => renderGroup(group, children)}
renderInput={(params) => renderInput(params)}
renderOption={(optionProps, option) => renderOption(optionProps, option.label)}
value={selectedValue}
/>
);
}
function AutocompletePopper(props: any) {
return <Popper {...props} placement="top" />;
}
function useSuggestionMatch(term: string, list: Array<string>) {
const throttledTerm = useThrottle(term);
return React.useMemo(() => {
return !throttledTerm || throttledTerm.trim() === ''
? undefined
: matchSorter(list, term, { keys: [(item) => item] });
}, [list, term, throttledTerm]);
}