wunderbar improvements
This commit is contained in:
parent
dc679add87
commit
30d8a0406d
18 changed files with 588 additions and 1143 deletions
50
flow-typed/search.js
vendored
50
flow-typed/search.js
vendored
|
@ -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<SearchSuggestion> },
|
||||
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<SearchSuggestion>,
|
||||
},
|
||||
};
|
||||
|
||||
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<ResolvedSearchResult>,
|
||||
query: string,
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -37,7 +37,7 @@ function FileThumbnail(props: Props) {
|
|||
);
|
||||
}
|
||||
|
||||
const url = passedThumbnail || (uri ? thumbnailFromClaim : Placeholder);
|
||||
const url = thumbnail || (hasResolvedClaim ? Placeholder : '');
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
|
@ -1,33 +1,26 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { doFocusSearchInput, doBlurSearchInput, doUpdateSearchQuery } from 'redux/actions/search';
|
||||
import { selectSearchValue, selectSearchSuggestions, selectSearchBarFocused } from 'redux/selectors/search';
|
||||
import { selectLanguage } from 'redux/selectors/settings';
|
||||
import { selectLanguage, makeSelectClientSetting } from 'redux/selectors/settings';
|
||||
import { doToast } from 'redux/actions/notifications';
|
||||
import { doSearch } from 'redux/actions/search';
|
||||
import { withRouter } from 'react-router';
|
||||
import { doResolveUris, SETTINGS } from 'lbry-redux';
|
||||
import analytics from 'analytics';
|
||||
import Wunderbar from './view';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
|
||||
const select = state => ({
|
||||
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));
|
||||
|
|
|
@ -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<Any>, 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>` 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 `<input />` 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 `<input />` and
|
||||
// * dropdown menu elements rendered by `Autocomplete`.
|
||||
// */
|
||||
// wrapperProps: PropTypes.object,
|
||||
// /**
|
||||
// * This is a shorthand for `wrapperProps={{ style: <your styles> }}`.
|
||||
// * 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
|
||||
// * `<input>` 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 <input {...props} />;
|
||||
},
|
||||
onChange() {},
|
||||
onSelect() {},
|
||||
renderMenu(items, value, style) {
|
||||
return <div style={{ ...style, ...this.menuStyle }} children={items} />;
|
||||
},
|
||||
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 <input> 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 <input> 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 (
|
||||
<div style={{ ...this.props.wrapperStyle }} {...this.props.wrapperProps}>
|
||||
{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 && (
|
||||
<pre style={{ marginLeft: 300 }}>
|
||||
{JSON.stringify(
|
||||
this._debugStates.slice(Math.max(0, this._debugStates.length - 5), this._debugStates.length),
|
||||
null,
|
||||
2
|
||||
)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
/* eslint-enable */
|
|
@ -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<string>,
|
||||
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<Props, State> {
|
||||
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 (
|
||||
<div
|
||||
// @if TARGET='app'
|
||||
onDoubleClick={e => {
|
||||
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 (
|
||||
<Form className="wunderbar__wrapper" onSubmit={() => handleSelect(term)}>
|
||||
<Combobox className="wunderbar" onSelect={handleSelect}>
|
||||
<Icon icon={ICONS.SEARCH} />
|
||||
<Autocomplete
|
||||
autoHighlight
|
||||
wrapperStyle={{ flex: 1, position: 'relative' }}
|
||||
value={searchQuery}
|
||||
items={suggestions}
|
||||
getItemValue={item => item.value}
|
||||
onChange={this.handleChange}
|
||||
onSelect={this.handleSubmit}
|
||||
inputProps={{
|
||||
onFocus: doFocus,
|
||||
onBlur: doBlur,
|
||||
}}
|
||||
renderInput={props => (
|
||||
<input
|
||||
{...props}
|
||||
ref={el => {
|
||||
props.ref(el);
|
||||
this.input = el;
|
||||
}}
|
||||
className="wunderbar__input"
|
||||
placeholder={__('Search')}
|
||||
/>
|
||||
)}
|
||||
renderItem={({ value, type, shorthand }, isHighlighted) => (
|
||||
<div
|
||||
// Use value + type for key because there might be suggestions with same value but different type
|
||||
key={`${value}-${type}`}
|
||||
className={classnames('wunderbar__suggestion', {
|
||||
'wunderbar__active-suggestion': isHighlighted,
|
||||
})}
|
||||
>
|
||||
<Icon icon={this.getSuggestionIcon(type)} />
|
||||
<span className="wunderbar__suggestion-label">
|
||||
{type === SEARCH_TYPES.TAG ? <Tag name={value} /> : shorthand || value}
|
||||
</span>
|
||||
{isHighlighted && (
|
||||
<span className="wunderbar__suggestion-label--action">
|
||||
{type === SEARCH_TYPES.SEARCH && __('Search')}
|
||||
{type === SEARCH_TYPES.CHANNEL && __('View channel')}
|
||||
{type === SEARCH_TYPES.FILE && __('View file')}
|
||||
{type === SEARCH_TYPES.TAG && __('View Tag')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<ComboboxInput
|
||||
ref={inputRef}
|
||||
className="wunderbar__input"
|
||||
placeholder={__('Search')}
|
||||
onChange={e => setTerm(e.target.value)}
|
||||
value={term}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withRouter(WunderBar);
|
||||
{results && results.length > 0 && (
|
||||
<ComboboxPopover portal={false} className="wunderbar__suggestions">
|
||||
<ComboboxList>
|
||||
{uriFromQueryIsValid ? <WunderbarTopSuggestion query={nameFromQuery} /> : null}
|
||||
|
||||
<div className="wunderbar__label--results">{__('Search Results')}</div>
|
||||
{results.slice(0, 5).map(uri => (
|
||||
<WunderbarSuggestion key={uri} uri={uri} />
|
||||
))}
|
||||
<ComboboxOption value={term} className="wunderbar__more-results">
|
||||
<Button button="link" label={__('View All Results')} />
|
||||
</ComboboxOption>
|
||||
</ComboboxList>
|
||||
</ComboboxPopover>
|
||||
)}
|
||||
</Combobox>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
|
9
ui/component/wunderbarSuggestion/index.js
Normal file
9
ui/component/wunderbarSuggestion/index.js
Normal file
|
@ -0,0 +1,9 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { makeSelectClaimForUri } from 'lbry-redux';
|
||||
import WunderbarSuggestion from './view';
|
||||
|
||||
const select = (state, props) => ({
|
||||
claim: makeSelectClaimForUri(props.uri)(state),
|
||||
});
|
||||
|
||||
export default connect(select)(WunderbarSuggestion);
|
44
ui/component/wunderbarSuggestion/view.jsx
Normal file
44
ui/component/wunderbarSuggestion/view.jsx
Normal file
|
@ -0,0 +1,44 @@
|
|||
// @flow
|
||||
import React from 'react';
|
||||
import classnames from 'classnames';
|
||||
import { ComboboxOption } from '@reach/combobox';
|
||||
import FileThumbnail from 'component/fileThumbnail';
|
||||
import ChannelThumbnail from 'component/channelThumbnail';
|
||||
|
||||
type Props = {
|
||||
claim: ?Claim,
|
||||
uri: string,
|
||||
noComboBox?: boolean,
|
||||
};
|
||||
|
||||
export default function WunderbarSuggestion(props: Props) {
|
||||
const { claim, uri, noComboBox = false } = props;
|
||||
|
||||
if (!claim) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isChannel = claim.value_type === 'channel';
|
||||
|
||||
const Wrapper = noComboBox
|
||||
? (props: any) => <div>{props.children}</div>
|
||||
: (props: any) => <ComboboxOption value={uri}>{props.children}</ComboboxOption>;
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<div
|
||||
className={classnames('wunderbar__suggestion', {
|
||||
'wunderbar__suggestion--channel': isChannel,
|
||||
})}
|
||||
>
|
||||
{isChannel ? <ChannelThumbnail uri={uri} /> : <FileThumbnail uri={uri} />}
|
||||
<span className="wunderbar__suggestion-label">
|
||||
<div>{claim.value.title}</div>
|
||||
<div className="wunderbar__suggestion-name">
|
||||
{isChannel ? claim.name : (claim.signing_channel && claim.signing_channel.name) || __('Anonymous')}
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
15
ui/component/wunderbarTopSuggestion/index.js
Normal file
15
ui/component/wunderbarTopSuggestion/index.js
Normal file
|
@ -0,0 +1,15 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { doResolveUris, makeSelectClaimForUri } from 'lbry-redux';
|
||||
import { makeSelectWinningUriForQuery } from 'redux/selectors/search';
|
||||
import WunderbarTopSuggestion from './view';
|
||||
|
||||
const select = (state, props) => {
|
||||
const winningUri = makeSelectWinningUriForQuery(props.query)(state);
|
||||
const winningClaim = winningUri ? makeSelectClaimForUri(winningUri)(state) : undefined;
|
||||
|
||||
return { winningUri, winningClaim };
|
||||
};
|
||||
|
||||
export default connect(select, {
|
||||
doResolveUris,
|
||||
})(WunderbarTopSuggestion);
|
59
ui/component/wunderbarTopSuggestion/view.jsx
Normal file
59
ui/component/wunderbarTopSuggestion/view.jsx
Normal file
|
@ -0,0 +1,59 @@
|
|||
// @flow
|
||||
import React from 'react';
|
||||
import LbcSymbol from 'component/common/lbc-symbol';
|
||||
import WunderbarSuggestion from 'component/wunderbarSuggestion';
|
||||
import { ComboboxOption } from '@reach/combobox';
|
||||
import { parseURI } from 'lbry-redux';
|
||||
|
||||
type Props = {
|
||||
query: string,
|
||||
winningUri: ?string,
|
||||
doResolveUris: (Array<string>) => void,
|
||||
};
|
||||
|
||||
export default function WunderbarTopClaim(props: Props) {
|
||||
const { query, winningUri, doResolveUris } = props;
|
||||
|
||||
const uriFromQuery = `lbry://${query}`;
|
||||
|
||||
let channelUriFromQuery;
|
||||
try {
|
||||
const { isChannel } = parseURI(uriFromQuery);
|
||||
|
||||
if (!isChannel) {
|
||||
channelUriFromQuery = `lbry://@${query}`;
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
React.useEffect(() => {
|
||||
let urisToResolve = [];
|
||||
if (uriFromQuery) {
|
||||
urisToResolve.push(uriFromQuery);
|
||||
}
|
||||
|
||||
if (channelUriFromQuery) {
|
||||
urisToResolve.push(channelUriFromQuery);
|
||||
}
|
||||
|
||||
if (urisToResolve.length > 0) {
|
||||
doResolveUris(urisToResolve);
|
||||
}
|
||||
}, [doResolveUris, uriFromQuery, channelUriFromQuery]);
|
||||
|
||||
if (!winningUri) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ComboboxOption value={winningUri} className="wunderbar__winning-claim">
|
||||
<div className="wunderbar__label">
|
||||
<LbcSymbol postfix={__('Winning for "%query%"', { query })} />
|
||||
</div>
|
||||
|
||||
<WunderbarSuggestion uri={winningUri} noComboBox />
|
||||
</ComboboxOption>
|
||||
<hr className="wunderbar__top-separator" />
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -119,14 +119,8 @@ export const FILE_DELETE = 'FILE_DELETE';
|
|||
export const SEARCH_START = 'SEARCH_START';
|
||||
export const SEARCH_SUCCESS = 'SEARCH_SUCCESS';
|
||||
export const SEARCH_FAIL = 'SEARCH_FAIL';
|
||||
export const RESOLVED_SEARCH_START = 'RESOLVED_SEARCH_START';
|
||||
export const RESOLVED_SEARCH_SUCCESS = 'RESOLVED_SEARCH_SUCCESS';
|
||||
export const RESOLVED_SEARCH_FAIL = 'RESOLVED_SEARCH_FAIL';
|
||||
export const UPDATE_SEARCH_QUERY = 'UPDATE_SEARCH_QUERY';
|
||||
export const UPDATE_SEARCH_OPTIONS = 'UPDATE_SEARCH_OPTIONS';
|
||||
export const UPDATE_SEARCH_SUGGESTIONS = 'UPDATE_SEARCH_SUGGESTIONS';
|
||||
export const SEARCH_FOCUS = 'SEARCH_FOCUS';
|
||||
export const SEARCH_BLUR = 'SEARCH_BLUR';
|
||||
|
||||
// Settings
|
||||
export const DAEMON_SETTINGS_RECEIVED = 'DAEMON_SETTINGS_RECEIVED';
|
||||
|
|
31
ui/effects/use-lighthouse.js
Normal file
31
ui/effects/use-lighthouse.js
Normal file
|
@ -0,0 +1,31 @@
|
|||
// @flow
|
||||
import React from 'react';
|
||||
import { lighthouse } from 'redux/actions/search';
|
||||
import { getSearchQueryString } from 'util/query-params';
|
||||
import useThrottle from './use-throttle';
|
||||
|
||||
export default function useLighthouse(query: string, showMature?: boolean, size?: number = 5) {
|
||||
const [results, setResults] = React.useState();
|
||||
const [loading, setLoading] = React.useState();
|
||||
const queryString = getSearchQueryString(query, { nsfw: showMature, size });
|
||||
const throttledQuery = useThrottle(queryString, 500);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (throttledQuery) {
|
||||
setLoading(true);
|
||||
setResults(null);
|
||||
|
||||
lighthouse
|
||||
.search(throttledQuery)
|
||||
.then(results => {
|
||||
setResults(results.map(result => `lbry://${result.name}#${result.claimId}`));
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
}, [throttledQuery]);
|
||||
|
||||
return { results, loading };
|
||||
}
|
49
ui/effects/use-throttle.js
Normal file
49
ui/effects/use-throttle.js
Normal file
|
@ -0,0 +1,49 @@
|
|||
// @flow
|
||||
import React from 'react';
|
||||
|
||||
const useEffectOnce = effect => {
|
||||
React.useEffect(effect, []);
|
||||
};
|
||||
|
||||
function useUnmount(fn: () => any): void {
|
||||
const fnRef = React.useRef(fn);
|
||||
|
||||
// update the ref each render so if it change the newest callback will be invoked
|
||||
fnRef.current = fn;
|
||||
|
||||
useEffectOnce(() => () => fnRef.current());
|
||||
}
|
||||
|
||||
export function useThrottle(value: string, ms: number = 200) {
|
||||
const [state, setState] = React.useState(value);
|
||||
const timeout = React.useRef();
|
||||
const nextValue = React.useRef(null);
|
||||
const hasNextValue = React.useRef(0);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!timeout.current) {
|
||||
setState(value);
|
||||
const timeoutCallback = () => {
|
||||
if (hasNextValue.current) {
|
||||
hasNextValue.current = false;
|
||||
setState(nextValue.current);
|
||||
timeout.current = setTimeout(timeoutCallback, ms);
|
||||
} else {
|
||||
timeout.current = undefined;
|
||||
}
|
||||
};
|
||||
timeout.current = setTimeout(timeoutCallback, ms);
|
||||
} else {
|
||||
nextValue.current = value;
|
||||
hasNextValue.current = true;
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
useUnmount(() => {
|
||||
timeout.current && clearTimeout(timeout.current);
|
||||
});
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
export default useThrottle;
|
|
@ -1,16 +1,9 @@
|
|||
// @flow
|
||||
import * as ACTIONS from 'constants/action_types';
|
||||
import { buildURI, doResolveUris, batchActions } from 'lbry-redux';
|
||||
import {
|
||||
makeSelectSearchUris,
|
||||
selectSuggestions,
|
||||
makeSelectQueryWithOptions,
|
||||
selectSearchValue,
|
||||
} from 'redux/selectors/search';
|
||||
import debounce from 'util/debounce';
|
||||
import { makeSelectSearchUris, makeSelectQueryWithOptions, selectSearchValue } from 'redux/selectors/search';
|
||||
import handleFetchResponse from 'util/handle-fetch';
|
||||
|
||||
const DEBOUNCED_SEARCH_SUGGESTION_MS = 300;
|
||||
type Dispatch = (action: any) => any;
|
||||
type GetState = () => { search: SearchState };
|
||||
|
||||
|
@ -22,62 +15,13 @@ type SearchOptions = {
|
|||
isBackgroundSearch?: boolean,
|
||||
};
|
||||
|
||||
// We can't use env's because they aren't passed into node_modules
|
||||
let CONNECTION_STRING = 'https://lighthouse.lbry.com/';
|
||||
let lighthouse = {
|
||||
CONNECTION_STRING: 'https://lighthouse.lbry.com/search',
|
||||
search: (queryString: string) => fetch(`${lighthouse.CONNECTION_STRING}?${queryString}`).then(handleFetchResponse),
|
||||
};
|
||||
|
||||
export const setSearchApi = (endpoint: string) => {
|
||||
CONNECTION_STRING = endpoint.replace(/\/*$/, '/'); // exactly one slash at the end;
|
||||
};
|
||||
|
||||
export const getSearchSuggestions = (value: string) => (dispatch: Dispatch, getState: GetState) => {
|
||||
const query = value.trim();
|
||||
|
||||
// strip out any basic stuff for more accurate search results
|
||||
let searchValue = query.replace(/lbry:\/\//g, '').replace(/-/g, ' ');
|
||||
if (searchValue.includes('#')) {
|
||||
// This should probably be more robust, but I think it's fine for now
|
||||
// Remove everything after # to get rid of the claim id
|
||||
searchValue = searchValue.substring(0, searchValue.indexOf('#'));
|
||||
}
|
||||
|
||||
const suggestions = selectSuggestions(getState());
|
||||
if (suggestions[searchValue]) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(`${CONNECTION_STRING}autocomplete?s=${encodeURIComponent(searchValue)}`)
|
||||
.then(handleFetchResponse)
|
||||
.then(apiSuggestions => {
|
||||
dispatch({
|
||||
type: ACTIONS.UPDATE_SEARCH_SUGGESTIONS,
|
||||
data: {
|
||||
query: searchValue,
|
||||
suggestions: apiSuggestions,
|
||||
},
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
// If the fetch fails, do nothing
|
||||
// Basic search suggestions are already populated at this point
|
||||
});
|
||||
};
|
||||
|
||||
const throttledSearchSuggestions = debounce((dispatch, query) => {
|
||||
dispatch(getSearchSuggestions(query));
|
||||
}, DEBOUNCED_SEARCH_SUGGESTION_MS);
|
||||
|
||||
export const doUpdateSearchQuery = (query: string, shouldSkipSuggestions: ?boolean) => (dispatch: Dispatch) => {
|
||||
dispatch({
|
||||
type: ACTIONS.UPDATE_SEARCH_QUERY,
|
||||
data: { query },
|
||||
});
|
||||
|
||||
if (!query) return;
|
||||
|
||||
// Don't fetch new suggestions if the user just added a space
|
||||
if (!query.endsWith(' ') || !shouldSkipSuggestions) {
|
||||
throttledSearchSuggestions(dispatch, query);
|
||||
}
|
||||
lighthouse.CONNECTION_STRING = endpoint.replace(/\/*$/, '/'); // exactly one slash at the end;
|
||||
};
|
||||
|
||||
export const doSearch = (rawQuery: string, searchOptions: SearchOptions) => (
|
||||
|
@ -85,7 +29,6 @@ export const doSearch = (rawQuery: string, searchOptions: SearchOptions) => (
|
|||
getState: GetState
|
||||
) => {
|
||||
const query = rawQuery.replace(/^lbry:\/\//i, '').replace(/\//, ' ');
|
||||
const isBackgroundSearch = (searchOptions && searchOptions.isBackgroundSearch) || false;
|
||||
|
||||
if (!query) {
|
||||
dispatch({
|
||||
|
@ -108,16 +51,8 @@ export const doSearch = (rawQuery: string, searchOptions: SearchOptions) => (
|
|||
type: ACTIONS.SEARCH_START,
|
||||
});
|
||||
|
||||
// If the user is on the file page with a pre-populated uri and they select
|
||||
// the search option without typing anything, searchQuery will be empty
|
||||
// We need to populate it so the input is filled on the search page
|
||||
// isBackgroundSearch means the search is happening in the background, don't update the search query
|
||||
if (!state.search.searchQuery && !isBackgroundSearch) {
|
||||
dispatch(doUpdateSearchQuery(query));
|
||||
}
|
||||
|
||||
fetch(`${CONNECTION_STRING}search?${queryWithOptions}`)
|
||||
.then(handleFetchResponse)
|
||||
lighthouse
|
||||
.search(queryWithOptions)
|
||||
.then((data: Array<{ name: string, claimId: string }>) => {
|
||||
const uris = [];
|
||||
const actions = [];
|
||||
|
@ -158,16 +93,6 @@ export const doSearch = (rawQuery: string, searchOptions: SearchOptions) => (
|
|||
});
|
||||
};
|
||||
|
||||
export const doFocusSearchInput = () => (dispatch: Dispatch) =>
|
||||
dispatch({
|
||||
type: ACTIONS.SEARCH_FOCUS,
|
||||
});
|
||||
|
||||
export const doBlurSearchInput = () => (dispatch: Dispatch) =>
|
||||
dispatch({
|
||||
type: ACTIONS.SEARCH_BLUR,
|
||||
});
|
||||
|
||||
export const doUpdateSearchOptions = (newOptions: SearchOptions, additionalOptions: SearchOptions) => (
|
||||
dispatch: Dispatch,
|
||||
getState: GetState
|
||||
|
@ -185,3 +110,5 @@ export const doUpdateSearchOptions = (newOptions: SearchOptions, additionalOptio
|
|||
dispatch(doSearch(searchValue, additionalOptions));
|
||||
}
|
||||
};
|
||||
|
||||
export { lighthouse };
|
||||
|
|
|
@ -3,10 +3,8 @@ import * as ACTIONS from 'constants/action_types';
|
|||
import { handleActions } from 'util/redux-utils';
|
||||
import { SEARCH_OPTIONS } from 'constants/search';
|
||||
|
||||
const defaultState = {
|
||||
isActive: false, // does the user have any typed text in the search input
|
||||
focused: false, // is the search input focused
|
||||
searchQuery: '', // needs to be an empty string for input focusing
|
||||
const defaultState: SearchState = {
|
||||
// $FlowFixMe
|
||||
options: {
|
||||
[SEARCH_OPTIONS.RESULT_COUNT]: 30,
|
||||
[SEARCH_OPTIONS.CLAIM_TYPE]: SEARCH_OPTIONS.INCLUDE_FILES_AND_CHANNELS,
|
||||
|
@ -16,10 +14,8 @@ const defaultState = {
|
|||
[SEARCH_OPTIONS.MEDIA_IMAGE]: true,
|
||||
[SEARCH_OPTIONS.MEDIA_APPLICATION]: true,
|
||||
},
|
||||
suggestions: {},
|
||||
urisByQuery: {},
|
||||
resolvedResultsByQuery: {},
|
||||
resolvedResultsByQueryLastPageReached: {},
|
||||
searching: false,
|
||||
};
|
||||
|
||||
export default handleActions(
|
||||
|
@ -43,66 +39,6 @@ export default handleActions(
|
|||
searching: false,
|
||||
}),
|
||||
|
||||
[ACTIONS.RESOLVED_SEARCH_START]: (state: SearchState): SearchState => ({
|
||||
...state,
|
||||
searching: true,
|
||||
}),
|
||||
[ACTIONS.RESOLVED_SEARCH_SUCCESS]: (state: SearchState, action: ResolvedSearchSuccess): SearchState => {
|
||||
const resolvedResultsByQuery = Object.assign({}, state.resolvedResultsByQuery);
|
||||
const resolvedResultsByQueryLastPageReached = Object.assign({}, state.resolvedResultsByQueryLastPageReached);
|
||||
const { append, query, results, pageSize } = action.data;
|
||||
|
||||
if (append) {
|
||||
// todo: check for duplicates when concatenating?
|
||||
resolvedResultsByQuery[query] =
|
||||
resolvedResultsByQuery[query] && resolvedResultsByQuery[query].length
|
||||
? resolvedResultsByQuery[query].concat(results)
|
||||
: results;
|
||||
} else {
|
||||
resolvedResultsByQuery[query] = results;
|
||||
}
|
||||
|
||||
// the returned number of urls is less than the page size, so we're on the last page
|
||||
resolvedResultsByQueryLastPageReached[query] = results.length < pageSize;
|
||||
|
||||
return {
|
||||
...state,
|
||||
searching: false,
|
||||
resolvedResultsByQuery,
|
||||
resolvedResultsByQueryLastPageReached,
|
||||
};
|
||||
},
|
||||
|
||||
[ACTIONS.RESOLVED_SEARCH_FAIL]: (state: SearchState): SearchState => ({
|
||||
...state,
|
||||
searching: false,
|
||||
}),
|
||||
|
||||
[ACTIONS.UPDATE_SEARCH_QUERY]: (state: SearchState, action: UpdateSearchQuery): SearchState => ({
|
||||
...state,
|
||||
searchQuery: action.data.query,
|
||||
isActive: true,
|
||||
}),
|
||||
|
||||
[ACTIONS.UPDATE_SEARCH_SUGGESTIONS]: (state: SearchState, action: UpdateSearchSuggestions): SearchState => ({
|
||||
...state,
|
||||
suggestions: {
|
||||
...state.suggestions,
|
||||
[action.data.query]: action.data.suggestions,
|
||||
},
|
||||
}),
|
||||
|
||||
// sets isActive to false so the uri will be populated correctly if the
|
||||
// user is on a file page. The search query will still be present on any
|
||||
// other page
|
||||
[ACTIONS.SEARCH_FOCUS]: (state: SearchState): SearchState => ({
|
||||
...state,
|
||||
focused: true,
|
||||
}),
|
||||
[ACTIONS.SEARCH_BLUR]: (state: SearchState): SearchState => ({
|
||||
...state,
|
||||
focused: false,
|
||||
}),
|
||||
[ACTIONS.UPDATE_SEARCH_OPTIONS]: (state: SearchState, action: UpdateSearchOptions): SearchState => {
|
||||
const { options: oldOptions } = state;
|
||||
const newOptions = action.data;
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
// @flow
|
||||
import { SEARCH_TYPES } from 'constants/search';
|
||||
import { getSearchQueryString } from 'util/query-params';
|
||||
import { parseURI, makeSelectClaimForUri, makeSelectClaimIsNsfw, buildURI } from 'lbry-redux';
|
||||
import { makeSelectClientSetting } from 'redux/selectors/settings';
|
||||
import { parseURI, makeSelectClaimForUri, makeSelectClaimIsNsfw, buildURI, SETTINGS, isClaimNsfw } from 'lbry-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
|
||||
type State = { search: SearchState };
|
||||
|
@ -12,11 +12,6 @@ export const selectSearchValue: (state: State) => string = createSelector(select
|
|||
|
||||
export const selectSearchOptions: (state: State) => SearchOptions = createSelector(selectState, state => state.options);
|
||||
|
||||
export const selectSuggestions: (state: State) => { [string]: Array<SearchSuggestion> } = createSelector(
|
||||
selectState,
|
||||
state => state.suggestions
|
||||
);
|
||||
|
||||
export const selectIsSearching: (state: State) => boolean = createSelector(selectState, state => state.searching);
|
||||
|
||||
export const selectSearchUrisByQuery: (state: State) => { [string]: Array<string> } = createSelector(
|
||||
|
@ -31,92 +26,6 @@ export const makeSelectSearchUris = (query: string): ((state: State) => Array<st
|
|||
byQuery => byQuery[query ? query.replace(/^lbry:\/\//i, '').replace(/\//, ' ') : query]
|
||||
);
|
||||
|
||||
export const selectResolvedSearchResultsByQuery: (
|
||||
state: State
|
||||
) => { [string]: Array<ResolvedSearchResult> } = createSelector(selectState, state => state.resolvedResultsByQuery);
|
||||
|
||||
export const selectSearchBarFocused: boolean = createSelector(selectState, state => state.focused);
|
||||
|
||||
export const selectSearchSuggestions: Array<SearchSuggestion> = createSelector(
|
||||
selectSearchValue,
|
||||
selectSuggestions,
|
||||
(query: string, suggestions: { [string]: Array<string> }) => {
|
||||
if (!query) {
|
||||
return [];
|
||||
}
|
||||
const queryIsPrefix = query === 'lbry:' || query === 'lbry:/' || query === 'lbry://' || query === 'lbry://@';
|
||||
|
||||
if (queryIsPrefix) {
|
||||
// If it is a prefix, wait until something else comes to figure out what to do
|
||||
return [];
|
||||
} else if (query.startsWith('lbry://')) {
|
||||
// If it starts with a prefix, don't show any autocomplete results
|
||||
// They are probably typing/pasting in a lbry uri
|
||||
let type: string;
|
||||
try {
|
||||
let { isChannel } = parseURI(query);
|
||||
type = isChannel ? SEARCH_TYPES.CHANNEL : SEARCH_TYPES.FILE;
|
||||
} catch (e) {
|
||||
type = SEARCH_TYPES.SEARCH;
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
value: query,
|
||||
type: type,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
let searchSuggestions = [];
|
||||
searchSuggestions.push({
|
||||
value: query,
|
||||
type: SEARCH_TYPES.SEARCH,
|
||||
});
|
||||
|
||||
try {
|
||||
const uriObj = parseURI(query);
|
||||
searchSuggestions.push({
|
||||
value: buildURI(uriObj),
|
||||
shorthand: uriObj.isChannel ? uriObj.channelName : uriObj.streamName,
|
||||
type: uriObj.isChannel ? SEARCH_TYPES.CHANNEL : SEARCH_TYPES.FILE,
|
||||
});
|
||||
} catch (e) {}
|
||||
|
||||
searchSuggestions.push({
|
||||
value: query,
|
||||
type: SEARCH_TYPES.TAG,
|
||||
});
|
||||
|
||||
const apiSuggestions = suggestions[query] || [];
|
||||
if (apiSuggestions.length) {
|
||||
searchSuggestions = searchSuggestions.concat(
|
||||
apiSuggestions
|
||||
.filter(suggestion => suggestion !== query)
|
||||
.map(suggestion => {
|
||||
// determine if it's a channel
|
||||
try {
|
||||
const uriObj = parseURI(suggestion);
|
||||
return {
|
||||
value: buildURI(uriObj),
|
||||
shorthand: uriObj.isChannel ? uriObj.channelName : uriObj.streamName,
|
||||
type: uriObj.isChannel ? SEARCH_TYPES.CHANNEL : SEARCH_TYPES.FILE,
|
||||
};
|
||||
} catch (e) {
|
||||
// search result includes some character that isn't valid in claim names
|
||||
return {
|
||||
value: suggestion,
|
||||
type: SEARCH_TYPES.SEARCH,
|
||||
};
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return searchSuggestions;
|
||||
}
|
||||
);
|
||||
|
||||
// Creates a query string based on the state in the search reducer
|
||||
// Can be overrided by passing in custom sizes/from values for other areas pagination
|
||||
|
||||
|
@ -187,20 +96,34 @@ export const makeSelectWinningUriForQuery = (query: string) => {
|
|||
} catch (e) {}
|
||||
|
||||
return createSelector(
|
||||
makeSelectClientSetting(SETTINGS.SHOW_MATURE),
|
||||
makeSelectClaimForUri(uriFromQuery),
|
||||
makeSelectClaimForUri(channelUriFromQuery),
|
||||
(claim1, claim2) => {
|
||||
(matureEnabled, claim1, claim2) => {
|
||||
const claim1Mature = claim1 && isClaimNsfw(claim1);
|
||||
const claim2Mature = claim2 && isClaimNsfw(claim2);
|
||||
|
||||
if (!claim1 && !claim2) {
|
||||
return undefined;
|
||||
} else if (!claim1 && claim2) {
|
||||
return claim2.canonical_url;
|
||||
return matureEnabled ? claim2.canonical_url : claim2Mature ? undefined : claim2.canonical_url;
|
||||
} else if (claim1 && !claim2) {
|
||||
return claim1.canonical_url;
|
||||
return matureEnabled ? claim1.canonical_url : claim1Mature ? undefined : claim1.canonical_url;
|
||||
}
|
||||
|
||||
const effectiveAmount1 = claim1 && claim1.meta.effective_amount;
|
||||
const effectiveAmount2 = claim2 && claim2.meta.effective_amount;
|
||||
|
||||
if (!matureEnabled) {
|
||||
if (claim1Mature && !claim2Mature) {
|
||||
return claim2.canonical_url;
|
||||
} else if (claim2Mature && !claim1Mature) {
|
||||
return claim1.canonical_url;
|
||||
} else if (claim1Mature && claim2Mature) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return Number(effectiveAmount1) > Number(effectiveAmount2) ? claim1.canonical_url : claim2.canonical_url;
|
||||
}
|
||||
);
|
||||
|
|
|
@ -1,5 +1,11 @@
|
|||
.wunderbar {
|
||||
.wunderbar__wrapper {
|
||||
flex: 1;
|
||||
max-width: 30rem;
|
||||
margin-left: var(--spacing-s);
|
||||
margin-right: var(--spacing-s);
|
||||
}
|
||||
|
||||
.wunderbar {
|
||||
cursor: text;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
@ -7,9 +13,6 @@
|
|||
z-index: 1;
|
||||
font-size: var(--font-small);
|
||||
height: var(--height-input);
|
||||
max-width: 30rem;
|
||||
margin-left: var(--spacing-s);
|
||||
margin-right: var(--spacing-s);
|
||||
|
||||
@media (max-width: $breakpoint-small) {
|
||||
max-width: none;
|
||||
|
@ -40,11 +43,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
.wunderbar__active-suggestion {
|
||||
color: var(--color-search-suggestion);
|
||||
background-color: var(--color-search-suggestion-background);
|
||||
}
|
||||
|
||||
.wunderbar__input {
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
|
@ -75,39 +73,114 @@
|
|||
}
|
||||
}
|
||||
|
||||
.wunderbar__menu {
|
||||
min-width: 100%;
|
||||
overflow: hidden;
|
||||
background-color: var(--color-input-bg);
|
||||
margin-top: -4px;
|
||||
.wunderbar__results {
|
||||
margin-left: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.wunderbar__suggestions {
|
||||
z-index: 3;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: calc(var(--header-height) - (var(--height-input)) + 3px);
|
||||
@extend .card;
|
||||
box-shadow: var(--card-box-shadow);
|
||||
border-top-right-radius: 0;
|
||||
border-top-left-radius: 0;
|
||||
border-top: none;
|
||||
padding: var(--spacing-xs);
|
||||
margin: 0 var(--spacing-s);
|
||||
}
|
||||
|
||||
.wunderbar__top-claim {
|
||||
margin-bottom: var(--spacing-m);
|
||||
}
|
||||
|
||||
.wunderbar__label {
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.wunderbar__label--results {
|
||||
@extend .wunderbar__label;
|
||||
margin-left: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.wunderbar__top-separator {
|
||||
margin: var(--spacing-s) 0;
|
||||
width: 120%;
|
||||
transform: translateX(-10%);
|
||||
}
|
||||
|
||||
.wunderbar__suggestion {
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-items: flex-start;
|
||||
padding: var(--spacing-s);
|
||||
align-items: center;
|
||||
height: 3rem;
|
||||
|
||||
&:not(:first-of-type) {
|
||||
border-top: 1px solid transparent;
|
||||
}
|
||||
|
||||
.icon {
|
||||
.media__thumb {
|
||||
flex-shrink: 0;
|
||||
$width: 3rem;
|
||||
@include handleClaimListGifThumbnail($width);
|
||||
width: $width;
|
||||
height: calc(#{$width} * (9 / 16));
|
||||
margin-right: var(--spacing-s);
|
||||
|
||||
@media (min-width: $breakpoint-small) {
|
||||
$width: 5rem;
|
||||
@include handleClaimListGifThumbnail($width);
|
||||
width: $width;
|
||||
height: calc(#{$width} * (9 / 16));
|
||||
margin-right: var(--spacing-xs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.wunderbar__suggestion--channel {
|
||||
.channel-thumbnail {
|
||||
@include handleChannelGif(calc(5rem * 9 / 16));
|
||||
margin-right: var(--spacing-xs);
|
||||
|
||||
@media (min-width: $breakpoint-small) {
|
||||
@include handleChannelGif(calc(5rem * 9 / 16));
|
||||
margin-right: var(--spacing-s);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.wunderbar__suggestion-label {
|
||||
overflow: hidden;
|
||||
padding-left: var(--spacing-s);
|
||||
text-overflow: ellipsis;
|
||||
font-size: var(--font-small);
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.wunderbar__suggestion-label--action {
|
||||
margin-left: var(--spacing-m);
|
||||
opacity: 0.6;
|
||||
white-space: nowrap;
|
||||
.wunderbar__suggestion-name {
|
||||
@extend .help;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.wunderbar__more-results {
|
||||
margin-top: var(--spacing-xs);
|
||||
}
|
||||
|
||||
[data-reach-combobox-option] {
|
||||
border-radius: var(--border-radius);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-menu-background--active);
|
||||
}
|
||||
}
|
||||
|
||||
[data-reach-combobox-option][data-highlighted] {
|
||||
background-color: var(--color-menu-background--active);
|
||||
}
|
||||
|
||||
[data-reach-combobox-option][aria-selected='true'] {
|
||||
&:hover {
|
||||
background-color: var(--color-menu-background--active);
|
||||
}
|
||||
}
|
||||
|
||||
[data-reach-combobox-option] {
|
||||
padding: var(--spacing-xs);
|
||||
}
|
||||
|
|
88
yarn.lock
88
yarn.lock
|
@ -1829,6 +1829,14 @@
|
|||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.1.0.tgz#09a7a352a40508156e1256efdc015593feca28e0"
|
||||
|
||||
"@reach/auto-id@0.12.1", "@reach/auto-id@^0.12.1":
|
||||
version "0.12.1"
|
||||
resolved "https://registry.yarnpkg.com/@reach/auto-id/-/auto-id-0.12.1.tgz#2e4a7250d2067ec16a9b4ea732695bc75572405c"
|
||||
integrity sha512-s8cdY6dF0hEBB/28BbidB2EX6JfEBVIWLP6S2Jg0Xqq2H3xijL+zrsjL40jACwXRkignjuP+CvYsuFuO0+/GRA==
|
||||
dependencies:
|
||||
"@reach/utils" "0.12.1"
|
||||
tslib "^2.0.0"
|
||||
|
||||
"@reach/auto-id@^0.2.0":
|
||||
version "0.2.0"
|
||||
resolved "https://registry.yarnpkg.com/@reach/auto-id/-/auto-id-0.2.0.tgz#97f9e48fe736aa5c6f4f32cf73c1f19d005f8550"
|
||||
|
@ -1837,10 +1845,32 @@
|
|||
version "0.7.4"
|
||||
resolved "https://registry.yarnpkg.com/@reach/auto-id/-/auto-id-0.7.4.tgz#c20bf6db87ef2f34b1bfb4bf450d675524a368c1"
|
||||
|
||||
"@reach/combobox@^0.12.1":
|
||||
version "0.12.1"
|
||||
resolved "https://registry.yarnpkg.com/@reach/combobox/-/combobox-0.12.1.tgz#0ab5fda14d64988c3bfee8f00993bc2904eb47fc"
|
||||
integrity sha512-rHDSprqB2wPsZ/SzSr3MBIiRXgDa/yNYJm1R0o41lEWAYhVJnoJPcfyU4AvZeMkyAi7qB5vZwvGnjpUHrX0bmg==
|
||||
dependencies:
|
||||
"@reach/auto-id" "0.12.1"
|
||||
"@reach/descendants" "0.12.1"
|
||||
"@reach/popover" "0.12.1"
|
||||
"@reach/portal" "0.12.1"
|
||||
"@reach/utils" "0.12.1"
|
||||
highlight-words-core "1.2.2"
|
||||
prop-types "^15.7.2"
|
||||
tslib "^2.0.0"
|
||||
|
||||
"@reach/component-component@^0.1.3":
|
||||
version "0.1.3"
|
||||
resolved "https://registry.yarnpkg.com/@reach/component-component/-/component-component-0.1.3.tgz#5d156319572dc38995b246f81878bc2577c517e5"
|
||||
|
||||
"@reach/descendants@0.12.1":
|
||||
version "0.12.1"
|
||||
resolved "https://registry.yarnpkg.com/@reach/descendants/-/descendants-0.12.1.tgz#dd124e15ee66327692043fea798cc4b6232c1522"
|
||||
integrity sha512-lvpyQ2EixbN7GvT8LyjZfHWQZz/cKwa/p7E26YQOT8nvxm4ABKRGxaSTwUA7D+MLOX6NtHo2qyZ9wippaXB5sQ==
|
||||
dependencies:
|
||||
"@reach/utils" "0.12.1"
|
||||
tslib "^2.0.0"
|
||||
|
||||
"@reach/menu-button@0.7.4":
|
||||
version "0.7.4"
|
||||
resolved "https://registry.yarnpkg.com/@reach/menu-button/-/menu-button-0.7.4.tgz#6b2cb91e5471dfc67aa10380c2779eb3d21d98bf"
|
||||
|
@ -1851,10 +1881,26 @@
|
|||
prop-types "^15.7.2"
|
||||
warning "^4.0.3"
|
||||
|
||||
"@reach/observe-rect@1.2.0":
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/@reach/observe-rect/-/observe-rect-1.2.0.tgz#d7a6013b8aafcc64c778a0ccb83355a11204d3b2"
|
||||
integrity sha512-Ba7HmkFgfQxZqqaeIWWkNK0rEhpxVQHIoVyW1YDSkGsGIXzcaW4deC8B0pZrNSSyLTdIk7y+5olKt5+g0GmFIQ==
|
||||
|
||||
"@reach/observe-rect@^1.0.3", "@reach/observe-rect@^1.0.5":
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@reach/observe-rect/-/observe-rect-1.1.0.tgz#4e967a93852b6004c3895d9ed8d4e5b41895afde"
|
||||
|
||||
"@reach/popover@0.12.1":
|
||||
version "0.12.1"
|
||||
resolved "https://registry.yarnpkg.com/@reach/popover/-/popover-0.12.1.tgz#d35dbff5d7751600ac9a2a0703e8585b8820964d"
|
||||
integrity sha512-gpwd7mQK51xzDLtf+qCHsxQxeMokKNTssgoK2MIJVkPpMDrSK+rskcZswpWjHSGx9EQ27bxZb/3A0GjkN1rPLg==
|
||||
dependencies:
|
||||
"@reach/portal" "0.12.1"
|
||||
"@reach/rect" "0.12.1"
|
||||
"@reach/utils" "0.12.1"
|
||||
tabbable "^4.0.0"
|
||||
tslib "^2.0.0"
|
||||
|
||||
"@reach/popover@^0.7.4":
|
||||
version "0.7.4"
|
||||
resolved "https://registry.yarnpkg.com/@reach/popover/-/popover-0.7.4.tgz#def055c588a76ef4a48ac81f0b4e7373e2781b28"
|
||||
|
@ -1864,10 +1910,28 @@
|
|||
"@reach/utils" "^0.7.4"
|
||||
tabbable "^4.0.0"
|
||||
|
||||
"@reach/portal@0.12.1":
|
||||
version "0.12.1"
|
||||
resolved "https://registry.yarnpkg.com/@reach/portal/-/portal-0.12.1.tgz#995858a6e758d9446870937b60b8707a2efa11cb"
|
||||
integrity sha512-Lhmtd2Qw1DzNZ2m0GHNzCu+2TYUf6kBREPSQJf44AP6kThRs02p1clntbJcmW/rrpYFYgFNbgf5Lso7NyW9ZXg==
|
||||
dependencies:
|
||||
"@reach/utils" "0.12.1"
|
||||
tslib "^2.0.0"
|
||||
|
||||
"@reach/portal@^0.7.4":
|
||||
version "0.7.4"
|
||||
resolved "https://registry.yarnpkg.com/@reach/portal/-/portal-0.7.4.tgz#6e182f9061ba52dc6a5e460516b4aedf8623103f"
|
||||
|
||||
"@reach/rect@0.12.1":
|
||||
version "0.12.1"
|
||||
resolved "https://registry.yarnpkg.com/@reach/rect/-/rect-0.12.1.tgz#5b018da4dccca852df21668ae54d5941c16f5bf7"
|
||||
integrity sha512-c+EjKc+9ud832MpmYKsxu+2R0/XHyXk47ik/N+DIHsugIgKJn2B34r4r9benqsaHncUO1IQk5rTv40D7x+yR0g==
|
||||
dependencies:
|
||||
"@reach/observe-rect" "1.2.0"
|
||||
"@reach/utils" "0.12.1"
|
||||
prop-types "^15.7.2"
|
||||
tslib "^2.0.0"
|
||||
|
||||
"@reach/rect@^0.2.1":
|
||||
version "0.2.1"
|
||||
resolved "https://registry.yarnpkg.com/@reach/rect/-/rect-0.2.1.tgz#7343020174c90e2290b844d17c03fd9c78e6b601"
|
||||
|
@ -1890,6 +1954,15 @@
|
|||
"@reach/utils" "^0.2.3"
|
||||
warning "^4.0.2"
|
||||
|
||||
"@reach/utils@0.12.1", "@reach/utils@^0.12.1":
|
||||
version "0.12.1"
|
||||
resolved "https://registry.yarnpkg.com/@reach/utils/-/utils-0.12.1.tgz#02b9c15ba5cddf23e16f2d2ca77aef98a9702607"
|
||||
integrity sha512-5uH4OgO+GupAzZuf3b6Wv/9uC6NdMBlxS6FSKD6YqSxP4QJ0vjD34RVon6N/RMRORacCLyD/aaZIA7283YgeOg==
|
||||
dependencies:
|
||||
"@types/warning" "^3.0.0"
|
||||
tslib "^2.0.0"
|
||||
warning "^4.0.3"
|
||||
|
||||
"@reach/utils@^0.2.3":
|
||||
version "0.2.3"
|
||||
resolved "https://registry.yarnpkg.com/@reach/utils/-/utils-0.2.3.tgz#820f6a6af4301b4c5065cfc04bb89e6a3d1d723f"
|
||||
|
@ -2087,6 +2160,11 @@
|
|||
dependencies:
|
||||
source-map "^0.6.1"
|
||||
|
||||
"@types/warning@^3.0.0":
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@types/warning/-/warning-3.0.0.tgz#0d2501268ad8f9962b740d387c4654f5f8e23e52"
|
||||
integrity sha1-DSUBJorY+ZYrdA04fEZU9fjiPlI=
|
||||
|
||||
"@types/webpack-env@^1.15.1":
|
||||
version "1.15.3"
|
||||
resolved "https://registry.yarnpkg.com/@types/webpack-env/-/webpack-env-1.15.3.tgz#fb602cd4c2f0b7c0fb857e922075fdf677d25d84"
|
||||
|
@ -6253,6 +6331,11 @@ hex-color-regex@^1.1.0:
|
|||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/hex-color-regex/-/hex-color-regex-1.1.0.tgz#4c06fccb4602fe2602b3c93df82d7e7dbf1a8a8e"
|
||||
|
||||
highlight-words-core@1.2.2:
|
||||
version "1.2.2"
|
||||
resolved "https://registry.yarnpkg.com/highlight-words-core/-/highlight-words-core-1.2.2.tgz#1eff6d7d9f0a22f155042a00791237791b1eeaaa"
|
||||
integrity sha512-BXUKIkUuh6cmmxzi5OIbUJxrG8OAk2MqoL1DtO3Wo9D2faJg2ph5ntyuQeLqaHJmzER6H5tllCDA9ZnNe9BVGg==
|
||||
|
||||
highlight.js@^9.3.0:
|
||||
version "9.18.1"
|
||||
resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-9.18.1.tgz#ed21aa001fe6252bb10a3d76d47573c6539fe13c"
|
||||
|
@ -11736,6 +11819,11 @@ tslib@^1.9.0, tslib@^1.9.3:
|
|||
version "1.11.1"
|
||||
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.11.1.tgz#eb15d128827fbee2841549e171f45ed338ac7e35"
|
||||
|
||||
tslib@^2.0.0:
|
||||
version "2.0.3"
|
||||
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.0.3.tgz#8e0741ac45fc0c226e58a17bfc3e64b9bc6ca61c"
|
||||
integrity sha512-uZtkfKblCEQtZKBF6EBXVZeQNl82yqtDQdv+eck8u7tdPxjLu2/lp5/uPW+um2tpuxINHWy3GhiccY7QgEaVHQ==
|
||||
|
||||
tty-browserify@0.0.0:
|
||||
version "0.0.0"
|
||||
resolved "https://registry.yarnpkg.com/tty-browserify/-/tty-browserify-0.0.0.tgz#a157ba402da24e9bf957f9aa69d524eed42901a6"
|
||||
|
|
Loading…
Reference in a new issue