Move channel mentioning to use @mui/Autocomplete combobox without search functionality
This commit is contained in:
parent
a459e98cab
commit
ea84d1af56
10 changed files with 213 additions and 263 deletions
|
@ -84,7 +84,6 @@ export function CommentCreate(props: Props) {
|
||||||
settingsByChannelId,
|
settingsByChannelId,
|
||||||
shouldFetchComment,
|
shouldFetchComment,
|
||||||
supportDisabled,
|
supportDisabled,
|
||||||
uri,
|
|
||||||
createComment,
|
createComment,
|
||||||
doFetchCreatorSettings,
|
doFetchCreatorSettings,
|
||||||
doToast,
|
doToast,
|
||||||
|
@ -156,7 +155,10 @@ export function CommentCreate(props: Props) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function altEnterListener(e: SyntheticKeyboardEvent<*>) {
|
function altEnterListener(e: SyntheticKeyboardEvent<*>) {
|
||||||
if ((isLivestream || e.ctrlKey || e.metaKey) && e.keyCode === KEYCODES.ENTER) {
|
// $FlowFixMe
|
||||||
|
const isTyping = e.target.attributes['term'];
|
||||||
|
|
||||||
|
if (((isLivestream && !isTyping) || e.ctrlKey || e.metaKey) && e.keyCode === KEYCODES.ENTER) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
buttonRef.current.click();
|
buttonRef.current.click();
|
||||||
}
|
}
|
||||||
|
@ -443,10 +445,11 @@ export function CommentCreate(props: Props) {
|
||||||
closeSelector={() => setShowEmotes(false)}
|
closeSelector={() => setShowEmotes(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
autoFocus={isReply}
|
autoFocus={isReply}
|
||||||
charCount={charCount}
|
charCount={charCount}
|
||||||
className={isReply ? 'create__reply' : 'create___comment'}
|
className={isReply ? 'create__reply' : 'create__comment'}
|
||||||
disabled={isFetchingChannels}
|
disabled={isFetchingChannels}
|
||||||
isLivestream={isLivestream}
|
isLivestream={isLivestream}
|
||||||
label={
|
label={
|
||||||
|
@ -457,7 +460,7 @@ export function CommentCreate(props: Props) {
|
||||||
<SelectChannel tiny />
|
<SelectChannel tiny />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
name={isReply ? 'create__reply' : 'create___comment'}
|
name={isReply ? 'create__reply' : 'create__comment'}
|
||||||
onBlur={() => window.removeEventListener('keydown', altEnterListener)}
|
onBlur={() => window.removeEventListener('keydown', altEnterListener)}
|
||||||
onChange={(e) => setCommentValue(SIMPLE_SITE || !advancedEditor || isReply ? e.target.value : e)}
|
onChange={(e) => setCommentValue(SIMPLE_SITE || !advancedEditor || isReply ? e.target.value : e)}
|
||||||
onFocus={() => window.addEventListener('keydown', altEnterListener)}
|
onFocus={() => window.addEventListener('keydown', altEnterListener)}
|
||||||
|
@ -470,7 +473,6 @@ export function CommentCreate(props: Props) {
|
||||||
ref={formFieldRef}
|
ref={formFieldRef}
|
||||||
textAreaMaxLength={isLivestream ? FF_MAX_CHARS_IN_LIVESTREAM_COMMENT : FF_MAX_CHARS_IN_COMMENT}
|
textAreaMaxLength={isLivestream ? FF_MAX_CHARS_IN_LIVESTREAM_COMMENT : FF_MAX_CHARS_IN_COMMENT}
|
||||||
type={!SIMPLE_SITE && advancedEditor && !isReply ? 'markdown' : 'textarea'}
|
type={!SIMPLE_SITE && advancedEditor && !isReply ? 'markdown' : 'textarea'}
|
||||||
uri={uri}
|
|
||||||
value={commentValue}
|
value={commentValue}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -39,7 +39,6 @@ type Props = {
|
||||||
stretch?: boolean,
|
stretch?: boolean,
|
||||||
textAreaMaxLength?: number,
|
textAreaMaxLength?: number,
|
||||||
type?: string,
|
type?: string,
|
||||||
uri?: string,
|
|
||||||
value?: string | number,
|
value?: string | number,
|
||||||
onChange?: (any) => any,
|
onChange?: (any) => any,
|
||||||
openEmoteMenu?: () => void,
|
openEmoteMenu?: () => void,
|
||||||
|
@ -86,7 +85,6 @@ export class FormField extends React.PureComponent<Props> {
|
||||||
stretch,
|
stretch,
|
||||||
textAreaMaxLength,
|
textAreaMaxLength,
|
||||||
type,
|
type,
|
||||||
uri,
|
|
||||||
openEmoteMenu,
|
openEmoteMenu,
|
||||||
quickActionHandler,
|
quickActionHandler,
|
||||||
render,
|
render,
|
||||||
|
@ -254,8 +252,6 @@ export class FormField extends React.PureComponent<Props> {
|
||||||
id={name}
|
id={name}
|
||||||
maxLength={textAreaMaxLength || FF_MAX_CHARS_DEFAULT}
|
maxLength={textAreaMaxLength || FF_MAX_CHARS_DEFAULT}
|
||||||
inputRef={this.input}
|
inputRef={this.input}
|
||||||
hideSuggestions={hideSuggestions}
|
|
||||||
uri={uri}
|
|
||||||
isLivestream={isLivestream}
|
isLivestream={isLivestream}
|
||||||
{...inputProps}
|
{...inputProps}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -1,10 +1,9 @@
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import { selectClaimForUri, selectIsUriResolving } from 'redux/selectors/claims';
|
import { selectClaimForUri } from 'redux/selectors/claims';
|
||||||
import TextareaSuggestionsItem from './view';
|
import TextareaSuggestionsItem from './view';
|
||||||
|
|
||||||
const select = (state, props) => ({
|
const select = (state, props) => ({
|
||||||
claim: props.uri && selectClaimForUri(state, props.uri),
|
claim: props.uri && selectClaimForUri(state, props.uri),
|
||||||
isResolvingUri: props.uri && selectIsUriResolving(state, props.uri),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export default connect(select)(TextareaSuggestionsItem);
|
export default connect(select)(TextareaSuggestionsItem);
|
||||||
|
|
|
@ -1,43 +1,27 @@
|
||||||
// @flow
|
// @flow
|
||||||
import { ComboboxOption, ComboboxOptionText } from '@reach/combobox';
|
|
||||||
import ChannelThumbnail from 'component/channelThumbnail';
|
import ChannelThumbnail from 'component/channelThumbnail';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
claim?: Claim,
|
claim?: Claim,
|
||||||
isResolvingUri: boolean,
|
|
||||||
uri?: string,
|
uri?: string,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function TextareaSuggestionsItem(props: Props) {
|
export default function TextareaSuggestionsItem(props: Props) {
|
||||||
const { claim, isResolvingUri, uri } = props;
|
const { claim, uri, ...autocompleteProps } = props;
|
||||||
|
|
||||||
if (!claim) return null;
|
if (!claim) return null;
|
||||||
|
|
||||||
if (isResolvingUri) {
|
const value = claim.canonical_url.replace('lbry://', '').replace('#', ':');
|
||||||
return (
|
|
||||||
<div className="textareaSuggestion">
|
|
||||||
<div className="media__thumb media__thumb--resolving" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const canonicalMention = claim.canonical_url.replace('lbry://', '').replace('#', ':');
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ComboboxOption value={canonicalMention}>
|
<div {...autocompleteProps}>
|
||||||
<div className="textareaSuggestion">
|
<ChannelThumbnail xsmall uri={uri} />
|
||||||
<ChannelThumbnail xsmall uri={uri} />
|
|
||||||
|
|
||||||
<div className="textareaSuggestion__label">
|
<div className="textareaSuggestion__label">
|
||||||
<span className="textareaSuggestion__title">
|
<span className="textareaSuggestion__title">{(claim.value && claim.value.title) || value}</span>
|
||||||
{(claim.value && claim.value.title) || <ComboboxOptionText />}
|
<span className="textareaSuggestion__value">{value}</span>
|
||||||
</span>
|
|
||||||
<span className="textareaSuggestion__value">
|
|
||||||
<ComboboxOptionText />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</ComboboxOption>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -32,7 +32,7 @@ export default function TextareaTopSuggestion(props: Props) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return filteredTop && filteredTop.length > 0 ? (
|
return filteredTop && filteredTop.length > 0 ? (
|
||||||
<div className="textareaSuggestions__row">
|
<div className="textareaSuggestions__group">
|
||||||
<div className="textareaSuggestions__label">
|
<div className="textareaSuggestions__label">
|
||||||
<LbcSymbol prefix={__('Most Supported')} />
|
<LbcSymbol prefix={__('Most Supported')} />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,7 +1,5 @@
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import { doResolveUris } from 'redux/actions/claims';
|
import { doResolveUris } from 'redux/actions/claims';
|
||||||
import { getChannelFromClaim } from 'util/claim';
|
|
||||||
import { makeSelectClaimForUri } from 'redux/selectors/claims';
|
|
||||||
import { MAX_LIVESTREAM_COMMENTS } from 'constants/livestream';
|
import { MAX_LIVESTREAM_COMMENTS } from 'constants/livestream';
|
||||||
import { selectChannelMentionData } from 'redux/selectors/comments';
|
import { selectChannelMentionData } from 'redux/selectors/comments';
|
||||||
import { selectShowMatureContent } from 'redux/selectors/settings';
|
import { selectShowMatureContent } from 'redux/selectors/settings';
|
||||||
|
@ -9,14 +7,16 @@ import { withRouter } from 'react-router';
|
||||||
import TextareaWithSuggestions from './view';
|
import TextareaWithSuggestions from './view';
|
||||||
|
|
||||||
const select = (state, props) => {
|
const select = (state, props) => {
|
||||||
const claim = makeSelectClaimForUri(props.uri)(state);
|
const { pathname } = props.location;
|
||||||
const maxComments = props.isLivestream ? MAX_LIVESTREAM_COMMENTS : -1;
|
const maxComments = props.isLivestream ? MAX_LIVESTREAM_COMMENTS : -1;
|
||||||
const data = selectChannelMentionData(state, props.uri, maxComments);
|
const uri = `lbry:/${pathname.replaceAll(':', '#')}`;
|
||||||
const { canonicalCommentors, canonicalSubscriptions, commentorUris } = data;
|
|
||||||
|
const data = selectChannelMentionData(state, uri, maxComments);
|
||||||
|
const { canonicalCommentors, canonicalCreatorUri, canonicalSubscriptions, commentorUris } = data;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
canonicalCommentors,
|
canonicalCommentors,
|
||||||
canonicalCreatorUri: getChannelFromClaim(claim) && getChannelFromClaim(claim).canonical_url,
|
canonicalCreatorUri,
|
||||||
canonicalSubscriptions,
|
canonicalSubscriptions,
|
||||||
commentorUris,
|
commentorUris,
|
||||||
showMature: selectShowMatureContent(state),
|
showMature: selectShowMatureContent(state),
|
||||||
|
|
|
@ -1,108 +1,100 @@
|
||||||
// @flow
|
// @flow
|
||||||
import { Combobox, ComboboxInput, ComboboxPopover, ComboboxList } from '@reach/combobox';
|
|
||||||
import { matchSorter } from 'match-sorter';
|
import { matchSorter } from 'match-sorter';
|
||||||
import { SEARCH_OPTIONS } from 'constants/search';
|
|
||||||
import * as KEYCODES from 'constants/keycodes';
|
import * as KEYCODES from 'constants/keycodes';
|
||||||
import { regexInvalidURI } from 'util/lbryURI';
|
import Autocomplete from '@mui/material/Autocomplete';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Spinner from 'component/spinner';
|
|
||||||
import TextareaSuggestionsItem from 'component/textareaSuggestionsItem';
|
import TextareaSuggestionsItem from 'component/textareaSuggestionsItem';
|
||||||
import TextareaTopSuggestion from 'component/textareaTopSuggestion';
|
import TextField from '@mui/material/TextField';
|
||||||
import type { ElementRef } from 'react';
|
|
||||||
import useLighthouse from 'effects/use-lighthouse';
|
|
||||||
import useThrottle from 'effects/use-throttle';
|
import useThrottle from 'effects/use-throttle';
|
||||||
|
|
||||||
const mentionRegex = /@[^\s"=?!@$%^&*;,{}<>/\\]*/gm;
|
const mentionRegex = /@[^\s=&#:$@%?;/\\"<>%{}|^~[]*/gm;
|
||||||
|
|
||||||
const INPUT_DEBOUNCE_MS = 1000;
|
|
||||||
const LIGHTHOUSE_MIN_CHARACTERS = 4;
|
|
||||||
const SEARCH_SIZE = 10;
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
canonicalCommentors: Array<string>,
|
canonicalCommentors?: Array<string>,
|
||||||
canonicalCreatorUri: string,
|
canonicalCreatorUri?: string,
|
||||||
canonicalSubscriptions: Array<string>,
|
canonicalSubscriptions?: Array<string>,
|
||||||
className?: string,
|
className?: string,
|
||||||
commentorUris: Array<string>,
|
commentorUris?: Array<string>,
|
||||||
doResolveUris: (Array<string>) => void,
|
disabled?: boolean,
|
||||||
hideSuggestions?: boolean,
|
id: string,
|
||||||
inputRef: any,
|
inputRef: any,
|
||||||
isLivestream?: boolean,
|
isLivestream?: boolean,
|
||||||
maxLength?: number,
|
maxLength?: number,
|
||||||
name: string,
|
|
||||||
noTopSuggestion?: boolean,
|
|
||||||
placeholder?: string,
|
placeholder?: string,
|
||||||
showMature: boolean,
|
|
||||||
type?: string,
|
type?: string,
|
||||||
|
uri?: string,
|
||||||
value: any,
|
value: any,
|
||||||
|
doResolveUris: (Array<string>) => void,
|
||||||
|
onBlur: (any) => any,
|
||||||
onChange: (any) => any,
|
onChange: (any) => any,
|
||||||
|
onFocus: (any) => any,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function TextareaWithSuggestions(props: Props) {
|
export default function TextareaWithSuggestions(props: Props) {
|
||||||
const {
|
const {
|
||||||
canonicalCommentors,
|
canonicalCommentors,
|
||||||
canonicalCreatorUri,
|
canonicalCreatorUri,
|
||||||
canonicalSubscriptions,
|
canonicalSubscriptions: canonicalSubs,
|
||||||
className,
|
className,
|
||||||
commentorUris,
|
commentorUris,
|
||||||
doResolveUris,
|
disabled,
|
||||||
hideSuggestions,
|
id,
|
||||||
inputRef,
|
inputRef,
|
||||||
isLivestream,
|
isLivestream,
|
||||||
maxLength,
|
maxLength,
|
||||||
name,
|
|
||||||
noTopSuggestion,
|
|
||||||
placeholder,
|
placeholder,
|
||||||
showMature,
|
|
||||||
type,
|
type,
|
||||||
value: commentValue,
|
value: commentValue,
|
||||||
|
doResolveUris,
|
||||||
|
onBlur,
|
||||||
onChange,
|
onChange,
|
||||||
|
onFocus,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const inputProps = { className, placeholder };
|
const inputDefaultProps = { className, placeholder, maxLength, type, disabled };
|
||||||
|
|
||||||
const comboboxListRef: ElementRef<any> = React.useRef();
|
|
||||||
|
|
||||||
const [suggestionValue, setSuggestionValue] = React.useState(undefined);
|
const [suggestionValue, setSuggestionValue] = React.useState(undefined);
|
||||||
const [debouncedTerm, setDebouncedTerm] = React.useState('');
|
const [selectedValue, setSelectedValue] = React.useState(undefined);
|
||||||
const [topSuggestion, setTopSuggestion] = React.useState('');
|
const [highlightedSuggestion, setHighlightedSuggestion] = React.useState('');
|
||||||
const [canonicalSearchUris, setCanonicalSearchUris] = React.useState([]);
|
const [shouldClose, setClose] = React.useState();
|
||||||
|
|
||||||
const suggestionTerm = suggestionValue && suggestionValue.term;
|
const suggestionTerm = suggestionValue && suggestionValue.term;
|
||||||
const isUriFromTermValid = suggestionTerm && !regexInvalidURI.test(suggestionTerm.substring(1));
|
|
||||||
|
|
||||||
const additionalOptions = { isBackgroundSearch: false, [SEARCH_OPTIONS.CLAIM_TYPE]: SEARCH_OPTIONS.INCLUDE_CHANNELS };
|
const shouldFilter = (uri, previous) => uri !== canonicalCreatorUri && (!previous || !previous.includes(uri));
|
||||||
const { results, loading } = useLighthouse(debouncedTerm, showMature, SEARCH_SIZE, additionalOptions, 0);
|
const filteredCommentors = canonicalCommentors && canonicalCommentors.filter((uri) => shouldFilter(uri));
|
||||||
const stringifiedResults = JSON.stringify(results);
|
const filteredSubs = canonicalSubs && canonicalSubs.filter((uri) => shouldFilter(uri, filteredCommentors));
|
||||||
|
|
||||||
const shouldFilter = (uri, previousLists) =>
|
const allOptions = [];
|
||||||
uri !== canonicalCreatorUri && (!previousLists || !previousLists.includes(uri));
|
if (canonicalCreatorUri) allOptions.push(canonicalCreatorUri);
|
||||||
|
if (filteredSubs) allOptions.push(...filteredSubs);
|
||||||
|
if (filteredCommentors) allOptions.push(...filteredCommentors);
|
||||||
|
|
||||||
const filteredCommentors = canonicalCommentors.filter((uri) => shouldFilter(uri));
|
const allOptionsGrouped =
|
||||||
const filteredSubs = canonicalSubscriptions.filter((uri) => shouldFilter(uri, filteredCommentors));
|
allOptions.length > 0
|
||||||
const filteredTop = shouldFilter(topSuggestion, [...filteredCommentors, ...filteredSubs]) && topSuggestion;
|
? allOptions.map((option) => {
|
||||||
const filteredSearch =
|
const groupName =
|
||||||
canonicalSearchUris &&
|
(canonicalCreatorUri === option && __('Creator')) ||
|
||||||
canonicalSearchUris.filter((uri) => shouldFilter(uri, [...filteredCommentors, ...filteredSubs, filteredTop || '']));
|
(filteredSubs && filteredSubs.includes(option) && __('Following')) ||
|
||||||
|
(filteredCommentors && filteredCommentors.includes(option) && __('From comments'));
|
||||||
|
|
||||||
const creatorUriMatch = useSuggestionMatch(suggestionTerm || '', [canonicalCreatorUri]);
|
return {
|
||||||
const subscriptionsMatch = useSuggestionMatch(suggestionTerm || '', filteredSubs);
|
uri: option.replace('lbry://', '').replace('#', ':'),
|
||||||
const commentorsMatch = useSuggestionMatch(suggestionTerm || '', filteredCommentors);
|
group: groupName,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
const hasMinSearchLength = suggestionTerm && suggestionTerm.length >= LIGHTHOUSE_MIN_CHARACTERS;
|
const allMatches = useSuggestionMatch(
|
||||||
const isTyping = suggestionValue && debouncedTerm !== suggestionValue.term;
|
suggestionTerm || '',
|
||||||
const showPlaceholder = hasMinSearchLength && (isTyping || loading);
|
allOptionsGrouped.map(({ uri }) => uri)
|
||||||
|
);
|
||||||
|
|
||||||
/** --------- **/
|
/** --------- **/
|
||||||
/** Functions **/
|
/** Functions **/
|
||||||
/** --------- **/
|
/** --------- **/
|
||||||
|
|
||||||
function handleChange(e: SyntheticInputEvent<*>) {
|
function handleInputChange(value: string) {
|
||||||
onChange(e);
|
onChange({ target: { value } });
|
||||||
|
|
||||||
if (hideSuggestions) return;
|
|
||||||
|
|
||||||
const { value } = e.target;
|
|
||||||
|
|
||||||
const cursorIndex = inputRef && inputRef.current && inputRef.current.selectionStart;
|
const cursorIndex = inputRef && inputRef.current && inputRef.current.selectionStart;
|
||||||
const mentionMatches = value.match(mentionRegex);
|
const mentionMatches = value.match(mentionRegex);
|
||||||
|
@ -139,17 +131,24 @@ export default function TextareaWithSuggestions(props: Props) {
|
||||||
|
|
||||||
const handleSelect = React.useCallback(
|
const handleSelect = React.useCallback(
|
||||||
(selectedValue: string) => {
|
(selectedValue: string) => {
|
||||||
|
setSelectedValue(selectedValue);
|
||||||
|
|
||||||
if (!suggestionValue) return;
|
if (!suggestionValue) return;
|
||||||
|
|
||||||
|
const elem = inputRef && inputRef.current;
|
||||||
|
const newCursorPos = suggestionValue.index + selectedValue.length + 1;
|
||||||
|
|
||||||
const newValue =
|
const newValue =
|
||||||
commentValue.substring(0, suggestionValue.index) + // 1) From start of comment value until term start
|
commentValue.substring(0, suggestionValue.index) + // 1) From start of comment value until term start
|
||||||
`${selectedValue}` + // 2) Add the selected value
|
`${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.length > suggestionValue.lastIndex // 3) If there is more content until the the end of the comment value:
|
||||||
? commentValue.substring(suggestionValue.index + 1, commentValue.length) // 3.a) from term end, add the rest of 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
|
: ' '); // 3.b) or else, add a space for new input after
|
||||||
|
|
||||||
onChange({ target: { value: newValue } });
|
onChange({ target: { value: newValue } });
|
||||||
inputRef.current.focus();
|
setSuggestionValue(undefined);
|
||||||
|
elem.focus();
|
||||||
|
elem.setSelectionRange(newCursorPos, newCursorPos);
|
||||||
},
|
},
|
||||||
[commentValue, inputRef, onChange, suggestionValue]
|
[commentValue, inputRef, onChange, suggestionValue]
|
||||||
);
|
);
|
||||||
|
@ -158,132 +157,89 @@ export default function TextareaWithSuggestions(props: Props) {
|
||||||
/** Effects **/
|
/** Effects **/
|
||||||
/** ------- **/
|
/** ------- **/
|
||||||
|
|
||||||
|
// For disabling sending on Enter on Livestream chat
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
if (!isLivestream) return;
|
||||||
if (isTyping && suggestionValue) setDebouncedTerm(!hasMinSearchLength ? '' : suggestionValue.term);
|
|
||||||
}, INPUT_DEBOUNCE_MS);
|
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
if (suggestionTerm && inputRef) {
|
||||||
}, [hasMinSearchLength, isTyping, suggestionValue]);
|
inputRef.current.setAttribute('term', suggestionTerm);
|
||||||
|
} else {
|
||||||
React.useEffect(() => {
|
inputRef.current.removeAttribute('term');
|
||||||
if (!stringifiedResults) return;
|
|
||||||
|
|
||||||
const arrayResults = JSON.parse(stringifiedResults);
|
|
||||||
if (doResolveUris && arrayResults && arrayResults.length > 0) {
|
|
||||||
// $FlowFixMe
|
|
||||||
doResolveUris(arrayResults)
|
|
||||||
.then((response) => {
|
|
||||||
try {
|
|
||||||
// $FlowFixMe
|
|
||||||
const canonical_urls = Object.values(response).map(({ canonical_url }) => canonical_url);
|
|
||||||
setCanonicalSearchUris(canonical_urls);
|
|
||||||
} catch (e) {}
|
|
||||||
})
|
|
||||||
.catch((e) => {});
|
|
||||||
}
|
}
|
||||||
}, [doResolveUris, stringifiedResults]);
|
}, [inputRef, isLivestream, suggestionTerm]);
|
||||||
|
|
||||||
// Only resolve commentors on Livestreams when actually mentioning/looking for it
|
// Only resolve commentors on Livestreams when first trying to mention/looking for it
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (isLivestream && commentorUris && suggestionValue) doResolveUris(commentorUris);
|
if (isLivestream && commentorUris && suggestionTerm) doResolveUris(commentorUris);
|
||||||
}, [commentorUris, doResolveUris, isLivestream, suggestionValue]);
|
}, [commentorUris, doResolveUris, isLivestream, suggestionTerm]);
|
||||||
|
|
||||||
|
// Allow selecting with TAB key
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!inputRef || !suggestionValue) return;
|
|
||||||
|
|
||||||
function handleKeyDown(e: SyntheticKeyboardEvent<*>) {
|
function handleKeyDown(e: SyntheticKeyboardEvent<*>) {
|
||||||
const { keyCode } = e;
|
const { keyCode } = e;
|
||||||
|
|
||||||
const activeSelection = document.querySelector('[data-reach-combobox-option][data-highlighted]');
|
if (highlightedSuggestion && keyCode === KEYCODES.TAB) {
|
||||||
const firstValue = document.querySelectorAll('[data-reach-combobox-option] .textareaSuggestion__value')[0];
|
|
||||||
|
|
||||||
if (keyCode === KEYCODES.UP || keyCode === KEYCODES.DOWN) {
|
|
||||||
const selectedId = activeSelection && activeSelection.getAttribute('id');
|
|
||||||
const selectedItem = selectedId && document.querySelector(`li[id="${selectedId}"]`);
|
|
||||||
|
|
||||||
if (selectedItem) selectedItem.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
|
||||||
} else if (keyCode === KEYCODES.TAB) {
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
handleSelect(highlightedSuggestion.uri);
|
||||||
const activeValue = document.querySelector(
|
|
||||||
'[data-reach-combobox-option][data-highlighted] .textareaSuggestion__value'
|
|
||||||
);
|
|
||||||
|
|
||||||
if (activeValue && activeValue.innerText) {
|
|
||||||
handleSelect(activeValue.innerText);
|
|
||||||
} else if (firstValue && firstValue.innerText) {
|
|
||||||
handleSelect(firstValue.innerText);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
}, [handleSelect, inputRef, suggestionValue]);
|
}, [handleSelect, highlightedSuggestion]);
|
||||||
|
|
||||||
/** ------ **/
|
/** ------ **/
|
||||||
/** Render **/
|
/** Render **/
|
||||||
/** ------ **/
|
/** ------ **/
|
||||||
|
|
||||||
const suggestionsRow = (label: string, suggestions: any) => (
|
const renderGroup = (group: string, children: any) => (
|
||||||
<div className="textareaSuggestions__row">
|
<div className="textareaSuggestions__group">
|
||||||
<div className="textareaSuggestions__label">{label}</div>
|
<label className="textareaSuggestions__label">{group}</label>
|
||||||
|
{children}
|
||||||
{suggestions.map((suggestion) => (
|
|
||||||
<TextareaSuggestionsItem key={suggestion} uri={suggestion} />
|
|
||||||
))}
|
|
||||||
|
|
||||||
<hr className="textareaSuggestions__topSeparator" />
|
<hr className="textareaSuggestions__topSeparator" />
|
||||||
</div>
|
</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} />;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Combobox onSelect={handleSelect} aria-label={name}>
|
<Autocomplete
|
||||||
{/* Regular Textarea Field */}
|
autoHighlight
|
||||||
<ComboboxInput
|
disableClearable
|
||||||
{...inputProps}
|
filterOptions={(options) => options.filter(({ uri }) => allMatches && allMatches.includes(uri))}
|
||||||
value={commentValue}
|
freeSolo
|
||||||
as="textarea"
|
fullWidth
|
||||||
id={name}
|
getOptionLabel={(option) => option.uri}
|
||||||
maxLength={maxLength}
|
groupBy={(option) => option.group}
|
||||||
onChange={(e) => handleChange(e)}
|
id={id}
|
||||||
ref={inputRef}
|
inputValue={commentValue}
|
||||||
selectOnClick
|
loading={!allMatches || allMatches.length === 0}
|
||||||
type={type}
|
loadingText={__('Nothing found')}
|
||||||
autocomplete={false}
|
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) */
|
||||||
{/* Possible Suggestions Box */}
|
onChange={(event, value) => handleSelect(value.uri)}
|
||||||
{suggestionValue && isUriFromTermValid && (
|
onClose={(event, reason) => reason !== 'selectOption' && setClose(true)}
|
||||||
<ComboboxPopover persistSelection className="textarea__suggestions">
|
onFocus={() => onFocus()}
|
||||||
<ComboboxList ref={comboboxListRef}>
|
onHighlightChange={(event, option) => setHighlightedSuggestion(option)}
|
||||||
{creatorUriMatch && creatorUriMatch.length > 0 && suggestionsRow(__('Creator'), creatorUriMatch)}
|
onInputChange={(event, value, reason) => reason === 'input' && handleInputChange(value)}
|
||||||
{subscriptionsMatch && subscriptionsMatch.length > 0 && suggestionsRow(__('Following'), subscriptionsMatch)}
|
onOpen={() => suggestionTerm && setClose(false)}
|
||||||
{commentorsMatch && commentorsMatch.length > 0 && suggestionsRow(__('From comments'), commentorsMatch)}
|
/* '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 */
|
||||||
{hasMinSearchLength &&
|
open={!!suggestionTerm && !shouldClose}
|
||||||
(showPlaceholder ? (
|
options={allOptionsGrouped}
|
||||||
<Spinner type="small" />
|
renderGroup={({ group, children }) => renderGroup(group, children)}
|
||||||
) : (
|
renderInput={(params) => renderInput(params)}
|
||||||
results && (
|
renderOption={(optionProps, option) => <TextareaSuggestionsItem uri={option.uri} {...optionProps} />}
|
||||||
<>
|
value={selectedValue}
|
||||||
{!noTopSuggestion && (
|
/>
|
||||||
<TextareaTopSuggestion
|
|
||||||
query={debouncedTerm}
|
|
||||||
filteredTop={filteredTop}
|
|
||||||
setTopSuggestion={setTopSuggestion}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{filteredSearch && filteredSearch.length > 0 && suggestionsRow(__('From search'), filteredSearch)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
))}
|
|
||||||
</ComboboxList>
|
|
||||||
</ComboboxPopover>
|
|
||||||
)}
|
|
||||||
</Combobox>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -11,7 +11,7 @@ import {
|
||||||
selectClaimIdForUri,
|
selectClaimIdForUri,
|
||||||
selectClaimIdsByUri,
|
selectClaimIdsByUri,
|
||||||
} from 'redux/selectors/claims';
|
} from 'redux/selectors/claims';
|
||||||
import { isClaimNsfw } from 'util/claim';
|
import { isClaimNsfw, getChannelFromClaim } from 'util/claim';
|
||||||
import { selectSubscriptionUris } from 'redux/selectors/subscriptions';
|
import { selectSubscriptionUris } from 'redux/selectors/subscriptions';
|
||||||
|
|
||||||
type State = { claims: any, comments: CommentsState };
|
type State = { claims: any, comments: CommentsState };
|
||||||
|
@ -264,7 +264,7 @@ export const selectRepliesForParentId = createCachedSelector(
|
||||||
* @param filterInputs Values returned by filterCommentsDepOnList.
|
* @param filterInputs Values returned by filterCommentsDepOnList.
|
||||||
*/
|
*/
|
||||||
const filterComments = (comments: Array<Comment>, claimId?: string, filterInputs: any) => {
|
const filterComments = (comments: Array<Comment>, claimId?: string, filterInputs: any) => {
|
||||||
const filterProps = filterInputs.reduce(function (acc, cur, i) {
|
const filterProps = filterInputs.reduce((acc, cur, i) => {
|
||||||
acc[filterCommentsPropKeys[i]] = cur;
|
acc[filterCommentsPropKeys[i]] = cur;
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
|
@ -392,30 +392,38 @@ export const selectSuperChatTotalAmountForUri = (state: State, uri: string) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const selectChannelMentionData = createCachedSelector(
|
export const selectChannelMentionData = createCachedSelector(
|
||||||
|
(state, uri) => uri,
|
||||||
selectClaimIdsByUri,
|
selectClaimIdsByUri,
|
||||||
selectClaimsById,
|
selectClaimsById,
|
||||||
selectTopLevelCommentsForUri,
|
selectTopLevelCommentsForUri,
|
||||||
selectSubscriptionUris,
|
selectSubscriptionUris,
|
||||||
(claimIdsByUri, claimsById, topLevelComments, subscriptionUris) => {
|
(uri, claimIdsByUri, claimsById, topLevelComments, subscriptionUris) => {
|
||||||
|
let canonicalCreatorUri;
|
||||||
const commentorUris = [];
|
const commentorUris = [];
|
||||||
const canonicalCommentors = [];
|
const canonicalCommentors = [];
|
||||||
const canonicalSubscriptions = [];
|
const canonicalSubscriptions = [];
|
||||||
|
|
||||||
topLevelComments.forEach((comment) => {
|
if (uri) {
|
||||||
const uri = comment.channel_url;
|
const claimId = claimIdsByUri[uri];
|
||||||
|
const claim = claimsById[claimId];
|
||||||
|
const channelFromClaim = claim && getChannelFromClaim(claim);
|
||||||
|
canonicalCreatorUri = channelFromClaim && channelFromClaim.canonical_url;
|
||||||
|
|
||||||
if (!commentorUris.includes(uri)) {
|
topLevelComments.forEach(({ channel_url: uri }) => {
|
||||||
// Update: commentorUris
|
// Check: if there are duplicate commentors
|
||||||
commentorUris.push(uri);
|
if (!commentorUris.includes(uri)) {
|
||||||
|
// Update: commentorUris
|
||||||
|
commentorUris.push(uri);
|
||||||
|
|
||||||
// Update: canonicalCommentors
|
// Update: canonicalCommentors
|
||||||
const claimId = claimIdsByUri[uri];
|
const claimId = claimIdsByUri[uri];
|
||||||
const claim = claimsById[claimId];
|
const claim = claimsById[claimId];
|
||||||
if (claim && claim.canonical_url) {
|
if (claim && claim.canonical_url) {
|
||||||
canonicalCommentors.push(claim.canonical_url);
|
canonicalCommentors.push(claim.canonical_url);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
});
|
}
|
||||||
|
|
||||||
subscriptionUris.forEach((uri) => {
|
subscriptionUris.forEach((uri) => {
|
||||||
// Update: canonicalSubscriptions
|
// Update: canonicalSubscriptions
|
||||||
|
@ -426,11 +434,6 @@ export const selectChannelMentionData = createCachedSelector(
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return { canonicalCommentors, canonicalCreatorUri, canonicalSubscriptions, commentorUris };
|
||||||
topLevelComments,
|
|
||||||
commentorUris,
|
|
||||||
canonicalCommentors,
|
|
||||||
canonicalSubscriptions,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
)((state, uri, maxCount) => `${String(uri)}:${maxCount}`);
|
)((state, uri, maxCount) => `${String(uri)}:${maxCount}`);
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
$thumbnailWidth: 1.5rem;
|
$thumbnailWidth: 1.5rem;
|
||||||
$thumbnailWidthSmall: 1rem;
|
$thumbnailWidthSmall: 1rem;
|
||||||
|
|
||||||
.create___comment {
|
.create__comment {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,16 +1,45 @@
|
||||||
.textarea__suggestions {
|
.MuiAutocomplete-inputRoot {
|
||||||
|
padding: 0 !important;
|
||||||
|
font-family: inherit !important;
|
||||||
|
font-weight: inherit !important;
|
||||||
|
font-size: inherit !important;
|
||||||
|
|
||||||
|
.MuiOutlinedInput-notchedOutline {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create__comment {
|
||||||
|
@extend textarea;
|
||||||
|
|
||||||
|
color: var(--color-text) !important;
|
||||||
|
min-height: calc(var(--height-input) * 1.5) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.MuiAutocomplete-paper {
|
||||||
@extend .card;
|
@extend .card;
|
||||||
background-color: var(--color-card-background);
|
background-color: var(--color-card-background);
|
||||||
max-height: 30vh;
|
|
||||||
overflow-y: scroll;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
box-shadow: var(--card-box-shadow);
|
box-shadow: var(--card-box-shadow);
|
||||||
|
color: var(--color-text) !important;
|
||||||
|
|
||||||
|
.textareaSuggestions__group {
|
||||||
|
&:last-child hr {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textareaSuggestions__label {
|
||||||
|
@extend .wunderbar__label;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Mui-focused {
|
||||||
|
background-color: var(--color-menu-background--active);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
> .icon {
|
> .icon {
|
||||||
top: 0;
|
top: 0;
|
||||||
left: var(--spacing-m);
|
left: var(--spacing-m);
|
||||||
height: 100%;
|
height: 100%;
|
||||||
position: absolute;
|
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
stroke: var(--color-input-placeholder);
|
stroke: var(--color-input-placeholder);
|
||||||
}
|
}
|
||||||
|
@ -18,61 +47,42 @@
|
||||||
@media (min-width: $breakpoint-small) {
|
@media (min-width: $breakpoint-small) {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.textareaSuggestions__label:first-of-type {
|
|
||||||
margin-top: var(--spacing-xs);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.textareaSuggestion {
|
.MuiAutocomplete-option {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0 var(--spacing-xxs);
|
padding: 0 var(--spacing-xxs);
|
||||||
margin-left: var(--spacing-xxs);
|
margin: 0 var(--spacing-xxs);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
|
||||||
.channel-thumbnail {
|
.channel-thumbnail {
|
||||||
@include handleChannelGif(2.1rem);
|
@include handleChannelGif(2.1rem);
|
||||||
position: absolute;
|
margin-right: 0;
|
||||||
|
|
||||||
@media (min-width: $breakpoint-small) {
|
@media (min-width: $breakpoint-small) {
|
||||||
@include handleChannelGif(2.1rem);
|
@include handleChannelGif(2.1rem);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.textareaSuggestions__row {
|
.textareaSuggestion__label {
|
||||||
&:last-child hr {
|
@extend .wunderbar__suggestion-label;
|
||||||
display: none;
|
margin-left: var(--spacing-m);
|
||||||
|
display: block;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.textareaSuggestion__title {
|
||||||
|
@extend .wunderbar__suggestion-title;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textareaSuggestion__value {
|
||||||
|
@extend .wunderbar__suggestion-name;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.textareaSuggestions__topSeparator {
|
.textareaSuggestions__topSeparator {
|
||||||
@extend .wunderbar__top-separator;
|
@extend .wunderbar__top-separator;
|
||||||
}
|
}
|
||||||
|
|
||||||
.textareaSuggestion__label {
|
|
||||||
@extend .wunderbar__suggestion-label;
|
|
||||||
margin-left: var(--spacing-m);
|
|
||||||
display: block;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.textareaSuggestions__label {
|
|
||||||
@extend .wunderbar__label;
|
|
||||||
}
|
|
||||||
|
|
||||||
.textareaSuggestions__topSeparator {
|
|
||||||
@extend .wunderbar__top-separator;
|
|
||||||
}
|
|
||||||
|
|
||||||
.textareaSuggestion__value {
|
|
||||||
@extend .wunderbar__suggestion-name;
|
|
||||||
margin-left: calc(var(--spacing-l) - var(--spacing-xxs));
|
|
||||||
}
|
|
||||||
|
|
||||||
.textareaSuggestion__title {
|
|
||||||
@extend .wunderbar__suggestion-title;
|
|
||||||
margin-left: calc(var(--spacing-l) - var(--spacing-xxs));
|
|
||||||
}
|
|
||||||
|
|
Loading…
Reference in a new issue