939d26801a
## Issue 7075 Move "explore tags" to a stable position ## Changes Move the buttons in the suggestions popup to the top row so that it's position won't be constantly changing depending on the number of results.
367 lines
12 KiB
JavaScript
367 lines
12 KiB
JavaScript
// @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 K_KEY_CODE = 75;
|
|
const L_KEY_CODE = 76;
|
|
const ESC_KEY_CODE = 27;
|
|
|
|
const WUNDERBAR_INPUT_DEBOUNCE_MS = 1000;
|
|
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<any> = 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 (keyCode === K_KEY_CODE && ctrlKey) {
|
|
inputRef.current.focus();
|
|
inputRef.current.select();
|
|
return;
|
|
}
|
|
|
|
if (inputRef.current === document.activeElement && keyCode === ESC_KEY_CODE) {
|
|
// If the user presses escape and the text has already been cleared then blur the widget
|
|
if (inputRef.current.value === '') {
|
|
inputRef.current.blur();
|
|
} else {
|
|
// Remove the current text
|
|
inputRef.current.value = '';
|
|
inputRef.current.focus();
|
|
}
|
|
}
|
|
|
|
// @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 (
|
|
<>
|
|
<Form
|
|
className={classnames('wunderbar__wrapper', { 'wunderbar__wrapper--mobile': isMobile })}
|
|
onSubmit={() => handleSelect(term)}
|
|
>
|
|
<Combobox className="wunderbar" onSelect={handleSelect}>
|
|
<Icon icon={ICONS.SEARCH} />
|
|
<ComboboxInput
|
|
ref={inputRef}
|
|
className="wunderbar__input"
|
|
placeholder={__('Search')}
|
|
onChange={(e) => setTerm(e.target.value)}
|
|
value={term}
|
|
/>
|
|
|
|
{isFocused && (
|
|
<ComboboxPopover
|
|
portal={false}
|
|
className={classnames('wunderbar__suggestions', { 'wunderbar__suggestions--mobile': isMobile })}
|
|
>
|
|
<ComboboxList>
|
|
{!noBottomLinks && (
|
|
<div className="wunderbar__bottom-links">
|
|
<ComboboxOption value={term} className="wunderbar__more-results">
|
|
<Button button="link" label={__('View All Results')} />
|
|
</ComboboxOption>
|
|
<ComboboxOption value={`${TAG_SEARCH_PREFIX}${term}`} className="wunderbar__more-results">
|
|
<Button className="wunderbar__tag-search" button="link">
|
|
{__('Explore')}
|
|
<div className="tag">{term.split(' ').join('')}</div>
|
|
</Button>
|
|
</ComboboxOption>
|
|
</div>
|
|
)}
|
|
|
|
<hr className="wunderbar__top-separator" />
|
|
|
|
{uriFromQueryIsValid && !noTopSuggestion ? <WunderbarTopSuggestion query={nameFromQuery} /> : null}
|
|
|
|
<div className="wunderbar__label">{__('Search Results')}</div>
|
|
|
|
{showPlaceholder && term.length > LIGHTHOUSE_MIN_CHARACTERS ? <Spinner type="small" /> : null}
|
|
|
|
{!showPlaceholder && results
|
|
? results.slice(0, isMobile ? 20 : 5).map((uri) => <WunderbarSuggestion key={uri} uri={uri} />)
|
|
: null}
|
|
</ComboboxList>
|
|
</ComboboxPopover>
|
|
)}
|
|
</Combobox>
|
|
</Form>
|
|
{isMobile && !term && (
|
|
<div className="main--empty">
|
|
<Yrbl subtitle={__('Search for something...')} alwaysShow />
|
|
</div>
|
|
)}
|
|
|
|
{isMobile && noResults && (
|
|
<div className="main--empty">
|
|
<Yrbl type="sad" subtitle={__('No results')} alwaysShow />
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|