lbry-desktop/ui/component/channelMentionSuggestions/view.jsx

221 lines
8.2 KiB
React
Raw Normal View History

2021-09-30 21:16:11 +02:00
// @flow
import { Combobox, ComboboxInput, ComboboxPopover, ComboboxList } from '@reach/combobox';
import { Form } from 'component/common/form';
import { parseURI } from 'lbry-redux';
import { SEARCH_OPTIONS } from 'constants/search';
import * as KEYCODES from 'constants/keycodes';
import ChannelMentionSuggestion from 'component/channelMentionSuggestion';
import ChannelMentionTopSuggestion from 'component/channelMentionTopSuggestion';
import React from 'react';
import Spinner from 'component/spinner';
import type { ElementRef } from 'react';
import useLighthouse from 'effects/use-lighthouse';
const INPUT_DEBOUNCE_MS = 1000;
const LIGHTHOUSE_MIN_CHARACTERS = 3;
type Props = {
inputRef: any,
mentionTerm: string,
noTopSuggestion?: boolean,
showMature: boolean,
creatorUri: string,
isLivestream: boolean,
commentorUris: Array<string>,
unresolvedCommentors: Array<string>,
subscriptionUris: Array<string>,
unresolvedSubscriptions: Array<string>,
doResolveUris: (Array<string>) => void,
customSelectAction?: (string) => void,
};
export default function ChannelMentionSuggestions(props: Props) {
const {
unresolvedCommentors,
unresolvedSubscriptions,
isLivestream,
creatorUri,
inputRef,
showMature,
noTopSuggestion,
mentionTerm,
doResolveUris,
customSelectAction,
} = props;
const comboboxInputRef: ElementRef<any> = React.useRef();
const comboboxListRef: ElementRef<any> = React.useRef();
const [debouncedTerm, setDebouncedTerm] = React.useState('');
const isRefFocused = (ref) => ref && ref.current && ref.current === document.activeElement;
let subscriptionUris = props.subscriptionUris.filter((uri) => uri !== creatorUri);
let commentorUris = props.commentorUris.filter((uri) => uri !== creatorUri && !subscriptionUris.includes(uri));
const termToMatch = mentionTerm && mentionTerm.replace('@', '').toLowerCase();
const allShownUris = [creatorUri, ...subscriptionUris, ...commentorUris];
const possibleMatches = allShownUris.filter((uri) => {
try {
const { channelName } = parseURI(uri);
return channelName.toLowerCase().includes(termToMatch);
} catch (e) {}
});
const hasSubscriptionsResolved =
subscriptionUris &&
!subscriptionUris.every((uri) => unresolvedSubscriptions && unresolvedSubscriptions.includes(uri));
const hasCommentorsShown =
commentorUris.length > 0 && commentorUris.some((uri) => possibleMatches && possibleMatches.includes(uri));
const searchSize = 5;
const additionalOptions = { isBackgroundSearch: false, [SEARCH_OPTIONS.CLAIM_TYPE]: SEARCH_OPTIONS.INCLUDE_CHANNELS };
const { results, loading } = useLighthouse(debouncedTerm, showMature, searchSize, additionalOptions, 0);
const stringifiedResults = JSON.stringify(results);
const hasMinLength = mentionTerm && mentionTerm.length >= LIGHTHOUSE_MIN_CHARACTERS;
const isTyping = debouncedTerm !== mentionTerm;
const showPlaceholder = isTyping || loading;
const handleSelect = React.useCallback(
(value) => {
if (customSelectAction) {
// Give them full results, as our resolved one might truncate the claimId.
customSelectAction(value || (results && results.find((r) => r.startsWith(value))) || '');
}
if (inputRef && inputRef.current) inputRef.current.focus();
},
[customSelectAction, inputRef, results]
);
React.useEffect(() => {
const timer = setTimeout(() => {
if (isTyping) setDebouncedTerm(!hasMinLength ? '' : mentionTerm);
}, INPUT_DEBOUNCE_MS);
return () => clearTimeout(timer);
}, [isTyping, mentionTerm, hasMinLength, possibleMatches.length]);
React.useEffect(() => {
if (!inputRef) return;
if (mentionTerm) {
inputRef.current.classList.add('textarea-mention');
} else {
inputRef.current.classList.remove('textarea-mention');
}
}, [inputRef, mentionTerm]);
React.useEffect(() => {
if (!inputRef || !comboboxInputRef || !mentionTerm) return;
function handleKeyDown(event) {
const { keyCode } = event;
const activeElement = document.activeElement;
if (keyCode === KEYCODES.UP || keyCode === KEYCODES.DOWN) {
if (isRefFocused(comboboxInputRef)) {
const selectedId = activeElement && activeElement.getAttribute('aria-activedescendant');
const selectedItem = selectedId && document.querySelector(`li[id="${selectedId}"]`);
if (selectedItem) selectedItem.scrollIntoView({ block: 'nearest', inline: 'nearest' });
} else {
comboboxInputRef.current.focus();
}
} else {
if (keyCode === KEYCODES.TAB) {
event.preventDefault();
const activeValue = activeElement && activeElement.getAttribute('value');
if (activeValue) {
handleSelect(activeValue);
} else if (possibleMatches.length) {
handleSelect(possibleMatches[0]);
} else if (results) {
handleSelect(mentionTerm);
}
}
inputRef.current.focus();
}
}
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [handleSelect, inputRef, mentionTerm, possibleMatches, results]);
React.useEffect(() => {
if (!stringifiedResults) return;
const arrayResults = JSON.parse(stringifiedResults);
if (arrayResults && arrayResults.length > 0) doResolveUris(arrayResults);
}, [doResolveUris, stringifiedResults]);
// Only resolve commentors on Livestreams if actually mentioning/looking for it
React.useEffect(() => {
if (isLivestream && unresolvedCommentors && mentionTerm) doResolveUris(unresolvedCommentors);
}, [doResolveUris, isLivestream, mentionTerm, unresolvedCommentors]);
// Only resolve the subscriptions that match the mention term, instead of all
React.useEffect(() => {
if (isTyping) return;
let urisToResolve = [];
subscriptionUris.map(
(uri) =>
hasMinLength &&
possibleMatches.includes(uri) &&
unresolvedSubscriptions.includes(uri) &&
urisToResolve.push(uri)
);
if (urisToResolve.length > 0) doResolveUris(urisToResolve);
}, [doResolveUris, hasMinLength, isTyping, possibleMatches, subscriptionUris, unresolvedSubscriptions]);
const suggestionsRow = (label: string, suggestions: Array<string>, hasSuggestionsBelow: boolean) => {
if (mentionTerm !== '@' && suggestions !== results) {
suggestions = suggestions.filter((uri) => possibleMatches.includes(uri));
} else if (suggestions === results) {
suggestions = suggestions.filter((uri) => !allShownUris.includes(uri));
}
return !suggestions.length ? null : (
<>
<div className="channel-mention__label">{label}</div>
{suggestions.map((uri) => (
<ChannelMentionSuggestion key={uri} uri={uri} />
))}
{hasSuggestionsBelow && <hr className="channel-mention__top-separator" />}
</>
);
};
return (
<Form onSubmit={() => handleSelect(mentionTerm)}>
<Combobox className="channel-mention" onSelect={handleSelect}>
<ComboboxInput ref={comboboxInputRef} className="channel-mention__input--none" value={mentionTerm} />
{mentionTerm && (
<ComboboxPopover portal={false} className="channel-mention__suggestions">
<ComboboxList ref={comboboxListRef}>
{creatorUri &&
suggestionsRow(
__('Creator'),
[creatorUri],
hasSubscriptionsResolved || hasCommentorsShown || !showPlaceholder
)}
{hasSubscriptionsResolved &&
suggestionsRow(__('Following'), subscriptionUris, hasCommentorsShown || !showPlaceholder)}
{commentorUris.length > 0 && suggestionsRow(__('From comments'), commentorUris, !showPlaceholder)}
{showPlaceholder
? hasMinLength && <Spinner type="small" />
: results && (
<>
{!noTopSuggestion && <ChannelMentionTopSuggestion query={debouncedTerm} />}
{suggestionsRow(__('From search'), results, false)}
</>
)}
</ComboboxList>
</ComboboxPopover>
)}
</Combobox>
</Form>
);
}