feat: add support for search filters

This commit is contained in:
Sean Yesmunt 2019-02-18 11:24:18 -05:00
parent ec1d5bd41a
commit 2b725cb317
12 changed files with 708 additions and 432 deletions

772
dist/bundle.js vendored

File diff suppressed because it is too large Load diff

View file

@ -117,6 +117,7 @@ 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 UPDATE_SEARCH_QUERY = 'UPDATE_SEARCH_QUERY'; 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 UPDATE_SEARCH_SUGGESTIONS = 'UPDATE_SEARCH_SUGGESTIONS';
export const SEARCH_FOCUS = 'SEARCH_FOCUS'; export const SEARCH_FOCUS = 'SEARCH_FOCUS';
export const SEARCH_BLUR = 'SEARCH_BLUR'; export const SEARCH_BLUR = 'SEARCH_BLUR';

View file

@ -1,3 +1,18 @@
export const FILE = 'file'; export const SEARCH_TYPES = {
export const CHANNEL = 'channel'; FILE: 'file',
export const SEARCH = 'search'; CHANNEL: 'channel',
SEARCH: 'search',
};
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',
};

View file

@ -1,10 +1,10 @@
import * as ACTIONS from 'constants/action_types'; import * as ACTIONS from 'constants/action_types';
import * as THUMBNAIL_STATUSES from 'constants/thumbnail_upload_statuses'; import * as THUMBNAIL_STATUSES from 'constants/thumbnail_upload_statuses';
import * as SEARCH_TYPES from 'constants/search';
import * as SETTINGS from 'constants/settings'; import * as SETTINGS from 'constants/settings';
import * as TRANSACTIONS from 'constants/transaction_types'; import * as TRANSACTIONS from 'constants/transaction_types';
import * as SORT_OPTIONS from 'constants/sort_options'; import * as SORT_OPTIONS from 'constants/sort_options';
import * as PAGES from 'constants/pages'; import * as PAGES from 'constants/pages';
import { SEARCH_TYPES, SEARCH_OPTIONS } from 'constants/search';
import Lbry from 'lbry'; import Lbry from 'lbry';
import Lbryapi from 'lbryapi'; import Lbryapi from 'lbryapi';
@ -14,7 +14,16 @@ import { selectState as selectSearchState } from 'redux/selectors/search';
export { Toast } from 'types/Notification'; export { Toast } from 'types/Notification';
// constants // constants
export { ACTIONS, THUMBNAIL_STATUSES, SEARCH_TYPES, SETTINGS, TRANSACTIONS, SORT_OPTIONS, PAGES }; export {
ACTIONS,
THUMBNAIL_STATUSES,
SEARCH_TYPES,
SEARCH_OPTIONS,
SETTINGS,
TRANSACTIONS,
SORT_OPTIONS,
PAGES,
};
// common // common
export { Lbry, Lbryapi }; export { Lbry, Lbryapi };
@ -59,6 +68,7 @@ export {
doFocusSearchInput, doFocusSearchInput,
doBlurSearchInput, doBlurSearchInput,
setSearchApi, setSearchApi,
doUpdateSearchOptions,
} from 'redux/actions/search'; } from 'redux/actions/search';
export { doBlackListedOutpointsSubscribe } from 'redux/actions/blacklist'; export { doBlackListedOutpointsSubscribe } from 'redux/actions/blacklist';
@ -191,11 +201,13 @@ export {
makeSelectSearchUris, makeSelectSearchUris,
selectSearchQuery, selectSearchQuery,
selectSearchValue, selectSearchValue,
selectSearchOptions,
selectIsSearching, selectIsSearching,
selectSearchUrisByQuery, selectSearchUrisByQuery,
selectWunderBarAddress, selectWunderBarAddress,
selectSearchBarFocused, selectSearchBarFocused,
selectSearchSuggestions, selectSearchSuggestions,
makeSelectQueryWithOptions,
} from 'redux/selectors/search'; } from 'redux/selectors/search';
export { export {

View file

@ -1,32 +1,35 @@
// @flow // @flow
import type { SearchState, SearchOptions } from 'types/Search';
import * as ACTIONS from 'constants/action_types'; import * as ACTIONS from 'constants/action_types';
import { buildURI } from 'lbryURI'; import { buildURI } from 'lbryURI';
import { doResolveUri } from 'redux/actions/claims'; import { doResolveUri } from 'redux/actions/claims';
import { makeSelectSearchUris, selectSuggestions } from 'redux/selectors/search'; import {
makeSelectSearchUris,
selectSuggestions,
makeSelectQueryWithOptions,
selectSearchQuery,
} from 'redux/selectors/search';
import { batchActions } from 'util/batchActions'; import { batchActions } from 'util/batchActions';
import debounce from 'util/debounce'; import debounce from 'util/debounce';
import handleFetchResponse from 'util/handle-fetch'; import handleFetchResponse from 'util/handle-fetch';
const DEFAULTSEARCHRESULTSIZE = 10;
const DEFAULTSEARCHRESULTFROM = 0;
const DEBOUNCED_SEARCH_SUGGESTION_MS = 300; const DEBOUNCED_SEARCH_SUGGESTION_MS = 300;
type Dispatch = (action: any) => any; type Dispatch = (action: any) => any;
type GetState = () => {}; type GetState = () => { search: SearchState };
// We can't use env's because they aren't passed into node_modules // We can't use env's because they aren't passed into node_modules
let CONNECTION_STRING = 'https://lighthouse.lbry.io/'; let CONNECTION_STRING = 'https://lighthouse.lbry.io/';
export const setSearchApi = endpoint => { export const setSearchApi = (endpoint: string) => {
CONNECTION_STRING = endpoint.replace(/\/*$/, '/'); // exactly one slash at the end; CONNECTION_STRING = endpoint.replace(/\/*$/, '/'); // exactly one slash at the end;
}; };
export const doSearch = ( export const doSearch = (
rawQuery: string, rawQuery: string, // pass in a query if you don't want to search for what's in the search bar
size: number = DEFAULTSEARCHRESULTSIZE, size: ?number, // only pass in if you don't want to use the users setting (ex: related content)
from: number = DEFAULTSEARCHRESULTFROM, from: ?number,
isBackgroundSearch: boolean = false isBackgroundSearch: boolean = false
) => (dispatch: Dispatch, getState: GetState) => { ) => (dispatch: Dispatch, getState: GetState) => {
const state = getState();
const query = rawQuery.replace(/^lbry:\/\//i, '').replace(/\//, ' '); const query = rawQuery.replace(/^lbry:\/\//i, '').replace(/\//, ' ');
if (!query) { if (!query) {
@ -36,8 +39,11 @@ export const doSearch = (
return; return;
} }
const state = getState();
const queryWithOptions = makeSelectQueryWithOptions(query, size, from, isBackgroundSearch)(state);
// If we have already searched for something, we don't need to do anything // If we have already searched for something, we don't need to do anything
const urisForQuery = makeSelectSearchUris(query)(state); const urisForQuery = makeSelectSearchUris(queryWithOptions)(state);
if (urisForQuery && !!urisForQuery.length) { if (urisForQuery && !!urisForQuery.length) {
return; return;
} }
@ -57,8 +63,7 @@ export const doSearch = (
}); });
} }
const encodedQuery = encodeURIComponent(query); fetch(`${CONNECTION_STRING}search?${queryWithOptions}`)
fetch(`${CONNECTION_STRING}search?s=${encodedQuery}&size=${size}&from=${from}`)
.then(handleFetchResponse) .then(handleFetchResponse)
.then(data => { .then(data => {
const uris = []; const uris = [];
@ -76,7 +81,7 @@ export const doSearch = (
actions.push({ actions.push({
type: ACTIONS.SEARCH_SUCCESS, type: ACTIONS.SEARCH_SUCCESS,
data: { data: {
query, query: queryWithOptions,
uris, uris,
}, },
}); });
@ -149,3 +154,21 @@ export const doBlurSearchInput = () => (dispatch: Dispatch) =>
dispatch({ dispatch({
type: ACTIONS.SEARCH_BLUR, type: ACTIONS.SEARCH_BLUR,
}); });
export const doUpdateSearchOptions = (newOptions: SearchOptions) => (
dispatch: Dispatch,
getState: GetState
) => {
const state = getState();
const searchQuery = selectSearchQuery(state);
dispatch({
type: ACTIONS.UPDATE_SEARCH_OPTIONS,
data: newOptions,
});
if (searchQuery) {
// After updating, perform a search with the new options
dispatch(doSearch(searchQuery));
}
};

View file

@ -2,6 +2,7 @@
import type { import type {
NotificationState, NotificationState,
DoToast, DoToast,
DoError,
DoNotification, DoNotification,
DoEditNotification, DoEditNotification,
DoDeleteNotification, DoDeleteNotification,
@ -55,7 +56,7 @@ const notificationsReducer = handleActions(
let notifications = state.notifications.slice(); let notifications = state.notifications.slice();
notifications = notifications.map( notifications = notifications.map(
(pastNotification) => pastNotification =>
pastNotification.id === notification.id ? notification : pastNotification pastNotification.id === notification.id ? notification : pastNotification
); );
@ -67,7 +68,7 @@ const notificationsReducer = handleActions(
[ACTIONS.DELETE_NOTIFICATION]: (state: NotificationState, action: DoDeleteNotification) => { [ACTIONS.DELETE_NOTIFICATION]: (state: NotificationState, action: DoDeleteNotification) => {
const { id } = action.data; const { id } = action.data;
let newNotifications = state.notifications.slice(); let newNotifications = state.notifications.slice();
newNotifications = newNotifications.filter((notification) => notification.id !== id); newNotifications = newNotifications.filter(notification => notification.id !== id);
return { return {
...state, ...state,
@ -76,7 +77,7 @@ const notificationsReducer = handleActions(
}, },
// Errors // Errors
[ACTIONS.CREATE_ERROR]: (state: NotificationState, action: DoToast) => { [ACTIONS.CREATE_ERROR]: (state: NotificationState, action: DoError) => {
const error = action.data; const error = action.data;
const newErrors = state.errors.slice(); const newErrors = state.errors.slice();
newErrors.push(error); newErrors.push(error);

View file

@ -1,56 +1,29 @@
// @flow // @flow
import * as ACTIONS from 'constants/action_types'; 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';
type SearchSuccess = { import type {
type: ACTIONS.SEARCH_SUCCESS, SearchState,
data: { SearchSuccess,
query: string, UpdateSearchQuery,
uris: Array<string>, UpdateSearchSuggestions,
}, HistoryNavigate,
}; UpdateSearchOptions,
} from 'types/Search';
type UpdateSearchQuery = {
type: ACTIONS.UPDATE_SEARCH_QUERY,
data: {
query: string,
},
};
type SearchSuggestion = {
value: string,
shorthand: string,
type: string,
};
type UpdateSearchSuggestions = {
type: ACTIONS.UPDATE_SEARCH_SUGGESTIONS,
data: {
query: string,
suggestions: Array<SearchSuggestion>,
},
};
type SearchState = {
isActive: boolean,
searchQuery: string,
suggestions: Array<SearchSuggestion>,
urisByQuery: {},
};
type HistoryNavigate = {
type: ACTIONS.HISTORY_NAVIGATE,
data: {
url: string,
index?: number,
scrollY?: number,
},
};
const defaultState = { const defaultState = {
isActive: false, // does the user have any typed text in the search input isActive: false, // does the user have any typed text in the search input
focused: false, // is the search input focused focused: false, // is the search input focused
searchQuery: '', // needs to be an empty string for input focusing 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: {}, suggestions: {},
urisByQuery: {}, urisByQuery: {},
}; };
@ -123,6 +96,18 @@ export const searchReducer = handleActions(
...state, ...state,
focused: false, 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 defaultState
); );

View file

@ -17,7 +17,7 @@ type ActionResult = {
type WalletState = { type WalletState = {
balance: any, balance: any,
blocks: any, blocks: any,
latestBlock: number, latestBlock: ?number,
transactions: any, transactions: any,
fetchingTransactions: boolean, fetchingTransactions: boolean,
gettingNewAddress: boolean, gettingNewAddress: boolean,
@ -73,7 +73,7 @@ reducers[ACTIONS.FETCH_TRANSACTIONS_COMPLETED] = (state: WalletState, action) =>
const { transactions } = action.data; const { transactions } = action.data;
transactions.forEach((transaction) => { transactions.forEach(transaction => {
byId[transaction.txid] = transaction; byId[transaction.txid] = transaction;
}); });

View file

@ -3,6 +3,7 @@ import { makeSelectCurrentParam } from 'redux/selectors/navigation';
import { selectSearchUrisByQuery } from 'redux/selectors/search'; import { selectSearchUrisByQuery } from 'redux/selectors/search';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { isClaimNsfw } from 'util/claim'; import { isClaimNsfw } from 'util/claim';
import { getSearchQueryString } from 'util/query_params';
const selectState = state => state.claims || {}; const selectState = state => state.claims || {};
@ -297,7 +298,9 @@ export const makeSelectRecommendedContentForUri = uri =>
const { title } = claim.value.stream.metadata; const { title } = claim.value.stream.metadata;
let searchUris = searchUrisByQuery[title.replace(/\//, ' ')]; const searchQuery = getSearchQueryString(title.replace(/\//, ' '));
let searchUris = searchUrisByQuery[searchQuery];
if (searchUris) { if (searchUris) {
searchUris = searchUris.filter(searchUri => searchUri !== currentUri); searchUris = searchUris.filter(searchUri => searchUri !== currentUri);
recommendedContent = searchUris; recommendedContent = searchUris;

View file

@ -1,25 +1,48 @@
import * as SEARCH_TYPES from 'constants/search'; // @flow
import type { SearchState, SearchOptions, SearchSuggestion } from 'types/Search';
import { SEARCH_TYPES, SEARCH_OPTIONS } from 'constants/search';
import { getSearchQueryString } from 'util/query_params';
import { normalizeURI, parseURI } from 'lbryURI'; import { normalizeURI, parseURI } from 'lbryURI';
import { selectCurrentPage, selectCurrentParams } from 'redux/selectors/navigation'; import { selectCurrentPage, selectCurrentParams } from 'redux/selectors/navigation';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
export const selectState = state => state.search || {}; type State = { search: SearchState };
export const selectSearchValue = createSelector(selectState, state => state.searchQuery); export const selectState = (state: State): SearchState => state.search;
export const selectSuggestions = createSelector(selectState, state => state.suggestions); export const selectSearchValue: (state: State) => string = createSelector(
selectState,
export const selectSearchQuery = createSelector( state => state.searchQuery
selectCurrentPage,
selectCurrentParams,
(page, params) => (page === 'search' ? params && params.query : null)
); );
export const selectIsSearching = createSelector(selectState, state => state.searching); export const selectSearchOptions: (state: State) => SearchOptions = createSelector(
selectState,
state => state.options
);
export const selectSearchUrisByQuery = createSelector(selectState, state => state.urisByQuery); export const selectSuggestions: (
state: State
) => { [string]: Array<SearchSuggestion> } = createSelector(
selectState,
state => state.suggestions
);
export const makeSelectSearchUris = query => export const selectSearchQuery: (state: State) => ?string = createSelector(
selectCurrentPage,
selectCurrentParams,
(page: string, params: ?{ query: string }) => (page === 'search' ? params && params.query : null)
);
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 // replace statement below is kind of ugly, and repeated in doSearch action
createSelector( createSelector(
selectSearchUrisByQuery, selectSearchUrisByQuery,
@ -30,7 +53,7 @@ export const selectWunderBarAddress = createSelector(
selectCurrentPage, selectCurrentPage,
selectSearchQuery, selectSearchQuery,
selectCurrentParams, selectCurrentParams,
(page, query, params) => { (page: string, query: string, params: { uri: string }) => {
// only populate the wunderbar address if we are on the file/channel pages // only populate the wunderbar address if we are on the file/channel pages
// or show the search query // or show the search query
if (page === 'show') { if (page === 'show') {
@ -40,13 +63,12 @@ export const selectWunderBarAddress = createSelector(
} }
); );
export const selectSearchBarFocused = createSelector(selectState, state => state.focused); export const selectSearchBarFocused: boolean = createSelector(selectState, state => state.focused);
// export const selectSear
export const selectSearchSuggestions = createSelector( export const selectSearchSuggestions: Array<SearchSuggestion> = createSelector(
selectSearchValue, selectSearchValue,
selectSuggestions, selectSuggestions,
(query, suggestions) => { (query: string, suggestions: { [string]: Array<string> }) => {
if (!query) { if (!query) {
return []; return [];
} }
@ -117,3 +139,23 @@ export const selectSearchSuggestions = createSelector(
return searchSuggestions; 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
export const makeSelectQueryWithOptions = (
customQuery: ?string,
customSize: ?number,
customFrom: ?number,
isBackgroundSearch: boolean = false // If it's a background search, don't use the users settings
) =>
createSelector(selectSearchQuery, selectSearchOptions, (query, options) => {
const size = customSize || options[SEARCH_OPTIONS.RESULT_COUNT];
const queryString = getSearchQueryString(
customQuery || query,
{ ...options, size, from: customFrom },
!isBackgroundSearch
);
return queryString;
});

68
src/types/Search.js Normal file
View file

@ -0,0 +1,68 @@
// @flow
import * as ACTIONS from 'constants/action_types';
export type SearchSuggestion = {
value: string,
shorthand: string,
type: string,
};
export 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,
};
export type SearchState = {
isActive: boolean,
searchQuery: string,
options: SearchOptions,
suggestions: { [string]: Array<SearchSuggestion> },
urisByQuery: {},
};
export type SearchSuccess = {
type: ACTIONS.SEARCH_SUCCESS,
data: {
query: string,
uris: Array<string>,
},
};
export type UpdateSearchQuery = {
type: ACTIONS.UPDATE_SEARCH_QUERY,
data: {
query: string,
},
};
export type UpdateSearchSuggestions = {
type: ACTIONS.UPDATE_SEARCH_SUGGESTIONS,
data: {
query: string,
suggestions: Array<SearchSuggestion>,
},
};
export type HistoryNavigate = {
type: ACTIONS.HISTORY_NAVIGATE,
data: {
url: string,
index?: number,
scrollY?: number,
},
};
export type UpdateSearchOptions = {
type: ACTIONS.UPDATE_SEARCH_OPTIONS,
data: SearchOptions,
};

View file

@ -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 {}; if (queryString === '') return {};
const parts = queryString const parts = queryString
.split('?') .split('?')
@ -14,7 +20,7 @@ export function parseQueryParams(queryString) {
return params; return params;
} }
export function toQueryString(params) { export function toQueryString(params: { [string]: string | number }) {
if (!params) return ''; if (!params) return '';
const parts = []; const parts = [];
@ -26,3 +32,39 @@ export function toQueryString(params) {
return parts.join('&'); return parts.join('&');
} }
export const getSearchQueryString = (
query: string,
options: any = {},
includeUserOptions: boolean = false
) => {
const encodedQuery = encodeURIComponent(query);
const queryParams = [
`s=${encodedQuery}`,
`size=${options.size || DEFAULT_SEARCH_SIZE}`,
`from=${options.from || DEFAULT_SEARCH_RESULT_FROM}`,
];
if (includeUserOptions) {
queryParams.push(`claimType=${options[SEARCH_OPTIONS.CLAIM_TYPE]}`);
// If they are only searching for channels, strip out the media info
if (options[SEARCH_OPTIONS.CLAIM_TYPE] !== 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),
''
)}`
);
}
}
return queryParams.join('&');
};