// @flow import type { ElementRef } from 'react'; import { URL, URL_LOCAL, URL_DEV, KNOWN_APP_DOMAINS } from 'config'; import * as PAGES from 'constants/pages'; import * as ICONS from 'constants/icons'; import React from 'react'; import classnames from 'classnames'; import Icon from 'component/common/icon'; import { isURIValid, normalizeURI, parseURI } from 'lbry-redux'; import { Combobox, ComboboxInput, ComboboxPopover, ComboboxList, ComboboxOption } from '@reach/combobox'; // import '@reach/combobox/styles.css'; --> 'scss/third-party.scss' import useLighthouse from 'effects/use-lighthouse'; import { Form } from 'component/common/form'; import Button from 'component/button'; import WunderbarTopSuggestion from 'component/wunderbarTopSuggestion'; import WunderbarSuggestion from 'component/wunderbarSuggestion'; import { useHistory } from 'react-router'; import { formatLbryUrlForWeb } from 'util/url'; import Yrbl from 'component/yrbl'; import { SEARCH_OPTIONS } from 'constants/search'; import Spinner from 'component/spinner'; const LBRY_PROTOCOL = 'lbry://'; const WEB_DEV_PREFIX = `${URL_DEV}/`; const WEB_LOCAL_PREFIX = `${URL_LOCAL}/`; const WEB_PROD_PREFIX = `${URL}/`; const SEARCH_PREFIX = `$/${PAGES.SEARCH}q=`; const INVALID_URL_ERROR = "Invalid LBRY URL entered. Only A-Z, a-z, 0-9, and '-' allowed."; const TAG_SEARCH_PREFIX = 'tag:'; const L_KEY_CODE = 76; const ESC_KEY_CODE = 27; const WUNDERBAR_INPUT_DEBOUNCE_MS = 500; const LIGHTHOUSE_MIN_CHARACTERS = 3; type Props = { searchQuery: ?string, onSearch: (string) => void, navigateToSearchPage: (string) => void, doResolveUris: (string) => void, doShowSnackBar: (string) => void, showMature: boolean, isMobile: boolean, doCloseMobileSearch: () => void, channelsOnly?: boolean, noTopSuggestion?: boolean, noBottomLinks?: boolean, customSelectAction?: (string) => void, }; export default function WunderBarSuggestions(props: Props) { const { navigateToSearchPage, doShowSnackBar, doResolveUris, showMature, isMobile, doCloseMobileSearch, channelsOnly, noTopSuggestion, noBottomLinks, customSelectAction, } = props; const inputRef: ElementRef = React.useRef(); const isFocused = inputRef && inputRef.current && inputRef.current === document.activeElement; const { push, location: { search }, } = useHistory(); const urlParams = new URLSearchParams(search); const queryFromUrl = urlParams.get('q') || ''; const [term, setTerm] = React.useState(queryFromUrl); const [debouncedTerm, setDebouncedTerm] = React.useState(''); const searchSize = isMobile ? 20 : 5; const additionalOptions = channelsOnly ? { isBackgroundSearch: false, [SEARCH_OPTIONS.CLAIM_TYPE]: SEARCH_OPTIONS.INCLUDE_CHANNELS } : {}; const { results, loading } = useLighthouse(debouncedTerm, showMature, searchSize, additionalOptions, 0); const noResults = debouncedTerm && !loading && results && results.length === 0; const nameFromQuery = debouncedTerm.trim().replace(/\s+/g, '').replace(/:/g, '#'); const uriFromQuery = `lbry://${nameFromQuery}`; let uriFromQueryIsValid = false; let channelUrlForTopTest; try { const { isChannel } = parseURI(uriFromQuery); uriFromQueryIsValid = true; if (!isChannel) { channelUrlForTopTest = `lbry://@${uriFromQuery}`; } } catch (e) {} const topUrisToTest = [uriFromQuery]; if (channelUrlForTopTest) { topUrisToTest.push(uriFromQuery); } const isTyping = debouncedTerm !== term; const showPlaceholder = isTyping || loading; function handleSelect(value) { if (!value) { return; } doCloseMobileSearch(); const knownAppDomains = KNOWN_APP_DOMAINS.map((x) => `https://${x}/`); // Match WEB_PROD_PREFIX's 'https://xx/' format. const webDomainList = [WEB_PROD_PREFIX, ...knownAppDomains, WEB_LOCAL_PREFIX, WEB_DEV_PREFIX]; const webDomainIndex = webDomainList.findIndex((x) => value.includes(x)); const wasCopiedFromWeb = webDomainIndex !== -1; const isLbryUrl = value.startsWith('lbry://'); if (inputRef.current) { inputRef.current.blur(); } if (customSelectAction) { // Give them full results, as our resolved one might truncate the claimId. customSelectAction(results ? results.find((r) => r.startsWith(value)) : ''); return; } if (wasCopiedFromWeb) { const prefix = webDomainList[webDomainIndex]; let query = value.slice(prefix.length).replace(/:/g, '#'); if (query.includes(SEARCH_PREFIX)) { query = query.slice(SEARCH_PREFIX.length); navigateToSearchPage(query); } else { try { const lbryUrl = `lbry://${query}`; parseURI(lbryUrl); const formattedLbryUrl = formatLbryUrlForWeb(lbryUrl); push(formattedLbryUrl); return; } catch (e) {} } } if (value.startsWith(TAG_SEARCH_PREFIX)) { const tag = value.slice(TAG_SEARCH_PREFIX.length); push(`/$/${PAGES.DISCOVER}?t=${tag}`); } else if (!isLbryUrl) { navigateToSearchPage(value); } else { let query = 'lbry://' + value.slice(LBRY_PROTOCOL.length).replace(/:/g, '#'); try { if (isURIValid(query)) { const uri = normalizeURI(query); const normalizedWebUrl = formatLbryUrlForWeb(uri); push(normalizedWebUrl); } else { doShowSnackBar(INVALID_URL_ERROR); } } catch (e) { navigateToSearchPage(value); } } } React.useEffect(() => { const timer = setTimeout(() => { if (debouncedTerm !== term) { setDebouncedTerm(term.length < LIGHTHOUSE_MIN_CHARACTERS ? '' : term); } }, WUNDERBAR_INPUT_DEBOUNCE_MS); return () => clearTimeout(timer); }, [term, debouncedTerm]); React.useEffect(() => { function handleHomeEndCaretPos(elem, shiftKey, isHome) { if (elem) { const cur = elem.selectionStart ? elem.selectionStart : 0; let begin; let final; let scrollPx; let direction = 'none'; if (isHome) { begin = 0; final = shiftKey ? cur : begin; scrollPx = 0; direction = 'backward'; } else { final = elem.value.length; begin = shiftKey ? cur : final; scrollPx = elem.scrollWidth - elem.clientWidth; } elem.setSelectionRange(begin, final, direction); elem.scrollLeft = scrollPx; return true; } return false; } function overrideHomeEndHandling(event) { const { ctrlKey, metaKey, shiftKey, key } = event; if (!ctrlKey && !metaKey) { if (key === 'Home' || key === 'End') { if (handleHomeEndCaretPos(inputRef.current, shiftKey, key === 'Home')) { event.stopPropagation(); } } } } // Injecting the listener at the element level puts it before // ReachUI::ComboBoxInput's listener, allowing us to skip their handling. if (inputRef.current) { inputRef.current.addEventListener('keydown', overrideHomeEndHandling); } return () => { if (inputRef.current) { inputRef.current.removeEventListener('keydown', overrideHomeEndHandling); } }; }, [inputRef]); React.useEffect(() => { function handleKeyDown(event) { const { ctrlKey, metaKey, keyCode } = event; if (!inputRef.current) { return; } if (inputRef.current === document.activeElement && keyCode === ESC_KEY_CODE) { inputRef.current.blur(); } // @if TARGET='app' const shouldFocus = process.platform === 'darwin' ? keyCode === L_KEY_CODE && metaKey : keyCode === L_KEY_CODE && ctrlKey; if (shouldFocus) { inputRef.current.focus(); } // @endif } window.addEventListener('keydown', handleKeyDown); // @if TARGET='app' function handleDoubleClick(event) { if (!inputRef.current) { return; } event.stopPropagation(); } inputRef.current.addEventListener('dblclick', handleDoubleClick); // @endif return () => { window.removeEventListener('keydown', handleKeyDown); // @if TARGET='app' if (inputRef.current) { inputRef.current.removeEventListener('dblclick', handleDoubleClick); } // @endif }; }, [inputRef]); React.useEffect(() => { if (isMobile && inputRef.current) { inputRef.current.focus(); } }, [inputRef, isMobile]); const stringifiedResults = JSON.stringify(results); React.useEffect(() => { if (stringifiedResults) { const arrayResults = JSON.parse(stringifiedResults); if (arrayResults && arrayResults.length > 0) { doResolveUris(arrayResults); } } }, [doResolveUris, stringifiedResults]); return ( <>
handleSelect(term)} > setTerm(e.target.value)} value={term} /> {isFocused && ( {uriFromQueryIsValid && !noTopSuggestion ? : null}
{__('Search Results')}
{showPlaceholder && term.length > LIGHTHOUSE_MIN_CHARACTERS ? : null} {!showPlaceholder && results ? results.slice(0, isMobile ? 20 : 5).map((uri) => ) : null} {!noBottomLinks && (
)}
)}
{isMobile && !term && (
)} {isMobile && noResults && (
)} ); }