From 0df388280eea876a46d5ae6f2aa19c0904d39ce7 Mon Sep 17 00:00:00 2001 From: Sean Yesmunt Date: Mon, 27 Jul 2020 16:04:12 -0400 Subject: [PATCH] add search code from lbry-redux --- flow-typed/search.js | 84 ++++++++++ package.json | 2 +- ui/component/recommendedContent/index.js | 10 +- ui/component/searchOptions/index.js | 8 +- ui/component/searchOptions/view.jsx | 2 +- ui/component/wunderbar/index.js | 12 +- ui/component/wunderbar/view.jsx | 3 +- ui/constants/action_types.js | 6 + ui/constants/search.js | 20 +++ ui/index.jsx | 3 +- ui/page/search/index.js | 11 +- ui/reducers.js | 3 +- ui/redux/actions/search.js | 188 +++++++++++++++++++++++ ui/redux/reducers/search.js | 117 ++++++++++++++ ui/redux/selectors/content.js | 2 +- ui/redux/selectors/search.js | 177 +++++++++++++++++++++ ui/util/handle-fetch.js | 3 +- ui/util/query-params.js | 70 +++++++-- yarn.lock | 4 +- 19 files changed, 672 insertions(+), 53 deletions(-) create mode 100644 flow-typed/search.js create mode 100644 ui/redux/actions/search.js create mode 100644 ui/redux/reducers/search.js create mode 100644 ui/redux/selectors/search.js diff --git a/flow-typed/search.js b/flow-typed/search.js new file mode 100644 index 000000000..2a2152e48 --- /dev/null +++ b/flow-typed/search.js @@ -0,0 +1,84 @@ +// @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 + RESULT_COUNT: number, + CLAIM_TYPE: string, + INCLUDE_FILES: string, + INCLUDE_CHANNELS: string, + INCLUDE_FILES_AND_CHANNELS: string, + MEDIA_AUDIO: string, + MEDIA_VIDEO: string, + MEDIA_TEXT: string, + MEDIA_IMAGE: string, + MEDIA_APPLICATION: string, +}; + +declare type SearchState = { + isActive: boolean, + searchQuery: string, + options: SearchOptions, + suggestions: { [string]: Array }, + urisByQuery: {}, + resolvedResultsByQuery: {}, + resolvedResultsByQueryLastPageReached: {}, +}; + +declare type SearchSuccess = { + type: ACTIONS.SEARCH_SUCCESS, + data: { + query: string, + uris: Array, + }, +}; + +declare type UpdateSearchQuery = { + type: ACTIONS.UPDATE_SEARCH_QUERY, + data: { + query: string, + }, +}; + +declare type UpdateSearchSuggestions = { + type: ACTIONS.UPDATE_SEARCH_SUGGESTIONS, + data: { + query: string, + suggestions: Array, + }, +}; + +declare type UpdateSearchOptions = { + type: ACTIONS.UPDATE_SEARCH_OPTIONS, + data: SearchOptions, +}; + +declare type ResolvedSearchResult = { + channel: string, + channel_claim_id: string, + claimId: string, + duration: number, + fee: number, + name: string, + nsfw: boolean, + release_time: string, + thumbnail_url: string, + title: string, +}; + +declare type ResolvedSearchSuccess = { + type: ACTIONS.RESOLVED_SEARCH_SUCCESS, + data: { + append: boolean, + pageSize: number, + results: Array, + query: string, + }, +}; diff --git a/package.json b/package.json index 2ec8bdd6c..09c5cbc94 100644 --- a/package.json +++ b/package.json @@ -135,7 +135,7 @@ "imagesloaded": "^4.1.4", "json-loader": "^0.5.4", "lbry-format": "https://github.com/lbryio/lbry-format.git", - "lbry-redux": "lbryio/lbry-redux#a1d5ce7e7e854c1c11e630609ef06a98e3b100c1", + "lbry-redux": "lbryio/lbry-redux#021e7e3b798efeea467ce6437cd181dfa2e6badc", "lbryinc": "lbryio/lbryinc#cff5dd60934c4c6080e135f47ebbece1548c658c", "lint-staged": "^7.0.2", "localforage": "^1.7.1", diff --git a/ui/component/recommendedContent/index.js b/ui/component/recommendedContent/index.js index 35bdc735d..1509e617f 100644 --- a/ui/component/recommendedContent/index.js +++ b/ui/component/recommendedContent/index.js @@ -1,11 +1,7 @@ import { connect } from 'react-redux'; -import { - makeSelectClaimForUri, - makeSelectClaimIsNsfw, - doSearch, - makeSelectRecommendedContentForUri, - selectIsSearching, -} from 'lbry-redux'; +import { makeSelectClaimForUri, makeSelectClaimIsNsfw } from 'lbry-redux'; +import { doSearch } from 'redux/actions/search'; +import { makeSelectRecommendedContentForUri, selectIsSearching } from 'redux/selectors/search'; import { selectUserVerifiedEmail } from 'redux/selectors/user'; import RecommendedVideos from './view'; diff --git a/ui/component/searchOptions/index.js b/ui/component/searchOptions/index.js index b9e4ef966..012cbf3c6 100644 --- a/ui/component/searchOptions/index.js +++ b/ui/component/searchOptions/index.js @@ -1,5 +1,6 @@ import { connect } from 'react-redux'; -import { selectSearchOptions, doUpdateSearchOptions, makeSelectQueryWithOptions } from 'lbry-redux'; +import { doUpdateSearchOptions } from 'redux/actions/search'; +import { selectSearchOptions, makeSelectQueryWithOptions } from 'redux/selectors/search'; import { doToggleSearchExpanded } from 'redux/actions/app'; import { selectSearchOptionsExpanded } from 'redux/selectors/app'; import SearchOptions from './view'; @@ -18,7 +19,4 @@ const perform = (dispatch, ownProps) => { }; }; -export default connect( - select, - perform -)(SearchOptions); +export default connect(select, perform)(SearchOptions); diff --git a/ui/component/searchOptions/view.jsx b/ui/component/searchOptions/view.jsx index 03500fa43..ca2f621f0 100644 --- a/ui/component/searchOptions/view.jsx +++ b/ui/component/searchOptions/view.jsx @@ -1,7 +1,7 @@ // @flow +import { SEARCH_OPTIONS } from 'constants/search'; import * as ICONS from 'constants/icons'; import React from 'react'; -import { SEARCH_OPTIONS } from 'lbry-redux'; import { Form, FormField } from 'component/common/form'; import Button from 'component/button'; diff --git a/ui/component/wunderbar/index.js b/ui/component/wunderbar/index.js index 4384effa4..519bbeecf 100644 --- a/ui/component/wunderbar/index.js +++ b/ui/component/wunderbar/index.js @@ -1,13 +1,7 @@ import { connect } from 'react-redux'; -import { - doFocusSearchInput, - doBlurSearchInput, - doUpdateSearchQuery, - selectSearchValue, - selectSearchSuggestions, - selectSearchBarFocused, - SETTINGS, -} from 'lbry-redux'; +import { SETTINGS } from 'lbry-redux'; +import { doFocusSearchInput, doBlurSearchInput, doUpdateSearchQuery } from 'redux/actions/search'; +import { selectSearchValue, selectSearchSuggestions, selectSearchBarFocused } from 'redux/selectors/search'; import { makeSelectClientSetting } from 'redux/selectors/settings'; import { doToast } from 'redux/actions/notifications'; import analytics from 'analytics'; diff --git a/ui/component/wunderbar/view.jsx b/ui/component/wunderbar/view.jsx index e8e4c0675..689f8aa9c 100644 --- a/ui/component/wunderbar/view.jsx +++ b/ui/component/wunderbar/view.jsx @@ -1,10 +1,11 @@ // @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 { normalizeURI, SEARCH_TYPES, isURIValid } from 'lbry-redux'; +import { normalizeURI, isURIValid } from 'lbry-redux'; import { withRouter } from 'react-router'; import Icon from 'component/common/icon'; import Autocomplete from './internal/autocomplete'; diff --git a/ui/constants/action_types.js b/ui/constants/action_types.js index 98c52458a..050a0ecfd 100644 --- a/ui/constants/action_types.js +++ b/ui/constants/action_types.js @@ -116,8 +116,14 @@ 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'; diff --git a/ui/constants/search.js b/ui/constants/search.js index 980e159bd..c6dcfa628 100644 --- a/ui/constants/search.js +++ b/ui/constants/search.js @@ -2,3 +2,23 @@ export const FILE = 'file'; export const CHANNEL = 'channel'; export const SEARCH = 'search'; export const DEBOUNCE_WAIT_DURATION_MS = 250; + +export const SEARCH_TYPES = { + FILE: 'file', + CHANNEL: 'channel', + SEARCH: 'search', + TAG: 'tag', +}; + +export const SEARCH_OPTIONS = { + RESULT_COUNT: 'size', + CLAIM_TYPE: 'claimType', + INCLUDE_FILES: 'file', + INCLUDE_CHANNELS: 'channel', + INCLUDE_FILES_AND_CHANNELS: 'file,channel', + MEDIA_AUDIO: 'audio', + MEDIA_VIDEO: 'video', + MEDIA_TEXT: 'text', + MEDIA_IMAGE: 'image', + MEDIA_APPLICATION: 'application', +}; diff --git a/ui/index.jsx b/ui/index.jsx index b4bb646af..460dedaa8 100644 --- a/ui/index.jsx +++ b/ui/index.jsx @@ -15,7 +15,8 @@ import React, { Fragment, useState, useEffect } from 'react'; import ReactDOM from 'react-dom'; import { Provider } from 'react-redux'; import { doDaemonReady, doAutoUpdate, doOpenModal, doHideModal, doToggle3PAnalytics } from 'redux/actions/app'; -import { Lbry, isURIValid, setSearchApi, apiCall } from 'lbry-redux'; +import { Lbry, isURIValid, apiCall } from 'lbry-redux'; +import { setSearchApi } from 'redux/actions/search'; import { doSetLanguage, doFetchLanguage, doUpdateIsNightAsync } from 'redux/actions/settings'; import { Lbryio, doBlackListedOutpointsSubscribe, doFilteredOutpointsSubscribe } from 'lbryinc'; import rewards from 'rewards'; diff --git a/ui/page/search/index.js b/ui/page/search/index.js index c2cfc0113..e9ae20ae6 100644 --- a/ui/page/search/index.js +++ b/ui/page/search/index.js @@ -1,12 +1,7 @@ import { connect } from 'react-redux'; -import { - doSearch, - selectIsSearching, - makeSelectSearchUris, - makeSelectQueryWithOptions, - doToast, - SETTINGS, -} from 'lbry-redux'; +import { doToast, SETTINGS } from 'lbry-redux'; +import { doSearch } from 'redux/actions/search'; +import { selectIsSearching, makeSelectSearchUris, makeSelectQueryWithOptions } from 'redux/selectors/search'; import { makeSelectClientSetting } from 'redux/selectors/settings'; import { selectUserVerifiedEmail } from 'redux/selectors/user'; import analytics from 'analytics'; diff --git a/ui/reducers.js b/ui/reducers.js index 7ee89c66b..4e5195801 100644 --- a/ui/reducers.js +++ b/ui/reducers.js @@ -1,6 +1,6 @@ import { combineReducers } from 'redux'; import { connectRouter } from 'connected-react-router'; -import { claimsReducer, fileInfoReducer, searchReducer, walletReducer, tagsReducer, publishReducer } from 'lbry-redux'; +import { claimsReducer, fileInfoReducer, walletReducer, tagsReducer, publishReducer } from 'lbry-redux'; import { costInfoReducer, blacklistReducer, @@ -19,6 +19,7 @@ import rewardsReducer from 'redux/reducers/rewards'; import userReducer from 'redux/reducers/user'; import commentsReducer from 'redux/reducers/comments'; import blockedReducer from 'redux/reducers/blocked'; +import searchReducer from 'redux/reducers/search'; export default history => combineReducers({ diff --git a/ui/redux/actions/search.js b/ui/redux/actions/search.js new file mode 100644 index 000000000..5f11c15e6 --- /dev/null +++ b/ui/redux/actions/search.js @@ -0,0 +1,188 @@ +// @flow +import * as ACTIONS from 'constants/action_types'; +import { buildURI, doResolveUri, batchActions } from 'lbry-redux'; +import { + makeSelectSearchUris, + selectSuggestions, + makeSelectQueryWithOptions, + selectSearchValue, +} from 'redux/selectors/search'; +import debounce from 'util/debounce'; +import handleFetchResponse from 'util/handle-fetch'; + +const DEBOUNCED_SEARCH_SUGGESTION_MS = 300; +type Dispatch = (action: any) => any; +type GetState = () => { search: SearchState }; + +type SearchOptions = { + size?: number, + from?: number, + related_to?: string, + nsfw?: boolean, + isBackgroundSearch?: boolean, + resolveResults?: boolean, +}; + +// We can't use env's because they aren't passed into node_modules +let CONNECTION_STRING = 'https://lighthouse.lbry.com/'; + +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=${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 }, + }); + + // 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) => ( + dispatch: Dispatch, + getState: GetState +) => { + const query = rawQuery.replace(/^lbry:\/\//i, '').replace(/\//, ' '); + const resolveResults = searchOptions && searchOptions.resolveResults; + const isBackgroundSearch = (searchOptions && searchOptions.isBackgroundSearch) || false; + + if (!query) { + dispatch({ + type: ACTIONS.SEARCH_FAIL, + }); + return; + } + + const state = getState(); + + let queryWithOptions = makeSelectQueryWithOptions(query, searchOptions)(state); + + // If we have already searched for something, we don't need to do anything + const urisForQuery = makeSelectSearchUris(queryWithOptions)(state); + if (urisForQuery && !!urisForQuery.length) { + return; + } + + dispatch({ + 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) + .then((data: Array<{ name: string, claimId: string }>) => { + const uris = []; + const actions = []; + + data.forEach(result => { + if (result) { + const { name, claimId } = result; + const urlObj: LbryUrlObj = {}; + + if (name.startsWith('@')) { + urlObj.channelName = name; + urlObj.channelClaimId = claimId; + } else { + urlObj.streamName = name; + urlObj.streamClaimId = claimId; + } + + const url = buildURI(urlObj); + if (resolveResults) { + actions.push(doResolveUri(url)); + } + uris.push(url); + } + }); + + actions.push({ + type: ACTIONS.SEARCH_SUCCESS, + data: { + query: queryWithOptions, + uris, + }, + }); + dispatch(batchActions(...actions)); + }) + .catch(e => { + dispatch({ + type: ACTIONS.SEARCH_FAIL, + }); + }); +}; + +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 +) => { + const state = getState(); + const searchValue = selectSearchValue(state); + + dispatch({ + type: ACTIONS.UPDATE_SEARCH_OPTIONS, + data: newOptions, + }); + + if (searchValue) { + // After updating, perform a search with the new options + dispatch(doSearch(searchValue, additionalOptions)); + } +}; diff --git a/ui/redux/reducers/search.js b/ui/redux/reducers/search.js new file mode 100644 index 000000000..5e5602656 --- /dev/null +++ b/ui/redux/reducers/search.js @@ -0,0 +1,117 @@ +// @flow +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 + options: { + [SEARCH_OPTIONS.RESULT_COUNT]: 30, + [SEARCH_OPTIONS.CLAIM_TYPE]: SEARCH_OPTIONS.INCLUDE_FILES_AND_CHANNELS, + [SEARCH_OPTIONS.MEDIA_AUDIO]: true, + [SEARCH_OPTIONS.MEDIA_VIDEO]: true, + [SEARCH_OPTIONS.MEDIA_TEXT]: true, + [SEARCH_OPTIONS.MEDIA_IMAGE]: true, + [SEARCH_OPTIONS.MEDIA_APPLICATION]: true, + }, + suggestions: {}, + urisByQuery: {}, + resolvedResultsByQuery: {}, + resolvedResultsByQueryLastPageReached: {}, +}; + +export default handleActions( + { + [ACTIONS.SEARCH_START]: (state: SearchState): SearchState => ({ + ...state, + searching: true, + }), + [ACTIONS.SEARCH_SUCCESS]: (state: SearchState, action: SearchSuccess): SearchState => { + const { query, uris } = action.data; + + return { + ...state, + searching: false, + urisByQuery: Object.assign({}, state.urisByQuery, { [query]: uris }), + }; + }, + + [ACTIONS.SEARCH_FAIL]: (state: SearchState): SearchState => ({ + ...state, + 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; + const options = { ...oldOptions, ...newOptions }; + return { + ...state, + options, + }; + }, + }, + defaultState +); diff --git a/ui/redux/selectors/content.js b/ui/redux/selectors/content.js index 021a73ae3..cf676c523 100644 --- a/ui/redux/selectors/content.js +++ b/ui/redux/selectors/content.js @@ -6,7 +6,6 @@ import { makeSelectClaimsInChannelForCurrentPageState, makeSelectClaimIsNsfw, makeSelectClaimIsMine, - makeSelectRecommendedContentForUri, makeSelectMediaTypeForUri, selectBalance, parseURI, @@ -14,6 +13,7 @@ import { makeSelectContentTypeForUri, makeSelectFileNameForUri, } from 'lbry-redux'; +import { makeSelectRecommendedContentForUri } from 'redux/selectors/search'; import { selectBlockedChannels } from 'redux/selectors/blocked'; import { selectAllCostInfoByUri, makeSelectCostInfoForUri } from 'lbryinc'; import { selectShowMatureContent } from 'redux/selectors/settings'; diff --git a/ui/redux/selectors/search.js b/ui/redux/selectors/search.js new file mode 100644 index 000000000..631f509f7 --- /dev/null +++ b/ui/redux/selectors/search.js @@ -0,0 +1,177 @@ +// @flow +import { SEARCH_TYPES } from 'constants/search'; +import { getSearchQueryString } from 'util/query-params'; +import { normalizeURI, parseURI, makeSelectClaimForUri, makeSelectClaimIsNsfw, buildURI } from 'lbry-redux'; +import { createSelector } from 'reselect'; + +type State = { search: SearchState }; + +export const selectState = (state: State): SearchState => state.search; + +export const selectSearchValue: (state: State) => string = createSelector(selectState, state => state.searchQuery); + +export const selectSearchOptions: (state: State) => SearchOptions = createSelector(selectState, state => state.options); + +export const selectSuggestions: (state: State) => { [string]: Array } = createSelector( + selectState, + state => state.suggestions +); + +export const selectIsSearching: (state: State) => boolean = createSelector(selectState, state => state.searching); + +export const selectSearchUrisByQuery: (state: State) => { [string]: Array } = createSelector( + selectState, + state => state.urisByQuery +); + +export const makeSelectSearchUris = (query: string): ((state: State) => Array) => + // replace statement below is kind of ugly, and repeated in doSearch action + createSelector( + selectSearchUrisByQuery, + byQuery => byQuery[query ? query.replace(/^lbry:\/\//i, '').replace(/\//, ' ') : query] + ); + +export const selectResolvedSearchResultsByQuery: ( + state: State +) => { [string]: Array } = createSelector(selectState, state => state.resolvedResultsByQuery); + +export const selectSearchBarFocused: boolean = createSelector(selectState, state => state.focused); + +export const selectSearchSuggestions: Array = createSelector( + selectSearchValue, + selectSuggestions, + (query: string, suggestions: { [string]: Array }) => { + 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 + return [ + { + value: query, + type: query[7] === '@' ? SEARCH_TYPES.CHANNEL : SEARCH_TYPES.FILE, + }, + ]; + } + + let searchSuggestions = []; + try { + const uri = normalizeURI(query); + const { channelName, streamName, isChannel } = parseURI(uri); + searchSuggestions.push( + { + value: query, + type: SEARCH_TYPES.SEARCH, + }, + { + value: uri, + shorthand: isChannel ? channelName : streamName, + type: isChannel ? SEARCH_TYPES.CHANNEL : SEARCH_TYPES.FILE, + } + ); + } catch (e) { + searchSuggestions.push({ + value: query, + type: SEARCH_TYPES.SEARCH, + }); + } + + 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 uri = normalizeURI(suggestion); + const { channelName, streamName, isChannel } = parseURI(uri); + + return { + value: uri, + shorthand: isChannel ? channelName : streamName, + type: 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 + +type CustomOptions = { + isBackgroundSearch?: boolean, + size?: number, + from?: number, + related_to?: string, + nsfw?: boolean, +}; + +export const makeSelectQueryWithOptions = (customQuery: ?string, options: CustomOptions) => + createSelector(selectSearchValue, selectSearchOptions, (query, defaultOptions) => { + const searchOptions = { ...defaultOptions, ...options }; + const queryString = getSearchQueryString(customQuery || query, searchOptions); + + return queryString; + }); + +export const makeSelectRecommendedContentForUri = (uri: string) => + createSelector( + makeSelectClaimForUri(uri), + selectSearchUrisByQuery, + makeSelectClaimIsNsfw(uri), + (claim, searchUrisByQuery, isMature) => { + let recommendedContent; + if (claim) { + // always grab full URL - this can change once search returns canonical + const currentUri = buildURI({ streamClaimId: claim.claim_id, streamName: claim.name }); + + const { title } = claim.value; + + if (!title) { + return; + } + + const options: { + related_to?: string, + nsfw?: boolean, + isBackgroundSearch?: boolean, + } = { related_to: claim.claim_id, isBackgroundSearch: true }; + + if (!isMature) { + options['nsfw'] = false; + } + const searchQuery = getSearchQueryString(title.replace(/\//, ' '), options); + + let searchUris = searchUrisByQuery[searchQuery]; + if (searchUris) { + searchUris = searchUris.filter(searchUri => searchUri !== currentUri); + recommendedContent = searchUris; + } + } + + return recommendedContent; + } + ); diff --git a/ui/util/handle-fetch.js b/ui/util/handle-fetch.js index 779c80a33..83ce10513 100644 --- a/ui/util/handle-fetch.js +++ b/ui/util/handle-fetch.js @@ -1,3 +1,4 @@ -export default function handleFetchResponse(response) { +// @flow +export default function handleFetchResponse(response: Response): Promise { return response.status === 200 ? Promise.resolve(response.json()) : Promise.reject(new Error(response.statusText)); } diff --git a/ui/util/query-params.js b/ui/util/query-params.js index 80f1c5fc2..081a957fa 100644 --- a/ui/util/query-params.js +++ b/ui/util/query-params.js @@ -1,4 +1,10 @@ -export function parseQueryParams(queryString) { +// @flow +import { SEARCH_OPTIONS } from 'constants/search'; + +const DEFAULT_SEARCH_RESULT_FROM = 0; +const DEFAULT_SEARCH_SIZE = 20; + +export function parseQueryParams(queryString: string) { if (queryString === '') return {}; const parts = queryString .split('?') @@ -14,21 +20,8 @@ export function parseQueryParams(queryString) { return params; } -export function toQueryString(params) { - if (!params) return ''; - - const parts = []; - Object.keys(params).forEach(key => { - if (Object.prototype.hasOwnProperty.call(params, key) && params[key]) { - parts.push(`${key}=${params[key]}`); - } - }); - - return parts.join('&'); -} - // https://stackoverflow.com/questions/5999118/how-can-i-add-or-update-a-query-string-parameter -export function updateQueryParam(uri, key, value) { +export function updateQueryParam(uri: string, key: string, value: string) { const re = new RegExp('([?&])' + key + '=.*?(&|$)', 'i'); const separator = uri.includes('?') ? '&' : '?'; if (uri.match(re)) { @@ -37,3 +30,50 @@ export function updateQueryParam(uri, key, value) { return uri + separator + key + '=' + value; } } + +export const getSearchQueryString = (query: string, options: any = {}) => { + const encodedQuery = encodeURIComponent(query); + const queryParams = [ + `s=${encodedQuery}`, + `size=${options.size || DEFAULT_SEARCH_SIZE}`, + `from=${options.from || DEFAULT_SEARCH_RESULT_FROM}`, + ]; + const { isBackgroundSearch } = options; + const includeUserOptions = typeof isBackgroundSearch === 'undefined' ? false : !isBackgroundSearch; + + if (includeUserOptions) { + const claimType = options[SEARCH_OPTIONS.CLAIM_TYPE]; + if (claimType) { + queryParams.push(`claimType=${claimType}`); + + // If they are only searching for channels, strip out the media info + if (!claimType.includes(SEARCH_OPTIONS.INCLUDE_CHANNELS)) { + queryParams.push( + `mediaType=${[ + SEARCH_OPTIONS.MEDIA_FILE, + SEARCH_OPTIONS.MEDIA_AUDIO, + SEARCH_OPTIONS.MEDIA_VIDEO, + SEARCH_OPTIONS.MEDIA_TEXT, + SEARCH_OPTIONS.MEDIA_IMAGE, + SEARCH_OPTIONS.MEDIA_APPLICATION, + ].reduce((acc, currentOption) => (options[currentOption] ? `${acc}${currentOption},` : acc), '')}` + ); + } + } + } + + const additionalOptions = {}; + const { related_to } = options; + const { nsfw } = options; + if (related_to) additionalOptions['related_to'] = related_to; + if (typeof nsfw !== 'undefined') additionalOptions['nsfw'] = nsfw; + + if (additionalOptions) { + Object.keys(additionalOptions).forEach(key => { + const option = additionalOptions[key]; + queryParams.push(`${key}=${option}`); + }); + } + + return queryParams.join('&'); +}; diff --git a/yarn.lock b/yarn.lock index b442e6256..8345fb821 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6385,9 +6385,9 @@ lazy-val@^1.0.4: yargs "^13.2.2" zstd-codec "^0.1.1" -lbry-redux@lbryio/lbry-redux#a1d5ce7e7e854c1c11e630609ef06a98e3b100c1: +lbry-redux@lbryio/lbry-redux#021e7e3b798efeea467ce6437cd181dfa2e6badc: version "0.0.1" - resolved "https://codeload.github.com/lbryio/lbry-redux/tar.gz/a1d5ce7e7e854c1c11e630609ef06a98e3b100c1" + resolved "https://codeload.github.com/lbryio/lbry-redux/tar.gz/021e7e3b798efeea467ce6437cd181dfa2e6badc" dependencies: proxy-polyfill "0.1.6" reselect "^3.0.0"