Add support for suggesting Emotes while typing ':'

This commit is contained in:
Rafael 2021-12-06 15:06:40 -03:00 committed by Thomas Zarebczan
parent ea84d1af56
commit 4ce3881636
2 changed files with 143 additions and 61 deletions

View file

@ -4,14 +4,30 @@ import React from 'react';
type Props = { type Props = {
claim?: Claim, claim?: Claim,
emote?: any,
uri?: string, uri?: string,
}; };
export default function TextareaSuggestionsItem(props: Props) { export default function TextareaSuggestionsItem(props: Props) {
const { claim, uri, ...autocompleteProps } = props; const { claim, emote, uri, ...autocompleteProps } = props;
if (!claim) return null; if (emote) {
const { name: value, url } = emote;
return (
<div {...autocompleteProps}>
<img className="emote" src={url} />
<div className="textareaSuggestion__label">
<span className="textareaSuggestion__title textareaSuggestion__value textareaSuggestion__value--emote">
{value}
</span>
</div>
</div>
);
}
if (claim) {
const value = claim.canonical_url.replace('lbry://', '').replace('#', ':'); const value = claim.canonical_url.replace('lbry://', '').replace('#', ':');
return ( return (
@ -25,3 +41,6 @@ export default function TextareaSuggestionsItem(props: Props) {
</div> </div>
); );
} }
return null;
}

View file

@ -1,4 +1,5 @@
// @flow // @flow
import { EMOTES_48px as EMOTES } from 'constants/emotes';
import { matchSorter } from 'match-sorter'; import { matchSorter } from 'match-sorter';
import * as KEYCODES from 'constants/keycodes'; import * as KEYCODES from 'constants/keycodes';
import Autocomplete from '@mui/material/Autocomplete'; import Autocomplete from '@mui/material/Autocomplete';
@ -7,7 +8,21 @@ import TextareaSuggestionsItem from 'component/textareaSuggestionsItem';
import TextField from '@mui/material/TextField'; import TextField from '@mui/material/TextField';
import useThrottle from 'effects/use-throttle'; import useThrottle from 'effects/use-throttle';
const mentionRegex = /@[^\s=&#:$@%?;/\\"<>%{}|^~[]*/gm; const SUGGESTION_REGEX = new RegExp(
'(?<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
*
*/
type Props = { type Props = {
canonicalCommentors?: Array<string>, canonicalCommentors?: Array<string>,
@ -44,7 +59,7 @@ export default function TextareaWithSuggestions(props: Props) {
maxLength, maxLength,
placeholder, placeholder,
type, type,
value: commentValue, value: messageValue,
doResolveUris, doResolveUris,
onBlur, onBlur,
onChange, onChange,
@ -59,26 +74,33 @@ export default function TextareaWithSuggestions(props: Props) {
const [shouldClose, setClose] = React.useState(); const [shouldClose, setClose] = React.useState();
const suggestionTerm = suggestionValue && suggestionValue.term; const suggestionTerm = suggestionValue && suggestionValue.term;
const isEmote = suggestionValue && suggestionValue.isEmote;
const shouldFilter = (uri, previous) => uri !== canonicalCreatorUri && (!previous || !previous.includes(uri)); const shouldFilter = (uri, previous) => uri !== canonicalCreatorUri && (!previous || !previous.includes(uri));
const filteredCommentors = canonicalCommentors && canonicalCommentors.filter((uri) => shouldFilter(uri)); const filteredCommentors = canonicalCommentors && canonicalCommentors.filter((uri) => shouldFilter(uri));
const filteredSubs = canonicalSubs && canonicalSubs.filter((uri) => shouldFilter(uri, filteredCommentors)); const filteredSubs = canonicalSubs && canonicalSubs.filter((uri) => shouldFilter(uri, filteredCommentors));
const allOptions = []; const allOptions = [];
if (isEmote) {
const emoteNames = EMOTES.map(({ name }) => name.toLowerCase());
allOptions.push(...emoteNames);
} else {
if (canonicalCreatorUri) allOptions.push(canonicalCreatorUri); if (canonicalCreatorUri) allOptions.push(canonicalCreatorUri);
if (filteredSubs) allOptions.push(...filteredSubs); if (filteredSubs) allOptions.push(...filteredSubs);
if (filteredCommentors) allOptions.push(...filteredCommentors); if (filteredCommentors) allOptions.push(...filteredCommentors);
}
const allOptionsGrouped = const allOptionsGrouped =
allOptions.length > 0 allOptions.length > 0
? allOptions.map((option) => { ? allOptions.map((option) => {
const groupName = const groupName = isEmote
(canonicalCreatorUri === option && __('Creator')) || ? __('Emotes')
: (canonicalCreatorUri === option && __('Creator')) ||
(filteredSubs && filteredSubs.includes(option) && __('Following')) || (filteredSubs && filteredSubs.includes(option) && __('Following')) ||
(filteredCommentors && filteredCommentors.includes(option) && __('From comments')); (filteredCommentors && filteredCommentors.includes(option) && __('From comments'));
return { return {
uri: option.replace('lbry://', '').replace('#', ':'), label: isEmote ? option : option.replace('lbry://', '').replace('#', ':'),
group: groupName, group: groupName,
}; };
}) })
@ -86,7 +108,7 @@ export default function TextareaWithSuggestions(props: Props) {
const allMatches = useSuggestionMatch( const allMatches = useSuggestionMatch(
suggestionTerm || '', suggestionTerm || '',
allOptionsGrouped.map(({ uri }) => uri) allOptionsGrouped.map(({ label }) => label)
); );
/** --------- **/ /** --------- **/
@ -97,35 +119,65 @@ export default function TextareaWithSuggestions(props: Props) {
onChange({ target: { value } }); onChange({ target: { value } });
const cursorIndex = inputRef && inputRef.current && inputRef.current.selectionStart; const cursorIndex = inputRef && inputRef.current && inputRef.current.selectionStart;
const mentionMatches = value.match(mentionRegex);
const matchIndexes = []; const suggestionMatches = value.match(SUGGESTION_REGEX);
let mentionIndex;
let mentionLastIndex;
const mentionValue = if (!suggestionMatches) {
mentionMatches && if (suggestionValue) setSuggestionValue(undefined);
mentionMatches.find((match, index) => { return; // Exit here and avoid unnecessary behavior
const previousIndex = matchIndexes[index - 1] + 1 || 0; }
mentionIndex = value.substring(previousIndex).search(mentionRegex) + previousIndex;
matchIndexes.push(mentionIndex); 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, // 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 // in case of there being more in the same comment message
if (matchIndexes) { if (previousLastIndexes) {
mentionLastIndex = mentionIndex + match.length; return cursorIndex >= currentSuggestionIndex && cursorIndex <= currentLastIndex;
if (cursorIndex >= mentionIndex && cursorIndex <= mentionLastIndex) {
return match;
}
} }
}); });
}
if (currentSuggestionValue) {
const token = isEmote ? ':' : '@';
const tokenIndex = currentSuggestionValue.indexOf(token);
if (mentionValue) {
// $FlowFixMe // $FlowFixMe
setSuggestionValue({ term: mentionValue, index: mentionIndex, lastIndex: mentionLastIndex }); setSuggestionValue({
} else if (suggestionValue) { beforeTerm: currentSuggestionValue.substring(0, tokenIndex), // in case of a space or newline
setSuggestionValue(undefined); term: currentSuggestionValue.substring(tokenIndex),
index: currentSuggestionIndex,
lastIndex: currentLastIndex,
isEmote,
});
} }
} }
@ -136,28 +188,30 @@ export default function TextareaWithSuggestions(props: Props) {
if (!suggestionValue) return; if (!suggestionValue) return;
const elem = inputRef && inputRef.current; const elem = inputRef && inputRef.current;
const newCursorPos = suggestionValue.index + selectedValue.length + 1; const newCursorPos = suggestionValue.beforeTerm.length + suggestionValue.index + selectedValue.length + 1;
const newValue = const contentBegin = messageValue.substring(0, suggestionValue.index);
commentValue.substring(0, suggestionValue.index) + // 1) From start of comment value until term start const replaceValue = suggestionValue.beforeTerm + selectedValue;
`${selectedValue}` + // 2) Add the selected value const contentEnd =
(commentValue.length > suggestionValue.lastIndex // 3) If there is more content until the the end of the comment value: messageValue.length > suggestionValue.lastIndex
? commentValue.substring(suggestionValue.lastIndex, commentValue.length) // 3.a) from term end, add the rest of comment value ? messageValue.substring(suggestionValue.lastIndex, messageValue.length)
: ' '); // 3.b) or else, add a space for new input after : ' ';
const newValue = contentBegin + replaceValue + contentEnd;
onChange({ target: { value: newValue } }); onChange({ target: { value: newValue } });
setSuggestionValue(undefined); setSuggestionValue(undefined);
elem.focus(); elem.focus();
elem.setSelectionRange(newCursorPos, newCursorPos); elem.setSelectionRange(newCursorPos, newCursorPos);
}, },
[commentValue, inputRef, onChange, suggestionValue] [messageValue, inputRef, onChange, suggestionValue]
); );
/** ------- **/ /** ------- **/
/** Effects **/ /** Effects **/
/** ------- **/ /** ------- **/
// For disabling sending on Enter on Livestream chat // Disable sending on Enter on Livestream chat
React.useEffect(() => { React.useEffect(() => {
if (!isLivestream) return; if (!isLivestream) return;
@ -175,19 +229,21 @@ export default function TextareaWithSuggestions(props: Props) {
// Allow selecting with TAB key // Allow selecting with TAB key
React.useEffect(() => { React.useEffect(() => {
if (!suggestionTerm) return; // only if there is a term, or else can't tab to navigate page
function handleKeyDown(e: SyntheticKeyboardEvent<*>) { function handleKeyDown(e: SyntheticKeyboardEvent<*>) {
const { keyCode } = e; const { keyCode } = e;
if (highlightedSuggestion && keyCode === KEYCODES.TAB) { if (highlightedSuggestion && keyCode === KEYCODES.TAB) {
e.preventDefault(); e.preventDefault();
handleSelect(highlightedSuggestion.uri); handleSelect(highlightedSuggestion.label);
} }
} }
window.addEventListener('keydown', handleKeyDown); window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown);
}, [handleSelect, highlightedSuggestion]); }, [handleSelect, highlightedSuggestion, suggestionTerm]);
/** ------ **/ /** ------ **/
/** Render **/ /** Render **/
@ -209,25 +265,32 @@ export default function TextareaWithSuggestions(props: Props) {
return <TextField inputRef={inputRef} multiline select={false} {...autocompleteProps} />; 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;
return <TextareaSuggestionsItem uri={label} emote={emoteValue} {...optionProps} />;
};
return ( return (
<Autocomplete <Autocomplete
autoHighlight autoHighlight
disableClearable disableClearable
filterOptions={(options) => options.filter(({ uri }) => allMatches && allMatches.includes(uri))} filterOptions={(options) => options.filter(({ label }) => allMatches && allMatches.includes(label))}
freeSolo freeSolo
fullWidth fullWidth
getOptionLabel={(option) => option.uri} getOptionLabel={(option) => option.label}
groupBy={(option) => option.group} groupBy={(option) => option.group}
id={id} id={id}
inputValue={commentValue} inputValue={messageValue}
loading={!allMatches || allMatches.length === 0} loading={!allMatches || allMatches.length === 0}
loadingText={__('Nothing found')} loadingText={__('Nothing found')}
onBlur={() => onBlur()} onBlur={() => onBlur && onBlur()}
/* Different from onInputChange, onChange is only used for the selected value, /* 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) */ so here it is acting simply as a selection handler (see it as onSelect) */
onChange={(event, value) => handleSelect(value.uri)} onChange={(event, value) => handleSelect(value.label)}
onClose={(event, reason) => reason !== 'selectOption' && setClose(true)} onClose={(event, reason) => reason !== 'selectOption' && setClose(true)}
onFocus={() => onFocus()} onFocus={() => onFocus && onFocus()}
onHighlightChange={(event, option) => setHighlightedSuggestion(option)} onHighlightChange={(event, option) => setHighlightedSuggestion(option)}
onInputChange={(event, value, reason) => reason === 'input' && handleInputChange(value)} onInputChange={(event, value, reason) => reason === 'input' && handleInputChange(value)}
onOpen={() => suggestionTerm && setClose(false)} onOpen={() => suggestionTerm && setClose(false)}
@ -237,7 +300,7 @@ export default function TextareaWithSuggestions(props: Props) {
options={allOptionsGrouped} options={allOptionsGrouped}
renderGroup={({ group, children }) => renderGroup(group, children)} renderGroup={({ group, children }) => renderGroup(group, children)}
renderInput={(params) => renderInput(params)} renderInput={(params) => renderInput(params)}
renderOption={(optionProps, option) => <TextareaSuggestionsItem uri={option.uri} {...optionProps} />} renderOption={(optionProps, option) => renderOption(optionProps, option.label)}
value={selectedValue} value={selectedValue}
/> />
); );