manifold/web/components/answers/answers-panel.tsx

258 lines
8.1 KiB
TypeScript
Raw Normal View History

import { sortBy, partition, sum } from 'lodash'
import { useEffect, useState } from 'react'
2022-02-20 22:25:58 +00:00
import { FreeResponseContract, MultipleChoiceContract } from 'common/contract'
2022-02-20 22:25:58 +00:00
import { Col } from '../layout/col'
import { useUser } from 'web/hooks/use-user'
import { getDpmOutcomeProbability } from 'common/calculate-dpm'
import { useAnswers } from 'web/hooks/use-answers'
import { tradingAllowed } from 'web/lib/firebase/contracts'
2022-02-20 22:25:58 +00:00
import { AnswerItem } from './answer-item'
import { CreateAnswerPanel } from './create-answer-panel'
import { AnswerResolvePanel } from './answer-resolve-panel'
import { Spacer } from '../layout/spacer'
import { getOutcomeProbability } from 'common/calculate'
import { Answer } from 'common/answer'
import clsx from 'clsx'
import { formatPercent } from 'common/util/format'
import { Modal } from 'web/components/layout/modal'
import { AnswerBetPanel } from 'web/components/answers/answer-bet-panel'
import { Row } from 'web/components/layout/row'
import { Avatar } from 'web/components/avatar'
import { Linkify } from 'web/components/linkify'
import { Button } from 'web/components/button'
import { useAdmin } from 'web/hooks/use-admin'
import { needsAdminToResolve } from 'web/pages/[username]/[contractSlug]'
import { CHOICE_ANSWER_COLORS } from '../charts/contract/choice'
import { useChartAnswers } from '../charts/contract/choice'
import { ChatIcon } from '@heroicons/react/outline'
2022-02-20 22:25:58 +00:00
export function AnswersPanel(props: {
contract: FreeResponseContract | MultipleChoiceContract
onAnswerCommentClick: (answer: Answer) => void
}) {
const isAdmin = useAdmin()
const { contract, onAnswerCommentClick } = props
const { creatorId, resolution, resolutions, totalBets, outcomeType } =
contract
const [showAllAnswers, setShowAllAnswers] = useState(false)
2022-02-20 22:25:58 +00:00
2022-09-13 13:48:41 +00:00
const answers = (useAnswers(contract.id) ?? contract.answers).filter(
(a) => a.number != 0 || contract.outcomeType === 'MULTIPLE_CHOICE'
)
const hasZeroBetAnswers = answers.some((answer) => totalBets[answer.id] < 1)
const [winningAnswers, losingAnswers] = partition(
answers.filter((a) => (showAllAnswers ? true : totalBets[a.id] > 0)),
(answer) =>
answer.id === resolution || (resolutions && resolutions[answer.id])
2022-02-20 22:25:58 +00:00
)
const sortedAnswers = [
...sortBy(winningAnswers, (answer) =>
resolutions ? -1 * resolutions[answer.id] : 0
),
...sortBy(
resolution ? [] : losingAnswers,
(answer) => -1 * getDpmOutcomeProbability(contract.totalShares, answer.id)
),
]
const answerItems = sortBy(
losingAnswers.length > 0 ? losingAnswers : sortedAnswers,
(answer) => -getOutcomeProbability(contract, answer.id)
)
2022-02-20 22:25:58 +00:00
const user = useUser()
const [resolveOption, setResolveOption] = useState<
'CHOOSE' | 'CHOOSE_MULTIPLE' | 'CANCEL' | undefined
>()
const [chosenAnswers, setChosenAnswers] = useState<{
[answerId: string]: number
}>({})
const chosenTotal = sum(Object.values(chosenAnswers))
2022-02-20 22:25:58 +00:00
const onChoose = (answerId: string, prob: number) => {
if (resolveOption === 'CHOOSE') {
setChosenAnswers({ [answerId]: prob })
} else {
setChosenAnswers((chosenAnswers) => {
return {
...chosenAnswers,
[answerId]: prob,
}
})
}
}
const onDeselect = (answerId: string) => {
setChosenAnswers((chosenAnswers) => {
const newChosenAnswers = { ...chosenAnswers }
delete newChosenAnswers[answerId]
return newChosenAnswers
})
}
useEffect(() => {
2022-02-20 22:25:58 +00:00
setChosenAnswers({})
}, [resolveOption])
const showChoice = resolution
? undefined
: resolveOption === 'CHOOSE'
? 'radio'
: resolveOption === 'CHOOSE_MULTIPLE'
? 'checkbox'
: undefined
const colorSortedAnswer = useChartAnswers(contract).map(
(value, _index) => value.text
)
2022-02-20 22:25:58 +00:00
return (
<Col className="gap-3">
{(resolveOption || resolution) &&
sortedAnswers.map((answer) => (
<AnswerItem
key={answer.id}
answer={answer}
contract={contract}
showChoice={showChoice}
chosenProb={chosenAnswers[answer.id]}
totalChosenProb={chosenTotal}
onChoose={onChoose}
onDeselect={onDeselect}
/>
))}
2022-02-20 22:25:58 +00:00
{!resolveOption && (
<Col
className={clsx(
'gap-2 pr-2 md:pr-0',
tradingAllowed(contract) ? '' : '-mb-6'
)}
>
{answerItems.map((item) => (
<OpenAnswer
key={item.id}
answer={item}
contract={contract}
colorIndex={colorSortedAnswer.indexOf(item.text)}
onAnswerCommentClick={onAnswerCommentClick}
/>
))}
{hasZeroBetAnswers && !showAllAnswers && (
<Button
className="self-end"
color="gray-white"
onClick={() => setShowAllAnswers(true)}
size="md"
>
Show More
</Button>
)}
</Col>
)}
{answers.length <= 1 && (
<div className="pb-4 text-gray-500">No answers yet...</div>
)}
{outcomeType === 'FREE_RESPONSE' && tradingAllowed(contract) && (
<CreateAnswerPanel contract={contract} />
)}
2022-02-20 22:25:58 +00:00
{(user?.id === creatorId || (isAdmin && needsAdminToResolve(contract))) &&
!resolution && (
<>
<Spacer h={2} />
<AnswerResolvePanel
isAdmin={isAdmin}
isCreator={user?.id === creatorId}
contract={contract}
resolveOption={resolveOption}
setResolveOption={setResolveOption}
chosenAnswers={chosenAnswers}
/>
</>
)}
2022-02-20 22:25:58 +00:00
</Col>
)
}
function OpenAnswer(props: {
contract: FreeResponseContract | MultipleChoiceContract
answer: Answer
colorIndex: number | undefined
onAnswerCommentClick: (answer: Answer) => void
}) {
const { answer, contract, colorIndex, onAnswerCommentClick } = props
const { username, avatarUrl, text } = answer
const prob = getDpmOutcomeProbability(contract.totalShares, answer.id)
const probPercent = formatPercent(prob)
const [open, setOpen] = useState(false)
const color =
colorIndex != undefined && colorIndex < CHOICE_ANSWER_COLORS.length
? CHOICE_ANSWER_COLORS[colorIndex] + '55' // semi-transparent
: '#B1B1C755'
const colorWidth = 100 * Math.max(prob, 0.01)
return (
<Col className="my-1 px-2">
2022-09-09 21:08:42 +00:00
<Modal open={open} setOpen={setOpen} position="center">
<AnswerBetPanel
answer={answer}
contract={contract}
closePanel={() => setOpen(false)}
className="sm:max-w-84 !rounded-md bg-white !px-8 !py-6"
isModal={true}
/>
</Modal>
<Col
className={clsx(
'relative w-full rounded-lg transition-all',
tradingAllowed(contract) ? 'text-greyscale-7' : 'text-greyscale-5'
)}
style={{
background: `linear-gradient(to right, ${color} ${colorWidth}%, #FBFBFF ${colorWidth}%)`,
}}
>
<Row className="z-20 -mb-1 justify-between gap-2 py-2 px-3">
<Row>
<Avatar
className="mt-0.5 mr-2 inline h-5 w-5 border border-transparent transition-transform hover:border-none"
username={username}
avatarUrl={avatarUrl}
/>
2022-10-13 14:04:01 +00:00
<Linkify className="text-md whitespace-pre-line" text={text} />
</Row>
<Row className="gap-2">
<div className="my-auto text-xl">{probPercent}</div>
{tradingAllowed(contract) && (
<Button
size="2xs"
color="gray-outline"
onClick={() => setOpen(true)}
className="my-auto"
>
BUY
</Button>
)}
{
<button
className="p-1"
onClick={() => onAnswerCommentClick(answer)}
>
<ChatIcon className="text-greyscale-4 hover:text-greyscale-6 h-5 w-5 transition-colors" />
</button>
}
</Row>
</Row>
</Col>
</Col>
)
}