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:
parent
dcc3c61f52
commit
456d9398a1
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
||||
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
|
||||
const contractComments = comments.filter((c) => c.contractId)
|
||||
const commentsByContract = groupBy(contractComments, 'contractId')
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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,8 +212,6 @@ export function UserPage(props: { user: User; currentUser?: User }) {
|
|||
</Row>
|
||||
)}
|
||||
<Spacer h={5} />
|
||||
|
||||
{usersContracts !== 'loading' && contractsById && usersComments ? (
|
||||
<QueryUncontrolledTabs
|
||||
currentPageForAnalytics={'profile'}
|
||||
labelClassName={'pb-2 pt-1 '}
|
||||
|
@ -270,47 +221,22 @@ export function UserPage(props: { user: User; currentUser?: User }) {
|
|||
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>
|
||||
),
|
||||
content: <UserCommentsList user={user} />,
|
||||
},
|
||||
{
|
||||
title: 'Bets',
|
||||
content: (
|
||||
<div>
|
||||
<>
|
||||
<PortfolioValueSection userId={user.id} />
|
||||
<BetsList
|
||||
user={user}
|
||||
bets={userBets}
|
||||
hideBetsBefore={isCurrentUser ? 0 : JUNE_1_2022}
|
||||
contractsById={contractsById}
|
||||
/>
|
||||
</div>
|
||||
<BetsList user={user} />
|
||||
</>
|
||||
),
|
||||
tabIcon: <span className="px-0.5 font-bold">{betCount}</span>,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<LoadingIndicator />
|
||||
)}
|
||||
</Col>
|
||||
</Page>
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 />
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue
Block a user