add search code from lbry-redux
This commit is contained in:
parent
142e695150
commit
0df388280e
19 changed files with 672 additions and 53 deletions
84
flow-typed/search.js
vendored
Normal file
84
flow-typed/search.js
vendored
Normal file
|
@ -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<SearchSuggestion> },
|
||||
urisByQuery: {},
|
||||
resolvedResultsByQuery: {},
|
||||
resolvedResultsByQueryLastPageReached: {},
|
||||
};
|
||||
|
||||
declare type SearchSuccess = {
|
||||
type: ACTIONS.SEARCH_SUCCESS,
|
||||
data: {
|
||||
query: string,
|
||||
uris: Array<string>,
|
||||
},
|
||||
};
|
||||
|
||||
declare type UpdateSearchQuery = {
|
||||
type: ACTIONS.UPDATE_SEARCH_QUERY,
|
||||
data: {
|
||||
query: string,
|
||||
},
|
||||
};
|
||||
|
||||
declare type UpdateSearchSuggestions = {
|
||||
type: ACTIONS.UPDATE_SEARCH_SUGGESTIONS,
|
||||
data: {
|
||||
query: string,
|
||||
suggestions: Array<SearchSuggestion>,
|
||||
},
|
||||
};
|
||||
|
||||
declare type UpdateSearchOptions = {
|
||||
type: ACTIONS.UPDATE_SEARCH_OPTIONS,
|
||||
data: SearchOptions,
|
||||
};
|
||||
|
||||
declare type ResolvedSearchResult = {
|
||||
channel: string,
|
||||
channel_claim_id: string,
|
||||
claimId: string,
|
||||
duration: number,
|
||||
fee: number,
|
||||
name: string,
|
||||
nsfw: boolean,
|
||||
release_time: string,
|
||||
thumbnail_url: string,
|
||||
title: string,
|
||||
};
|
||||
|
||||
declare type ResolvedSearchSuccess = {
|
||||
type: ACTIONS.RESOLVED_SEARCH_SUCCESS,
|
||||
data: {
|
||||
append: boolean,
|
||||
pageSize: number,
|
||||
results: Array<ResolvedSearchResult>,
|
||||
query: string,
|
||||
},
|
||||
};
|
|
@ -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",
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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',
|
||||
};
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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({
|
||||
|
|
188
ui/redux/actions/search.js
Normal file
188
ui/redux/actions/search.js
Normal file
|
@ -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));
|
||||
}
|
||||
};
|
117
ui/redux/reducers/search.js
Normal file
117
ui/redux/reducers/search.js
Normal file
|
@ -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
|
||||
);
|
|
@ -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';
|
||||
|
|
177
ui/redux/selectors/search.js
Normal file
177
ui/redux/selectors/search.js
Normal file
|
@ -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<SearchSuggestion> } = createSelector(
|
||||
selectState,
|
||||
state => state.suggestions
|
||||
);
|
||||
|
||||
export const selectIsSearching: (state: State) => boolean = createSelector(selectState, state => state.searching);
|
||||
|
||||
export const selectSearchUrisByQuery: (state: State) => { [string]: Array<string> } = createSelector(
|
||||
selectState,
|
||||
state => state.urisByQuery
|
||||
);
|
||||
|
||||
export const makeSelectSearchUris = (query: string): ((state: State) => Array<string>) =>
|
||||
// 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<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
|
||||
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;
|
||||
}
|
||||
);
|
|
@ -1,3 +1,4 @@
|
|||
export default function handleFetchResponse(response) {
|
||||
// @flow
|
||||
export default function handleFetchResponse(response: Response): Promise<any> {
|
||||
return response.status === 200 ? Promise.resolve(response.json()) : Promise.reject(new Error(response.statusText));
|
||||
}
|
||||
|
|
|
@ -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('&');
|
||||
};
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Add table
Reference in a new issue