diff --git a/ui/component/textareaSuggestionsItem/view.jsx b/ui/component/textareaSuggestionsItem/view.jsx
index d116ead65..129f05dcd 100644
--- a/ui/component/textareaSuggestionsItem/view.jsx
+++ b/ui/component/textareaSuggestionsItem/view.jsx
@@ -4,24 +4,43 @@ import React from 'react';
type Props = {
claim?: Claim,
+ emote?: any,
uri?: string,
};
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;
- const value = claim.canonical_url.replace('lbry://', '').replace('#', ':');
+ return (
+
+
- return (
-
-
-
-
-
{(claim.value && claim.value.title) || value}
-
{value}
+
+
+ {value}
+
+
-
- );
+ );
+ }
+
+ if (claim) {
+ const value = claim.canonical_url.replace('lbry://', '').replace('#', ':');
+
+ return (
+
+
+
+
+ {(claim.value && claim.value.title) || value}
+ {value}
+
+
+ );
+ }
+
+ return null;
}
diff --git a/ui/component/textareaWithSuggestions/view.jsx b/ui/component/textareaWithSuggestions/view.jsx
index 4c6378cd0..fd1e22c2b 100644
--- a/ui/component/textareaWithSuggestions/view.jsx
+++ b/ui/component/textareaWithSuggestions/view.jsx
@@ -1,4 +1,5 @@
// @flow
+import { EMOTES_48px as EMOTES } from 'constants/emotes';
import { matchSorter } from 'match-sorter';
import * as KEYCODES from 'constants/keycodes';
import Autocomplete from '@mui/material/Autocomplete';
@@ -7,7 +8,21 @@ import TextareaSuggestionsItem from 'component/textareaSuggestionsItem';
import TextField from '@mui/material/TextField';
import useThrottle from 'effects/use-throttle';
-const mentionRegex = /@[^\s=:$@%?;/\\"<>%{}|^~[]*/gm;
+const SUGGESTION_REGEX = new RegExp(
+ '(?
(?:^| |\n)@[^\\s=$@%?:;/\\"<>%{}|^~[]*(?::[\\w]+)?)|(?(?:^| |\n):[\\w]*:?)',
+ 'gm'
+);
+
+/** Regex Explained step-by-step:
+ *
+ * 1) (?....) = 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 = {
canonicalCommentors?: Array,
@@ -44,7 +59,7 @@ export default function TextareaWithSuggestions(props: Props) {
maxLength,
placeholder,
type,
- value: commentValue,
+ value: messageValue,
doResolveUris,
onBlur,
onChange,
@@ -59,26 +74,33 @@ export default function TextareaWithSuggestions(props: Props) {
const [shouldClose, setClose] = React.useState();
const suggestionTerm = suggestionValue && suggestionValue.term;
+ const isEmote = suggestionValue && suggestionValue.isEmote;
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);
+ if (isEmote) {
+ const emoteNames = EMOTES.map(({ name }) => name.toLowerCase());
+ allOptions.push(...emoteNames);
+ } else {
+ 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'));
+ const groupName = isEmote
+ ? __('Emotes')
+ : (canonicalCreatorUri === option && __('Creator')) ||
+ (filteredSubs && filteredSubs.includes(option) && __('Following')) ||
+ (filteredCommentors && filteredCommentors.includes(option) && __('From comments'));
return {
- uri: option.replace('lbry://', '').replace('#', ':'),
+ label: isEmote ? option : option.replace('lbry://', '').replace('#', ':'),
group: groupName,
};
})
@@ -86,7 +108,7 @@ export default function TextareaWithSuggestions(props: Props) {
const allMatches = useSuggestionMatch(
suggestionTerm || '',
- allOptionsGrouped.map(({ uri }) => uri)
+ allOptionsGrouped.map(({ label }) => label)
);
/** --------- **/
@@ -97,35 +119,65 @@ export default function TextareaWithSuggestions(props: Props) {
onChange({ target: { value } });
const cursorIndex = inputRef && inputRef.current && inputRef.current.selectionStart;
- const mentionMatches = value.match(mentionRegex);
- const matchIndexes = [];
- let mentionIndex;
- let mentionLastIndex;
+ const suggestionMatches = value.match(SUGGESTION_REGEX);
- const mentionValue =
- mentionMatches &&
- mentionMatches.find((match, index) => {
- const previousIndex = matchIndexes[index - 1] + 1 || 0;
- mentionIndex = value.substring(previousIndex).search(mentionRegex) + previousIndex;
- matchIndexes.push(mentionIndex);
+ 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 (matchIndexes) {
- mentionLastIndex = mentionIndex + match.length;
-
- if (cursorIndex >= mentionIndex && cursorIndex <= mentionLastIndex) {
- return match;
- }
+ if (previousLastIndexes) {
+ return cursorIndex >= currentSuggestionIndex && cursorIndex <= currentLastIndex;
}
});
+ }
+
+ if (currentSuggestionValue) {
+ const token = isEmote ? ':' : '@';
+ const tokenIndex = currentSuggestionValue.indexOf(token);
- if (mentionValue) {
// $FlowFixMe
- setSuggestionValue({ term: mentionValue, index: mentionIndex, lastIndex: mentionLastIndex });
- } else if (suggestionValue) {
- setSuggestionValue(undefined);
+ setSuggestionValue({
+ beforeTerm: currentSuggestionValue.substring(0, tokenIndex), // in case of a space or newline
+ term: currentSuggestionValue.substring(tokenIndex),
+ index: currentSuggestionIndex,
+ lastIndex: currentLastIndex,
+ isEmote,
+ });
}
}
@@ -136,28 +188,30 @@ export default function TextareaWithSuggestions(props: Props) {
if (!suggestionValue) return;
const elem = inputRef && inputRef.current;
- const newCursorPos = suggestionValue.index + selectedValue.length + 1;
+ const newCursorPos = suggestionValue.beforeTerm.length + suggestionValue.index + selectedValue.length + 1;
- 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:
- ? commentValue.substring(suggestionValue.lastIndex, commentValue.length) // 3.a) from term end, add the rest of comment value
- : ' '); // 3.b) or else, add a space for new input after
+ 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);
},
- [commentValue, inputRef, onChange, suggestionValue]
+ [messageValue, inputRef, onChange, suggestionValue]
);
/** ------- **/
/** Effects **/
/** ------- **/
- // For disabling sending on Enter on Livestream chat
+ // Disable sending on Enter on Livestream chat
React.useEffect(() => {
if (!isLivestream) return;
@@ -175,19 +229,21 @@ export default function TextareaWithSuggestions(props: Props) {
// 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.uri);
+ handleSelect(highlightedSuggestion.label);
}
}
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
- }, [handleSelect, highlightedSuggestion]);
+ }, [handleSelect, highlightedSuggestion, suggestionTerm]);
/** ------ **/
/** Render **/
@@ -209,25 +265,32 @@ export default function TextareaWithSuggestions(props: Props) {
return ;
};
+ 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 ;
+ };
+
return (
options.filter(({ uri }) => allMatches && allMatches.includes(uri))}
+ filterOptions={(options) => options.filter(({ label }) => allMatches && allMatches.includes(label))}
freeSolo
fullWidth
- getOptionLabel={(option) => option.uri}
+ getOptionLabel={(option) => option.label}
groupBy={(option) => option.group}
id={id}
- inputValue={commentValue}
+ inputValue={messageValue}
loading={!allMatches || allMatches.length === 0}
loadingText={__('Nothing found')}
- onBlur={() => onBlur()}
+ 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.uri)}
+ onChange={(event, value) => handleSelect(value.label)}
onClose={(event, reason) => reason !== 'selectOption' && setClose(true)}
- onFocus={() => onFocus()}
+ onFocus={() => onFocus && onFocus()}
onHighlightChange={(event, option) => setHighlightedSuggestion(option)}
onInputChange={(event, value, reason) => reason === 'input' && handleInputChange(value)}
onOpen={() => suggestionTerm && setClose(false)}
@@ -237,7 +300,7 @@ export default function TextareaWithSuggestions(props: Props) {
options={allOptionsGrouped}
renderGroup={({ group, children }) => renderGroup(group, children)}
renderInput={(params) => renderInput(params)}
- renderOption={(optionProps, option) => }
+ renderOption={(optionProps, option) => renderOption(optionProps, option.label)}
value={selectedValue}
/>
);