More work on contract page and tabs (#912)

* Consolidate comment thread component code

* Move `visibleBets` work down into bets tab

* Remove unnecessary cruft from contract page props

* Don't load all comments in contract page static props anymore

* Tidy up props a bit

* Memoize bets tab

* Memoize recommended contracts widget
This commit is contained in:
Marshall Polaris 2022-09-21 00:02:10 -07:00 committed by GitHub
parent c7f29af2ee
commit a10605e74c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 125 additions and 214 deletions

View File

@ -1,10 +1,10 @@
import { Bet } from 'common/bet' import { Bet } from 'common/bet'
import { ContractComment } from 'common/comment'
import { resolvedPayout } from 'common/calculate' import { resolvedPayout } from 'common/calculate'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
import { formatMoney } from 'common/util/format' import { formatMoney } from 'common/util/format'
import { groupBy, mapValues, sumBy, sortBy, keyBy } from 'lodash' import { groupBy, mapValues, sumBy, sortBy, keyBy } from 'lodash'
import { useState, useMemo, useEffect } from 'react' import { useState, useMemo, useEffect } from 'react'
import { useComments } from 'web/hooks/use-comments'
import { listUsers, User } from 'web/lib/firebase/users' import { listUsers, User } from 'web/lib/firebase/users'
import { FeedBet } from '../feed/feed-bets' import { FeedBet } from '../feed/feed-bets'
import { FeedComment } from '../feed/feed-comments' import { FeedComment } from '../feed/feed-comments'
@ -61,12 +61,10 @@ export function ContractLeaderboard(props: {
) : null ) : null
} }
export function ContractTopTrades(props: { export function ContractTopTrades(props: { contract: Contract; bets: Bet[] }) {
contract: Contract const { contract, bets } = props
bets: Bet[] // todo: this stuff should be calced in DB at resolve time
comments: ContractComment[] const comments = useComments(contract.id)
}) {
const { contract, bets, comments } = props
const commentsById = keyBy(comments, 'id') const commentsById = keyBy(comments, 'id')
const betsById = keyBy(bets, 'id') const betsById = keyBy(bets, 'id')

View File

@ -5,19 +5,19 @@ import { FeedBet } from '../feed/feed-bets'
import { FeedLiquidity } from '../feed/feed-liquidity' import { FeedLiquidity } from '../feed/feed-liquidity'
import { FeedAnswerCommentGroup } from '../feed/feed-answer-comment-group' import { FeedAnswerCommentGroup } from '../feed/feed-answer-comment-group'
import { FeedCommentThread, ContractCommentInput } from '../feed/feed-comments' import { FeedCommentThread, ContractCommentInput } from '../feed/feed-comments'
import { CommentTipMap } from 'web/hooks/use-tip-txns'
import { groupBy, sortBy } from 'lodash' import { groupBy, sortBy } from 'lodash'
import { Bet } from 'common/bet' import { Bet } from 'common/bet'
import { Contract, FreeResponseContract } from 'common/contract' import { Contract } from 'common/contract'
import { ContractComment } from 'common/comment' import { PAST_BETS } from 'common/user'
import { PAST_BETS, User } from 'common/user'
import { ContractBetsTable, BetsSummary } from '../bets-list' import { ContractBetsTable, BetsSummary } from '../bets-list'
import { Spacer } from '../layout/spacer' import { Spacer } from '../layout/spacer'
import { Tabs } from '../layout/tabs' import { Tabs } from '../layout/tabs'
import { Col } from '../layout/col' import { Col } from '../layout/col'
import { LoadingIndicator } from 'web/components/loading-indicator'
import { useComments } from 'web/hooks/use-comments' import { useComments } from 'web/hooks/use-comments'
import { useLiquidity } from 'web/hooks/use-liquidity' import { useLiquidity } from 'web/hooks/use-liquidity'
import { useTipTxns } from 'web/hooks/use-tip-txns' import { useTipTxns } from 'web/hooks/use-tip-txns'
import { useUser } from 'web/hooks/use-user'
import { capitalize } from 'lodash' import { capitalize } from 'lodash'
import { import {
DEV_HOUSE_LIQUIDITY_PROVIDER_ID, DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
@ -25,21 +25,13 @@ import {
} from 'common/antes' } from 'common/antes'
import { useIsMobile } from 'web/hooks/use-is-mobile' import { useIsMobile } from 'web/hooks/use-is-mobile'
export function ContractTabs(props: { export function ContractTabs(props: { contract: Contract; bets: Bet[] }) {
contract: Contract const { contract, bets } = props
user: User | null | undefined
bets: Bet[]
comments: ContractComment[]
}) {
const { contract, user, bets, comments } = props
const isMobile = useIsMobile() const isMobile = useIsMobile()
const user = useUser()
const userBets = const userBets =
user && bets.filter((bet) => !bet.isAnte && bet.userId === user.id) user && bets.filter((bet) => !bet.isAnte && bet.userId === user.id)
const visibleBets = bets.filter(
(bet) => !bet.isAnte && !bet.isRedemption && bet.amount !== 0
)
const yourTrades = ( const yourTrades = (
<div> <div>
@ -61,15 +53,11 @@ export function ContractTabs(props: {
tabs={[ tabs={[
{ {
title: 'Comments', title: 'Comments',
content: ( content: <CommentsTabContent contract={contract} />,
<CommentsTabContent contract={contract} comments={comments} />
),
}, },
{ {
title: capitalize(PAST_BETS), title: capitalize(PAST_BETS),
content: ( content: <BetsTabContent contract={contract} bets={bets} />,
<ContractBetsActivity contract={contract} bets={visibleBets} />
),
}, },
...(!user || !userBets?.length ...(!user || !userBets?.length
? [] ? []
@ -86,46 +74,87 @@ export function ContractTabs(props: {
const CommentsTabContent = memo(function CommentsTabContent(props: { const CommentsTabContent = memo(function CommentsTabContent(props: {
contract: Contract contract: Contract
comments: ContractComment[]
}) { }) {
const { contract, comments } = props const { contract } = props
const tips = useTipTxns({ contractId: contract.id }) const tips = useTipTxns({ contractId: contract.id })
const updatedComments = useComments(contract.id) ?? comments const comments = useComments(contract.id)
if (comments == null) {
return <LoadingIndicator />
}
if (contract.outcomeType === 'FREE_RESPONSE') { if (contract.outcomeType === 'FREE_RESPONSE') {
const generalComments = comments.filter(
(c) => c.answerOutcome === undefined && c.betId === undefined
)
const sortedAnswers = sortBy(
contract.answers,
(a) => -getOutcomeProbability(contract, a.id)
)
const commentsByOutcome = groupBy(
comments,
(c) => c.answerOutcome ?? c.betOutcome ?? '_'
)
return ( return (
<> <>
<FreeResponseContractCommentsActivity {sortedAnswers.map((answer) => (
contract={contract} <div key={answer.id} className="relative pb-4">
comments={updatedComments} <span
tips={tips} className="absolute top-5 left-5 -ml-px h-[calc(100%-2rem)] w-0.5 bg-gray-200"
/> aria-hidden="true"
/>
<FeedAnswerCommentGroup
contract={contract}
answer={answer}
answerComments={sortBy(
commentsByOutcome[answer.number.toString()] ?? [],
(c) => c.createdTime
)}
tips={tips}
/>
</div>
))}
<Col className="mt-8 flex w-full"> <Col className="mt-8 flex w-full">
<div className="text-md mt-8 mb-2 text-left">General Comments</div> <div className="text-md mt-8 mb-2 text-left">General Comments</div>
<div className="mb-4 w-full border-b border-gray-200" /> <div className="mb-4 w-full border-b border-gray-200" />
<ContractCommentsActivity <ContractCommentInput className="mb-5" contract={contract} />
contract={contract} {generalComments.map((comment) => (
comments={updatedComments.filter( <FeedCommentThread
(comment) => key={comment.id}
comment.answerOutcome === undefined && contract={contract}
comment.betId === undefined parentComment={comment}
)} threadComments={[]}
tips={tips} tips={tips}
/> />
))}
</Col> </Col>
</> </>
) )
} else { } else {
const commentsByParent = groupBy(comments, (c) => c.replyToCommentId ?? '_')
const topLevelComments = commentsByParent['_'] ?? []
return ( return (
<ContractCommentsActivity <>
contract={contract} <ContractCommentInput className="mb-5" contract={contract} />
comments={comments} {sortBy(topLevelComments, (c) => -c.createdTime).map((parent) => (
tips={tips} <FeedCommentThread
/> key={parent.id}
contract={contract}
parentComment={parent}
threadComments={sortBy(
commentsByParent[parent.id] ?? [],
(c) => c.createdTime
)}
tips={tips}
/>
))}
</>
) )
} }
}) })
function ContractBetsActivity(props: { contract: Contract; bets: Bet[] }) { const BetsTabContent = memo(function BetsTabContent(props: {
contract: Contract
bets: Bet[]
}) {
const { contract, bets } = props const { contract, bets } = props
const [page, setPage] = useState(0) const [page, setPage] = useState(0)
const ITEMS_PER_PAGE = 50 const ITEMS_PER_PAGE = 50
@ -133,6 +162,9 @@ function ContractBetsActivity(props: { contract: Contract; bets: Bet[] }) {
const end = start + ITEMS_PER_PAGE const end = start + ITEMS_PER_PAGE
const lps = useLiquidity(contract.id) ?? [] const lps = useLiquidity(contract.id) ?? []
const visibleBets = bets.filter(
(bet) => !bet.isAnte && !bet.isRedemption && bet.amount !== 0
)
const visibleLps = lps.filter( const visibleLps = lps.filter(
(l) => (l) =>
!l.isAnte && !l.isAnte &&
@ -142,7 +174,7 @@ function ContractBetsActivity(props: { contract: Contract; bets: Bet[] }) {
) )
const items = [ const items = [
...bets.map((bet) => ({ ...visibleBets.map((bet) => ({
type: 'bet' as const, type: 'bet' as const,
id: bet.id + '-' + bet.isSold, id: bet.id + '-' + bet.isSold,
bet, bet,
@ -184,74 +216,4 @@ function ContractBetsActivity(props: { contract: Contract; bets: Bet[] }) {
/> />
</> </>
) )
} })
function ContractCommentsActivity(props: {
contract: Contract
comments: ContractComment[]
tips: CommentTipMap
}) {
const { contract, comments, tips } = props
const commentsByParentId = groupBy(comments, (c) => c.replyToCommentId ?? '_')
const topLevelComments = sortBy(
commentsByParentId['_'] ?? [],
(c) => -c.createdTime
)
return (
<>
<ContractCommentInput className="mb-5" contract={contract} />
{topLevelComments.map((parent) => (
<FeedCommentThread
key={parent.id}
contract={contract}
parentComment={parent}
threadComments={sortBy(
commentsByParentId[parent.id] ?? [],
(c) => c.createdTime
)}
tips={tips}
/>
))}
</>
)
}
function FreeResponseContractCommentsActivity(props: {
contract: FreeResponseContract
comments: ContractComment[]
tips: CommentTipMap
}) {
const { contract, comments, tips } = props
const sortedAnswers = sortBy(
contract.answers,
(answer) => -getOutcomeProbability(contract, answer.number.toString())
)
const commentsByOutcome = groupBy(
comments,
(c) => c.answerOutcome ?? c.betOutcome ?? '_'
)
return (
<>
{sortedAnswers.map((answer) => (
<div key={answer.id} className="relative pb-4">
<span
className="absolute top-5 left-5 -ml-px h-[calc(100%-2rem)] w-0.5 bg-gray-200"
aria-hidden="true"
/>
<FeedAnswerCommentGroup
contract={contract}
answer={answer}
answerComments={sortBy(
commentsByOutcome[answer.number.toString()] ?? [],
(c) => c.createdTime
)}
tips={tips}
/>
</div>
))}
</>
)
}

View File

@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from 'react' import React, { memo, useEffect, useMemo, useState } from 'react'
import { ArrowLeftIcon } from '@heroicons/react/outline' import { ArrowLeftIcon } from '@heroicons/react/outline'
import { useContractWithPreload } from 'web/hooks/use-contract' import { useContractWithPreload } from 'web/hooks/use-contract'
@ -17,7 +17,6 @@ import {
import { SEO } from 'web/components/SEO' import { SEO } from 'web/components/SEO'
import { Page } from 'web/components/page' import { Page } from 'web/components/page'
import { Bet, listAllBets } from 'web/lib/firebase/bets' import { Bet, listAllBets } from 'web/lib/firebase/bets'
import { listAllComments } from 'web/lib/firebase/comments'
import Custom404 from '../404' import Custom404 from '../404'
import { AnswersPanel } from 'web/components/answers/answers-panel' import { AnswersPanel } from 'web/components/answers/answers-panel'
import { fromPropz, usePropz } from 'web/hooks/use-propz' import { fromPropz, usePropz } from 'web/hooks/use-propz'
@ -32,8 +31,6 @@ import { CPMMBinaryContract } from 'common/contract'
import { AlertBox } from 'web/components/alert-box' import { AlertBox } from 'web/components/alert-box'
import { useTracking } from 'web/hooks/use-tracking' import { useTracking } from 'web/hooks/use-tracking'
import { useSaveReferral } from 'web/hooks/use-save-referral' import { useSaveReferral } from 'web/hooks/use-save-referral'
import { User } from 'common/user'
import { ContractComment } from 'common/comment'
import { getOpenGraphProps } from 'common/contract-details' import { getOpenGraphProps } from 'common/contract-details'
import { ContractDescription } from 'web/components/contract/contract-description' import { ContractDescription } from 'web/components/contract/contract-description'
import { import {
@ -54,25 +51,14 @@ export const getStaticProps = fromPropz(getStaticPropz)
export async function getStaticPropz(props: { export async function getStaticPropz(props: {
params: { username: string; contractSlug: string } params: { username: string; contractSlug: string }
}) { }) {
const { username, contractSlug } = props.params const { contractSlug } = props.params
const contract = (await getContractFromSlug(contractSlug)) || null const contract = (await getContractFromSlug(contractSlug)) || null
const contractId = contract?.id const contractId = contract?.id
const bets = contractId ? await listAllBets(contractId) : []
const [bets, comments] = await Promise.all([
contractId ? listAllBets(contractId) : [],
contractId ? listAllComments(contractId) : [],
])
return { return {
props: { // Limit the data sent to the client. Client will still load all bets directly.
contract, props: { contract, bets: bets.slice(0, 5000) },
username,
slug: contractSlug,
// Limit the data sent to the client. Client will still load all bets and comments directly.
bets: bets.slice(0, 5000),
comments: comments.slice(0, 1000),
},
revalidate: 5, // regenerate after five seconds revalidate: 5, // regenerate after five seconds
} }
} }
@ -83,21 +69,11 @@ export async function getStaticPaths() {
export default function ContractPage(props: { export default function ContractPage(props: {
contract: Contract | null contract: Contract | null
username: string
bets: Bet[] bets: Bet[]
comments: ContractComment[]
slug: string
backToHome?: () => void backToHome?: () => void
}) { }) {
props = usePropz(props, getStaticPropz) ?? { props = usePropz(props, getStaticPropz) ?? { contract: null, bets: [] }
contract: null,
username: '',
comments: [],
bets: [],
slug: '',
}
const user = useUser()
const inIframe = useIsIframe() const inIframe = useIsIframe()
if (inIframe) { if (inIframe) {
return <ContractEmbedPage {...props} /> return <ContractEmbedPage {...props} />
@ -109,9 +85,7 @@ export default function ContractPage(props: {
return <Custom404 /> return <Custom404 />
} }
return ( return <ContractPageContent key={contract.id} {...{ ...props, contract }} />
<ContractPageContent key={contract.id} {...{ ...props, contract, user }} />
)
} }
// requires an admin to resolve a week after market closes // requires an admin to resolve a week after market closes
@ -119,12 +93,10 @@ export function needsAdminToResolve(contract: Contract) {
return !contract.isResolved && dayjs().diff(contract.closeTime, 'day') > 7 return !contract.isResolved && dayjs().diff(contract.closeTime, 'day') > 7
} }
export function ContractPageSidebar(props: { export function ContractPageSidebar(props: { contract: Contract }) {
user: User | null | undefined const { contract } = props
contract: Contract
}) {
const { contract, user } = props
const { creatorId, isResolved, outcomeType } = contract const { creatorId, isResolved, outcomeType } = contract
const user = useUser()
const isCreator = user?.id === creatorId const isCreator = user?.id === creatorId
const isBinary = outcomeType === 'BINARY' const isBinary = outcomeType === 'BINARY'
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
@ -173,11 +145,11 @@ export function ContractPageSidebar(props: {
export function ContractPageContent( export function ContractPageContent(
props: Parameters<typeof ContractPage>[0] & { props: Parameters<typeof ContractPage>[0] & {
contract: Contract contract: Contract
user?: User | null
} }
) { ) {
const { backToHome, comments, user } = props const { backToHome } = props
const contract = useContractWithPreload(props.contract) ?? props.contract const contract = useContractWithPreload(props.contract) ?? props.contract
const user = useUser()
usePrefetch(user?.id) usePrefetch(user?.id)
useTracking( useTracking(
'view market', 'view market',
@ -217,9 +189,8 @@ export function ContractPageContent(
contractId: contract.id, contractId: contract.id,
}) })
const rightSidebar = <ContractPageSidebar user={user} contract={contract} />
return ( return (
<Page rightSidebar={rightSidebar}> <Page rightSidebar={<ContractPageSidebar contract={contract} />}>
{showConfetti && ( {showConfetti && (
<FullscreenConfetti recycle={false} numberOfPieces={300} /> <FullscreenConfetti recycle={false} numberOfPieces={300} />
)} )}
@ -228,7 +199,7 @@ export function ContractPageContent(
<SEO <SEO
title={question} title={question}
description={ogCardProps.description} description={ogCardProps.description}
url={`/${props.username}/${props.slug}`} url={`/${contract.creatorUsername}/${contract.slug}`}
ogCardProps={ogCardProps} ogCardProps={ogCardProps}
/> />
)} )}
@ -271,22 +242,13 @@ export function ContractPageContent(
<> <>
<div className="grid grid-cols-1 sm:grid-cols-2"> <div className="grid grid-cols-1 sm:grid-cols-2">
<ContractLeaderboard contract={contract} bets={bets} /> <ContractLeaderboard contract={contract} bets={bets} />
<ContractTopTrades <ContractTopTrades contract={contract} bets={bets} />
contract={contract}
bets={bets}
comments={comments}
/>
</div> </div>
<Spacer h={12} /> <Spacer h={12} />
</> </>
)} )}
<ContractTabs <ContractTabs contract={contract} bets={bets} />
contract={contract}
user={user}
bets={bets}
comments={comments}
/>
{!user ? ( {!user ? (
<Col className="mt-4 max-w-sm items-center xl:hidden"> <Col className="mt-4 max-w-sm items-center xl:hidden">
<BetSignUpPrompt /> <BetSignUpPrompt />
@ -307,26 +269,28 @@ export function ContractPageContent(
) )
} }
function RecommendedContractsWidget(props: { contract: Contract }) { const RecommendedContractsWidget = memo(
const { contract } = props function RecommendedContractsWidget(props: { contract: Contract }) {
const user = useUser() const { contract } = props
const [recommendations, setRecommendations] = useState<Contract[]>([]) const user = useUser()
useEffect(() => { const [recommendations, setRecommendations] = useState<Contract[]>([])
if (user) { useEffect(() => {
getRecommendedContracts(contract, user.id, 6).then(setRecommendations) if (user) {
getRecommendedContracts(contract, user.id, 6).then(setRecommendations)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [contract.id, user?.id])
if (recommendations.length === 0) {
return null
} }
// eslint-disable-next-line react-hooks/exhaustive-deps return (
}, [contract.id, user?.id]) <Col className="mt-2 gap-2 px-2 sm:px-0">
if (recommendations.length === 0) { <Title className="text-gray-700" text="Recommended" />
return null <ContractsGrid
contracts={recommendations}
trackingPostfix=" recommended"
/>
</Col>
)
} }
return ( )
<Col className="mt-2 gap-2 px-2 sm:px-0">
<Title className="text-gray-700" text="Recommended" />
<ContractsGrid
contracts={recommendations}
trackingPostfix=" recommended"
/>
</Col>
)
}

View File

@ -34,20 +34,14 @@ export const getStaticProps = fromPropz(getStaticPropz)
export async function getStaticPropz(props: { export async function getStaticPropz(props: {
params: { username: string; contractSlug: string } params: { username: string; contractSlug: string }
}) { }) {
const { username, contractSlug } = props.params const { contractSlug } = props.params
const contract = (await getContractFromSlug(contractSlug)) || null const contract = (await getContractFromSlug(contractSlug)) || null
const contractId = contract?.id const contractId = contract?.id
const bets = contractId ? await listAllBets(contractId) : [] const bets = contractId ? await listAllBets(contractId) : []
return { return {
props: { props: { contract, bets },
contract,
username,
slug: contractSlug,
bets,
},
revalidate: 60, // regenerate after a minute revalidate: 60, // regenerate after a minute
} }
} }
@ -58,16 +52,9 @@ export async function getStaticPaths() {
export default function ContractEmbedPage(props: { export default function ContractEmbedPage(props: {
contract: Contract | null contract: Contract | null
username: string
bets: Bet[] bets: Bet[]
slug: string
}) { }) {
props = usePropz(props, getStaticPropz) ?? { props = usePropz(props, getStaticPropz) ?? { contract: null, bets: [] }
contract: null,
username: '',
bets: [],
slug: '',
}
const contract = useContractWithPreload(props.contract) const contract = useContractWithPreload(props.contract)
const { bets } = props const { bets } = props