diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx index a76457c6..6a5bd0d3 100644 --- a/web/components/contract-search.tsx +++ b/web/components/contract-search.tsx @@ -1,15 +1,9 @@ /* eslint-disable react-hooks/exhaustive-deps */ import algoliasearch from 'algoliasearch/lite' import { SearchOptions } from '@algolia/client-search' - +import { useRouter } from 'next/router' import { Contract } from 'common/contract' import { User } from 'common/user' -import { - SORTS, - Sort, - useQuery, - useSort, -} from '../hooks/use-sort-and-query-params' import { ContractHighlightOptions, ContractsGrid, @@ -20,12 +14,12 @@ import { useEffect, useLayoutEffect, useRef, useMemo } from 'react' import { ENV, IS_PRIVATE_MANIFOLD } from 'common/envs/constants' import { useFollows } from 'web/hooks/use-follows' import { - getKey, - saveState, - loadState, + storageStore, + historyStore, + urlParamsStore, usePersistentState, } from 'web/hooks/use-persistent-state' -import { safeLocalStorage, safeSessionStorage } from 'web/lib/util/local' +import { safeSessionStorage } from 'web/lib/util/local' import { track, trackCallback } from 'web/lib/service/analytics' import ContractSearchFirestore from 'web/pages/contract-search-firestore' import { useMemberGroups } from 'web/hooks/use-group' @@ -44,6 +38,19 @@ const searchClient = algoliasearch( const indexPrefix = ENV === 'DEV' ? 'dev-' : '' const searchIndexName = ENV === 'DEV' ? 'dev-contracts' : 'contractsIndex' +const SORTS = [ + { label: 'Newest', value: 'newest' }, + { label: 'Trending', value: 'score' }, + { label: 'Most traded', value: 'most-traded' }, + { label: '24h volume', value: '24-hour-vol' }, + { label: 'Last updated', value: 'last-updated' }, + { label: 'Subsidy', value: 'liquidity' }, + { label: 'Close date', value: 'close-date' }, + { label: 'Resolve date', value: 'resolve-date' }, +] as const + +export type Sort = typeof SORTS[number]['value'] + type filter = 'personal' | 'open' | 'closed' | 'resolved' | 'all' type SearchParameters = { @@ -97,29 +104,25 @@ export function ContractSearch(props: { noControls, } = props - const store = safeSessionStorage() - const persistAs = (name: string) => { - return persistPrefix ? { prefix: persistPrefix, name, store } : undefined - } - const [state, setState] = usePersistentState( { numPages: 1, pages: [] as Contract[][], showTime: null as ShowTime | null, }, - persistAs('state') + { key: `${persistPrefix}-search`, store: historyStore() } ) - const searchParameters = useRef(null) + const searchParams = useRef(null) + const searchParamsStore = historyStore() const requestId = useRef(0) useLayoutEffect(() => { - if (persistPrefix && store) { - const parameters = loadState(getKey(persistPrefix, 'parameters'), store) - if (parameters !== undefined) { - console.log('Restoring search parameters: ', parameters) - searchParameters.current = parameters as SearchParameters + if (persistPrefix) { + const params = searchParamsStore.get(`${persistPrefix}-params`) + if (params !== undefined) { + console.log('Restoring search parameters: ', params) + searchParams.current = params } } }, []) @@ -131,11 +134,10 @@ export function ContractSearch(props: { const performQuery = async (freshQuery?: boolean) => { console.log('Performing query.') - if (searchParameters.current == null) { + if (searchParams.current == null) { return } - const { query, sort, openClosedFilter, facetFilters } = - searchParameters.current + const { query, sort, openClosedFilter, facetFilters } = searchParams.current const id = ++requestId.current const requestedPage = freshQuery ? 0 : state.pages.length if (freshQuery || requestedPage < state.numPages) { @@ -159,10 +161,8 @@ export function ContractSearch(props: { const newPage = results.hits as any as Contract[] const showTime = sort === 'close-date' || sort === 'resolve-date' ? sort : null - setState((curr) => { - const pages = freshQuery ? [newPage] : [...curr.pages, newPage] - return { numPages: results.nbPages, pages, showTime } - }) + const pages = freshQuery ? [newPage] : [...state.pages, newPage] + setState({ numPages: results.nbPages, pages, showTime }) if (freshQuery && isWholePage) window.scrollTo(0, 0) } } @@ -170,12 +170,12 @@ export function ContractSearch(props: { const onSearchParametersChanged = useRef( debounce((params) => { - if (!isEqual(searchParameters.current, params)) { - console.log('Old vs new:', searchParameters.current, params) - if (persistPrefix && store) { - saveState(getKey(persistPrefix, 'parameters'), params, store) + if (!isEqual(searchParams.current, params)) { + console.log('Old vs new:', searchParams.current, params) + if (persistPrefix) { + searchParamsStore.set(`${persistPrefix}-params`, params) } - searchParameters.current = params + searchParams.current = params performQuery(true) } }, 100) @@ -244,28 +244,21 @@ function ContractSearchControls(props: { noControls, } = props - const localStore = safeLocalStorage() - const sessionStore = safeSessionStorage() - const persistAs = (name: string, store?: Storage) => { - return persistPrefix ? { prefix: persistPrefix, name, store } : undefined - } - - const initialSort = defaultSort ?? 'score' - const [sort, setSort] = useSort(initialSort, { - useUrl: !!useQuerySortUrlParams, - persist: persistAs('sort', localStore), + const router = useRouter() + const [query, setQuery] = usePersistentState('', { + key: 'q', + store: urlParamsStore(router), }) - const [query, setQuery] = useQuery('', { - useUrl: !!useQuerySortUrlParams, - persist: persistAs('query', sessionStore), - }) - const [filter, setFilter] = usePersistentState( - defaultFilter ?? 'open', - persistAs('filter', sessionStore) - ) - const [pillFilter, setPillFilter] = usePersistentState( - null, - persistAs('pillFilter', sessionStore) + const [state, setState] = usePersistentState( + { + sort: defaultSort ?? 'score', + filter: defaultFilter ?? 'open', + pillFilter: null as string | null, + }, + { + key: `${persistPrefix}-params`, + store: storageStore(safeSessionStorage()), + } ) const follows = useFollows(user?.id) @@ -300,14 +293,16 @@ function ContractSearchControls(props: { ...additionalFilters, additionalFilter ? '' : 'visibility:public', - filter === 'open' ? 'isResolved:false' : '', - filter === 'closed' ? 'isResolved:false' : '', - filter === 'resolved' ? 'isResolved:true' : '', + state.filter === 'open' ? 'isResolved:false' : '', + state.filter === 'closed' ? 'isResolved:false' : '', + state.filter === 'resolved' ? 'isResolved:true' : '', - pillFilter && pillFilter !== 'personal' && pillFilter !== 'your-bets' - ? `groupLinks.slug:${pillFilter}` + state.pillFilter && + state.pillFilter !== 'personal' && + state.pillFilter !== 'your-bets' + ? `groupLinks.slug:${state.pillFilter}` : '', - pillFilter === 'personal' + state.pillFilter === 'personal' ? // Show contracts in groups that the user is a member of memberGroupSlugs .map((slug) => `groupLinks.slug:${slug}`) @@ -319,18 +314,24 @@ function ContractSearchControls(props: { ) : '', // Subtract contracts you bet on from For you. - pillFilter === 'personal' && user ? `uniqueBettorIds:-${user.id}` : '', - pillFilter === 'your-bets' && user + state.pillFilter === 'personal' && user + ? `uniqueBettorIds:-${user.id}` + : '', + state.pillFilter === 'your-bets' && user ? // Show contracts bet on by the user `uniqueBettorIds:${user.id}` : '', ].filter((f) => f) const openClosedFilter = - filter === 'open' ? 'open' : filter === 'closed' ? 'closed' : undefined + state.filter === 'open' + ? 'open' + : state.filter === 'closed' + ? 'closed' + : undefined const selectPill = (pill: string | null) => () => { - setPillFilter(pill) + setState({ ...state, pillFilter: pill }) track('select search category', { category: pill ?? 'all' }) } @@ -340,25 +341,25 @@ function ContractSearchControls(props: { } const selectFilter = (newFilter: filter) => { - if (newFilter === filter) return - setFilter(newFilter) + if (newFilter === state.filter) return + setState({ ...state, filter: newFilter }) track('select search filter', { filter: newFilter }) } const selectSort = (newSort: Sort) => { - if (newSort === sort) return - setSort(newSort) + if (newSort === state.sort) return + setState({ ...state, sort: newSort }) track('select search sort', { sort: newSort }) } useEffect(() => { onSearchParametersChanged({ query: query, - sort: sort, + sort: state.sort, openClosedFilter: openClosedFilter, facetFilters: facetFilters, }) - }, [query, sort, openClosedFilter, JSON.stringify(facetFilters)]) + }, [query, state.sort, openClosedFilter, JSON.stringify(facetFilters)]) if (noControls) { return <> @@ -373,14 +374,14 @@ function ContractSearchControls(props: { type="text" value={query} onChange={(e) => updateQuery(e.target.value)} - onBlur={trackCallback('search', { query })} + onBlur={trackCallback('search', { query: query })} placeholder={'Search'} className="input input-bordered w-full" /> {!query && ( selectSort(e.target.value as Sort)} > {SORTS.map((option) => ( @@ -408,14 +409,14 @@ function ContractSearchControls(props: { All {user ? 'For you' : 'Featured'} @@ -424,7 +425,7 @@ function ContractSearchControls(props: { {user && ( Your bets @@ -435,7 +436,7 @@ function ContractSearchControls(props: { return ( {name} diff --git a/web/hooks/use-persistent-state.ts b/web/hooks/use-persistent-state.ts index b34eb469..883a5750 100644 --- a/web/hooks/use-persistent-state.ts +++ b/web/hooks/use-persistent-state.ts @@ -1,88 +1,105 @@ -import { useLayoutEffect, useEffect, useState } from 'react' +import { useEffect } from 'react' import { useStateCheckEquality } from './use-state-check-equality' +import { NextRouter } from 'next/router' -export type PersistenceOptions = { - store?: Storage - prefix: string - name: string +export type PersistenceOptions = { key: string; store: PersistentStore } + +export interface PersistentStore { + get: (k: string) => T | undefined + set: (k: string, v: T | undefined) => void } -export const getKey = (prefix: string, name: string) => `${prefix}-${name}` - -export const saveState = (key: string, val: unknown, store: Storage) => { - if (val === undefined) { - store.removeItem(key) +const withURLParam = (location: Location, k: string, v?: string) => { + const newParams = new URLSearchParams(location.search) + if (!v) { + newParams.delete(k) } else { - store.setItem(key, JSON.stringify(val)) + newParams.set(k, v) } + const newUrl = new URL(location.href) + newUrl.search = newParams.toString() + return newUrl } -export const loadState = (key: string, store: Storage) => { - const saved = store.getItem(key) - if (typeof saved === 'string') { - try { - return JSON.parse(saved) as unknown - } catch (e) { - console.error(e) +export const storageStore = (storage?: Storage): PersistentStore => ({ + get: (k: string) => { + if (!storage) { + return undefined } - } else { - return undefined - } -} + const saved = storage.getItem(k) + if (typeof saved === 'string') { + try { + return JSON.parse(saved) as T + } catch (e) { + console.error(e) + } + } else { + return undefined + } + }, + set: (k: string, v: T | undefined) => { + if (storage) { + if (v === undefined) { + storage.removeItem(k) + } else { + storage.setItem(k, JSON.stringify(v)) + } + } + }, +}) -const STATE_KEY = '__manifold' +export const urlParamsStore = (router: NextRouter) => ({ + get: (k: string) => { + const v = router.query[k] + return typeof v === 'string' ? v : undefined + }, + set: (k: string, v: string | undefined) => { + if (typeof window !== 'undefined') { + // see relevant discussion here https://github.com/vercel/next.js/discussions/18072 + const url = withURLParam(window.location, k, v).toString() + const updatedState = { ...window.history.state, as: url, url } + window.history.replaceState(updatedState, '', url) + } + }, +}) -const getHistoryState = (k: string) => { - if (typeof window !== 'undefined') { - return window.history.state?.options?.[STATE_KEY]?.[k] as T | undefined - } else { - return undefined - } -} - -const setHistoryState = (k: string, v: any) => { - if (typeof window !== 'undefined') { - const state = window.history.state ?? {} - const options = state.options ?? {} - const inner = options[STATE_KEY] ?? {} - window.history.replaceState( - { ...state, options: { ...options, [STATE_KEY]: { ...inner, [k]: v } } }, - '' - ) - } -} - -export const useHistoryState = (key: string, initialValue: T) => { - const [state, setState] = useState(getHistoryState(key) ?? initialValue) - const setter = (val: T) => { - console.log('Setting state: ', val) - setHistoryState(key, val) - setState(val) - } - return [state, setter] as const -} +export const historyStore = (prefix = '__manifold'): PersistentStore => ({ + get: (k: string) => { + if (typeof window !== 'undefined') { + return window.history.state?.options?.[prefix]?.[k] as T | undefined + } else { + return undefined + } + }, + set: (k: string, v: T | undefined) => { + if (typeof window !== 'undefined') { + const state = window.history.state ?? {} + const options = state.options ?? {} + const inner = options[prefix] ?? {} + window.history.replaceState( + { + ...state, + options: { ...options, [prefix]: { ...inner, [k]: v } }, + }, + '' + ) + } + }, +}) export const usePersistentState = ( initial: T, - persist?: PersistenceOptions + persist?: PersistenceOptions ) => { const store = persist?.store - const key = persist ? getKey(persist.prefix, persist.name) : null - useLayoutEffect(() => { - if (key != null && store != null) { - const saved = loadState(key, store) as T - console.log('Loading state for: ', key, saved) - if (saved !== undefined) { - setState(saved) - } - } - }, []) - const [state, setState] = useStateCheckEquality(initial) + const key = persist?.key + const savedValue = key != null && store != null ? store.get(key) : undefined + const [state, setState] = useStateCheckEquality(savedValue ?? initial) useEffect(() => { if (key != null && store != null) { console.log('Saving state for: ', key, state) - saveState(key, state, store) + store.set(key, state) } - }, [key, state, store]) + }, [key, state]) return [state, setState] as const } diff --git a/web/hooks/use-sort-and-query-params.tsx b/web/hooks/use-sort-and-query-params.tsx deleted file mode 100644 index 09c4146a..00000000 --- a/web/hooks/use-sort-and-query-params.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import { useState } from 'react' -import { - usePersistentState, - PersistenceOptions, -} from 'web/hooks/use-persistent-state' -import { NextRouter, useRouter } from 'next/router' - -export const SORTS = [ - { label: 'Newest', value: 'newest' }, - { label: 'Trending', value: 'score' }, - { label: 'Most traded', value: 'most-traded' }, - { label: '24h volume', value: '24-hour-vol' }, - { label: 'Last updated', value: 'last-updated' }, - { label: 'Subsidy', value: 'liquidity' }, - { label: 'Close date', value: 'close-date' }, - { label: 'Resolve date', value: 'resolve-date' }, -] as const - -export type Sort = typeof SORTS[number]['value'] - -type UpdatedQueryParams = { [k: string]: string } -type QuerySortOpts = { useUrl: boolean; persist?: PersistenceOptions } - -function withURLParams(location: Location, params: UpdatedQueryParams) { - const newParams = new URLSearchParams(location.search) - for (const [k, v] of Object.entries(params)) { - if (!v) { - newParams.delete(k) - } else { - newParams.set(k, v) - } - } - const newUrl = new URL(location.href) - newUrl.search = newParams.toString() - return newUrl -} - -function updateURL(params: UpdatedQueryParams) { - // see relevant discussion here https://github.com/vercel/next.js/discussions/18072 - const url = withURLParams(window.location, params).toString() - const updatedState = { ...window.history.state, as: url, url } - window.history.replaceState(updatedState, '', url) -} - -function getStringURLParam(router: NextRouter, k: string) { - const v = router.query[k] - return typeof v === 'string' ? v : null -} - -export function useQuery(defaultQuery: string, opts?: QuerySortOpts) { - const useUrl = opts?.useUrl ?? false - const router = useRouter() - const initialQuery = useUrl ? getStringURLParam(router, 'q') : null - const [query, setQuery] = usePersistentState( - initialQuery ?? defaultQuery, - opts?.persist - ) - if (!useUrl) { - return [query, setQuery] as const - } else { - return [query, (q: string) => (setQuery(q), updateURL({ q }))] as const - } -} - -export function useSort(defaultSort: Sort, opts?: QuerySortOpts) { - const useUrl = opts?.useUrl ?? false - const router = useRouter() - const initialSort = useUrl ? (getStringURLParam(router, 's') as Sort) : null - const [sort, setSort] = usePersistentState( - initialSort ?? defaultSort, - opts?.persist - ) - if (!useUrl) { - return [sort, setSort] as const - } else { - return [sort, (s: Sort) => (setSort(s), updateURL({ s }))] as const - } -} diff --git a/web/pages/contract-search-firestore.tsx b/web/pages/contract-search-firestore.tsx index 1bd201d5..8f5d8fea 100644 --- a/web/pages/contract-search-firestore.tsx +++ b/web/pages/contract-search-firestore.tsx @@ -1,9 +1,13 @@ +import { useRouter } from 'next/router' import { Answer } from 'common/answer' import { searchInAny } from 'common/util/parse' import { sortBy } from 'lodash' import { ContractsGrid } from 'web/components/contract/contracts-grid' import { useContracts } from 'web/hooks/use-contracts' -import { Sort, useQuery, useSort } from 'web/hooks/use-sort-and-query-params' +import { + usePersistentState, + urlParamsStore, +} from 'web/hooks/use-persistent-state' const MAX_CONTRACTS_RENDERED = 100 @@ -17,8 +21,10 @@ export default function ContractSearchFirestore(props: { }) { const contracts = useContracts() const { additionalFilter } = props - const [query, setQuery] = useQuery('', { useUrl: true }) - const [sort, setSort] = useSort('score', { useUrl: true }) + const router = useRouter() + const store = urlParamsStore(router) + const [query, setQuery] = usePersistentState('', { key: 'q', store }) + const [sort, setSort] = usePersistentState('score', { key: 'sort', store }) let matches = (contracts ?? []).filter((c) => searchInAny( @@ -91,7 +97,7 @@ export default function ContractSearchFirestore(props: {