2021-12-02 17:49:13 +01:00
|
|
|
// @flow
|
|
|
|
import { matchSorter } from 'match-sorter';
|
|
|
|
import * as KEYCODES from 'constants/keycodes';
|
2021-12-06 18:28:36 +01:00
|
|
|
import Autocomplete from '@mui/material/Autocomplete';
|
2021-12-02 17:49:13 +01:00
|
|
|
import React from 'react';
|
|
|
|
import TextareaSuggestionsItem from 'component/textareaSuggestionsItem';
|
2021-12-06 18:28:36 +01:00
|
|
|
import TextField from '@mui/material/TextField';
|
2021-12-02 17:49:13 +01:00
|
|
|
import useThrottle from 'effects/use-throttle';
|
|
|
|
|
2021-12-06 18:28:36 +01:00
|
|
|
const mentionRegex = /@[^\s=&#:$@%?;/\\"<>%{}|^~[]*/gm;
|
2021-12-02 17:49:13 +01:00
|
|
|
|
|
|
|
type Props = {
|
2021-12-06 18:28:36 +01:00
|
|
|
canonicalCommentors?: Array<string>,
|
|
|
|
canonicalCreatorUri?: string,
|
|
|
|
canonicalSubscriptions?: Array<string>,
|
2021-12-02 17:49:13 +01:00
|
|
|
className?: string,
|
2021-12-06 18:28:36 +01:00
|
|
|
commentorUris?: Array<string>,
|
|
|
|
disabled?: boolean,
|
|
|
|
id: string,
|
2021-12-02 17:49:13 +01:00
|
|
|
inputRef: any,
|
|
|
|
isLivestream?: boolean,
|
|
|
|
maxLength?: number,
|
|
|
|
placeholder?: string,
|
|
|
|
type?: string,
|
2021-12-06 18:28:36 +01:00
|
|
|
uri?: string,
|
2021-12-02 17:49:13 +01:00
|
|
|
value: any,
|
2021-12-06 18:28:36 +01:00
|
|
|
doResolveUris: (Array<string>) => void,
|
|
|
|
onBlur: (any) => any,
|
2021-12-02 17:49:13 +01:00
|
|
|
onChange: (any) => any,
|
2021-12-06 18:28:36 +01:00
|
|
|
onFocus: (any) => any,
|
2021-12-02 17:49:13 +01:00
|
|
|
};
|
|
|
|
|
|
|
|
export default function TextareaWithSuggestions(props: Props) {
|
|
|
|
const {
|
|
|
|
canonicalCommentors,
|
|
|
|
canonicalCreatorUri,
|
2021-12-06 18:28:36 +01:00
|
|
|
canonicalSubscriptions: canonicalSubs,
|
2021-12-02 17:49:13 +01:00
|
|
|
className,
|
|
|
|
commentorUris,
|
2021-12-06 18:28:36 +01:00
|
|
|
disabled,
|
|
|
|
id,
|
2021-12-02 17:49:13 +01:00
|
|
|
inputRef,
|
|
|
|
isLivestream,
|
|
|
|
maxLength,
|
|
|
|
placeholder,
|
|
|
|
type,
|
|
|
|
value: commentValue,
|
2021-12-06 18:28:36 +01:00
|
|
|
doResolveUris,
|
|
|
|
onBlur,
|
2021-12-02 17:49:13 +01:00
|
|
|
onChange,
|
2021-12-06 18:28:36 +01:00
|
|
|
onFocus,
|
2021-12-02 17:49:13 +01:00
|
|
|
} = props;
|
|
|
|
|
2021-12-06 18:28:36 +01:00
|
|
|
const inputDefaultProps = { className, placeholder, maxLength, type, disabled };
|
2021-12-02 17:49:13 +01:00
|
|
|
|
|
|
|
const [suggestionValue, setSuggestionValue] = React.useState(undefined);
|
2021-12-06 18:28:36 +01:00
|
|
|
const [selectedValue, setSelectedValue] = React.useState(undefined);
|
|
|
|
const [highlightedSuggestion, setHighlightedSuggestion] = React.useState('');
|
|
|
|
const [shouldClose, setClose] = React.useState();
|
2021-12-02 17:49:13 +01:00
|
|
|
|
|
|
|
const suggestionTerm = suggestionValue && suggestionValue.term;
|
|
|
|
|
2021-12-06 18:28:36 +01:00
|
|
|
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 allOptions = [];
|
|
|
|
if (canonicalCreatorUri) allOptions.push(canonicalCreatorUri);
|
|
|
|
if (filteredSubs) allOptions.push(...filteredSubs);
|
|
|
|
if (filteredCommentors) allOptions.push(...filteredCommentors);
|
|
|
|
|
|
|
|
const allOptionsGrouped =
|
|
|
|
allOptions.length > 0
|
|
|
|
? allOptions.map((option) => {
|
|
|
|
const groupName =
|
|
|
|
(canonicalCreatorUri === option && __('Creator')) ||
|
|
|
|
(filteredSubs && filteredSubs.includes(option) && __('Following')) ||
|
|
|
|
(filteredCommentors && filteredCommentors.includes(option) && __('From comments'));
|
|
|
|
|
|
|
|
return {
|
|
|
|
uri: option.replace('lbry://', '').replace('#', ':'),
|
|
|
|
group: groupName,
|
|
|
|
};
|
|
|
|
})
|
|
|
|
: [];
|
2021-12-02 17:49:13 +01:00
|
|
|
|
2021-12-06 18:28:36 +01:00
|
|
|
const allMatches = useSuggestionMatch(
|
|
|
|
suggestionTerm || '',
|
|
|
|
allOptionsGrouped.map(({ uri }) => uri)
|
|
|
|
);
|
2021-12-02 17:49:13 +01:00
|
|
|
|
|
|
|
/** --------- **/
|
|
|
|
/** Functions **/
|
|
|
|
/** --------- **/
|
|
|
|
|
2021-12-06 18:28:36 +01:00
|
|
|
function handleInputChange(value: string) {
|
|
|
|
onChange({ target: { value } });
|
2021-12-02 17:49:13 +01:00
|
|
|
|
|
|
|
const cursorIndex = inputRef && inputRef.current && inputRef.current.selectionStart;
|
|
|
|
const mentionMatches = value.match(mentionRegex);
|
|
|
|
|
|
|
|
const matchIndexes = [];
|
|
|
|
let mentionIndex;
|
|
|
|
let mentionLastIndex;
|
|
|
|
|
|
|
|
const mentionValue =
|
|
|
|
mentionMatches &&
|
|
|
|
mentionMatches.find((match, index) => {
|
|
|
|
const previousIndex = matchIndexes[index - 1] + 1 || 0;
|
|
|
|
mentionIndex = value.substring(previousIndex).search(mentionRegex) + previousIndex;
|
|
|
|
matchIndexes.push(mentionIndex);
|
|
|
|
|
|
|
|
// 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 (matchIndexes) {
|
|
|
|
mentionLastIndex = mentionIndex + match.length;
|
|
|
|
|
|
|
|
if (cursorIndex >= mentionIndex && cursorIndex <= mentionLastIndex) {
|
|
|
|
return match;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
if (mentionValue) {
|
|
|
|
// $FlowFixMe
|
|
|
|
setSuggestionValue({ term: mentionValue, index: mentionIndex, lastIndex: mentionLastIndex });
|
|
|
|
} else if (suggestionValue) {
|
|
|
|
setSuggestionValue(undefined);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
const handleSelect = React.useCallback(
|
|
|
|
(selectedValue: string) => {
|
2021-12-06 18:28:36 +01:00
|
|
|
setSelectedValue(selectedValue);
|
|
|
|
|
2021-12-02 17:49:13 +01:00
|
|
|
if (!suggestionValue) return;
|
|
|
|
|
2021-12-06 18:28:36 +01:00
|
|
|
const elem = inputRef && inputRef.current;
|
|
|
|
const newCursorPos = suggestionValue.index + selectedValue.length + 1;
|
|
|
|
|
2021-12-02 17:49:13 +01:00
|
|
|
const newValue =
|
|
|
|
commentValue.substring(0, suggestionValue.index) + // 1) From start of comment value until term start
|
|
|
|
`${selectedValue}` + // 2) Add the selected value
|
|
|
|
(commentValue.length > suggestionValue.lastIndex // 3) If there is more content until the the end of the comment value:
|
2021-12-06 18:28:36 +01:00
|
|
|
? commentValue.substring(suggestionValue.lastIndex, commentValue.length) // 3.a) from term end, add the rest of comment value
|
2021-12-02 17:49:13 +01:00
|
|
|
: ' '); // 3.b) or else, add a space for new input after
|
|
|
|
|
|
|
|
onChange({ target: { value: newValue } });
|
2021-12-06 18:28:36 +01:00
|
|
|
setSuggestionValue(undefined);
|
|
|
|
elem.focus();
|
|
|
|
elem.setSelectionRange(newCursorPos, newCursorPos);
|
2021-12-02 17:49:13 +01:00
|
|
|
},
|
|
|
|
[commentValue, inputRef, onChange, suggestionValue]
|
|
|
|
);
|
|
|
|
|
|
|
|
/** ------- **/
|
|
|
|
/** Effects **/
|
|
|
|
/** ------- **/
|
|
|
|
|
2021-12-06 18:28:36 +01:00
|
|
|
// For disabling sending on Enter on Livestream chat
|
2021-12-02 17:49:13 +01:00
|
|
|
React.useEffect(() => {
|
2021-12-06 18:28:36 +01:00
|
|
|
if (!isLivestream) return;
|
2021-12-02 17:49:13 +01:00
|
|
|
|
2021-12-06 18:28:36 +01:00
|
|
|
if (suggestionTerm && inputRef) {
|
|
|
|
inputRef.current.setAttribute('term', suggestionTerm);
|
|
|
|
} else {
|
|
|
|
inputRef.current.removeAttribute('term');
|
2021-12-02 17:49:13 +01:00
|
|
|
}
|
2021-12-06 18:28:36 +01:00
|
|
|
}, [inputRef, isLivestream, suggestionTerm]);
|
2021-12-02 17:49:13 +01:00
|
|
|
|
2021-12-06 18:28:36 +01:00
|
|
|
// Only resolve commentors on Livestreams when first trying to mention/looking for it
|
2021-12-02 17:49:13 +01:00
|
|
|
React.useEffect(() => {
|
2021-12-06 18:28:36 +01:00
|
|
|
if (isLivestream && commentorUris && suggestionTerm) doResolveUris(commentorUris);
|
|
|
|
}, [commentorUris, doResolveUris, isLivestream, suggestionTerm]);
|
2021-12-02 17:49:13 +01:00
|
|
|
|
2021-12-06 18:28:36 +01:00
|
|
|
// Allow selecting with TAB key
|
2021-12-02 17:49:13 +01:00
|
|
|
React.useEffect(() => {
|
|
|
|
function handleKeyDown(e: SyntheticKeyboardEvent<*>) {
|
|
|
|
const { keyCode } = e;
|
|
|
|
|
2021-12-06 18:28:36 +01:00
|
|
|
if (highlightedSuggestion && keyCode === KEYCODES.TAB) {
|
2021-12-02 17:49:13 +01:00
|
|
|
e.preventDefault();
|
2021-12-06 18:28:36 +01:00
|
|
|
handleSelect(highlightedSuggestion.uri);
|
2021-12-02 17:49:13 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
|
|
|
|
|
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
2021-12-06 18:28:36 +01:00
|
|
|
}, [handleSelect, highlightedSuggestion]);
|
2021-12-02 17:49:13 +01:00
|
|
|
|
|
|
|
/** ------ **/
|
|
|
|
/** Render **/
|
|
|
|
/** ------ **/
|
|
|
|
|
2021-12-06 18:28:36 +01:00
|
|
|
const renderGroup = (group: string, children: any) => (
|
|
|
|
<div className="textareaSuggestions__group">
|
|
|
|
<label className="textareaSuggestions__label">{group}</label>
|
|
|
|
{children}
|
2021-12-02 17:49:13 +01:00
|
|
|
<hr className="textareaSuggestions__topSeparator" />
|
|
|
|
</div>
|
|
|
|
);
|
|
|
|
|
2021-12-06 18:28:36 +01:00
|
|
|
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} />;
|
|
|
|
};
|
|
|
|
|
2021-12-02 17:49:13 +01:00
|
|
|
return (
|
2021-12-06 18:28:36 +01:00
|
|
|
<Autocomplete
|
|
|
|
autoHighlight
|
|
|
|
disableClearable
|
|
|
|
filterOptions={(options) => options.filter(({ uri }) => allMatches && allMatches.includes(uri))}
|
|
|
|
freeSolo
|
|
|
|
fullWidth
|
|
|
|
getOptionLabel={(option) => option.uri}
|
|
|
|
groupBy={(option) => option.group}
|
|
|
|
id={id}
|
|
|
|
inputValue={commentValue}
|
|
|
|
loading={!allMatches || allMatches.length === 0}
|
|
|
|
loadingText={__('Nothing found')}
|
|
|
|
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.uri)}
|
|
|
|
onClose={(event, reason) => reason !== 'selectOption' && setClose(true)}
|
|
|
|
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
|
|
|
|
or else it will be displayed all the time as empty */
|
|
|
|
open={!!suggestionTerm && !shouldClose}
|
|
|
|
options={allOptionsGrouped}
|
|
|
|
renderGroup={({ group, children }) => renderGroup(group, children)}
|
|
|
|
renderInput={(params) => renderInput(params)}
|
|
|
|
renderOption={(optionProps, option) => <TextareaSuggestionsItem uri={option.uri} {...optionProps} />}
|
|
|
|
value={selectedValue}
|
|
|
|
/>
|
2021-12-02 17:49:13 +01:00
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
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]);
|
|
|
|
}
|