Revamp a lot of stuff on the user page to make it usably efficient (#751)

* Load bets and comments tabs data on user page independently

* Implement basic pagination on profile comments list

* Tweak server auth to return `null` instead of `undefined`

* Switch to SSR for user page

* Fix lint

* Fix broken contract fetching in user bets list

* Tidying
This commit is contained in:
Marshall Polaris 2022-08-12 20:42:58 -07:00 committed by GitHub
parent dcc3c61f52
commit 456d9398a1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 154 additions and 159 deletions

View File

@ -17,3 +17,22 @@ export function buildArray<T>(
return array return array
} }
export function groupConsecutive<T, U>(xs: T[], key: (x: T) => U) {
if (!xs.length) {
return []
}
const result = []
let curr = { key: key(xs[0]), items: [xs[0]] }
for (const x of xs.slice(1)) {
const k = key(x)
if (k !== curr.key) {
result.push(curr)
curr = { key: k, items: [x] }
} else {
curr.items.push(x)
}
}
result.push(curr)
return result
}

View File

@ -1,5 +1,14 @@
import Link from 'next/link' import Link from 'next/link'
import { groupBy, mapValues, sortBy, partition, sumBy } from 'lodash' import {
Dictionary,
keyBy,
groupBy,
mapValues,
sortBy,
partition,
sumBy,
uniq,
} from 'lodash'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import clsx from 'clsx' import clsx from 'clsx'
@ -19,6 +28,7 @@ import {
Contract, Contract,
contractPath, contractPath,
getBinaryProbPercent, getBinaryProbPercent,
getContractFromId,
} from 'web/lib/firebase/contracts' } from 'web/lib/firebase/contracts'
import { Row } from './layout/row' import { Row } from './layout/row'
import { UserLink } from './user-page' import { UserLink } from './user-page'
@ -41,10 +51,12 @@ import { trackLatency } from 'web/lib/firebase/tracking'
import { NumericContract } from 'common/contract' import { NumericContract } from 'common/contract'
import { formatNumericProbability } from 'common/pseudo-numeric' import { formatNumericProbability } from 'common/pseudo-numeric'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { useUserBets } from 'web/hooks/use-user-bets'
import { SellSharesModal } from './sell-modal' import { SellSharesModal } from './sell-modal'
import { useUnfilledBets } from 'web/hooks/use-bets' import { useUnfilledBets } from 'web/hooks/use-bets'
import { LimitBet } from 'common/bet' import { LimitBet } from 'common/bet'
import { floatingEqual } from 'common/util/math' import { floatingEqual } from 'common/util/math'
import { filterDefined } from 'common/util/array'
import { Pagination } from './pagination' import { Pagination } from './pagination'
import { LimitOrderTable } from './limit-bets' import { LimitOrderTable } from './limit-bets'
@ -52,25 +64,35 @@ type BetSort = 'newest' | 'profit' | 'closeTime' | 'value'
type BetFilter = 'open' | 'limit_bet' | 'sold' | 'closed' | 'resolved' | 'all' type BetFilter = 'open' | 'limit_bet' | 'sold' | 'closed' | 'resolved' | 'all'
const CONTRACTS_PER_PAGE = 50 const CONTRACTS_PER_PAGE = 50
const JUNE_1_2022 = new Date('2022-06-01T00:00:00.000Z').valueOf()
export function BetsList(props: { export function BetsList(props: { user: User }) {
user: User const { user } = props
bets: Bet[] | undefined
contractsById: { [id: string]: Contract } | undefined
hideBetsBefore?: number
}) {
const { user, bets: allBets, contractsById, hideBetsBefore } = props
const signedInUser = useUser() const signedInUser = useUser()
const isYourBets = user.id === signedInUser?.id const isYourBets = user.id === signedInUser?.id
const hideBetsBefore = isYourBets ? 0 : JUNE_1_2022
const userBets = useUserBets(user.id, { includeRedemptions: true })
const [contractsById, setContractsById] = useState<
Dictionary<Contract> | undefined
>()
// Hide bets before 06-01-2022 if this isn't your own profile // Hide bets before 06-01-2022 if this isn't your own profile
// NOTE: This means public profits also begin on 06-01-2022 as well. // NOTE: This means public profits also begin on 06-01-2022 as well.
const bets = useMemo( const bets = useMemo(
() => allBets?.filter((bet) => bet.createdTime >= (hideBetsBefore ?? 0)), () => userBets?.filter((bet) => bet.createdTime >= (hideBetsBefore ?? 0)),
[allBets, hideBetsBefore] [userBets, hideBetsBefore]
) )
useEffect(() => {
if (bets) {
const contractIds = uniq(bets.map((b) => b.contractId))
Promise.all(contractIds.map(getContractFromId)).then((contracts) => {
setContractsById(keyBy(filterDefined(contracts), 'id'))
})
}
}, [bets])
const [sort, setSort] = useState<BetSort>('newest') const [sort, setSort] = useState<BetSort>('newest')
const [filter, setFilter] = useState<BetFilter>('open') const [filter, setFilter] = useState<BetFilter>('open')
const [page, setPage] = useState(0) const [page, setPage] = useState(0)

View File

@ -1,6 +1,12 @@
import { useEffect, useState } from 'react'
import { Dictionary, keyBy, uniq } from 'lodash'
import { Comment } from 'common/comment' import { Comment } from 'common/comment'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
import { filterDefined, groupConsecutive } from 'common/util/array'
import { contractPath } from 'web/lib/firebase/contracts' import { contractPath } from 'web/lib/firebase/contracts'
import { getUsersComments } from 'web/lib/firebase/comments'
import { getContractFromId } from 'web/lib/firebase/contracts'
import { SiteLink } from './site-link' import { SiteLink } from './site-link'
import { Row } from './layout/row' import { Row } from './layout/row'
import { Avatar } from './avatar' import { Avatar } from './avatar'
@ -8,26 +14,52 @@ import { RelativeTimestamp } from './relative-timestamp'
import { UserLink } from './user-page' import { UserLink } from './user-page'
import { User } from 'common/user' import { User } from 'common/user'
import { Col } from './layout/col' import { Col } from './layout/col'
import { groupBy } from 'lodash'
import { Content } from './editor' import { Content } from './editor'
import { Pagination } from './pagination'
import { LoadingIndicator } from './loading-indicator'
export function UserCommentsList(props: { const COMMENTS_PER_PAGE = 50
user: User
comments: Comment[]
contractsById: { [id: string]: Contract }
}) {
const { comments, contractsById } = props
// we don't show comments in groups here atm, just comments on contracts type ContractComment = Comment & { contractId: string }
const contractComments = comments.filter((c) => c.contractId)
const commentsByContract = groupBy(contractComments, 'contractId')
export function UserCommentsList(props: { user: User }) {
const { user } = props
const [comments, setComments] = useState<ContractComment[] | undefined>()
const [contracts, setContracts] = useState<Dictionary<Contract> | undefined>()
const [page, setPage] = useState(0)
const start = page * COMMENTS_PER_PAGE
const end = start + COMMENTS_PER_PAGE
useEffect(() => {
getUsersComments(user.id).then((cs) => {
// we don't show comments in groups here atm, just comments on contracts
setComments(cs.filter((c) => c.contractId) as ContractComment[])
})
}, [user.id])
useEffect(() => {
if (comments) {
const contractIds = uniq(comments.map((c) => c.contractId))
Promise.all(contractIds.map(getContractFromId)).then((contracts) => {
setContracts(keyBy(filterDefined(contracts), 'id'))
})
}
}, [comments])
if (comments == null || contracts == null) {
return <LoadingIndicator />
}
const pageComments = groupConsecutive(
comments.slice(start, end),
(c) => c.contractId
)
return ( return (
<Col className={'bg-white'}> <Col className={'bg-white'}>
{Object.entries(commentsByContract).map(([contractId, comments]) => { {pageComments.map(({ key, items }, i) => {
const contract = contractsById[contractId] const contract = contracts[key]
return ( return (
<div key={contractId} className="border-b p-5"> <div key={start + i} className="border-b p-5">
<SiteLink <SiteLink
className="mb-2 block pb-2 font-medium text-indigo-700" className="mb-2 block pb-2 font-medium text-indigo-700"
href={contractPath(contract)} href={contractPath(contract)}
@ -35,7 +67,7 @@ export function UserCommentsList(props: {
{contract.question} {contract.question}
</SiteLink> </SiteLink>
<Col className="gap-6"> <Col className="gap-6">
{comments.map((comment) => ( {items.map((comment) => (
<ProfileComment <ProfileComment
key={comment.id} key={comment.id}
comment={comment} comment={comment}
@ -46,6 +78,12 @@ export function UserCommentsList(props: {
</div> </div>
) )
})} })}
<Pagination
page={page}
itemsPerPage={COMMENTS_PER_PAGE}
totalItems={comments.length}
setPage={setPage}
/>
</Col> </Col>
) )
} }

View File

@ -1,11 +1,11 @@
import clsx from 'clsx' import clsx from 'clsx'
import { Dictionary, keyBy, uniq } from 'lodash'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { LinkIcon } from '@heroicons/react/solid' import { LinkIcon } from '@heroicons/react/solid'
import { PencilIcon } from '@heroicons/react/outline' import { PencilIcon } from '@heroicons/react/outline'
import { User } from 'web/lib/firebase/users' import { User } from 'web/lib/firebase/users'
import { useUser } from 'web/hooks/use-user'
import { CreatorContractsList } from './contract/contracts-grid' import { CreatorContractsList } from './contract/contracts-grid'
import { SEO } from './SEO' import { SEO } from './SEO'
import { Page } from './page' import { Page } from './page'
@ -18,18 +18,12 @@ import { Row } from './layout/row'
import { genHash } from 'common/util/random' import { genHash } from 'common/util/random'
import { QueryUncontrolledTabs } from './layout/tabs' import { QueryUncontrolledTabs } from './layout/tabs'
import { UserCommentsList } from './comments-list' import { UserCommentsList } from './comments-list'
import { Comment, getUsersComments } from 'web/lib/firebase/comments'
import { Contract } from 'common/contract'
import { getContractFromId, listContracts } from 'web/lib/firebase/contracts'
import { LoadingIndicator } from './loading-indicator'
import { FullscreenConfetti } from 'web/components/fullscreen-confetti' import { FullscreenConfetti } from 'web/components/fullscreen-confetti'
import { BetsList } from './bets-list' import { BetsList } from './bets-list'
import { FollowersButton, FollowingButton } from './following-button' import { FollowersButton, FollowingButton } from './following-button'
import { UserFollowButton } from './follow-button' import { UserFollowButton } from './follow-button'
import { GroupsButton } from 'web/components/groups/groups-button' import { GroupsButton } from 'web/components/groups/groups-button'
import { PortfolioValueSection } from './portfolio/portfolio-value-section' import { PortfolioValueSection } from './portfolio/portfolio-value-section'
import { filterDefined } from 'common/util/array'
import { useUserBets } from 'web/hooks/use-user-bets'
import { ReferralsButton } from 'web/components/referrals-button' import { ReferralsButton } from 'web/components/referrals-button'
import { formatMoney } from 'common/util/format' import { formatMoney } from 'common/util/format'
import { ShareIconButton } from 'web/components/share-icon-button' import { ShareIconButton } from 'web/components/share-icon-button'
@ -56,26 +50,13 @@ export function UserLink(props: {
} }
export const TAB_IDS = ['markets', 'comments', 'bets', 'groups'] export const TAB_IDS = ['markets', 'comments', 'bets', 'groups']
const JUNE_1_2022 = new Date('2022-06-01T00:00:00.000Z').valueOf()
export function UserPage(props: { user: User; currentUser?: User }) { export function UserPage(props: { user: User }) {
const { user, currentUser } = props const { user } = props
const router = useRouter() const router = useRouter()
const currentUser = useUser()
const isCurrentUser = user.id === currentUser?.id const isCurrentUser = user.id === currentUser?.id
const bannerUrl = user.bannerUrl ?? defaultBannerUrl(user.id) const bannerUrl = user.bannerUrl ?? defaultBannerUrl(user.id)
const [usersComments, setUsersComments] = useState<Comment[] | undefined>()
const [usersContracts, setUsersContracts] = useState<Contract[] | 'loading'>(
'loading'
)
const userBets = useUserBets(user.id, { includeRedemptions: true })
const betCount =
userBets === undefined
? 0
: userBets.filter((bet) => !bet.isRedemption && bet.amount !== 0).length
const [contractsById, setContractsById] = useState<
Dictionary<Contract> | undefined
>()
const [showConfetti, setShowConfetti] = useState(false) const [showConfetti, setShowConfetti] = useState(false)
useEffect(() => { useEffect(() => {
@ -83,30 +64,6 @@ export function UserPage(props: { user: User; currentUser?: User }) {
setShowConfetti(claimedMana) setShowConfetti(claimedMana)
}, [router]) }, [router])
useEffect(() => {
if (!user) return
getUsersComments(user.id).then(setUsersComments)
listContracts(user.id).then(setUsersContracts)
}, [user])
// TODO: display comments on groups
useEffect(() => {
if (usersComments && userBets) {
const uniqueContractIds = uniq([
...usersComments.map((comment) => comment.contractId),
...(userBets?.map((bet) => bet.contractId) ?? []),
])
Promise.all(
uniqueContractIds.map((contractId) =>
contractId ? getContractFromId(contractId) : undefined
)
).then((contracts) => {
const contractsById = keyBy(filterDefined(contracts), 'id')
setContractsById(contractsById)
})
}
}, [userBets, usersComments])
const profit = user.profitCached.allTime const profit = user.profitCached.allTime
return ( return (
@ -163,9 +120,7 @@ export function UserPage(props: { user: User; currentUser?: User }) {
</span>{' '} </span>{' '}
profit profit
</span> </span>
<Spacer h={4} /> <Spacer h={4} />
{user.bio && ( {user.bio && (
<> <>
<div> <div>
@ -174,7 +129,6 @@ export function UserPage(props: { user: User; currentUser?: User }) {
<Spacer h={4} /> <Spacer h={4} />
</> </>
)} )}
<Col className="flex-wrap gap-2 sm:flex-row sm:items-center sm:gap-4"> <Col className="flex-wrap gap-2 sm:flex-row sm:items-center sm:gap-4">
<Row className="gap-4"> <Row className="gap-4">
<FollowingButton user={user} /> <FollowingButton user={user} />
@ -236,7 +190,6 @@ export function UserPage(props: { user: User; currentUser?: User }) {
</SiteLink> </SiteLink>
)} )}
</Col> </Col>
<Spacer h={5} /> <Spacer h={5} />
{currentUser?.id === user.id && ( {currentUser?.id === user.id && (
<Row <Row
@ -259,58 +212,31 @@ export function UserPage(props: { user: User; currentUser?: User }) {
</Row> </Row>
)} )}
<Spacer h={5} /> <Spacer h={5} />
<QueryUncontrolledTabs
{usersContracts !== 'loading' && contractsById && usersComments ? ( currentPageForAnalytics={'profile'}
<QueryUncontrolledTabs labelClassName={'pb-2 pt-1 '}
currentPageForAnalytics={'profile'} tabs={[
labelClassName={'pb-2 pt-1 '} {
tabs={[ title: 'Markets',
{ content: (
title: 'Markets', <CreatorContractsList user={currentUser} creator={user} />
content: ( ),
<CreatorContractsList user={currentUser} creator={user} /> },
), {
tabIcon: ( title: 'Comments',
<span className="px-0.5 font-bold"> content: <UserCommentsList user={user} />,
{usersContracts.length} },
</span> {
), title: 'Bets',
}, content: (
{ <>
title: 'Comments', <PortfolioValueSection userId={user.id} />
content: ( <BetsList user={user} />
<UserCommentsList </>
user={user} ),
contractsById={contractsById} },
comments={usersComments} ]}
/> />
),
tabIcon: (
<span className="px-0.5 font-bold">
{usersComments.length}
</span>
),
},
{
title: 'Bets',
content: (
<div>
<PortfolioValueSection userId={user.id} />
<BetsList
user={user}
bets={userBets}
hideBetsBefore={isCurrentUser ? 0 : JUNE_1_2022}
contractsById={contractsById}
/>
</div>
),
tabIcon: <span className="px-0.5 font-bold">{betCount}</span>,
},
]}
/>
) : (
<LoadingIndicator />
)}
</Col> </Col>
</Page> </Page>
) )

View File

@ -81,7 +81,7 @@ const authAndRefreshTokens = async (ctx: RequestContext) => {
// step 0: if you have no refresh token you are logged out // step 0: if you have no refresh token you are logged out
if (refresh == null) { if (refresh == null) {
console.debug('User is unauthenticated.') console.debug('User is unauthenticated.')
return undefined return null
} }
console.debug('User may be authenticated; checking cookies.') console.debug('User may be authenticated; checking cookies.')
@ -107,7 +107,7 @@ const authAndRefreshTokens = async (ctx: RequestContext) => {
} catch (e) { } catch (e) {
// big unexpected problem -- functionally, they are not logged in // big unexpected problem -- functionally, they are not logged in
console.error(e) console.error(e)
return undefined return null
} }
} }
@ -136,9 +136,10 @@ const authAndRefreshTokens = async (ctx: RequestContext) => {
} catch (e) { } catch (e) {
// big unexpected problem -- functionally, they are not logged in // big unexpected problem -- functionally, they are not logged in
console.error(e) console.error(e)
return undefined return null
} }
} }
return null
} }
export const authenticateOnServer = async (ctx: RequestContext) => { export const authenticateOnServer = async (ctx: RequestContext) => {
@ -158,7 +159,7 @@ export const authenticateOnServer = async (ctx: RequestContext) => {
// definitely not supposed to happen, but let's be maximally robust // definitely not supposed to happen, but let's be maximally robust
console.error(e) console.error(e)
} }
return creds return creds ?? null
} }
// note that we might want to define these types more generically if we want better // note that we might want to define these types more generically if we want better

View File

@ -1,48 +1,37 @@
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import React from 'react' import React from 'react'
import { getUserByUsername, User } from 'web/lib/firebase/users' import {
getUserByUsername,
getUserAndPrivateUser,
User,
UserAndPrivateUser,
} from 'web/lib/firebase/users'
import { UserPage } from 'web/components/user-page' import { UserPage } from 'web/components/user-page'
import { useUser } from 'web/hooks/use-user'
import Custom404 from '../404' import Custom404 from '../404'
import { useTracking } from 'web/hooks/use-tracking' import { useTracking } from 'web/hooks/use-tracking'
import { fromPropz, usePropz } from 'web/hooks/use-propz' import { GetServerSideProps } from 'next'
import { authenticateOnServer } from 'web/lib/firebase/server-auth'
export const getStaticProps = fromPropz(getStaticPropz) export const getServerSideProps: GetServerSideProps = async (ctx) => {
export async function getStaticPropz(props: { params: { username: string } }) { const creds = await authenticateOnServer(ctx)
const { username } = props.params const username = ctx.params!.username as string // eslint-disable-line @typescript-eslint/no-non-null-assertion
const user = await getUserByUsername(username) const [auth, user] = (await Promise.all([
creds != null ? getUserAndPrivateUser(creds.user.uid) : null,
return { getUserByUsername(username),
props: { ])) as [UserAndPrivateUser | null, User | null]
user, return { props: { auth, user } }
},
revalidate: 60, // regenerate after a minute
}
}
export async function getStaticPaths() {
return { paths: [], fallback: 'blocking' }
} }
export default function UserProfile(props: { user: User | null }) { export default function UserProfile(props: { user: User | null }) {
props = usePropz(props, getStaticPropz) ?? { user: undefined }
const { user } = props const { user } = props
const router = useRouter() const router = useRouter()
const { username } = router.query as { const { username } = router.query as {
username: string username: string
} }
const currentUser = useUser()
useTracking('view user profile', { username }) useTracking('view user profile', { username })
if (user === undefined) return <div /> return user ? <UserPage user={user} /> : <Custom404 />
return user ? (
<UserPage user={user} currentUser={currentUser || undefined} />
) : (
<Custom404 />
)
} }