diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx index 34e1ff0d..1b02afcb 100644 --- a/web/components/contract-search.tsx +++ b/web/components/contract-search.tsx @@ -1,44 +1,42 @@ /* eslint-disable react-hooks/exhaustive-deps */ -import algoliasearch, { SearchIndex } from 'algoliasearch/lite' +import algoliasearch from 'algoliasearch/lite' import { SearchOptions } from '@algolia/client-search' import { Contract } from 'common/contract' import { User } from 'common/user' -import { Sort, useQuery, useSort } from '../hooks/use-sort-and-query-params' +import { + SORTS, + Sort, + useQuery, + useSort, +} from '../hooks/use-sort-and-query-params' 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 { useEffect, useLayoutEffect, useRef, useMemo } from 'react' import { unstable_batchedUpdates } from 'react-dom' import { ENV, IS_PRIVATE_MANIFOLD } from 'common/envs/constants' import { useFollows } from 'web/hooks/use-follows' +import { + getKey, + saveState, + loadState, + usePersistentState, +} from 'web/hooks/use-persistent-state' +import { safeLocalStorage, 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' import { NEW_USER_GROUP_SLUGS } from 'common/group' import { PillButton } from './buttons/pill-button' -import { debounce, sortBy } from 'lodash' +import { debounce, isEqual, sortBy } from 'lodash' import { DEFAULT_CATEGORY_GROUPS } from 'common/categories' import { Col } from './layout/col' -import { safeLocalStorage } from 'web/lib/util/local' import clsx from 'clsx' -// TODO: this obviously doesn't work with SSR, common sense would suggest -// that we should save things like this in cookies so the server has them - -const MARKETS_SORT = 'markets_sort' - -function setSavedSort(s: Sort) { - safeLocalStorage()?.setItem(MARKETS_SORT, s) -} - -function getSavedSort() { - return safeLocalStorage()?.getItem(MARKETS_SORT) as Sort | null | undefined -} - const searchClient = algoliasearch( 'GJQPAYENIF', '75c28fc084a80e1129d427d470cf41a3' @@ -47,22 +45,11 @@ const searchClient = algoliasearch( const indexPrefix = ENV === 'DEV' ? 'dev-' : '' const searchIndexName = ENV === 'DEV' ? 'dev-contracts' : 'contractsIndex' -const sortOptions = [ - { 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' }, -] - type filter = 'personal' | 'open' | 'closed' | 'resolved' | 'all' type SearchParameters = { - index: SearchIndex query: string + sort: Sort numericFilters: SearchOptions['numericFilters'] facetFilters: SearchOptions['facetFilters'] showTime?: ShowTime @@ -88,6 +75,7 @@ export function ContractSearch(props: { hideQuickBet?: boolean } headerClassName?: string + persistPrefix?: string useQuerySortLocalStorage?: boolean useQuerySortUrlParams?: boolean isWholePage?: boolean @@ -104,29 +92,76 @@ export function ContractSearch(props: { cardHideOptions, highlightOptions, headerClassName, - useQuerySortLocalStorage, + persistPrefix, useQuerySortUrlParams, isWholePage, maxItems, noControls, } = props - const [numPages, setNumPages] = useState(1) - const [pages, setPages] = useState([]) - const [showTime, setShowTime] = useState() + const store = safeSessionStorage() + const persistAs = (name: string) => { + return persistPrefix ? { prefix: persistPrefix, name, store } : undefined + } - const searchParameters = useRef() + const [numPages, setNumPages] = usePersistentState(1, persistAs('numPages')) + const [pages, setPages] = usePersistentState( + [], + persistAs('pages') + ) + const [showTime, setShowTime] = usePersistentState( + null, + persistAs('showTime') + ) + + const searchParameters = useRef(null) const requestId = useRef(0) + useLayoutEffect(() => { + if (persistPrefix && store) { + const parameters = loadState(getKey(persistPrefix, 'parameters'), store) + const scrollY = loadState(getKey(persistPrefix, 'scrollY'), store) + if (parameters !== undefined) { + console.log('Restoring search parameters: ', parameters) + searchParameters.current = parameters as SearchParameters + } + if (scrollY !== undefined) { + console.log('Restoring scroll position: ', scrollY) + window.scrollTo(0, scrollY as number) + } + } + }, []) + + useEffect(() => { + if (persistPrefix && store) { + const handleScroll = (e: Event) => { + const scrollY = (e.currentTarget as Window).scrollY + console.log('Saving scroll position: ', scrollY) + saveState(getKey(persistPrefix, 'scrollY'), scrollY, store) + } + window.addEventListener('scroll', handleScroll) + return () => window.removeEventListener('scroll', handleScroll) + } + }, []) + + const searchIndex = useMemo( + () => searchClient.initIndex(searchIndexName), + [searchIndexName] + ) + const performQuery = async (freshQuery?: boolean) => { - if (searchParameters.current === undefined) { + console.log('Performing query.') + if (searchParameters.current == null) { 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, { + const index = params.query + ? searchIndex + : searchClient.initIndex(`${indexPrefix}contracts-${params.sort}`) + const results = await index.search(params.query, { facetFilters: params.facetFilters, numericFilters: params.numericFilters, page: requestedPage, @@ -135,11 +170,16 @@ export function ContractSearch(props: { // if there's a more recent request, forget about this one if (id === requestId.current) { const newPage = results.hits as any as Contract[] + const showTime = + params.sort === 'close-date' || params.sort === 'resolve-date' + ? params.sort + : null + // 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) + setShowTime(showTime) setNumPages(results.nbPages) if (freshQuery) { setPages([newPage]) @@ -154,8 +194,14 @@ export function ContractSearch(props: { const onSearchParametersChanged = useRef( debounce((params) => { - searchParameters.current = params - performQuery(true) + if (!isEqual(searchParameters.current, params)) { + console.log('Old vs new:', searchParameters.current, params) + if (persistPrefix && store) { + saveState(getKey(persistPrefix, 'parameters'), params, store) + } + searchParameters.current = params + performQuery(true) + } }, 100) ).current @@ -177,7 +223,7 @@ export function ContractSearch(props: { defaultFilter={defaultFilter} additionalFilter={additionalFilter} hideOrderSelector={hideOrderSelector} - useQuerySortLocalStorage={useQuerySortLocalStorage} + persistPrefix={persistPrefix ? `${persistPrefix}-controls` : undefined} useQuerySortUrlParams={useQuerySortUrlParams} user={user} onSearchParametersChanged={onSearchParametersChanged} @@ -186,7 +232,7 @@ export function ContractSearch(props: { void - useQuerySortLocalStorage?: boolean + persistPrefix?: string useQuerySortUrlParams?: boolean user?: User | null noControls?: boolean @@ -214,25 +260,35 @@ function ContractSearchControls(props: { additionalFilter, hideOrderSelector, onSearchParametersChanged, - useQuerySortLocalStorage, + persistPrefix, useQuerySortUrlParams, user, noControls, } = props - const savedSort = useQuerySortLocalStorage ? getSavedSort() : null - const initialSort = savedSort ?? defaultSort ?? 'score' - const querySortOpts = { useUrl: !!useQuerySortUrlParams } - const [sort, setSort] = useSort(initialSort, querySortOpts) - const [query, setQuery] = useQuery('', querySortOpts) - const [filter, setFilter] = useState(defaultFilter ?? 'open') - const [pillFilter, setPillFilter] = useState(undefined) + const localStore = safeLocalStorage() + const sessionStore = safeSessionStorage() + const persistAs = (name: string, store?: Storage) => { + return persistPrefix ? { prefix: persistPrefix, name, store } : undefined + } - useEffect(() => { - if (useQuerySortLocalStorage) { - setSavedSort(sort) - } - }, [sort]) + const initialSort = defaultSort ?? 'score' + const [sort, setSort] = useSort(initialSort, { + useUrl: !!useQuerySortUrlParams, + persist: persistAs('sort', localStore), + }) + 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 follows = useFollows(user?.id) const memberGroups = (useMemberGroups(user?.id) ?? []).filter( @@ -299,7 +355,7 @@ function ContractSearchControls(props: { filter === 'closed' ? `closeTime <= ${Date.now()}` : '', ].filter((f) => f) - const selectPill = (pill: string | undefined) => () => { + const selectPill = (pill: string | null) => () => { setPillFilter(pill) track('select search category', { category: pill ?? 'all' }) } @@ -320,23 +376,14 @@ function ContractSearchControls(props: { track('select search sort', { sort: newSort }) } - 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, + sort: sort, numericFilters: numericFilters, facetFilters: facetFilters, - showTime: - sort === 'close-date' || sort === 'resolve-date' ? sort : undefined, }) - }, [query, index, searchIndex, filter, JSON.stringify(facetFilters)]) + }, [query, sort, filter, JSON.stringify(facetFilters)]) if (noControls) { return <> @@ -373,7 +420,7 @@ function ContractSearchControls(props: { value={sort} onChange={(e) => selectSort(e.target.value as Sort)} > - {sortOptions.map((option) => ( + {SORTS.map((option) => ( @@ -387,7 +434,7 @@ function ContractSearchControls(props: { All diff --git a/web/hooks/use-persistent-state.ts b/web/hooks/use-persistent-state.ts new file mode 100644 index 00000000..27cabf52 --- /dev/null +++ b/web/hooks/use-persistent-state.ts @@ -0,0 +1,54 @@ +import { useState, useEffect } from 'react' + +export type PersistenceOptions = { + store?: Storage + prefix: string + name: string +} + +export const getKey = (prefix: string, name: string) => `${prefix}-${name}` + +export const saveState = (key: string, val: unknown, store: Storage) => { + if (val === undefined) { + store.removeItem(key) + } else { + store.setItem(key, JSON.stringify(val)) + } +} + +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) + } + } else { + return undefined + } +} + +export const usePersistentState = ( + defaultValue: T, + persist?: PersistenceOptions +) => { + const store = persist?.store + const key = persist ? getKey(persist.prefix, persist.name) : null + let initialValue + if (key != null && store != null) { + const saved = loadState(key, store) as T + console.log('Loading state for: ', key, saved) + if (saved !== undefined) { + initialValue = saved + } + } + const [state, setState] = useState(initialValue ?? defaultValue) + useEffect(() => { + if (key != null && store != null) { + console.log('Saving state for: ', key, state) + saveState(key, state, store) + } + }, [key, state, store]) + 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 index 0a2834d0..09c4146a 100644 --- a/web/hooks/use-sort-and-query-params.tsx +++ b/web/hooks/use-sort-and-query-params.tsx @@ -1,18 +1,25 @@ import { useState } from 'react' +import { + usePersistentState, + PersistenceOptions, +} from 'web/hooks/use-persistent-state' import { NextRouter, useRouter } from 'next/router' -export type Sort = - | 'newest' - | 'oldest' - | 'most-traded' - | '24-hour-vol' - | 'close-date' - | 'resolve-date' - | 'last-updated' - | 'score' +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 } +type QuerySortOpts = { useUrl: boolean; persist?: PersistenceOptions } function withURLParams(location: Location, params: UpdatedQueryParams) { const newParams = new URLSearchParams(location.search) @@ -44,7 +51,10 @@ 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] = useState(initialQuery ?? defaultQuery) + const [query, setQuery] = usePersistentState( + initialQuery ?? defaultQuery, + opts?.persist + ) if (!useUrl) { return [query, setQuery] as const } else { @@ -56,7 +66,10 @@ 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] = useState(initialSort ?? defaultSort) + const [sort, setSort] = usePersistentState( + initialSort ?? defaultSort, + opts?.persist + ) if (!useUrl) { return [sort, setSort] as const } else { diff --git a/web/lib/util/local.ts b/web/lib/util/local.ts index 0778c0ac..d533e345 100644 --- a/web/lib/util/local.ts +++ b/web/lib/util/local.ts @@ -1,4 +1,7 @@ -export const safeLocalStorage = () => (isLocalStorage() ? localStorage : null) +export const safeLocalStorage = () => + isLocalStorage() ? localStorage : undefined +export const safeSessionStorage = () => + isSessionStorage() ? sessionStorage : undefined const isLocalStorage = () => { try { @@ -9,3 +12,13 @@ const isLocalStorage = () => { return false } } + +const isSessionStorage = () => { + try { + sessionStorage.getItem('test') + sessionStorage.setItem('hi', 'mom') + return true + } catch (e) { + return false + } +} diff --git a/web/pages/contract-search-firestore.tsx b/web/pages/contract-search-firestore.tsx index ec480269..1bd201d5 100644 --- a/web/pages/contract-search-firestore.tsx +++ b/web/pages/contract-search-firestore.tsx @@ -34,8 +34,6 @@ export default function ContractSearchFirestore(props: { matches.sort((a, b) => b.createdTime - a.createdTime) } else if (sort === 'resolve-date') { matches = sortBy(matches, (contract) => -1 * (contract.resolutionTime ?? 0)) - } else if (sort === 'oldest') { - matches.sort((a, b) => a.createdTime - b.createdTime) } else if (sort === 'close-date') { matches = sortBy(matches, ({ volume24Hours }) => -1 * volume24Hours) matches = sortBy(matches, (contract) => contract.closeTime ?? Infinity) diff --git a/web/pages/home.tsx b/web/pages/home.tsx index 5b6c445c..1dadc1f0 100644 --- a/web/pages/home.tsx +++ b/web/pages/home.tsx @@ -1,14 +1,10 @@ -import React, { useEffect, useState } from 'react' import { useRouter } from 'next/router' import { PencilAltIcon } from '@heroicons/react/solid' import { Page } from 'web/components/page' import { Col } from 'web/components/layout/col' import { ContractSearch } from 'web/components/contract-search' -import { Contract } from 'common/contract' import { User } from 'common/user' -import { ContractPageContent } from './[username]/[contractSlug]' -import { getContractFromSlug } from 'web/lib/firebase/contracts' import { getUserAndPrivateUser } from 'web/lib/firebase/users' import { useTracking } from 'web/hooks/use-tracking' import { track } from 'web/lib/service/analytics' @@ -25,8 +21,6 @@ export const getServerSideProps: GetServerSideProps = async (ctx) => { const Home = (props: { auth: { user: User } | null }) => { const user = props.auth ? props.auth.user : null - const [contract, setContract] = useContractPage() - const router = useRouter() useTracking('view home') @@ -35,19 +29,13 @@ const Home = (props: { auth: { user: User } | null }) => { return ( <> - + { - // Show contract without navigating to contract page. - setContract(c) - // Update the url without switching pages in Nextjs. - history.pushState(null, '', `/${c.creatorUsername}/${c.slug}`) - }} - isWholePage /> - - {contract && ( - { - history.back() - }} - recommendedContracts={[]} - /> - )} ) } -const useContractPage = () => { - const [contract, setContract] = useState() - - useEffect(() => { - const updateContract = () => { - const path = location.pathname.split('/').slice(1) - if (path[0] === 'home') setContract(undefined) - else { - const [username, contractSlug] = path - if (!username || !contractSlug) setContract(undefined) - else { - // Show contract if route is to a contract: '/[username]/[contractSlug]'. - getContractFromSlug(contractSlug).then((contract) => { - const path = location.pathname.split('/').slice(1) - const [_username, contractSlug] = path - // Make sure we're still on the same contract. - if (contract?.slug === contractSlug) setContract(contract) - }) - } - } - } - - addEventListener('popstate', updateContract) - - const { pushState, replaceState } = window.history - - window.history.pushState = function () { - // eslint-disable-next-line prefer-rest-params - const args = [...(arguments as any)] as any - // Discard NextJS router state. - args[0] = null - pushState.apply(history, args) - updateContract() - } - - window.history.replaceState = function () { - // eslint-disable-next-line prefer-rest-params - const args = [...(arguments as any)] as any - // Discard NextJS router state. - args[0] = null - replaceState.apply(history, args) - updateContract() - } - - return () => { - removeEventListener('popstate', updateContract) - window.history.pushState = pushState - window.history.replaceState = replaceState - } - }, []) - - useEffect(() => { - if (contract) window.scrollTo(0, 0) - }, [contract]) - - return [contract, setContract] as const -} - export default Home