From 30d8a0406d1b3f75203387ee0bbd93fddbe44e0f Mon Sep 17 00:00:00 2001 From: Sean Yesmunt Date: Thu, 3 Dec 2020 12:29:47 -0500 Subject: [PATCH] wunderbar improvements --- flow-typed/search.js | 50 +- package.json | 3 + ui/component/fileThumbnail/view.jsx | 2 +- ui/component/wunderbar/index.js | 25 +- .../wunderbar/internal/autocomplete.jsx | 588 ------------------ ui/component/wunderbar/view.jsx | 349 +++++------ ui/component/wunderbarSuggestion/index.js | 9 + ui/component/wunderbarSuggestion/view.jsx | 44 ++ ui/component/wunderbarTopSuggestion/index.js | 15 + ui/component/wunderbarTopSuggestion/view.jsx | 59 ++ ui/constants/action_types.js | 6 - ui/effects/use-lighthouse.js | 31 + ui/effects/use-throttle.js | 49 ++ ui/redux/actions/search.js | 93 +-- ui/redux/reducers/search.js | 70 +-- ui/redux/selectors/search.js | 115 +--- ui/scss/component/_wunderbar.scss | 135 +++- yarn.lock | 88 +++ 18 files changed, 588 insertions(+), 1143 deletions(-) delete mode 100644 ui/component/wunderbar/internal/autocomplete.jsx create mode 100644 ui/component/wunderbarSuggestion/index.js create mode 100644 ui/component/wunderbarSuggestion/view.jsx create mode 100644 ui/component/wunderbarTopSuggestion/index.js create mode 100644 ui/component/wunderbarTopSuggestion/view.jsx create mode 100644 ui/effects/use-lighthouse.js create mode 100644 ui/effects/use-throttle.js diff --git a/flow-typed/search.js b/flow-typed/search.js index 2a2152e48..d762fd9e5 100644 --- a/flow-typed/search.js +++ b/flow-typed/search.js @@ -1,12 +1,6 @@ // @flow import * as ACTIONS from 'constants/action_types'; -declare type SearchSuggestion = { - value: string, - shorthand: string, - type: string, -}; - declare type SearchOptions = { // :( // https://github.com/facebook/flow/issues/6492 @@ -23,13 +17,9 @@ declare type SearchOptions = { }; declare type SearchState = { - isActive: boolean, - searchQuery: string, options: SearchOptions, - suggestions: { [string]: Array }, urisByQuery: {}, - resolvedResultsByQuery: {}, - resolvedResultsByQueryLastPageReached: {}, + searching: boolean, }; declare type SearchSuccess = { @@ -40,45 +30,7 @@ declare type SearchSuccess = { }, }; -declare type UpdateSearchQuery = { - type: ACTIONS.UPDATE_SEARCH_QUERY, - data: { - query: string, - }, -}; - -declare type UpdateSearchSuggestions = { - type: ACTIONS.UPDATE_SEARCH_SUGGESTIONS, - data: { - query: string, - suggestions: Array, - }, -}; - declare type UpdateSearchOptions = { type: ACTIONS.UPDATE_SEARCH_OPTIONS, data: SearchOptions, }; - -declare type ResolvedSearchResult = { - channel: string, - channel_claim_id: string, - claimId: string, - duration: number, - fee: number, - name: string, - nsfw: boolean, - release_time: string, - thumbnail_url: string, - title: string, -}; - -declare type ResolvedSearchSuccess = { - type: ACTIONS.RESOLVED_SEARCH_SUCCESS, - data: { - append: boolean, - pageSize: number, - results: Array, - query: string, - }, -}; diff --git a/package.json b/package.json index 981a9bab4..ec63fe800 100644 --- a/package.json +++ b/package.json @@ -72,9 +72,12 @@ "@datapunt/matomo-tracker-js": "^0.1.4", "@exponent/electron-cookies": "^2.0.0", "@hot-loader/react-dom": "^16.8", + "@reach/auto-id": "^0.12.1", + "@reach/combobox": "^0.12.1", "@reach/menu-button": "0.7.4", "@reach/rect": "^0.2.1", "@reach/tabs": "^0.1.5", + "@reach/utils": "^0.12.1", "@sentry/browser": "^5.12.1", "@sentry/webpack-plugin": "^1.10.0", "@types/three": "^0.93.1", diff --git a/ui/component/fileThumbnail/view.jsx b/ui/component/fileThumbnail/view.jsx index 9c2449347..d13829050 100644 --- a/ui/component/fileThumbnail/view.jsx +++ b/ui/component/fileThumbnail/view.jsx @@ -37,7 +37,7 @@ function FileThumbnail(props: Props) { ); } - const url = passedThumbnail || (uri ? thumbnailFromClaim : Placeholder); + const url = thumbnail || (hasResolvedClaim ? Placeholder : ''); return (
({ - suggestions: selectSearchSuggestions(state), - searchQuery: selectSearchValue(state), - isFocused: selectSearchBarFocused(state), +const select = (state, props) => ({ language: selectLanguage(state), + showMature: makeSelectClientSetting(SETTINGS.SHOW_MATURE)(state), }); const perform = (dispatch, ownProps) => ({ - doSearch: query => { + doResolveUris: uris => dispatch(doResolveUris(uris)), + doSearch: (query, options) => dispatch(doSearch(query, options)), + navigateToSearchPage: query => { let encodedQuery = encodeURIComponent(query); ownProps.history.push({ pathname: `/$/search`, search: `?q=${encodedQuery}` }); - dispatch(doUpdateSearchQuery(query)); analytics.apiLogSearch(); }, - navigateToUri: uri => { - ownProps.history.push(uri); - }, - updateSearchQuery: query => dispatch(doUpdateSearchQuery(query)), doShowSnackBar: message => dispatch(doToast({ isError: true, message })), - doFocus: () => dispatch(doFocusSearchInput()), - doBlur: () => dispatch(doBlurSearchInput()), }); export default withRouter(connect(select, perform)(Wunderbar)); diff --git a/ui/component/wunderbar/internal/autocomplete.jsx b/ui/component/wunderbar/internal/autocomplete.jsx deleted file mode 100644 index fdc402522..000000000 --- a/ui/component/wunderbar/internal/autocomplete.jsx +++ /dev/null @@ -1,588 +0,0 @@ -/* eslint-disable */ -/* -This is taken from https://github.com/reactjs/react-autocomplete - -We aren't using that component because (for now) there is no way to autohightlight -the first item if it isn't an exact match from what is in the search bar. - -Our use case is: -value in search bar: "hello" -first suggestion: "lbry://hello" - -I changed the function maybeAutoCompleteText to check if the suggestion contains -the search query anywhere, instead of the suggestion starting with it - -https://github.com/reactjs/react-autocomplete/issues/239 -*/ - -import React from 'react'; -import { findDOMNode } from 'react-dom'; -import scrollIntoView from 'dom-scroll-into-view'; - -const IMPERATIVE_API = [ - 'blur', - 'checkValidity', - 'click', - 'focus', - 'select', - 'setCustomValidity', - 'setSelectionRange', - 'setRangeText', -]; - -function getScrollOffset() { - return { - x: - window.pageXOffset !== undefined - ? window.pageXOffset - : (document.documentElement || document.body.parentNode || document.body).scrollLeft, - y: - window.pageYOffset !== undefined - ? window.pageYOffset - : (document.documentElement || document.body.parentNode || document.body).scrollTop, - }; -} - -export default class Autocomplete extends React.Component { - // static propTypes = { - // /** - // * The items to display in the dropdown menu - // */ - // items: PropTypes.array.isRequired, - // /** - // * The value to display in the input field - // */ - // value: PropTypes.any, - // /** - // * Arguments: `event: Event, value: String` - // * - // * Invoked every time the user changes the input's value. - // */ - // onChange: PropTypes.func, - // /** - // * Arguments: `value: String, item: Any` - // * - // * Invoked when the user selects an item from the dropdown menu. - // */ - // onSelect: PropTypes.func, - // /** - // * Arguments: `item: Any, value: String` - // * - // * Invoked for each entry in `items` and its return value is used to - // * determine whether or not it should be displayed in the dropdown menu. - // * By default all items are always rendered. - // */ - // shouldItemRender: PropTypes.func, - // /** - // * Arguments: `itemA: Any, itemB: Any, value: String` - // * - // * The function which is used to sort `items` before display. - // */ - // sortItems: PropTypes.func, - // /** - // * Arguments: `item: Any` - // * - // * Used to read the display value from each entry in `items`. - // */ - // getItemValue: PropTypes.func.isRequired, - // /** - // * Arguments: `item: Any, isHighlighted: Boolean, styles: Object` - // * - // * Invoked for each entry in `items` that also passes `shouldItemRender` to - // * generate the render tree for each item in the dropdown menu. `styles` is - // * an optional set of styles that can be applied to improve the look/feel - // * of the items in the dropdown menu. - // */ - // renderItem: PropTypes.func.isRequired, - // /** - // * Arguments: `items: Array, value: String, styles: Object` - // * - // * Invoked to generate the render tree for the dropdown menu. Ensure the - // * returned tree includes every entry in `items` or else the highlight order - // * and keyboard navigation logic will break. `styles` will contain - // * { top, left, minWidth } which are the coordinates of the top-left corner - // * and the width of the dropdown menu. - // */ - // renderMenu: PropTypes.func, - // /** - // * Styles that are applied to the dropdown menu in the default `renderMenu` - // * implementation. If you override `renderMenu` and you want to use - // * `menuStyle` you must manually apply them (`this.props.menuStyle`). - // */ - // menuStyle: PropTypes.object, - // /** - // * Arguments: `props: Object` - // * - // * Invoked to generate the input element. The `props` argument is the result - // * of merging `props.inputProps` with a selection of props that are required - // * both for functionality and accessibility. At the very least you need to - // * apply `props.ref` and all `props.on` event handlers. Failing to do - // * this will cause `Autocomplete` to behave unexpectedly. - // */ - // renderInput: PropTypes.func, - // /** - // * Props passed to `props.renderInput`. By default these props will be - // * applied to the `` element rendered by `Autocomplete`, unless you - // * have specified a custom value for `props.renderInput`. Any properties - // * supported by `HTMLInputElement` can be specified, apart from the - // * following which are set by `Autocomplete`: value, autoComplete, role, - // * aria-autocomplete. `inputProps` is commonly used for (but not limited to) - // * placeholder, event handlers (onFocus, onBlur, etc.), autoFocus, etc.. - // */ - // inputProps: PropTypes.object, - // /** - // * Props that are applied to the element which wraps the `` and - // * dropdown menu elements rendered by `Autocomplete`. - // */ - // wrapperProps: PropTypes.object, - // /** - // * This is a shorthand for `wrapperProps={{ style: }}`. - // * Note that `wrapperStyle` is applied before `wrapperProps`, so the latter - // * will win if it contains a `style` entry. - // */ - // wrapperStyle: PropTypes.object, - // /** - // * Whether or not to automatically highlight the top match in the dropdown - // * menu. - // */ - // autoHighlight: PropTypes.bool, - // /** - // * Whether or not to automatically select the highlighted item when the - // * `` loses focus. - // */ - // selectOnBlur: PropTypes.bool, - // /** - // * Arguments: `isOpen: Boolean` - // * - // * Invoked every time the dropdown menu's visibility changes (i.e. every - // * time it is displayed/hidden). - // */ - // onMenuVisibilityChange: PropTypes.func, - // /** - // * Used to override the internal logic which displays/hides the dropdown - // * menu. This is useful if you want to force a certain state based on your - // * UX/business logic. Use it together with `onMenuVisibilityChange` for - // * fine-grained control over the dropdown menu dynamics. - // */ - // open: PropTypes.bool, - // debug: PropTypes.bool, - // }; - - static defaultProps = { - value: '', - wrapperProps: {}, - wrapperStyle: { - display: 'inline-block', - }, - inputProps: {}, - renderInput(props) { - return ; - }, - onChange() {}, - onSelect() {}, - renderMenu(items, value, style) { - return
; - }, - menuStyle: { - position: 'absolute', - overflow: 'hidden', - }, - autoHighlight: true, - selectOnBlur: false, - onMenuVisibilityChange() {}, - }; - - constructor(props) { - super(props); - this.state = { - isOpen: false, - highlightedIndex: null, - }; - this._debugStates = []; - this.ensureHighlightedIndex = this.ensureHighlightedIndex.bind(this); - this.exposeAPI = this.exposeAPI.bind(this); - this.handleInputFocus = this.handleInputFocus.bind(this); - this.handleInputBlur = this.handleInputBlur.bind(this); - this.handleChange = this.handleChange.bind(this); - this.handleKeyDown = this.handleKeyDown.bind(this); - this.handleInputClick = this.handleInputClick.bind(this); - this.maybeAutoCompleteText = this.maybeAutoCompleteText.bind(this); - } - - UNSAFE_componentWillMount() { - // this.refs is frozen, so we need to assign a new object to it - this.refs = {}; - this._ignoreBlur = false; - this._ignoreFocus = false; - this._scrollOffset = null; - this._scrollTimer = null; - } - - componentWillUnmount() { - clearTimeout(this._scrollTimer); - this._scrollTimer = null; - } - - UNSAFE_componentWillReceiveProps(nextProps) { - if (this.state.highlightedIndex !== null) { - this.setState(this.ensureHighlightedIndex); - } - if (nextProps.autoHighlight && (this.props.value !== nextProps.value || this.state.highlightedIndex === null)) { - this.setState(this.maybeAutoCompleteText); - } - } - - componentDidMount() { - if (this.isOpen()) { - this.setMenuPositions(); - } - } - - componentDidUpdate(prevProps, prevState) { - if ((this.state.isOpen && !prevState.isOpen) || ('open' in this.props && this.props.open && !prevProps.open)) { - this.setMenuPositions(); - } - - this.maybeScrollItemIntoView(); - if (prevState.isOpen !== this.state.isOpen) { - this.props.onMenuVisibilityChange(this.state.isOpen); - } - } - - exposeAPI(el) { - this.refs.input = el; - IMPERATIVE_API.forEach(ev => (this[ev] = el && el[ev] && el[ev].bind(el))); - } - - maybeScrollItemIntoView() { - if (this.isOpen() && this.state.highlightedIndex !== null) { - const itemNode = this.refs[`item-${this.state.highlightedIndex}`]; - const menuNode = this.refs.menu; - scrollIntoView(findDOMNode(itemNode), findDOMNode(menuNode), { - onlyScrollIfNeeded: true, - }); - } - } - - handleKeyDown(event) { - if (Autocomplete.keyDownHandlers[event.key]) { - Autocomplete.keyDownHandlers[event.key].call(this, event); - } else if (!this.isOpen()) { - this.setState({ - isOpen: true, - }); - } - } - - handleChange(event) { - this.props.onChange(event, event.target.value); - } - - static keyDownHandlers = { - ArrowDown(event) { - event.preventDefault(); - const itemsLength = this.getFilteredItems(this.props).length; - if (!itemsLength) return; - const { highlightedIndex } = this.state; - const index = highlightedIndex === null || highlightedIndex === itemsLength - 1 ? 0 : highlightedIndex + 1; - this.setState({ - highlightedIndex: index, - isOpen: true, - }); - }, - - ArrowUp(event) { - event.preventDefault(); - const itemsLength = this.getFilteredItems(this.props).length; - if (!itemsLength) return; - const { highlightedIndex } = this.state; - const index = highlightedIndex === 0 || highlightedIndex === null ? itemsLength - 1 : highlightedIndex - 1; - this.setState({ - highlightedIndex: index, - isOpen: true, - }); - }, - - Enter(event) { - // Key code 229 is used for selecting items from character selectors (Pinyin, Kana, etc) - if (event.keyCode !== 13) return; - - const inputValue = this.refs.input.value; - if (!inputValue) return; - - if (!this.isOpen() || this.state.highlightedIndex == null) { - // User pressed enter before any search suggestions were populated - this.setState({ isOpen: false }, () => { - this.props.onSelect(inputValue); - this.refs.input.blur(); - }); - } else { - // text entered + menu item has been highlighted + enter is hit -> update value to that of selected menu item, close the menu - event.preventDefault(); - const item = this.getFilteredItems(this.props)[this.state.highlightedIndex]; - const value = this.props.getItemValue(item); - this.setState( - { - isOpen: false, - highlightedIndex: null, - }, - () => { - this.props.onSelect(value, item); - this.refs.input.blur(); - } - ); - } - }, - - Escape() { - // In case the user is currently hovering over the menu - this.setIgnoreBlur(false); - this.setState({ - highlightedIndex: null, - isOpen: false, - }); - }, - - Tab() { - // In case the user is currently hovering over the menu - this.setIgnoreBlur(false); - }, - }; - - getFilteredItems(props) { - let items = props.items; - - if (props.shouldItemRender) { - items = items.filter(item => props.shouldItemRender(item, props.value)); - } - - if (props.sortItems) { - items.sort((a, b) => props.sortItems(a, b, props.value)); - } - - return items; - } - - maybeAutoCompleteText(state, props) { - const { highlightedIndex } = state; - const { value, getItemValue } = props; - const index = highlightedIndex === null ? 0 : highlightedIndex; - const matchedItem = this.getFilteredItems(props)[index]; - if (value !== '' && matchedItem) { - const itemValue = getItemValue(matchedItem); - const itemValueDoesMatch = itemValue.toLowerCase().includes( - value.toLowerCase() - // below line is the the only thing that is changed from the real component - ); - if (itemValueDoesMatch) { - return { highlightedIndex: index }; - } - } - return { highlightedIndex: null }; - } - - ensureHighlightedIndex(state, props) { - if (state.highlightedIndex >= this.getFilteredItems(props).length) { - return { highlightedIndex: null }; - } - } - - setMenuPositions() { - const node = this.refs.input; - const rect = node.getBoundingClientRect(); - const computedStyle = global.window.getComputedStyle(node); - // const marginBottom = parseInt(computedStyle.marginBottom, 10) || 0; - const marginLeft = parseInt(computedStyle.marginLeft, 10) || 0; - const marginRight = parseInt(computedStyle.marginRight, 10) || 0; - this.setState({ - // We may need these if we go back to a fixed header - // menuTop: rect.bottom + marginBottom, - // menuLeft: rect.left + marginLeft, - menuWidth: rect.width + marginLeft + marginRight, - }); - } - - highlightItemFromMouse(index) { - this.setState({ highlightedIndex: index }); - } - - selectItemFromMouse(item) { - const value = this.props.getItemValue(item); - // The menu will de-render before a mouseLeave event - // happens. Clear the flag to release control over focus - this.setIgnoreBlur(false); - this.setState( - { - isOpen: false, - highlightedIndex: null, - }, - () => { - this.props.onSelect(value, item); - } - ); - } - - setIgnoreBlur(ignore) { - this._ignoreBlur = ignore; - } - - renderMenu() { - if (!this.props.value) { - return null; - } - - const items = this.getFilteredItems(this.props).map((item, index) => { - const element = this.props.renderItem(item, this.state.highlightedIndex === index, { - cursor: 'default', - }); - return React.cloneElement(element, { - onMouseEnter: () => this.highlightItemFromMouse(index), - onClick: () => this.selectItemFromMouse(item), - ref: e => (this.refs[`item-${index}`] = e), - }); - }); - const style = { - left: this.state.menuLeft, - top: this.state.menuTop, - minWidth: this.state.menuWidth, - }; - const menu = this.props.renderMenu(items, this.props.value, style); - return React.cloneElement(menu, { - ref: e => (this.refs.menu = e), - className: 'wunderbar__menu', - // Ignore blur to prevent menu from de-rendering before we can process click - onMouseEnter: () => this.setIgnoreBlur(true), - onMouseLeave: () => this.setIgnoreBlur(false), // uncomment this to inspect styling - }); - } - - handleInputBlur(event) { - if (this._ignoreBlur) { - this._ignoreFocus = true; - this._scrollOffset = getScrollOffset(); - this.refs.input.focus(); - return; - } - let setStateCallback; - const { highlightedIndex } = this.state; - if (this.props.selectOnBlur && highlightedIndex !== null) { - const items = this.getFilteredItems(this.props); - const item = items[highlightedIndex]; - const value = this.props.getItemValue(item); - setStateCallback = () => this.props.onSelect(value, item); - } - this.setState( - { - isOpen: false, - highlightedIndex: null, - }, - setStateCallback - ); - const { onBlur } = this.props.inputProps; - if (onBlur) { - onBlur(event); - } - } - - handleInputFocus(event) { - if (this._ignoreFocus) { - this._ignoreFocus = false; - const { x, y } = this._scrollOffset; - this._scrollOffset = null; - // Focus will cause the browser to scroll the into view. - // This can cause the mouse coords to change, which in turn - // could cause a new highlight to happen, cancelling the click - // event (when selecting with the mouse) - window.scrollTo(x, y); - // Some browsers wait until all focus event handlers have been - // processed before scrolling the into view, so let's - // scroll again on the next tick to ensure we're back to where - // the user was before focus was lost. We could do the deferred - // scroll only, but that causes a jarring split second jump in - // some browsers that scroll before the focus event handlers - // are triggered. - clearTimeout(this._scrollTimer); - this._scrollTimer = setTimeout(() => { - this._scrollTimer = null; - window.scrollTo(x, y); - }, 0); - return; - } - - // Highlight - this.refs.input.select(); - - this.setState({ isOpen: true }); - - const { onFocus } = this.props.inputProps; - if (onFocus) { - onFocus(event); - } - } - - isInputFocused() { - const el = this.refs.input; - return el ? el.ownerDocument && el === el.ownerDocument.activeElement : false; - } - - handleInputClick() { - // Input will not be focused if it's disabled - if (this.isInputFocused() && !this.isOpen()) this.setState({ isOpen: true }); - } - - composeEventHandlers(internal, external) { - return external - ? e => { - internal(e); - external(e); - } - : internal; - } - - isOpen() { - return 'open' in this.props ? this.props.open : this.state.isOpen; - } - - render() { - if (this.props.debug) { - // you don't like it, you love it - this._debugStates.push({ - id: this._debugStates.length, - state: this.state, - }); - } - - const { inputProps, items } = this.props; - - const open = this.isOpen(); - return ( -
- {this.props.renderInput({ - ...inputProps, - role: 'combobox', - 'aria-autocomplete': 'list', - 'aria-expanded': open, - autoComplete: 'off', - ref: this.exposeAPI, - onFocus: this.handleInputFocus, - onBlur: this.handleInputBlur, - onChange: this.handleChange, - onKeyDown: this.composeEventHandlers(this.handleKeyDown, inputProps.onKeyDown), - onClick: this.composeEventHandlers(this.handleInputClick, inputProps.onClick), - value: this.props.value, - })} - {open && !!items.length && this.renderMenu()} - {this.props.debug && ( -
-            {JSON.stringify(
-              this._debugStates.slice(Math.max(0, this._debugStates.length - 5), this._debugStates.length),
-              null,
-              2
-            )}
-          
- )} -
- ); - } -} -/* eslint-enable */ diff --git a/ui/component/wunderbar/view.jsx b/ui/component/wunderbar/view.jsx index d40ce68b4..990abb47f 100644 --- a/ui/component/wunderbar/view.jsx +++ b/ui/component/wunderbar/view.jsx @@ -1,16 +1,21 @@ // @flow import { URL, URL_LOCAL, URL_DEV } from 'config'; -import { SEARCH_TYPES } from 'constants/search'; import * as PAGES from 'constants/pages'; import * as ICONS from 'constants/icons'; import React from 'react'; -import classnames from 'classnames'; -import { withRouter } from 'react-router'; import Icon from 'component/common/icon'; -import Autocomplete from './internal/autocomplete'; -import Tag from 'component/tag'; -import { isURIValid, normalizeURI } from 'lbry-redux'; -import { formatLbryUrlForWeb } from '../../util/url'; +import { isURIValid, normalizeURI, parseURI } from 'lbry-redux'; +import { Combobox, ComboboxInput, ComboboxPopover, ComboboxList, ComboboxOption } from '@reach/combobox'; +import '@reach/combobox/styles.css'; +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 useThrottle from 'effects/use-throttle'; + const WEB_DEV_PREFIX = `${URL_DEV}/`; const WEB_LOCAL_PREFIX = `${URL_LOCAL}/`; const WEB_PROD_PREFIX = `${URL}/`; @@ -22,226 +27,158 @@ const ESC_KEY_CODE = 27; type Props = { searchQuery: ?string, - updateSearchQuery: string => void, onSearch: string => void, - onSubmit: string => void, - - navigateToUri: string => void, - doSearch: string => void, - - suggestions: Array, - doFocus: () => void, - doBlur: () => void, - focused: boolean, + navigateToSearchPage: string => void, + doResolveUris: string => void, doShowSnackBar: string => void, - history: { push: string => void }, + showMature: boolean, }; -type State = { - query: ?string, -}; - -class WunderBar extends React.PureComponent { - constructor() { - super(); - - this.state = { - query: null, - }; - - (this: any).handleSubmit = this.handleSubmit.bind(this); - (this: any).handleChange = this.handleChange.bind(this); - (this: any).handleKeyDown = this.handleKeyDown.bind(this); - } - - componentDidMount() { - window.addEventListener('keydown', this.handleKeyDown); - } - - componentWillUnmount() { - window.removeEventListener('keydown', this.handleKeyDown); - } - - getSuggestionIcon = (type: string) => { - switch (type) { - case SEARCH_TYPES.FILE: - return ICONS.FILE; - case SEARCH_TYPES.CHANNEL: - return ICONS.CHANNEL; - case SEARCH_TYPES.TAG: - return ICONS.TAG; - default: - return ICONS.SEARCH; +export default function WunderBar(props: Props) { + const { navigateToSearchPage, doShowSnackBar, doResolveUris, showMature } = props; + const inputRef = React.useRef(); + const { + push, + location: { search }, + } = useHistory(); + const urlParams = new URLSearchParams(search); + const queryFromUrl = urlParams.get('q') || ''; + const [term, setTerm] = React.useState(queryFromUrl); + const throttledTerm = useThrottle(term, 500) || ''; + const { results } = useLighthouse(throttledTerm, showMature); + const nameFromQuery = throttledTerm + .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) {} - handleKeyDown(event: SyntheticKeyboardEvent<*>) { - const { ctrlKey, metaKey, keyCode } = event; - const { doFocus, doBlur, focused } = this.props; - - if (this.input) { - if (focused && keyCode === ESC_KEY_CODE) { - this.input.blur(); - doBlur(); - return; - } - - // @if TARGET='app' - const shouldFocus = - process.platform === 'darwin' ? keyCode === L_KEY_CODE && metaKey : keyCode === L_KEY_CODE && ctrlKey; - - if (shouldFocus) { - this.input.focus(); - doFocus(); - } - // @endif - } + const topUrisToTest = [uriFromQuery]; + if (channelUrlForTopTest) { + topUrisToTest.push(uriFromQuery); } - handleChange(e: SyntheticInputEvent<*>) { - const { value } = e.target; - const { updateSearchQuery } = this.props; - updateSearchQuery(value); - } - - onSubmitWebUri(uri: string, prefix: string) { - // Allow copying a lbry.tv url and pasting it into the search bar - const { doSearch, navigateToUri, updateSearchQuery } = this.props; - - let query = uri.slice(prefix.length); - query = query.replace(/:/g, '#'); - if (query.includes(SEARCH_PREFIX)) { - query = query.slice(SEARCH_PREFIX.length); - doSearch(query); - } else { - // TODO - double check this code path - let path = `lbry://${query}`; - const uri = formatLbryUrlForWeb(path); - navigateToUri(uri); - updateSearchQuery(''); - } - } - - onClickSuggestion(query: string, suggestion: { value: string, type: string }): void { - const { navigateToUri, doSearch, doShowSnackBar } = this.props; - - if (suggestion.type === SEARCH_TYPES.SEARCH) { - doSearch(query); - } else if (suggestion.type === SEARCH_TYPES.TAG) { - const encodedSuggestion = encodeURIComponent(suggestion.value); - const uri = `/$/${PAGES.DISCOVER}?t=${encodedSuggestion}`; - navigateToUri(uri); - } else if (isURIValid(query)) { - let uri = normalizeURI(query); - uri = formatLbryUrlForWeb(uri); - navigateToUri(uri); - } else { - doShowSnackBar(INVALID_URL_ERROR); - } - } - - onSubmitRawString(st: string): void { - const { navigateToUri, doSearch, doShowSnackBar } = this.props; - // Currently no suggestion is highlighted. The user may have started - // typing, then lost focus and came back later on the same page - try { - if (isURIValid(st)) { - const uri = normalizeURI(st); - navigateToUri(uri); - } else { - doShowSnackBar(INVALID_URL_ERROR); - } - } catch (e) { - doSearch(st); - } - } - - handleSubmit(value: string, suggestion?: { value: string, type: string }) { - let query = value.trim(); - this.input && this.input.blur(); - - const includesLbryTvProd = query.includes(WEB_PROD_PREFIX); - const includesLbryTvLocal = query.includes(WEB_LOCAL_PREFIX); - const includesLbryTvDev = query.includes(WEB_DEV_PREFIX); + function handleSelect(value) { + const includesLbryTvProd = value.includes(WEB_PROD_PREFIX); + const includesLbryTvLocal = value.includes(WEB_LOCAL_PREFIX); + const includesLbryTvDev = value.includes(WEB_DEV_PREFIX); const wasCopiedFromWeb = includesLbryTvDev || includesLbryTvLocal || includesLbryTvProd; + const isLbryUrl = value.startsWith('lbry://'); + + if (inputRef.current) { + inputRef.current.blur(); + } if (wasCopiedFromWeb) { let prefix = WEB_PROD_PREFIX; if (includesLbryTvLocal) prefix = WEB_LOCAL_PREFIX; if (includesLbryTvDev) prefix = WEB_DEV_PREFIX; - this.onSubmitWebUri(query, prefix); - } else if (suggestion) { - this.onClickSuggestion(query, suggestion); + + 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 (!isLbryUrl) { + navigateToSearchPage(value); } else { - this.onSubmitRawString(query); + try { + if (isURIValid(value)) { + const uri = normalizeURI(value); + const normalizedWebUrl = formatLbryUrlForWeb(uri); + push(normalizedWebUrl); + } else { + doShowSnackBar(INVALID_URL_ERROR); + } + } catch (e) { + navigateToSearchPage(value); + } } } - input: ?HTMLInputElement; + React.useEffect(() => { + function handleKeyDown(event) { + const { ctrlKey, metaKey, keyCode } = event; - render() { - const { suggestions, doFocus, doBlur, searchQuery } = this.props; + if (!inputRef.current) { + return; + } - return ( -
{ - e.stopPropagation(); - }} - // @endif + if (inputRef.current === document.activeElement && keyCode === ESC_KEY_CODE) { + inputRef.current.blur(); + } - className="wunderbar" - > + // @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); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [inputRef]); + + 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)}> + - item.value} - onChange={this.handleChange} - onSelect={this.handleSubmit} - inputProps={{ - onFocus: doFocus, - onBlur: doBlur, - }} - renderInput={props => ( - { - props.ref(el); - this.input = el; - }} - className="wunderbar__input" - placeholder={__('Search')} - /> - )} - renderItem={({ value, type, shorthand }, isHighlighted) => ( -
- - - {type === SEARCH_TYPES.TAG ? : shorthand || value} - - {isHighlighted && ( - - {type === SEARCH_TYPES.SEARCH && __('Search')} - {type === SEARCH_TYPES.CHANNEL && __('View channel')} - {type === SEARCH_TYPES.FILE && __('View file')} - {type === SEARCH_TYPES.TAG && __('View Tag')} - - )} -
- )} + setTerm(e.target.value)} + value={term} /> -
- ); - } -} -export default withRouter(WunderBar); + {results && results.length > 0 && ( + + + {uriFromQueryIsValid ? : null} + +
{__('Search Results')}
+ {results.slice(0, 5).map(uri => ( + + ))} + +