// @flow import { Combobox, ComboboxInput, ComboboxPopover, ComboboxList } from '@reach/combobox'; import { Form } from 'component/common/form'; import { parseURI, regexInvalidURI } from 'util/lbryURI'; 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, commentorUris: Array, subscriptionUris: Array, unresolvedSubscriptions: Array, canonicalCreator: string, canonicalCommentors: Array, canonicalSubscriptions: Array, doResolveUris: (Array) => void, customSelectAction?: (string, number) => void, }; export default function ChannelMentionSuggestions(props: Props) { const { unresolvedSubscriptions, canonicalCreator, creatorUri, inputRef, showMature, noTopSuggestion, mentionTerm, doResolveUris, customSelectAction, } = props; const comboboxInputRef: ElementRef = React.useRef(); const comboboxListRef: ElementRef = React.useRef(); const mainEl = document.querySelector('.channel-mention__suggestions'); const [debouncedTerm, setDebouncedTerm] = React.useState(''); const [mostSupported, setMostSupported] = React.useState(''); const [canonicalResults, setCanonicalResults] = React.useState([]); const isRefFocused = (ref) => ref && ref.current === document.activeElement; const subscriptionUris = props.subscriptionUris.filter((uri) => uri !== creatorUri); const canonicalSubscriptions = props.canonicalSubscriptions.filter((uri) => uri !== canonicalCreator); const commentorUris = props.commentorUris.filter((uri) => uri !== creatorUri && !subscriptionUris.includes(uri)); const canonicalCommentors = props.canonicalCommentors.filter( (uri) => uri !== canonicalCreator && !canonicalSubscriptions.includes(uri) ); const termToMatch = mentionTerm && mentionTerm.replace('@', '').toLowerCase(); const allShownUris = [creatorUri, ...subscriptionUris, ...commentorUris]; const allShownCanonical = [canonicalCreator, ...canonicalSubscriptions, ...canonicalCommentors]; const possibleMatches = allShownUris.filter((uri) => { try { // yuck a try catch in a filter? const { channelName } = parseURI(uri); return channelName && channelName.toLowerCase().includes(termToMatch); } catch (e) {} }); 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 isUriFromTermValid = !regexInvalidURI.test(mentionTerm.substring(1)); const handleSelect = React.useCallback( (value, key) => { if (customSelectAction) { // Give them full results, as our resolved one might truncate the claimId. customSelectAction(value || (results && results.find((r) => r.startsWith(value))) || '', Number(key)); } }, [customSelectAction, results] ); React.useEffect(() => { const timer = setTimeout(() => { if (isTyping) setDebouncedTerm(!hasMinLength ? '' : mentionTerm); }, INPUT_DEBOUNCE_MS); return () => clearTimeout(timer); }, [hasMinLength, isTyping, mentionTerm]); React.useEffect(() => { if (!mainEl) return; const header = document.querySelector('.header__navigation'); function handleReflow() { const boxAtTopOfPage = header && mainEl.getBoundingClientRect().top <= header.offsetHeight; const boxAtBottomOfPage = mainEl.getBoundingClientRect().bottom >= window.innerHeight; if (boxAtTopOfPage) { mainEl.setAttribute('flow-bottom', ''); } if (mainEl.getAttribute('flow-bottom') !== null && boxAtBottomOfPage) { mainEl.removeAttribute('flow-bottom'); } } handleReflow(); window.addEventListener('scroll', handleReflow); return () => window.removeEventListener('scroll', handleReflow); }, [mainEl]); 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 { // $FlowFixMe comboboxInputRef.current.focus(); } } else { if ((isRefFocused(comboboxInputRef) || isRefFocused(inputRef)) && keyCode === KEYCODES.TAB) { event.preventDefault(); const activeValue = activeElement && activeElement.getAttribute('value'); if (activeValue) { handleSelect(activeValue, keyCode); } else if (possibleMatches.length) { // $FlowFixMe const suggest = allShownCanonical.find((matchUri) => possibleMatches.find((uri) => uri.includes(matchUri))); if (suggest) handleSelect(suggest, keyCode); } else if (results) { handleSelect(mentionTerm, keyCode); } } if (isRefFocused(comboboxInputRef)) { // $FlowFixMe inputRef.current.focus(); } } } window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [allShownCanonical, handleSelect, inputRef, mentionTerm, possibleMatches, results]); React.useEffect(() => { if (!stringifiedResults) return; const arrayResults = JSON.parse(stringifiedResults); if (arrayResults && arrayResults.length > 0) { // $FlowFixMe doResolveUris(arrayResults).then((response) => { try { // $FlowFixMe const canonical_urls = Object.values(response).map(({ canonical_url }) => canonical_url); setCanonicalResults(canonical_urls); } catch (e) {} }); } }, [doResolveUris, stringifiedResults]); // Only resolve the subscriptions that match the mention term, instead of all React.useEffect(() => { if (isTyping) return; const 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, canonical: Array, hasSuggestionsBelow: boolean ) => { if (mentionTerm.length > 1 && suggestions !== results) { suggestions = suggestions.filter((uri) => possibleMatches.includes(uri)); } else if (suggestions === results) { suggestions = suggestions.filter((uri) => !allShownUris.includes(uri)); } // $FlowFixMe suggestions = suggestions .map((matchUri) => canonical.find((uri) => matchUri.includes(uri))) .filter((uri) => Boolean(uri)); if (canonical === canonicalResults) { suggestions = suggestions.filter((uri) => uri !== mostSupported); } return !suggestions.length ? null : ( <>
{label}
{suggestions.map((uri) => ( ))} {hasSuggestionsBelow &&
} ); }; return isRefFocused(inputRef) || isRefFocused(comboboxInputRef) ? (
handleSelect(mentionTerm)}> {mentionTerm && isUriFromTermValid && ( {creatorUri && suggestionsRow( __('Creator'), [creatorUri], [canonicalCreator], canonicalSubscriptions.length > 0 || commentorUris.length > 0 || !showPlaceholder )} {canonicalSubscriptions.length > 0 && suggestionsRow( __('Following'), subscriptionUris, canonicalSubscriptions, commentorUris.length > 0 || !showPlaceholder )} {commentorUris.length > 0 && suggestionsRow(__('From comments'), commentorUris, canonicalCommentors, !showPlaceholder)} {hasMinLength && (showPlaceholder ? ( ) : ( results && ( <> {!noTopSuggestion && ( setMostSupported(winningUri)} /> )} {suggestionsRow(__('From search'), results, canonicalResults, false)} ) ))} )}
) : null; }