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
}
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 { groupBy, mapValues, sortBy, partition, sumBy } from 'lodash'
import {
Dictionary,
keyBy,
groupBy,
mapValues,
sortBy,
partition,
sumBy,
uniq,
} from 'lodash'
import dayjs from 'dayjs'
import { useEffect, useMemo, useState } from 'react'
import clsx from 'clsx'
@ -19,6 +28,7 @@ import {
Contract,
contractPath,
getBinaryProbPercent,
getContractFromId,
} from 'web/lib/firebase/contracts'
import { Row } from './layout/row'
import { UserLink } from './user-page'
@ -41,10 +51,12 @@ import { trackLatency } from 'web/lib/firebase/tracking'
import { NumericContract } from 'common/contract'
import { formatNumericProbability } from 'common/pseudo-numeric'
import { useUser } from 'web/hooks/use-user'
import { useUserBets } from 'web/hooks/use-user-bets'
import { SellSharesModal } from './sell-modal'
import { useUnfilledBets } from 'web/hooks/use-bets'
import { LimitBet } from 'common/bet'
import { floatingEqual } from 'common/util/math'
import { filterDefined } from 'common/util/array'
import { Pagination } from './pagination'
import { LimitOrderTable } from './limit-bets'
@ -52,25 +64,35 @@ type BetSort = 'newest' | 'profit' | 'closeTime' | 'value'
type BetFilter = 'open' | 'limit_bet' | 'sold' | 'closed' | 'resolved' | 'all'
const CONTRACTS_PER_PAGE = 50
const JUNE_1_2022 = new Date('2022-06-01T00:00:00.000Z').valueOf()
export function BetsList(props: {
user: User
bets: Bet[] | undefined
contractsById: { [id: string]: Contract } | undefined
hideBetsBefore?: number
}) {
const { user, bets: allBets, contractsById, hideBetsBefore } = props
export function BetsList(props: { user: User }) {
const { user } = props
const signedInUser = useUser()
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
// NOTE: This means public profits also begin on 06-01-2022 as well.
const bets = useMemo(
() => allBets?.filter((bet) => bet.createdTime >= (hideBetsBefore ?? 0)),
[allBets, hideBetsBefore]
() => userBets?.filter((bet) => bet.createdTime >= (hideBetsBefore ?? 0)),
[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 [filter, setFilter] = useState<BetFilter>('open')
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 { Contract } from 'common/contract'
import { filterDefined, groupConsecutive } from 'common/util/array'
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 { Row } from './layout/row'
import { Avatar } from './avatar'
@ -8,26 +14,52 @@ import { RelativeTimestamp } from './relative-timestamp'
import { UserLink } from './user-page'
import { User } from 'common/user'
import { Col } from './layout/col'
import { groupBy } from 'lodash'
import { Content } from './editor'
import { Pagination } from './pagination'
import { LoadingIndicator } from './loading-indicator'
export function UserCommentsList(props: {
user: User
comments: Comment[]
contractsById: { [id: string]: Contract }
}) {
const { comments, contractsById } = props
const COMMENTS_PER_PAGE = 50
// we don't show comments in groups here atm, just comments on contracts
const contractComments = comments.filter((c) => c.contractId)
const commentsByContract = groupBy(contractComments, 'contractId')
type ContractComment = Comment & { contractId: string }
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 (
<Col className={'bg-white'}>
{Object.entries(commentsByContract).map(([contractId, comments]) => {
const contract = contractsById[contractId]
{pageComments.map(({ key, items }, i) => {
const contract = contracts[key]
return (
<div key={contractId} className="border-b p-5">
<div key={start + i} className="border-b p-5">
<SiteLink
className="mb-2 block pb-2 font-medium text-indigo-700"
href={contractPath(contract)}
@ -35,7 +67,7 @@ export function UserCommentsList(props: {
{contract.question}
</SiteLink>
<Col className="gap-6">
{comments.map((comment) => (
{items.map((comment) => (
<ProfileComment
key={comment.id}
comment={comment}
@ -46,6 +78,12 @@ export function UserCommentsList(props: {
</div>
)
})}
<Pagination
page={page}
itemsPerPage={COMMENTS_PER_PAGE}
totalItems={comments.length}
setPage={setPage}
/>
</Col>
)
}

View File

@ -1,11 +1,11 @@
import clsx from 'clsx'
import { Dictionary, keyBy, uniq } from 'lodash'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/router'
import { LinkIcon } from '@heroicons/react/solid'
import { PencilIcon } from '@heroicons/react/outline'
import { User } from 'web/lib/firebase/users'
import { useUser } from 'web/hooks/use-user'
import { CreatorContractsList } from './contract/contracts-grid'
import { SEO } from './SEO'
import { Page } from './page'
@ -18,18 +18,12 @@ import { Row } from './layout/row'
import { genHash } from 'common/util/random'
import { QueryUncontrolledTabs } from './layout/tabs'
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 { BetsList } from './bets-list'
import { FollowersButton, FollowingButton } from './following-button'
import { UserFollowButton } from './follow-button'
import { GroupsButton } from 'web/components/groups/groups-button'
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 { formatMoney } from 'common/util/format'
import { ShareIconButton } from 'web/components/share-icon-button'
@ -56,26 +50,13 @@ export function UserLink(props: {
}
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 }) {
const { user, currentUser } = props
export function UserPage(props: { user: User }) {
const { user } = props
const router = useRouter()
const currentUser = useUser()
const isCurrentUser = user.id === currentUser?.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)
useEffect(() => {
@ -83,30 +64,6 @@ export function UserPage(props: { user: User; currentUser?: User }) {
setShowConfetti(claimedMana)
}, [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
return (
@ -163,9 +120,7 @@ export function UserPage(props: { user: User; currentUser?: User }) {
</span>{' '}
profit
</span>
<Spacer h={4} />
{user.bio && (
<>
<div>
@ -174,7 +129,6 @@ export function UserPage(props: { user: User; currentUser?: User }) {
<Spacer h={4} />
</>
)}
<Col className="flex-wrap gap-2 sm:flex-row sm:items-center sm:gap-4">
<Row className="gap-4">
<FollowingButton user={user} />
@ -236,7 +190,6 @@ export function UserPage(props: { user: User; currentUser?: User }) {
</SiteLink>
)}
</Col>
<Spacer h={5} />
{currentUser?.id === user.id && (
<Row
@ -259,58 +212,31 @@ export function UserPage(props: { user: User; currentUser?: User }) {
</Row>
)}
<Spacer h={5} />
{usersContracts !== 'loading' && contractsById && usersComments ? (
<QueryUncontrolledTabs
currentPageForAnalytics={'profile'}
labelClassName={'pb-2 pt-1 '}
tabs={[
{
title: 'Markets',
content: (
<CreatorContractsList user={currentUser} creator={user} />
),
tabIcon: (
<span className="px-0.5 font-bold">
{usersContracts.length}
</span>
),
},
{
title: 'Comments',
content: (
<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 />
)}
<QueryUncontrolledTabs
currentPageForAnalytics={'profile'}
labelClassName={'pb-2 pt-1 '}
tabs={[
{
title: 'Markets',
content: (
<CreatorContractsList user={currentUser} creator={user} />
),
},
{
title: 'Comments',
content: <UserCommentsList user={user} />,
},
{
title: 'Bets',
content: (
<>
<PortfolioValueSection userId={user.id} />
<BetsList user={user} />
</>
),
},
]}
/>
</Col>
</Page>
)

View File

@ -81,7 +81,7 @@ const authAndRefreshTokens = async (ctx: RequestContext) => {
// step 0: if you have no refresh token you are logged out
if (refresh == null) {
console.debug('User is unauthenticated.')
return undefined
return null
}
console.debug('User may be authenticated; checking cookies.')
@ -107,7 +107,7 @@ const authAndRefreshTokens = async (ctx: RequestContext) => {
} catch (e) {
// big unexpected problem -- functionally, they are not logged in
console.error(e)
return undefined
return null
}
}
@ -136,9 +136,10 @@ const authAndRefreshTokens = async (ctx: RequestContext) => {
} catch (e) {
// big unexpected problem -- functionally, they are not logged in
console.error(e)
return undefined
return null
}
}
return null
}
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
console.error(e)
}
return creds
return creds ?? null
}
// 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 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 { useUser } from 'web/hooks/use-user'
import Custom404 from '../404'
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 async function getStaticPropz(props: { params: { username: string } }) {
const { username } = props.params
const user = await getUserByUsername(username)
return {
props: {
user,
},
revalidate: 60, // regenerate after a minute
}
}
export async function getStaticPaths() {
return { paths: [], fallback: 'blocking' }
export const getServerSideProps: GetServerSideProps = async (ctx) => {
const creds = await authenticateOnServer(ctx)
const username = ctx.params!.username as string // eslint-disable-line @typescript-eslint/no-non-null-assertion
const [auth, user] = (await Promise.all([
creds != null ? getUserAndPrivateUser(creds.user.uid) : null,
getUserByUsername(username),
])) as [UserAndPrivateUser | null, User | null]
return { props: { auth, user } }
}
export default function UserProfile(props: { user: User | null }) {
props = usePropz(props, getStaticPropz) ?? { user: undefined }
const { user } = props
const router = useRouter()
const { username } = router.query as {
username: string
}
const currentUser = useUser()
useTracking('view user profile', { username })
if (user === undefined) return <div />
return user ? (
<UserPage user={user} currentUser={currentUser || undefined} />
) : (
<Custom404 />
)
return user ? <UserPage user={user} /> : <Custom404 />
}