WIP persistence work (#762)

* WIP persistence work

* Fix up close date filter, kill custom scroll restoration

* Use built-in Next.js scroll restoration machinery

* Tweaking stuff

* Implement 'history state' idea

* Clean up and unify persistent state stores

* Respect options for persisting contract search

* Fix typing in common lib

* Clean up console logging
This commit is contained in:
Marshall Polaris 2022-08-29 21:56:11 -07:00 committed by GitHub
parent 1d948821ca
commit 1369f3b967
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 280 additions and 326 deletions

View File

@ -1,6 +1,6 @@
import { union } from 'lodash' import { union } from 'lodash'
export const removeUndefinedProps = <T>(obj: T): T => { export const removeUndefinedProps = <T extends object>(obj: T): T => {
const newObj: any = {} const newObj: any = {}
for (const key of Object.keys(obj)) { for (const key of Object.keys(obj)) {
@ -37,4 +37,3 @@ export const subtractObjects = <T extends { [key: string]: number }>(
return newObj as T return newObj as T
} }

View File

@ -1,44 +1,35 @@
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
import algoliasearch, { SearchIndex } from 'algoliasearch/lite' import algoliasearch from 'algoliasearch/lite'
import { SearchOptions } from '@algolia/client-search' import { SearchOptions } from '@algolia/client-search'
import { useRouter } from 'next/router'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
import { User } from 'common/user' import { User } from 'common/user'
import { Sort, useQuery, useSort } from '../hooks/use-sort-and-query-params'
import { import {
ContractHighlightOptions, ContractHighlightOptions,
ContractsGrid, ContractsGrid,
} from './contract/contracts-grid' } from './contract/contracts-grid'
import { ShowTime } from './contract/contract-details' import { ShowTime } from './contract/contract-details'
import { Row } from './layout/row' 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 { ENV, IS_PRIVATE_MANIFOLD } from 'common/envs/constants'
import { useFollows } from 'web/hooks/use-follows' import { useFollows } from 'web/hooks/use-follows'
import {
storageStore,
historyStore,
urlParamStore,
usePersistentState,
} from 'web/hooks/use-persistent-state'
import { safeLocalStorage } from 'web/lib/util/local'
import { track, trackCallback } from 'web/lib/service/analytics' import { track, trackCallback } from 'web/lib/service/analytics'
import ContractSearchFirestore from 'web/pages/contract-search-firestore' import ContractSearchFirestore from 'web/pages/contract-search-firestore'
import { useMemberGroups } from 'web/hooks/use-group' import { useMemberGroups } from 'web/hooks/use-group'
import { NEW_USER_GROUP_SLUGS } from 'common/group' import { NEW_USER_GROUP_SLUGS } from 'common/group'
import { PillButton } from './buttons/pill-button' 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 { DEFAULT_CATEGORY_GROUPS } from 'common/categories'
import { Col } from './layout/col' import { Col } from './layout/col'
import { safeLocalStorage } from 'web/lib/util/local'
import clsx from 'clsx' 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( const searchClient = algoliasearch(
'GJQPAYENIF', 'GJQPAYENIF',
'75c28fc084a80e1129d427d470cf41a3' '75c28fc084a80e1129d427d470cf41a3'
@ -47,7 +38,7 @@ const searchClient = algoliasearch(
const indexPrefix = ENV === 'DEV' ? 'dev-' : '' const indexPrefix = ENV === 'DEV' ? 'dev-' : ''
const searchIndexName = ENV === 'DEV' ? 'dev-contracts' : 'contractsIndex' const searchIndexName = ENV === 'DEV' ? 'dev-contracts' : 'contractsIndex'
const sortOptions = [ const SORTS = [
{ label: 'Newest', value: 'newest' }, { label: 'Newest', value: 'newest' },
{ label: 'Trending', value: 'score' }, { label: 'Trending', value: 'score' },
{ label: 'Most traded', value: 'most-traded' }, { label: 'Most traded', value: 'most-traded' },
@ -56,16 +47,17 @@ const sortOptions = [
{ label: 'Subsidy', value: 'liquidity' }, { label: 'Subsidy', value: 'liquidity' },
{ label: 'Close date', value: 'close-date' }, { label: 'Close date', value: 'close-date' },
{ label: 'Resolve date', value: 'resolve-date' }, { label: 'Resolve date', value: 'resolve-date' },
] ] as const
export type Sort = typeof SORTS[number]['value']
type filter = 'personal' | 'open' | 'closed' | 'resolved' | 'all' type filter = 'personal' | 'open' | 'closed' | 'resolved' | 'all'
type SearchParameters = { type SearchParameters = {
index: SearchIndex
query: string query: string
numericFilters: SearchOptions['numericFilters'] sort: Sort
openClosedFilter: 'open' | 'closed' | undefined
facetFilters: SearchOptions['facetFilters'] facetFilters: SearchOptions['facetFilters']
showTime?: ShowTime
} }
type AdditionalFilter = { type AdditionalFilter = {
@ -88,8 +80,8 @@ export function ContractSearch(props: {
hideQuickBet?: boolean hideQuickBet?: boolean
} }
headerClassName?: string headerClassName?: string
useQuerySortLocalStorage?: boolean persistPrefix?: string
useQuerySortUrlParams?: boolean useQueryUrlParam?: boolean
isWholePage?: boolean isWholePage?: boolean
maxItems?: number maxItems?: number
noControls?: boolean noControls?: boolean
@ -104,66 +96,94 @@ export function ContractSearch(props: {
cardHideOptions, cardHideOptions,
highlightOptions, highlightOptions,
headerClassName, headerClassName,
useQuerySortLocalStorage, persistPrefix,
useQuerySortUrlParams, useQueryUrlParam,
isWholePage, isWholePage,
maxItems, maxItems,
noControls, noControls,
} = props } = props
const [numPages, setNumPages] = useState(1) const [state, setState] = usePersistentState(
const [pages, setPages] = useState<Contract[][]>([]) {
const [showTime, setShowTime] = useState<ShowTime | undefined>() numPages: 1,
pages: [] as Contract[][],
showTime: null as ShowTime | null,
},
!persistPrefix
? undefined
: { key: `${persistPrefix}-search`, store: historyStore() }
)
const searchParameters = useRef<SearchParameters | undefined>() const searchParams = useRef<SearchParameters | null>(null)
const searchParamsStore = historyStore<SearchParameters>()
const requestId = useRef(0) const requestId = useRef(0)
useLayoutEffect(() => {
if (persistPrefix) {
const params = searchParamsStore.get(`${persistPrefix}-params`)
if (params !== undefined) {
searchParams.current = params
}
}
}, [])
const searchIndex = useMemo(
() => searchClient.initIndex(searchIndexName),
[searchIndexName]
)
const performQuery = async (freshQuery?: boolean) => { const performQuery = async (freshQuery?: boolean) => {
if (searchParameters.current === undefined) { if (searchParams.current == null) {
return return
} }
const params = searchParameters.current const { query, sort, openClosedFilter, facetFilters } = searchParams.current
const id = ++requestId.current const id = ++requestId.current
const requestedPage = freshQuery ? 0 : pages.length const requestedPage = freshQuery ? 0 : state.pages.length
if (freshQuery || requestedPage < numPages) { if (freshQuery || requestedPage < state.numPages) {
const results = await params.index.search(params.query, { const index = query
facetFilters: params.facetFilters, ? searchIndex
numericFilters: params.numericFilters, : searchClient.initIndex(`${indexPrefix}contracts-${sort}`)
const numericFilters = query
? []
: [
openClosedFilter === 'open' ? `closeTime > ${Date.now()}` : '',
openClosedFilter === 'closed' ? `closeTime <= ${Date.now()}` : '',
].filter((f) => f)
const results = await index.search(query, {
facetFilters,
numericFilters,
page: requestedPage, page: requestedPage,
hitsPerPage: 20, hitsPerPage: 20,
}) })
// if there's a more recent request, forget about this one // if there's a more recent request, forget about this one
if (id === requestId.current) { if (id === requestId.current) {
const newPage = results.hits as any as Contract[] const newPage = results.hits as any as Contract[]
// this spooky looking function is the easiest way to get react to const showTime =
// batch this and not do multiple renders. we can throw it out in react 18. sort === 'close-date' || sort === 'resolve-date' ? sort : null
// see https://github.com/reactwg/react-18/discussions/21 const pages = freshQuery ? [newPage] : [...state.pages, newPage]
unstable_batchedUpdates(() => { setState({ numPages: results.nbPages, pages, showTime })
setShowTime(params.showTime) if (freshQuery && isWholePage) window.scrollTo(0, 0)
setNumPages(results.nbPages)
if (freshQuery) {
setPages([newPage])
if (isWholePage) window.scrollTo(0, 0)
} else {
setPages((pages) => [...pages, newPage])
}
})
} }
} }
} }
const onSearchParametersChanged = useRef( const onSearchParametersChanged = useRef(
debounce((params) => { debounce((params) => {
searchParameters.current = params if (!isEqual(searchParams.current, params)) {
performQuery(true) if (persistPrefix) {
searchParamsStore.set(`${persistPrefix}-params`, params)
}
searchParams.current = params
performQuery(true)
}
}, 100) }, 100)
).current ).current
const contracts = pages const contracts = state.pages
.flat() .flat()
.filter((c) => !additionalFilter?.excludeContractIds?.includes(c.id)) .filter((c) => !additionalFilter?.excludeContractIds?.includes(c.id))
const renderedContracts = const renderedContracts =
pages.length === 0 ? undefined : contracts.slice(0, maxItems) state.pages.length === 0 ? undefined : contracts.slice(0, maxItems)
if (IS_PRIVATE_MANIFOLD || process.env.NEXT_PUBLIC_FIREBASE_EMULATE) { if (IS_PRIVATE_MANIFOLD || process.env.NEXT_PUBLIC_FIREBASE_EMULATE) {
return <ContractSearchFirestore additionalFilter={additionalFilter} /> return <ContractSearchFirestore additionalFilter={additionalFilter} />
@ -177,8 +197,8 @@ export function ContractSearch(props: {
defaultFilter={defaultFilter} defaultFilter={defaultFilter}
additionalFilter={additionalFilter} additionalFilter={additionalFilter}
hideOrderSelector={hideOrderSelector} hideOrderSelector={hideOrderSelector}
useQuerySortLocalStorage={useQuerySortLocalStorage} persistPrefix={persistPrefix ? `${persistPrefix}-controls` : undefined}
useQuerySortUrlParams={useQuerySortUrlParams} useQueryUrlParam={useQueryUrlParam}
user={user} user={user}
onSearchParametersChanged={onSearchParametersChanged} onSearchParametersChanged={onSearchParametersChanged}
noControls={noControls} noControls={noControls}
@ -186,7 +206,7 @@ export function ContractSearch(props: {
<ContractsGrid <ContractsGrid
contracts={renderedContracts} contracts={renderedContracts}
loadMore={noControls ? undefined : performQuery} loadMore={noControls ? undefined : performQuery}
showTime={showTime} showTime={state.showTime ?? undefined}
onContractClick={onContractClick} onContractClick={onContractClick}
highlightOptions={highlightOptions} highlightOptions={highlightOptions}
cardHideOptions={cardHideOptions} cardHideOptions={cardHideOptions}
@ -202,8 +222,8 @@ function ContractSearchControls(props: {
additionalFilter?: AdditionalFilter additionalFilter?: AdditionalFilter
hideOrderSelector?: boolean hideOrderSelector?: boolean
onSearchParametersChanged: (params: SearchParameters) => void onSearchParametersChanged: (params: SearchParameters) => void
useQuerySortLocalStorage?: boolean persistPrefix?: string
useQuerySortUrlParams?: boolean useQueryUrlParam?: boolean
user?: User | null user?: User | null
noControls?: boolean noControls?: boolean
}) { }) {
@ -214,25 +234,36 @@ function ContractSearchControls(props: {
additionalFilter, additionalFilter,
hideOrderSelector, hideOrderSelector,
onSearchParametersChanged, onSearchParametersChanged,
useQuerySortLocalStorage, persistPrefix,
useQuerySortUrlParams, useQueryUrlParam,
user, user,
noControls, noControls,
} = props } = props
const savedSort = useQuerySortLocalStorage ? getSavedSort() : null const router = useRouter()
const initialSort = savedSort ?? defaultSort ?? 'score' const [query, setQuery] = usePersistentState(
const querySortOpts = { useUrl: !!useQuerySortUrlParams } '',
const [sort, setSort] = useSort(initialSort, querySortOpts) !useQueryUrlParam
const [query, setQuery] = useQuery('', querySortOpts) ? undefined
const [filter, setFilter] = useState<filter>(defaultFilter ?? 'open') : {
const [pillFilter, setPillFilter] = useState<string | undefined>(undefined) key: 'q',
store: urlParamStore(router),
}
)
useEffect(() => { const [state, setState] = usePersistentState(
if (useQuerySortLocalStorage) { {
setSavedSort(sort) sort: defaultSort ?? 'score',
} filter: defaultFilter ?? 'open',
}, [sort]) pillFilter: null as string | null,
},
!persistPrefix
? undefined
: {
key: `${persistPrefix}-params`,
store: storageStore(safeLocalStorage()),
}
)
const follows = useFollows(user?.id) const follows = useFollows(user?.id)
const memberGroups = (useMemberGroups(user?.id) ?? []).filter( const memberGroups = (useMemberGroups(user?.id) ?? []).filter(
@ -266,14 +297,16 @@ function ContractSearchControls(props: {
...additionalFilters, ...additionalFilters,
additionalFilter ? '' : 'visibility:public', additionalFilter ? '' : 'visibility:public',
filter === 'open' ? 'isResolved:false' : '', state.filter === 'open' ? 'isResolved:false' : '',
filter === 'closed' ? 'isResolved:false' : '', state.filter === 'closed' ? 'isResolved:false' : '',
filter === 'resolved' ? 'isResolved:true' : '', state.filter === 'resolved' ? 'isResolved:true' : '',
pillFilter && pillFilter !== 'personal' && pillFilter !== 'your-bets' state.pillFilter &&
? `groupLinks.slug:${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 ? // Show contracts in groups that the user is a member of
memberGroupSlugs memberGroupSlugs
.map((slug) => `groupLinks.slug:${slug}`) .map((slug) => `groupLinks.slug:${slug}`)
@ -285,22 +318,24 @@ function ContractSearchControls(props: {
) )
: '', : '',
// Subtract contracts you bet on from For you. // Subtract contracts you bet on from For you.
pillFilter === 'personal' && user ? `uniqueBettorIds:-${user.id}` : '', state.pillFilter === 'personal' && user
pillFilter === 'your-bets' && user ? `uniqueBettorIds:-${user.id}`
: '',
state.pillFilter === 'your-bets' && user
? // Show contracts bet on by the user ? // Show contracts bet on by the user
`uniqueBettorIds:${user.id}` `uniqueBettorIds:${user.id}`
: '', : '',
].filter((f) => f) ].filter((f) => f)
const numericFilters = query const openClosedFilter =
? [] state.filter === 'open'
: [ ? 'open'
filter === 'open' ? `closeTime > ${Date.now()}` : '', : state.filter === 'closed'
filter === 'closed' ? `closeTime <= ${Date.now()}` : '', ? 'closed'
].filter((f) => f) : undefined
const selectPill = (pill: string | undefined) => () => { const selectPill = (pill: string | null) => () => {
setPillFilter(pill) setState({ ...state, pillFilter: pill })
track('select search category', { category: pill ?? 'all' }) track('select search category', { category: pill ?? 'all' })
} }
@ -309,34 +344,25 @@ function ContractSearchControls(props: {
} }
const selectFilter = (newFilter: filter) => { const selectFilter = (newFilter: filter) => {
if (newFilter === filter) return if (newFilter === state.filter) return
setFilter(newFilter) setState({ ...state, filter: newFilter })
track('select search filter', { filter: newFilter }) track('select search filter', { filter: newFilter })
} }
const selectSort = (newSort: Sort) => { const selectSort = (newSort: Sort) => {
if (newSort === sort) return if (newSort === state.sort) return
setSort(newSort) setState({ ...state, sort: newSort })
track('select search sort', { sort: newSort }) 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(() => { useEffect(() => {
onSearchParametersChanged({ onSearchParametersChanged({
index: query ? searchIndex : index,
query: query, query: query,
numericFilters: numericFilters, sort: state.sort,
openClosedFilter: openClosedFilter,
facetFilters: facetFilters, facetFilters: facetFilters,
showTime:
sort === 'close-date' || sort === 'resolve-date' ? sort : undefined,
}) })
}, [query, index, searchIndex, filter, JSON.stringify(facetFilters)]) }, [query, state.sort, openClosedFilter, JSON.stringify(facetFilters)])
if (noControls) { if (noControls) {
return <></> return <></>
@ -351,14 +377,14 @@ function ContractSearchControls(props: {
type="text" type="text"
value={query} value={query}
onChange={(e) => updateQuery(e.target.value)} onChange={(e) => updateQuery(e.target.value)}
onBlur={trackCallback('search', { query })} onBlur={trackCallback('search', { query: query })}
placeholder={'Search'} placeholder={'Search'}
className="input input-bordered w-full" className="input input-bordered w-full"
/> />
{!query && ( {!query && (
<select <select
className="select select-bordered" className="select select-bordered"
value={filter} value={state.filter}
onChange={(e) => selectFilter(e.target.value as filter)} onChange={(e) => selectFilter(e.target.value as filter)}
> >
<option value="open">Open</option> <option value="open">Open</option>
@ -370,10 +396,10 @@ function ContractSearchControls(props: {
{!hideOrderSelector && !query && ( {!hideOrderSelector && !query && (
<select <select
className="select select-bordered" className="select select-bordered"
value={sort} value={state.sort}
onChange={(e) => selectSort(e.target.value as Sort)} onChange={(e) => selectSort(e.target.value as Sort)}
> >
{sortOptions.map((option) => ( {SORTS.map((option) => (
<option key={option.value} value={option.value}> <option key={option.value} value={option.value}>
{option.label} {option.label}
</option> </option>
@ -386,14 +412,14 @@ function ContractSearchControls(props: {
<Row className="scrollbar-hide items-start gap-2 overflow-x-auto"> <Row className="scrollbar-hide items-start gap-2 overflow-x-auto">
<PillButton <PillButton
key={'all'} key={'all'}
selected={pillFilter === undefined} selected={state.pillFilter === undefined}
onSelect={selectPill(undefined)} onSelect={selectPill(null)}
> >
All All
</PillButton> </PillButton>
<PillButton <PillButton
key={'personal'} key={'personal'}
selected={pillFilter === 'personal'} selected={state.pillFilter === 'personal'}
onSelect={selectPill('personal')} onSelect={selectPill('personal')}
> >
{user ? 'For you' : 'Featured'} {user ? 'For you' : 'Featured'}
@ -402,7 +428,7 @@ function ContractSearchControls(props: {
{user && ( {user && (
<PillButton <PillButton
key={'your-bets'} key={'your-bets'}
selected={pillFilter === 'your-bets'} selected={state.pillFilter === 'your-bets'}
onSelect={selectPill('your-bets')} onSelect={selectPill('your-bets')}
> >
Your bets Your bets
@ -413,7 +439,7 @@ function ContractSearchControls(props: {
return ( return (
<PillButton <PillButton
key={slug} key={slug}
selected={pillFilter === slug} selected={state.pillFilter === slug}
onSelect={selectPill(slug)} onSelect={selectPill(slug)}
> >
{name} {name}

View File

@ -0,0 +1,106 @@
import { useEffect } from 'react'
import { useStateCheckEquality } from './use-state-check-equality'
import { NextRouter } from 'next/router'
export type PersistenceOptions<T> = { key: string; store: PersistentStore<T> }
export interface PersistentStore<T> {
get: (k: string) => T | undefined
set: (k: string, v: T | undefined) => void
}
const withURLParam = (location: Location, k: string, v?: string) => {
const newParams = new URLSearchParams(location.search)
if (!v) {
newParams.delete(k)
} else {
newParams.set(k, v)
}
const newUrl = new URL(location.href)
newUrl.search = newParams.toString()
return newUrl
}
export const storageStore = <T>(storage?: Storage): PersistentStore<T> => ({
get: (k: string) => {
if (!storage) {
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))
}
}
},
})
export const urlParamStore = (router: NextRouter): PersistentStore<string> => ({
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)
}
},
})
export const historyStore = <T>(prefix = '__manifold'): PersistentStore<T> => ({
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 = <T>(
initial: T,
persist?: PersistenceOptions<T>
) => {
const store = persist?.store
const key = persist?.key
// note that it's important in some cases to get the state correct during the
// first render, or scroll restoration won't take into account the saved state
const savedValue = key != null && store != null ? store.get(key) : undefined
const [state, setState] = useStateCheckEquality(savedValue ?? initial)
useEffect(() => {
if (key != null && store != null) {
store.set(key, state)
}
}, [key, state])
return [state, setState] as const
}

View File

@ -1,41 +0,0 @@
import { useRouter } from 'next/router'
import { useEffect, useRef } from 'react'
// From: https://jak-ch-ll.medium.com/next-js-preserve-scroll-history-334cf699802a
export const usePreserveScroll = () => {
const router = useRouter()
const scrollPositions = useRef<{ [url: string]: number }>({})
const isBack = useRef(false)
useEffect(() => {
router.beforePopState(() => {
isBack.current = true
return true
})
const onRouteChangeStart = () => {
const url = router.pathname
scrollPositions.current[url] = window.scrollY
}
const onRouteChangeComplete = (url: any) => {
if (isBack.current && scrollPositions.current[url]) {
window.scroll({
top: scrollPositions.current[url],
behavior: 'auto',
})
}
isBack.current = false
}
router.events.on('routeChangeStart', onRouteChangeStart)
router.events.on('routeChangeComplete', onRouteChangeComplete)
return () => {
router.events.off('routeChangeStart', onRouteChangeStart)
router.events.off('routeChangeComplete', onRouteChangeComplete)
}
}, [router])
}

View File

@ -1,65 +0,0 @@
import { useState } from 'react'
import { NextRouter, useRouter } from 'next/router'
export type Sort =
| 'newest'
| 'oldest'
| 'most-traded'
| '24-hour-vol'
| 'close-date'
| 'resolve-date'
| 'last-updated'
| 'score'
type UpdatedQueryParams = { [k: string]: string }
type QuerySortOpts = { useUrl: boolean }
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] = useState(initialQuery ?? defaultQuery)
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] = useState(initialSort ?? defaultSort)
if (!useUrl) {
return [sort, setSort] as const
} else {
return [sort, (s: Sort) => (setSort(s), updateURL({ s }))] as const
}
}

View File

@ -1,4 +1,7 @@
export const safeLocalStorage = () => (isLocalStorage() ? localStorage : null) export const safeLocalStorage = () =>
isLocalStorage() ? localStorage : undefined
export const safeSessionStorage = () =>
isSessionStorage() ? sessionStorage : undefined
const isLocalStorage = () => { const isLocalStorage = () => {
try { try {
@ -9,3 +12,13 @@ const isLocalStorage = () => {
return false return false
} }
} }
const isSessionStorage = () => {
try {
sessionStorage.getItem('test')
sessionStorage.setItem('hi', 'mom')
return true
} catch (e) {
return false
}
}

View File

@ -8,6 +8,7 @@ module.exports = {
reactStrictMode: true, reactStrictMode: true,
optimizeFonts: false, optimizeFonts: false,
experimental: { experimental: {
scrollRestoration: true,
externalDir: true, externalDir: true,
modularizeImports: { modularizeImports: {
'@heroicons/react/solid/?(((\\w*)?/?)*)': { '@heroicons/react/solid/?(((\\w*)?/?)*)': {

View File

@ -3,7 +3,6 @@ import type { AppProps } from 'next/app'
import { useEffect } from 'react' import { useEffect } from 'react'
import Head from 'next/head' import Head from 'next/head'
import Script from 'next/script' import Script from 'next/script'
import { usePreserveScroll } from 'web/hooks/use-preserve-scroll'
import { QueryClient, QueryClientProvider } from 'react-query' import { QueryClient, QueryClientProvider } from 'react-query'
import { AuthProvider } from 'web/components/auth-context' import { AuthProvider } from 'web/components/auth-context'
import Welcome from 'web/components/onboarding/welcome' import Welcome from 'web/components/onboarding/welcome'
@ -26,8 +25,6 @@ function printBuildInfo() {
} }
function MyApp({ Component, pageProps }: AppProps) { function MyApp({ Component, pageProps }: AppProps) {
usePreserveScroll()
useEffect(printBuildInfo, []) useEffect(printBuildInfo, [])
return ( return (

View File

@ -1,9 +1,13 @@
import { useRouter } from 'next/router'
import { Answer } from 'common/answer' import { Answer } from 'common/answer'
import { searchInAny } from 'common/util/parse' import { searchInAny } from 'common/util/parse'
import { sortBy } from 'lodash' import { sortBy } from 'lodash'
import { ContractsGrid } from 'web/components/contract/contracts-grid' import { ContractsGrid } from 'web/components/contract/contracts-grid'
import { useContracts } from 'web/hooks/use-contracts' import { useContracts } from 'web/hooks/use-contracts'
import { Sort, useQuery, useSort } from 'web/hooks/use-sort-and-query-params' import {
usePersistentState,
urlParamStore,
} from 'web/hooks/use-persistent-state'
const MAX_CONTRACTS_RENDERED = 100 const MAX_CONTRACTS_RENDERED = 100
@ -15,10 +19,12 @@ export default function ContractSearchFirestore(props: {
groupSlug?: string groupSlug?: string
} }
}) { }) {
const contracts = useContracts()
const { additionalFilter } = props const { additionalFilter } = props
const [query, setQuery] = useQuery('', { useUrl: true }) const contracts = useContracts()
const [sort, setSort] = useSort('score', { useUrl: true }) const router = useRouter()
const store = urlParamStore(router)
const [query, setQuery] = usePersistentState('', { key: 'q', store })
const [sort, setSort] = usePersistentState('score', { key: 'sort', store })
let matches = (contracts ?? []).filter((c) => let matches = (contracts ?? []).filter((c) =>
searchInAny( searchInAny(
@ -34,8 +40,6 @@ export default function ContractSearchFirestore(props: {
matches.sort((a, b) => b.createdTime - a.createdTime) matches.sort((a, b) => b.createdTime - a.createdTime)
} else if (sort === 'resolve-date') { } else if (sort === 'resolve-date') {
matches = sortBy(matches, (contract) => -1 * (contract.resolutionTime ?? 0)) 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') { } else if (sort === 'close-date') {
matches = sortBy(matches, ({ volume24Hours }) => -1 * volume24Hours) matches = sortBy(matches, ({ volume24Hours }) => -1 * volume24Hours)
matches = sortBy(matches, (contract) => contract.closeTime ?? Infinity) matches = sortBy(matches, (contract) => contract.closeTime ?? Infinity)
@ -93,7 +97,7 @@ export default function ContractSearchFirestore(props: {
<select <select
className="select select-bordered" className="select select-bordered"
value={sort} value={sort}
onChange={(e) => setSort(e.target.value as Sort)} onChange={(e) => setSort(e.target.value)}
> >
<option value="score">Trending</option> <option value="score">Trending</option>
<option value="newest">Newest</option> <option value="newest">Newest</option>

View File

@ -12,7 +12,7 @@ import { track } from 'web/lib/service/analytics'
import { authenticateOnServer } from 'web/lib/firebase/server-auth' import { authenticateOnServer } from 'web/lib/firebase/server-auth'
import { useSaveReferral } from 'web/hooks/use-save-referral' import { useSaveReferral } from 'web/hooks/use-save-referral'
import { GetServerSideProps } from 'next' import { GetServerSideProps } from 'next'
import { Sort } from 'web/hooks/use-sort-and-query-params' import { Sort } from 'web/components/contract-search'
import { Button } from 'web/components/button' import { Button } from 'web/components/button'
import { Spacer } from 'web/components/layout/spacer' import { Spacer } from 'web/components/layout/spacer'
import { useMemberGroups } from 'web/hooks/use-group' import { useMemberGroups } from 'web/hooks/use-group'

View File

@ -1,14 +1,10 @@
import React, { useEffect, useState } from 'react'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { PencilAltIcon } from '@heroicons/react/solid' import { PencilAltIcon } from '@heroicons/react/solid'
import { Page } from 'web/components/page' import { Page } from 'web/components/page'
import { Col } from 'web/components/layout/col' import { Col } from 'web/components/layout/col'
import { ContractSearch } from 'web/components/contract-search' import { ContractSearch } from 'web/components/contract-search'
import { Contract } from 'common/contract'
import { User } from 'common/user' 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 { getUserAndPrivateUser } from 'web/lib/firebase/users'
import { useTracking } from 'web/hooks/use-tracking' import { useTracking } from 'web/hooks/use-tracking'
import { track } from 'web/lib/service/analytics' 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 Home = (props: { auth: { user: User } | null }) => {
const user = props.auth ? props.auth.user : null const user = props.auth ? props.auth.user : null
const [contract, setContract] = useContractPage()
const router = useRouter() const router = useRouter()
useTracking('view home') useTracking('view home')
@ -35,19 +29,12 @@ const Home = (props: { auth: { user: User } | null }) => {
return ( return (
<> <>
<Page className={contract ? 'sr-only' : ''}> <Page>
<Col className="mx-auto w-full p-2"> <Col className="mx-auto w-full p-2">
<ContractSearch <ContractSearch
user={user} user={user}
useQuerySortLocalStorage={true} persistPrefix="home-search"
useQuerySortUrlParams={true} useQueryUrlParam={true}
onContractClick={(c) => {
// 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
/> />
</Col> </Col>
<button <button
@ -61,81 +48,8 @@ const Home = (props: { auth: { user: User } | null }) => {
<PencilAltIcon className="h-7 w-7" aria-hidden="true" /> <PencilAltIcon className="h-7 w-7" aria-hidden="true" />
</button> </button>
</Page> </Page>
{contract && (
<ContractPageContent
contract={contract}
user={user}
username={contract.creatorUsername}
slug={contract.slug}
bets={[]}
comments={[]}
backToHome={() => {
history.back()
}}
recommendedContracts={[]}
/>
)}
</> </>
) )
} }
const useContractPage = () => {
const [contract, setContract] = useState<Contract | undefined>()
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 export default Home