diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx index 464cb7f7..98debc9f 100644 --- a/web/components/contract-search.tsx +++ b/web/components/contract-search.tsx @@ -1,5 +1,6 @@ /* eslint-disable react-hooks/exhaustive-deps */ -import algoliasearch from 'algoliasearch/lite' +import algoliasearch, { SearchIndex } from 'algoliasearch/lite' +import { SearchOptions } from '@algolia/client-search' import { Contract } from 'common/contract' import { User } from 'common/user' @@ -12,6 +13,7 @@ import { ContractHighlightOptions, ContractsGrid, } from './contract/contracts-grid' +import { ShowTime } from './contract/contract-details' import { Row } from './layout/row' import { useEffect, useRef, useMemo, useState } from 'react' import { unstable_batchedUpdates } from 'react-dom' @@ -20,7 +22,7 @@ import { useFollows } from 'web/hooks/use-follows' import { track, trackCallback } from 'web/lib/service/analytics' import ContractSearchFirestore from 'web/pages/contract-search-firestore' import { useMemberGroups } from 'web/hooks/use-group' -import { Group, NEW_USER_GROUP_SLUGS } from 'common/group' +import { NEW_USER_GROUP_SLUGS } from 'common/group' import { PillButton } from './buttons/pill-button' import { sortBy } from 'lodash' import { DEFAULT_CATEGORY_GROUPS } from 'common/categories' @@ -49,15 +51,25 @@ export const DEFAULT_SORT = 'score' type filter = 'personal' | 'open' | 'closed' | 'resolved' | 'all' +type SearchParameters = { + index: SearchIndex + query: string + numericFilters: SearchOptions['numericFilters'] + facetFilters: SearchOptions['facetFilters'] + showTime?: ShowTime +} + +type AdditionalFilter = { + creatorId?: string + tag?: string + excludeContractIds?: string[] + groupSlug?: string +} + export function ContractSearch(props: { user?: User | null querySortOptions?: { defaultFilter?: filter } & QuerySortOptions - additionalFilter?: { - creatorId?: string - tag?: string - excludeContractIds?: string[] - groupSlug?: string - } + additionalFilter?: AdditionalFilter highlightOptions?: ContractHighlightOptions onContractClick?: (contract: Contract) => void hideOrderSelector?: boolean @@ -80,6 +92,106 @@ export function ContractSearch(props: { headerClassName, } = props + const [numPages, setNumPages] = useState(1) + const [pages, setPages] = useState([]) + const [showTime, setShowTime] = useState() + + const searchParameters = useRef() + const requestId = useRef(0) + + const performQuery = async (freshQuery?: boolean) => { + if (searchParameters.current === undefined) { + return + } + const params = searchParameters.current + const id = ++requestId.current + const requestedPage = freshQuery ? 0 : pages.length + if (freshQuery || requestedPage < numPages) { + const results = await params.index.search(params.query, { + facetFilters: params.facetFilters, + numericFilters: params.numericFilters, + page: requestedPage, + hitsPerPage: 20, + }) + // if there's a more recent request, forget about this one + if (id === requestId.current) { + const newPage = results.hits as any as Contract[] + // this spooky looking function is the easiest way to get react to + // batch this and not do multiple renders. we can throw it out in react 18. + // see https://github.com/reactwg/react-18/discussions/21 + unstable_batchedUpdates(() => { + setShowTime(params.showTime) + setNumPages(results.nbPages) + if (freshQuery) { + setPages([newPage]) + } else { + setPages((pages) => [...pages, newPage]) + } + }) + } + } + } + + const contracts = pages + .flat() + .filter((c) => !additionalFilter?.excludeContractIds?.includes(c.id)) + + if (IS_PRIVATE_MANIFOLD || process.env.NEXT_PUBLIC_FIREBASE_EMULATE) { + return ( + + ) + } + + return ( + + { + searchParameters.current = params + performQuery(true) + }} + /> + + + ) +} + +function ContractSearchControls(props: { + className?: string + additionalFilter?: AdditionalFilter + hideOrderSelector?: boolean + onSearchParametersChanged: (params: SearchParameters) => void + querySortOptions?: { defaultFilter?: filter } & QuerySortOptions + user?: User | null +}) { + const { + className, + additionalFilter, + hideOrderSelector, + onSearchParametersChanged, + querySortOptions, + user, + } = props + + const { query, setQuery, sort, setSort } = + useQueryAndSortParams(querySortOptions) + + const follows = useFollows(user?.id) const memberGroups = (useMemberGroups(user?.id) ?? []).filter( (group) => !NEW_USER_GROUP_SLUGS.includes(group.slug) ) @@ -93,28 +205,15 @@ export function ContractSearch(props: { (group) => group.contractIds.length ).reverse() - const defaultPillGroups = DEFAULT_CATEGORY_GROUPS as Group[] - - const pillGroups = - memberPillGroups.length > 0 ? memberPillGroups : defaultPillGroups - - const follows = useFollows(user?.id) - - const { query, setQuery, sort, setSort } = - useQueryAndSortParams(querySortOptions) + const pillGroups: { name: string; slug: string }[] = + memberPillGroups.length > 0 ? memberPillGroups : DEFAULT_CATEGORY_GROUPS const [filter, setFilter] = useState( querySortOptions?.defaultFilter ?? 'open' ) - const pillsEnabled = !additionalFilter && !query const [pillFilter, setPillFilter] = useState(undefined) - const selectPill = (pill: string | undefined) => () => { - setPillFilter(pill) - track('select search category', { category: pill ?? 'all' }) - } - const additionalFilters = [ additionalFilter?.creatorId ? `creatorId:${additionalFilter.creatorId}` @@ -160,57 +259,11 @@ export function ContractSearch(props: { filter === 'closed' ? `closeTime <= ${Date.now()}` : '', ].filter((f) => f) - const indexName = `${indexPrefix}contracts-${sort}` - const index = useMemo(() => searchClient.initIndex(indexName), [indexName]) - const searchIndex = useMemo( - () => searchClient.initIndex(searchIndexName), - [searchIndexName] - ) - - const [numPages, setNumPages] = useState(1) - const [pages, setPages] = useState([]) - const requestId = useRef(0) - - const performQuery = async (freshQuery?: boolean) => { - const id = ++requestId.current - const requestedPage = freshQuery ? 0 : pages.length - if (freshQuery || requestedPage < numPages) { - const algoliaIndex = query ? searchIndex : index - const results = await algoliaIndex.search(query, { - facetFilters, - numericFilters, - page: requestedPage, - hitsPerPage: 20, - }) - // if there's a more recent request, forget about this one - if (id === requestId.current) { - const newPage = results.hits as any as Contract[] - // this spooky looking function is the easiest way to get react to - // batch this and not do two renders. we can throw it out in react 18. - // see https://github.com/reactwg/react-18/discussions/21 - unstable_batchedUpdates(() => { - setNumPages(results.nbPages) - if (freshQuery) { - setPages([newPage]) - } else { - setPages((pages) => [...pages, newPage]) - } - }) - } - } + const selectPill = (pill: string | undefined) => () => { + setPillFilter(pill) + track('select search category', { category: pill ?? 'all' }) } - useEffect(() => { - performQuery(true) - }, [query, index, searchIndex, filter, JSON.stringify(facetFilters)]) - - const contracts = pages - .flat() - .filter((c) => !additionalFilter?.excludeContractIds?.includes(c.id)) - - const showTime = - sort === 'close-date' || sort === 'resolve-date' ? sort : undefined - const updateQuery = (newQuery: string) => { setQuery(newQuery) } @@ -227,115 +280,103 @@ export function ContractSearch(props: { track('select search sort', { sort: newSort }) } - if (IS_PRIVATE_MANIFOLD || process.env.NEXT_PUBLIC_FIREBASE_EMULATE) { - return ( - - ) - } + const indexName = `${indexPrefix}contracts-${sort}` + const index = useMemo(() => searchClient.initIndex(indexName), [indexName]) + const searchIndex = useMemo( + () => searchClient.initIndex(searchIndexName), + [searchIndexName] + ) + + useEffect(() => { + onSearchParametersChanged({ + index: query ? searchIndex : index, + query: query, + numericFilters: numericFilters, + facetFilters: facetFilters, + showTime: + sort === 'close-date' || sort === 'resolve-date' ? sort : undefined, + }) + }, [query, index, searchIndex, filter, JSON.stringify(facetFilters)]) return ( - - - - updateQuery(e.target.value)} - onBlur={trackCallback('search', { query })} - placeholder={'Search'} - className="input input-bordered w-full" - /> - {!query && ( - - )} - {!hideOrderSelector && !query && ( - - )} - - - {pillsEnabled && ( - - - All - - - {user ? 'For you' : 'Featured'} - - - {user && ( - - Your bets - - )} - - {pillGroups.map(({ name, slug }) => { - return ( - - {name} - - ) - })} - - )} - - - {filter === 'personal' && - (follows ?? []).length === 0 && - memberGroupSlugs.length === 0 ? ( - <>You're not following anyone, nor in any of your own groups yet. - ) : ( - + + updateQuery(e.target.value)} + onBlur={trackCallback('search', { query })} + placeholder={'Search'} + className="input input-bordered w-full" /> + {!query && ( + + )} + {!hideOrderSelector && !query && ( + + )} + + + {!additionalFilter && !query && ( + + + All + + + {user ? 'For you' : 'Featured'} + + + {user && ( + + Your bets + + )} + + {pillGroups.map(({ name, slug }) => { + return ( + + {name} + + ) + })} + )} )