Add support for suggesting Emotes while typing ':'
This commit is contained in:
parent
ea84d1af56
commit
4ce3881636
2 changed files with 143 additions and 61 deletions
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
Loading…
Reference in a new issue