wunderbar improvements

This commit is contained in:
Sean Yesmunt 2020-12-03 12:29:47 -05:00
parent dc679add87
commit 30d8a0406d
18 changed files with 588 additions and 1143 deletions

50
flow-typed/search.js vendored
View file

@ -1,12 +1,6 @@
// @flow // @flow
import * as ACTIONS from 'constants/action_types'; import * as ACTIONS from 'constants/action_types';
declare type SearchSuggestion = {
value: string,
shorthand: string,
type: string,
};
declare type SearchOptions = { declare type SearchOptions = {
// :( // :(
// https://github.com/facebook/flow/issues/6492 // https://github.com/facebook/flow/issues/6492
@ -23,13 +17,9 @@ declare type SearchOptions = {
}; };
declare type SearchState = { declare type SearchState = {
isActive: boolean,
searchQuery: string,
options: SearchOptions, options: SearchOptions,
suggestions: { [string]: Array<SearchSuggestion> },
urisByQuery: {}, urisByQuery: {},
resolvedResultsByQuery: {}, searching: boolean,
resolvedResultsByQueryLastPageReached: {},
}; };
declare type SearchSuccess = { 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 = { declare type UpdateSearchOptions = {
type: ACTIONS.UPDATE_SEARCH_OPTIONS, type: ACTIONS.UPDATE_SEARCH_OPTIONS,
data: SearchOptions, 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,
},
};

View file

@ -72,9 +72,12 @@
"@datapunt/matomo-tracker-js": "^0.1.4", "@datapunt/matomo-tracker-js": "^0.1.4",
"@exponent/electron-cookies": "^2.0.0", "@exponent/electron-cookies": "^2.0.0",
"@hot-loader/react-dom": "^16.8", "@hot-loader/react-dom": "^16.8",
"@reach/auto-id": "^0.12.1",
"@reach/combobox": "^0.12.1",
"@reach/menu-button": "0.7.4", "@reach/menu-button": "0.7.4",
"@reach/rect": "^0.2.1", "@reach/rect": "^0.2.1",
"@reach/tabs": "^0.1.5", "@reach/tabs": "^0.1.5",
"@reach/utils": "^0.12.1",
"@sentry/browser": "^5.12.1", "@sentry/browser": "^5.12.1",
"@sentry/webpack-plugin": "^1.10.0", "@sentry/webpack-plugin": "^1.10.0",
"@types/three": "^0.93.1", "@types/three": "^0.93.1",

View file

@ -37,7 +37,7 @@ function FileThumbnail(props: Props) {
); );
} }
const url = passedThumbnail || (uri ? thumbnailFromClaim : Placeholder); const url = thumbnail || (hasResolvedClaim ? Placeholder : '');
return ( return (
<div <div

View file

@ -1,33 +1,26 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { doFocusSearchInput, doBlurSearchInput, doUpdateSearchQuery } from 'redux/actions/search'; import { selectLanguage, makeSelectClientSetting } from 'redux/selectors/settings';
import { selectSearchValue, selectSearchSuggestions, selectSearchBarFocused } from 'redux/selectors/search';
import { selectLanguage } from 'redux/selectors/settings';
import { doToast } from 'redux/actions/notifications'; 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 analytics from 'analytics';
import Wunderbar from './view'; import Wunderbar from './view';
import { withRouter } from 'react-router-dom';
const select = state => ({ const select = (state, props) => ({
suggestions: selectSearchSuggestions(state),
searchQuery: selectSearchValue(state),
isFocused: selectSearchBarFocused(state),
language: selectLanguage(state), language: selectLanguage(state),
showMature: makeSelectClientSetting(SETTINGS.SHOW_MATURE)(state),
}); });
const perform = (dispatch, ownProps) => ({ const perform = (dispatch, ownProps) => ({
doSearch: query => { doResolveUris: uris => dispatch(doResolveUris(uris)),
doSearch: (query, options) => dispatch(doSearch(query, options)),
navigateToSearchPage: query => {
let encodedQuery = encodeURIComponent(query); let encodedQuery = encodeURIComponent(query);
ownProps.history.push({ pathname: `/$/search`, search: `?q=${encodedQuery}` }); ownProps.history.push({ pathname: `/$/search`, search: `?q=${encodedQuery}` });
dispatch(doUpdateSearchQuery(query));
analytics.apiLogSearch(); analytics.apiLogSearch();
}, },
navigateToUri: uri => {
ownProps.history.push(uri);
},
updateSearchQuery: query => dispatch(doUpdateSearchQuery(query)),
doShowSnackBar: message => dispatch(doToast({ isError: true, message })), doShowSnackBar: message => dispatch(doToast({ isError: true, message })),
doFocus: () => dispatch(doFocusSearchInput()),
doBlur: () => dispatch(doBlurSearchInput()),
}); });
export default withRouter(connect(select, perform)(Wunderbar)); export default withRouter(connect(select, perform)(Wunderbar));

View file

@ -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 */

View file

@ -1,16 +1,21 @@
// @flow // @flow
import { URL, URL_LOCAL, URL_DEV } from 'config'; import { URL, URL_LOCAL, URL_DEV } from 'config';
import { SEARCH_TYPES } from 'constants/search';
import * as PAGES from 'constants/pages'; import * as PAGES from 'constants/pages';
import * as ICONS from 'constants/icons'; import * as ICONS from 'constants/icons';
import React from 'react'; import React from 'react';
import classnames from 'classnames';
import { withRouter } from 'react-router';
import Icon from 'component/common/icon'; import Icon from 'component/common/icon';
import Autocomplete from './internal/autocomplete'; import { isURIValid, normalizeURI, parseURI } from 'lbry-redux';
import Tag from 'component/tag'; import { Combobox, ComboboxInput, ComboboxPopover, ComboboxList, ComboboxOption } from '@reach/combobox';
import { isURIValid, normalizeURI } from 'lbry-redux'; import '@reach/combobox/styles.css';
import { formatLbryUrlForWeb } from '../../util/url'; 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_DEV_PREFIX = `${URL_DEV}/`;
const WEB_LOCAL_PREFIX = `${URL_LOCAL}/`; const WEB_LOCAL_PREFIX = `${URL_LOCAL}/`;
const WEB_PROD_PREFIX = `${URL}/`; const WEB_PROD_PREFIX = `${URL}/`;
@ -22,226 +27,158 @@ const ESC_KEY_CODE = 27;
type Props = { type Props = {
searchQuery: ?string, searchQuery: ?string,
updateSearchQuery: string => void,
onSearch: string => void, onSearch: string => void,
onSubmit: string => void, navigateToSearchPage: string => void,
doResolveUris: string => void,
navigateToUri: string => void,
doSearch: string => void,
suggestions: Array<string>,
doFocus: () => void,
doBlur: () => void,
focused: boolean,
doShowSnackBar: string => void, doShowSnackBar: string => void,
history: { push: string => void }, showMature: boolean,
}; };
type State = { export default function WunderBar(props: Props) {
query: ?string, const { navigateToSearchPage, doShowSnackBar, doResolveUris, showMature } = props;
}; const inputRef = React.useRef();
const {
class WunderBar extends React.PureComponent<Props, State> { push,
constructor() { location: { search },
super(); } = useHistory();
const urlParams = new URLSearchParams(search);
this.state = { const queryFromUrl = urlParams.get('q') || '';
query: null, const [term, setTerm] = React.useState(queryFromUrl);
}; const throttledTerm = useThrottle(term, 500) || '';
const { results } = useLighthouse(throttledTerm, showMature);
(this: any).handleSubmit = this.handleSubmit.bind(this); const nameFromQuery = throttledTerm
(this: any).handleChange = this.handleChange.bind(this); .trim()
(this: any).handleKeyDown = this.handleKeyDown.bind(this); .replace(/\s+/g, '')
} .replace(/:/g, '#');
const uriFromQuery = `lbry://${nameFromQuery}`;
componentDidMount() { let uriFromQueryIsValid = false;
window.addEventListener('keydown', this.handleKeyDown); let channelUrlForTopTest;
} try {
const { isChannel } = parseURI(uriFromQuery);
componentWillUnmount() { uriFromQueryIsValid = true;
window.removeEventListener('keydown', this.handleKeyDown); if (!isChannel) {
} channelUrlForTopTest = `lbry://@${uriFromQuery}`;
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;
} }
}; } catch (e) {}
handleKeyDown(event: SyntheticKeyboardEvent<*>) { const topUrisToTest = [uriFromQuery];
const { ctrlKey, metaKey, keyCode } = event; if (channelUrlForTopTest) {
const { doFocus, doBlur, focused } = this.props; topUrisToTest.push(uriFromQuery);
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
}
} }
handleChange(e: SyntheticInputEvent<*>) { function handleSelect(value) {
const { value } = e.target; const includesLbryTvProd = value.includes(WEB_PROD_PREFIX);
const { updateSearchQuery } = this.props; const includesLbryTvLocal = value.includes(WEB_LOCAL_PREFIX);
updateSearchQuery(value); const includesLbryTvDev = value.includes(WEB_DEV_PREFIX);
}
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);
const wasCopiedFromWeb = includesLbryTvDev || includesLbryTvLocal || includesLbryTvProd; const wasCopiedFromWeb = includesLbryTvDev || includesLbryTvLocal || includesLbryTvProd;
const isLbryUrl = value.startsWith('lbry://');
if (inputRef.current) {
inputRef.current.blur();
}
if (wasCopiedFromWeb) { if (wasCopiedFromWeb) {
let prefix = WEB_PROD_PREFIX; let prefix = WEB_PROD_PREFIX;
if (includesLbryTvLocal) prefix = WEB_LOCAL_PREFIX; if (includesLbryTvLocal) prefix = WEB_LOCAL_PREFIX;
if (includesLbryTvDev) prefix = WEB_DEV_PREFIX; if (includesLbryTvDev) prefix = WEB_DEV_PREFIX;
this.onSubmitWebUri(query, prefix);
} else if (suggestion) { let query = value.slice(prefix.length).replace(/:/g, '#');
this.onClickSuggestion(query, suggestion);
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 { } 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() { if (!inputRef.current) {
const { suggestions, doFocus, doBlur, searchQuery } = this.props; return;
}
return ( if (inputRef.current === document.activeElement && keyCode === ESC_KEY_CODE) {
<div inputRef.current.blur();
// @if TARGET='app' }
onDoubleClick={e => {
e.stopPropagation();
}}
// @endif
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} /> <Icon icon={ICONS.SEARCH} />
<Autocomplete <ComboboxInput
autoHighlight ref={inputRef}
wrapperStyle={{ flex: 1, position: 'relative' }} className="wunderbar__input"
value={searchQuery} placeholder={__('Search')}
items={suggestions} onChange={e => setTerm(e.target.value)}
getItemValue={item => item.value} value={term}
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>
)}
/> />
</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>
);
}

View 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);

View 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>
);
}

View 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);

View 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" />
</>
);
}

View file

@ -119,14 +119,8 @@ export const FILE_DELETE = 'FILE_DELETE';
export const SEARCH_START = 'SEARCH_START'; export const SEARCH_START = 'SEARCH_START';
export const SEARCH_SUCCESS = 'SEARCH_SUCCESS'; export const SEARCH_SUCCESS = 'SEARCH_SUCCESS';
export const SEARCH_FAIL = 'SEARCH_FAIL'; 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_OPTIONS = 'UPDATE_SEARCH_OPTIONS';
export const UPDATE_SEARCH_SUGGESTIONS = 'UPDATE_SEARCH_SUGGESTIONS'; export const UPDATE_SEARCH_SUGGESTIONS = 'UPDATE_SEARCH_SUGGESTIONS';
export const SEARCH_FOCUS = 'SEARCH_FOCUS';
export const SEARCH_BLUR = 'SEARCH_BLUR';
// Settings // Settings
export const DAEMON_SETTINGS_RECEIVED = 'DAEMON_SETTINGS_RECEIVED'; export const DAEMON_SETTINGS_RECEIVED = 'DAEMON_SETTINGS_RECEIVED';

View 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 };
}

View 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;

View file

@ -1,16 +1,9 @@
// @flow // @flow
import * as ACTIONS from 'constants/action_types'; import * as ACTIONS from 'constants/action_types';
import { buildURI, doResolveUris, batchActions } from 'lbry-redux'; import { buildURI, doResolveUris, batchActions } from 'lbry-redux';
import { import { makeSelectSearchUris, makeSelectQueryWithOptions, selectSearchValue } from 'redux/selectors/search';
makeSelectSearchUris,
selectSuggestions,
makeSelectQueryWithOptions,
selectSearchValue,
} from 'redux/selectors/search';
import debounce from 'util/debounce';
import handleFetchResponse from 'util/handle-fetch'; import handleFetchResponse from 'util/handle-fetch';
const DEBOUNCED_SEARCH_SUGGESTION_MS = 300;
type Dispatch = (action: any) => any; type Dispatch = (action: any) => any;
type GetState = () => { search: SearchState }; type GetState = () => { search: SearchState };
@ -22,62 +15,13 @@ type SearchOptions = {
isBackgroundSearch?: boolean, isBackgroundSearch?: boolean,
}; };
// We can't use env's because they aren't passed into node_modules let lighthouse = {
let CONNECTION_STRING = 'https://lighthouse.lbry.com/'; CONNECTION_STRING: 'https://lighthouse.lbry.com/search',
search: (queryString: string) => fetch(`${lighthouse.CONNECTION_STRING}?${queryString}`).then(handleFetchResponse),
};
export const setSearchApi = (endpoint: string) => { export const setSearchApi = (endpoint: string) => {
CONNECTION_STRING = endpoint.replace(/\/*$/, '/'); // exactly one slash at the end; lighthouse.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);
}
}; };
export const doSearch = (rawQuery: string, searchOptions: SearchOptions) => ( export const doSearch = (rawQuery: string, searchOptions: SearchOptions) => (
@ -85,7 +29,6 @@ export const doSearch = (rawQuery: string, searchOptions: SearchOptions) => (
getState: GetState getState: GetState
) => { ) => {
const query = rawQuery.replace(/^lbry:\/\//i, '').replace(/\//, ' '); const query = rawQuery.replace(/^lbry:\/\//i, '').replace(/\//, ' ');
const isBackgroundSearch = (searchOptions && searchOptions.isBackgroundSearch) || false;
if (!query) { if (!query) {
dispatch({ dispatch({
@ -108,16 +51,8 @@ export const doSearch = (rawQuery: string, searchOptions: SearchOptions) => (
type: ACTIONS.SEARCH_START, type: ACTIONS.SEARCH_START,
}); });
// If the user is on the file page with a pre-populated uri and they select lighthouse
// the search option without typing anything, searchQuery will be empty .search(queryWithOptions)
// 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)
.then((data: Array<{ name: string, claimId: string }>) => { .then((data: Array<{ name: string, claimId: string }>) => {
const uris = []; const uris = [];
const actions = []; 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) => ( export const doUpdateSearchOptions = (newOptions: SearchOptions, additionalOptions: SearchOptions) => (
dispatch: Dispatch, dispatch: Dispatch,
getState: GetState getState: GetState
@ -185,3 +110,5 @@ export const doUpdateSearchOptions = (newOptions: SearchOptions, additionalOptio
dispatch(doSearch(searchValue, additionalOptions)); dispatch(doSearch(searchValue, additionalOptions));
} }
}; };
export { lighthouse };

View file

@ -3,10 +3,8 @@ import * as ACTIONS from 'constants/action_types';
import { handleActions } from 'util/redux-utils'; import { handleActions } from 'util/redux-utils';
import { SEARCH_OPTIONS } from 'constants/search'; import { SEARCH_OPTIONS } from 'constants/search';
const defaultState = { const defaultState: SearchState = {
isActive: false, // does the user have any typed text in the search input // $FlowFixMe
focused: false, // is the search input focused
searchQuery: '', // needs to be an empty string for input focusing
options: { options: {
[SEARCH_OPTIONS.RESULT_COUNT]: 30, [SEARCH_OPTIONS.RESULT_COUNT]: 30,
[SEARCH_OPTIONS.CLAIM_TYPE]: SEARCH_OPTIONS.INCLUDE_FILES_AND_CHANNELS, [SEARCH_OPTIONS.CLAIM_TYPE]: SEARCH_OPTIONS.INCLUDE_FILES_AND_CHANNELS,
@ -16,10 +14,8 @@ const defaultState = {
[SEARCH_OPTIONS.MEDIA_IMAGE]: true, [SEARCH_OPTIONS.MEDIA_IMAGE]: true,
[SEARCH_OPTIONS.MEDIA_APPLICATION]: true, [SEARCH_OPTIONS.MEDIA_APPLICATION]: true,
}, },
suggestions: {},
urisByQuery: {}, urisByQuery: {},
resolvedResultsByQuery: {}, searching: false,
resolvedResultsByQueryLastPageReached: {},
}; };
export default handleActions( export default handleActions(
@ -43,66 +39,6 @@ export default handleActions(
searching: false, 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 => { [ACTIONS.UPDATE_SEARCH_OPTIONS]: (state: SearchState, action: UpdateSearchOptions): SearchState => {
const { options: oldOptions } = state; const { options: oldOptions } = state;
const newOptions = action.data; const newOptions = action.data;

View file

@ -1,7 +1,7 @@
// @flow // @flow
import { SEARCH_TYPES } from 'constants/search';
import { getSearchQueryString } from 'util/query-params'; 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'; import { createSelector } from 'reselect';
type State = { search: SearchState }; 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 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 selectIsSearching: (state: State) => boolean = createSelector(selectState, state => state.searching);
export const selectSearchUrisByQuery: (state: State) => { [string]: Array<string> } = createSelector( 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] 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 // 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 // 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) {} } catch (e) {}
return createSelector( return createSelector(
makeSelectClientSetting(SETTINGS.SHOW_MATURE),
makeSelectClaimForUri(uriFromQuery), makeSelectClaimForUri(uriFromQuery),
makeSelectClaimForUri(channelUriFromQuery), makeSelectClaimForUri(channelUriFromQuery),
(claim1, claim2) => { (matureEnabled, claim1, claim2) => {
const claim1Mature = claim1 && isClaimNsfw(claim1);
const claim2Mature = claim2 && isClaimNsfw(claim2);
if (!claim1 && !claim2) { if (!claim1 && !claim2) {
return undefined; return undefined;
} else if (!claim1 && claim2) { } else if (!claim1 && claim2) {
return claim2.canonical_url; return matureEnabled ? claim2.canonical_url : claim2Mature ? undefined : claim2.canonical_url;
} else if (claim1 && !claim2) { } 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 effectiveAmount1 = claim1 && claim1.meta.effective_amount;
const effectiveAmount2 = claim2 && claim2.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; return Number(effectiveAmount1) > Number(effectiveAmount2) ? claim1.canonical_url : claim2.canonical_url;
} }
); );

View file

@ -1,5 +1,11 @@
.wunderbar { .wunderbar__wrapper {
flex: 1; flex: 1;
max-width: 30rem;
margin-left: var(--spacing-s);
margin-right: var(--spacing-s);
}
.wunderbar {
cursor: text; cursor: text;
display: flex; display: flex;
align-items: center; align-items: center;
@ -7,9 +13,6 @@
z-index: 1; z-index: 1;
font-size: var(--font-small); font-size: var(--font-small);
height: var(--height-input); height: var(--height-input);
max-width: 30rem;
margin-left: var(--spacing-s);
margin-right: var(--spacing-s);
@media (max-width: $breakpoint-small) { @media (max-width: $breakpoint-small) {
max-width: none; max-width: none;
@ -40,11 +43,6 @@
} }
} }
.wunderbar__active-suggestion {
color: var(--color-search-suggestion);
background-color: var(--color-search-suggestion-background);
}
.wunderbar__input { .wunderbar__input {
width: 100%; width: 100%;
align-items: center; align-items: center;
@ -75,39 +73,114 @@
} }
} }
.wunderbar__menu { .wunderbar__results {
min-width: 100%; margin-left: var(--spacing-xs);
overflow: hidden; }
background-color: var(--color-input-bg);
margin-top: -4px; .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 { .wunderbar__suggestion {
align-items: center;
cursor: pointer;
display: flex; display: flex;
flex-direction: row; align-items: center;
justify-items: flex-start; height: 3rem;
padding: var(--spacing-s);
&:not(:first-of-type) { .media__thumb {
border-top: 1px solid transparent;
}
.icon {
flex-shrink: 0; 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 { .wunderbar__suggestion-label {
overflow: hidden; font-size: var(--font-small);
padding-left: var(--spacing-s); min-width: 0;
text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.wunderbar__suggestion-label--action { .wunderbar__suggestion-name {
margin-left: var(--spacing-m); @extend .help;
opacity: 0.6; margin-top: 0;
white-space: nowrap; }
.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);
} }

View file

@ -1829,6 +1829,14 @@
version "2.1.0" version "2.1.0"
resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.1.0.tgz#09a7a352a40508156e1256efdc015593feca28e0" 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": "@reach/auto-id@^0.2.0":
version "0.2.0" version "0.2.0"
resolved "https://registry.yarnpkg.com/@reach/auto-id/-/auto-id-0.2.0.tgz#97f9e48fe736aa5c6f4f32cf73c1f19d005f8550" resolved "https://registry.yarnpkg.com/@reach/auto-id/-/auto-id-0.2.0.tgz#97f9e48fe736aa5c6f4f32cf73c1f19d005f8550"
@ -1837,10 +1845,32 @@
version "0.7.4" version "0.7.4"
resolved "https://registry.yarnpkg.com/@reach/auto-id/-/auto-id-0.7.4.tgz#c20bf6db87ef2f34b1bfb4bf450d675524a368c1" 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": "@reach/component-component@^0.1.3":
version "0.1.3" version "0.1.3"
resolved "https://registry.yarnpkg.com/@reach/component-component/-/component-component-0.1.3.tgz#5d156319572dc38995b246f81878bc2577c517e5" 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": "@reach/menu-button@0.7.4":
version "0.7.4" version "0.7.4"
resolved "https://registry.yarnpkg.com/@reach/menu-button/-/menu-button-0.7.4.tgz#6b2cb91e5471dfc67aa10380c2779eb3d21d98bf" resolved "https://registry.yarnpkg.com/@reach/menu-button/-/menu-button-0.7.4.tgz#6b2cb91e5471dfc67aa10380c2779eb3d21d98bf"
@ -1851,10 +1881,26 @@
prop-types "^15.7.2" prop-types "^15.7.2"
warning "^4.0.3" 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": "@reach/observe-rect@^1.0.3", "@reach/observe-rect@^1.0.5":
version "1.1.0" version "1.1.0"
resolved "https://registry.yarnpkg.com/@reach/observe-rect/-/observe-rect-1.1.0.tgz#4e967a93852b6004c3895d9ed8d4e5b41895afde" 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": "@reach/popover@^0.7.4":
version "0.7.4" version "0.7.4"
resolved "https://registry.yarnpkg.com/@reach/popover/-/popover-0.7.4.tgz#def055c588a76ef4a48ac81f0b4e7373e2781b28" resolved "https://registry.yarnpkg.com/@reach/popover/-/popover-0.7.4.tgz#def055c588a76ef4a48ac81f0b4e7373e2781b28"
@ -1864,10 +1910,28 @@
"@reach/utils" "^0.7.4" "@reach/utils" "^0.7.4"
tabbable "^4.0.0" 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": "@reach/portal@^0.7.4":
version "0.7.4" version "0.7.4"
resolved "https://registry.yarnpkg.com/@reach/portal/-/portal-0.7.4.tgz#6e182f9061ba52dc6a5e460516b4aedf8623103f" 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": "@reach/rect@^0.2.1":
version "0.2.1" version "0.2.1"
resolved "https://registry.yarnpkg.com/@reach/rect/-/rect-0.2.1.tgz#7343020174c90e2290b844d17c03fd9c78e6b601" resolved "https://registry.yarnpkg.com/@reach/rect/-/rect-0.2.1.tgz#7343020174c90e2290b844d17c03fd9c78e6b601"
@ -1890,6 +1954,15 @@
"@reach/utils" "^0.2.3" "@reach/utils" "^0.2.3"
warning "^4.0.2" 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": "@reach/utils@^0.2.3":
version "0.2.3" version "0.2.3"
resolved "https://registry.yarnpkg.com/@reach/utils/-/utils-0.2.3.tgz#820f6a6af4301b4c5065cfc04bb89e6a3d1d723f" resolved "https://registry.yarnpkg.com/@reach/utils/-/utils-0.2.3.tgz#820f6a6af4301b4c5065cfc04bb89e6a3d1d723f"
@ -2087,6 +2160,11 @@
dependencies: dependencies:
source-map "^0.6.1" 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": "@types/webpack-env@^1.15.1":
version "1.15.3" version "1.15.3"
resolved "https://registry.yarnpkg.com/@types/webpack-env/-/webpack-env-1.15.3.tgz#fb602cd4c2f0b7c0fb857e922075fdf677d25d84" 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" version "1.1.0"
resolved "https://registry.yarnpkg.com/hex-color-regex/-/hex-color-regex-1.1.0.tgz#4c06fccb4602fe2602b3c93df82d7e7dbf1a8a8e" 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: highlight.js@^9.3.0:
version "9.18.1" version "9.18.1"
resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-9.18.1.tgz#ed21aa001fe6252bb10a3d76d47573c6539fe13c" 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" version "1.11.1"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.11.1.tgz#eb15d128827fbee2841549e171f45ed338ac7e35" 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: tty-browserify@0.0.0:
version "0.0.0" version "0.0.0"
resolved "https://registry.yarnpkg.com/tty-browserify/-/tty-browserify-0.0.0.tgz#a157ba402da24e9bf957f9aa69d524eed42901a6" resolved "https://registry.yarnpkg.com/tty-browserify/-/tty-browserify-0.0.0.tgz#a157ba402da24e9bf957f9aa69d524eed42901a6"