manifold/web/components/contract/contract-tabs.tsx

284 lines
8.3 KiB
TypeScript
Raw Normal View History

import { memo, useState } from 'react'
import { getOutcomeProbability } from 'common/calculate'
import { Pagination } from 'web/components/pagination'
import { FeedBet } from '../feed/feed-bets'
import { FeedLiquidity } from '../feed/feed-liquidity'
import { FeedAnswerCommentGroup } from '../feed/feed-answer-comment-group'
import { FeedCommentThread, ContractCommentInput } from '../feed/feed-comments'
import { groupBy, sortBy, sum } from 'lodash'
import { Bet } from 'common/bet'
import { Contract } from 'common/contract'
import { PAST_BETS } from 'common/user'
import { ContractBetsTable } from '../bets-list'
import { Spacer } from '../layout/spacer'
import { Tabs } from '../layout/tabs'
import { Col } from '../layout/col'
import { LoadingIndicator } from 'web/components/loading-indicator'
2022-07-24 07:11:33 +00:00
import { useComments } from 'web/hooks/use-comments'
import { useLiquidity } from 'web/hooks/use-liquidity'
import { useTipTxns } from 'web/hooks/use-tip-txns'
2022-09-15 15:57:14 +00:00
import { capitalize } from 'lodash'
import {
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
HOUSE_LIQUIDITY_PROVIDER_ID,
} from 'common/antes'
import { buildArray } from 'common/util/array'
2022-09-28 17:11:26 +00:00
import { ContractComment } from 'common/comment'
import { Button } from 'web/components/button'
import { MINUTE_MS } from 'common/util/time'
import { useUser } from 'web/hooks/use-user'
import { Tooltip } from 'web/components/tooltip'
2022-09-30 19:48:04 +00:00
import { BountiedContractSmallBadge } from 'web/components/contract/bountied-contract-badge'
2022-09-30 19:44:44 +00:00
import { Row } from '../layout/row'
import {
storageStore,
usePersistentState,
} from 'web/hooks/use-persistent-state'
import { safeLocalStorage } from 'web/lib/util/local'
export function ContractTabs(props: {
contract: Contract
bets: Bet[]
userBets: Bet[]
2022-09-28 17:11:26 +00:00
comments: ContractComment[]
}) {
2022-09-28 17:11:26 +00:00
const { contract, bets, userBets, comments } = props
const yourTrades = (
<div>
<Spacer h={6} />
<ContractBetsTable contract={contract} bets={userBets} isYourBets />
<Spacer h={12} />
</div>
)
const tabs = buildArray(
{
2022-09-30 19:44:44 +00:00
title: 'Comments',
2022-09-28 17:11:26 +00:00
content: <CommentsTabContent contract={contract} comments={comments} />,
},
bets.length > 0 && {
title: capitalize(PAST_BETS),
content: <BetsTabContent contract={contract} bets={bets} />,
},
userBets.length > 0 && {
title: 'Your trades',
content: yourTrades,
}
)
return (
<Tabs className="mb-4" currentPageForAnalytics={'contract'} tabs={tabs} />
)
}
const CommentsTabContent = memo(function CommentsTabContent(props: {
contract: Contract
2022-09-28 17:11:26 +00:00
comments: ContractComment[]
}) {
const { contract } = props
const tips = useTipTxns({ contractId: contract.id })
2022-09-28 17:11:26 +00:00
const comments = useComments(contract.id) ?? props.comments
const [sort, setSort] = usePersistentState<'Newest' | 'Best'>('Newest', {
key: `contract-comments-sort`,
store: storageStore(safeLocalStorage()),
})
const me = useUser()
if (comments == null) {
return <LoadingIndicator />
}
const tipsOrBountiesAwarded =
Object.keys(tips).length > 0 || comments.some((c) => c.bountiesAwarded)
2022-10-03 22:38:12 +00:00
// replied to answers/comments are NOT newest, otherwise newest first
const shouldBeNewestFirst = (c: ContractComment) =>
c.replyToCommentId == undefined &&
(contract.outcomeType === 'FREE_RESPONSE'
? c.betId === undefined && c.answerOutcome == undefined
: true)
// TODO: links to comments are broken because tips load after render and
// comments will reorganize themselves if there are tips/bounties awarded
const sortedComments = sortBy(comments, [
sort === 'Best'
? (c) =>
// Is this too magic? If there are tips/bounties, 'Best' shows your own comments made within the last 10 minutes first, then sorts by score
tipsOrBountiesAwarded &&
c.createdTime > Date.now() - 10 * MINUTE_MS &&
c.userId === me?.id &&
shouldBeNewestFirst(c)
? -Infinity
: -((c.bountiesAwarded ?? 0) + sum(Object.values(tips[c.id] ?? [])))
: (c) => c,
(c) => (!shouldBeNewestFirst(c) ? c.createdTime : -c.createdTime),
])
const commentsByParent = groupBy(
sortedComments,
(c) => c.replyToCommentId ?? '_'
)
const topLevelComments = commentsByParent['_'] ?? []
2022-10-03 22:38:12 +00:00
const sortRow = comments.length > 0 && (
2022-10-07 21:26:23 +00:00
<Row className="mb-4 items-center">
2022-10-03 22:38:12 +00:00
<Button
size={'xs'}
color={'gray-white'}
onClick={() => setSort(sort === 'Newest' ? 'Best' : 'Newest')}
>
<Tooltip
text={
sort === 'Best'
? 'Highest tips + bounties first. Your new comments briefly appear to you first.'
: ''
}
>
Sort by: {sort}
</Tooltip>
</Button>
<BountiedContractSmallBadge contract={contract} showAmount />
</Row>
)
if (contract.outcomeType === 'FREE_RESPONSE') {
const sortedAnswers = sortBy(
contract.answers,
(a) => -getOutcomeProbability(contract, a.id)
)
const commentsByOutcome = groupBy(
2022-10-03 22:38:12 +00:00
sortedComments,
(c) => c.answerOutcome ?? c.betOutcome ?? '_'
)
const generalTopLevelComments = topLevelComments.filter(
(c) => c.answerOutcome === undefined && c.betId === undefined
)
2022-10-03 22:38:12 +00:00
return (
<>
2022-10-03 22:38:12 +00:00
{sortRow}
{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}
2022-10-03 22:38:12 +00:00
answerComments={commentsByOutcome[answer.number.toString()] ?? []}
tips={tips}
/>
</div>
))}
<Col className="mt-8 flex w-full">
<div className="text-md mt-8 mb-2 text-left">General Comments</div>
<div className="mb-4 w-full border-b border-gray-200" />
<ContractCommentInput className="mb-5" contract={contract} />
{sortRow}
{generalTopLevelComments.map((comment) => (
<FeedCommentThread
key={comment.id}
contract={contract}
parentComment={comment}
threadComments={commentsByParent[comment.id] ?? []}
tips={tips}
/>
))}
</Col>
</>
)
} else {
return (
<>
<ContractCommentInput className="mb-5" contract={contract} />
{sortRow}
{topLevelComments.map((parent) => (
<FeedCommentThread
key={parent.id}
contract={contract}
parentComment={parent}
threadComments={sortBy(
commentsByParent[parent.id] ?? [],
(c) => c.createdTime
)}
tips={tips}
/>
))}
</>
)
}
})
const BetsTabContent = memo(function BetsTabContent(props: {
contract: Contract
bets: Bet[]
}) {
const { contract, bets } = props
const [page, setPage] = useState(0)
const ITEMS_PER_PAGE = 50
const start = page * ITEMS_PER_PAGE
const end = start + ITEMS_PER_PAGE
const lps = useLiquidity(contract.id) ?? []
const visibleBets = bets.filter(
(bet) => !bet.isAnte && !bet.isRedemption && bet.amount !== 0
)
const visibleLps = lps.filter(
(l) =>
!l.isAnte &&
l.userId !== HOUSE_LIQUIDITY_PROVIDER_ID &&
l.userId !== DEV_HOUSE_LIQUIDITY_PROVIDER_ID &&
l.amount > 0
)
const items = [
...visibleBets.map((bet) => ({
type: 'bet' as const,
id: bet.id + '-' + bet.isSold,
bet,
})),
...visibleLps.map((lp) => ({
type: 'liquidity' as const,
id: lp.id,
lp,
})),
]
const pageItems = sortBy(items, (item) =>
item.type === 'bet'
? -item.bet.createdTime
: item.type === 'liquidity'
? -item.lp.createdTime
: undefined
).slice(start, end)
return (
<>
<Col className="mb-4 gap-4">
{pageItems.map((item) =>
item.type === 'bet' ? (
<FeedBet key={item.id} contract={contract} bet={item.bet} />
) : (
<FeedLiquidity key={item.id} liquidity={item.lp} />
)
)}
</Col>
<Pagination
page={page}
itemsPerPage={50}
totalItems={items.length}
setPage={setPage}
scrollToTop
nextTitle={'Older'}
prevTitle={'Newer'}
/>
</>
)
})