Inga/fr remove double comments (#1019)

incorporating answer comments into general comments section
This commit is contained in:
ingawei 2022-10-12 13:05:58 -05:00 committed by GitHub
parent 2cda3a4d4f
commit 1d618ba337
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 470 additions and 293 deletions

View File

@ -25,12 +25,14 @@ import { useAdmin } from 'web/hooks/use-admin'
import { needsAdminToResolve } from 'web/pages/[username]/[contractSlug]' import { needsAdminToResolve } from 'web/pages/[username]/[contractSlug]'
import { CHOICE_ANSWER_COLORS } from '../charts/contract/choice' import { CHOICE_ANSWER_COLORS } from '../charts/contract/choice'
import { useChartAnswers } from '../charts/contract/choice' import { useChartAnswers } from '../charts/contract/choice'
import { ChatIcon } from '@heroicons/react/outline'
export function AnswersPanel(props: { export function AnswersPanel(props: {
contract: FreeResponseContract | MultipleChoiceContract contract: FreeResponseContract | MultipleChoiceContract
onAnswerCommentClick: (answer: Answer) => void
}) { }) {
const isAdmin = useAdmin() const isAdmin = useAdmin()
const { contract } = props const { contract, onAnswerCommentClick } = props
const { creatorId, resolution, resolutions, totalBets, outcomeType } = const { creatorId, resolution, resolutions, totalBets, outcomeType } =
contract contract
const [showAllAnswers, setShowAllAnswers] = useState(false) const [showAllAnswers, setShowAllAnswers] = useState(false)
@ -138,6 +140,7 @@ export function AnswersPanel(props: {
answer={item} answer={item}
contract={contract} contract={contract}
colorIndex={colorSortedAnswer.indexOf(item.text)} colorIndex={colorSortedAnswer.indexOf(item.text)}
onAnswerCommentClick={onAnswerCommentClick}
/> />
))} ))}
{hasZeroBetAnswers && !showAllAnswers && ( {hasZeroBetAnswers && !showAllAnswers && (
@ -183,8 +186,9 @@ function OpenAnswer(props: {
contract: FreeResponseContract | MultipleChoiceContract contract: FreeResponseContract | MultipleChoiceContract
answer: Answer answer: Answer
colorIndex: number | undefined colorIndex: number | undefined
onAnswerCommentClick: (answer: Answer) => void
}) { }) {
const { answer, contract, colorIndex } = props const { answer, contract, colorIndex, onAnswerCommentClick } = props
const { username, avatarUrl, text } = answer const { username, avatarUrl, text } = answer
const prob = getDpmOutcomeProbability(contract.totalShares, answer.id) const prob = getDpmOutcomeProbability(contract.totalShares, answer.id)
const probPercent = formatPercent(prob) const probPercent = formatPercent(prob)
@ -240,6 +244,14 @@ function OpenAnswer(props: {
BUY BUY
</Button> </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>
</Row> </Row>
</Col> </Col>

View File

@ -1,12 +1,17 @@
import { PaperAirplaneIcon } from '@heroicons/react/solid' import { PaperAirplaneIcon, XCircleIcon } from '@heroicons/react/solid'
import { Editor } from '@tiptap/react' import { Editor } from '@tiptap/react'
import clsx from 'clsx' import clsx from 'clsx'
import { Answer } from 'common/answer'
import { AnyContractType, Contract } from 'common/contract'
import { User } from 'common/user' import { User } from 'common/user'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { MAX_COMMENT_LENGTH } from 'web/lib/firebase/comments' import { MAX_COMMENT_LENGTH } from 'web/lib/firebase/comments'
import Curve from 'web/public/custom-components/curve'
import { Avatar } from './avatar' import { Avatar } from './avatar'
import { TextEditor, useTextEditor } from './editor' import { TextEditor, useTextEditor } from './editor'
import { CommentsAnswer } from './feed/feed-answer-comment-group'
import { ContractCommentInput } from './feed/feed-comments'
import { Row } from './layout/row' import { Row } from './layout/row'
import { LoadingIndicator } from './loading-indicator' import { LoadingIndicator } from './loading-indicator'
@ -72,6 +77,40 @@ export function CommentInput(props: {
</Row> </Row>
) )
} }
export function AnswerCommentInput(props: {
contract: Contract<AnyContractType>
answerResponse: Answer
onCancelAnswerResponse?: () => void
}) {
const { contract, answerResponse, onCancelAnswerResponse } = props
const replyTo = {
id: answerResponse.id,
username: answerResponse.username,
}
return (
<>
<CommentsAnswer answer={answerResponse} contract={contract} />
<Row>
<div className="ml-1">
<Curve size={28} strokeWidth={1} color="#D8D8EB" />
</div>
<div className="relative w-full pt-1">
<ContractCommentInput
contract={contract}
parentAnswerOutcome={answerResponse.number.toString()}
replyTo={replyTo}
onSubmitComment={onCancelAnswerResponse}
/>
<button onClick={onCancelAnswerResponse}>
<div className="absolute -top-1 -right-2 h-4 w-4 rounded-full bg-white" />
<XCircleIcon className="text-greyscale-5 hover:text-greyscale-6 absolute -top-1 -right-2 h-5 w-5" />
</button>
</div>
</Row>
</>
)
}
export function CommentInputTextArea(props: { export function CommentInputTextArea(props: {
user: User | undefined | null user: User | undefined | null
@ -123,7 +162,7 @@ export function CommentInputTextArea(props: {
attrs: { label: replyTo.username, id: replyTo.id }, attrs: { label: replyTo.username, id: replyTo.id },
}) })
.insertContent(' ') .insertContent(' ')
.focus() .focus(undefined, { scrollIntoView: false })
.run() .run()
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps

View File

@ -0,0 +1,29 @@
import clsx from 'clsx'
import TriangleDownFillIcon from 'web/lib/icons/triangle-down-fill-icon'
import { Row } from '../layout/row'
export function ReplyToggle(props: {
seeReplies: boolean
numComments: number
onClick: () => void
}) {
const { seeReplies, numComments, onClick } = props
return (
<button
className={clsx(
'text-left text-sm text-indigo-600',
numComments === 0 ? 'hidden' : ''
)}
onClick={onClick}
>
<Row className="items-center gap-1">
<div>
{numComments} {numComments === 1 ? 'Reply' : 'Replies'}
</div>
<TriangleDownFillIcon
className={clsx('h-2 w-2', seeReplies ? 'rotate-180' : '')}
/>
</Row>
</button>
)
}

View File

@ -1,9 +1,8 @@
import { memo, useState } from 'react' import { memo, useState } from 'react'
import { getOutcomeProbability } from 'common/calculate'
import { Pagination } from 'web/components/pagination' import { Pagination } from 'web/components/pagination'
import { FeedBet } from '../feed/feed-bets' 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 { CommentsAnswer } from '../feed/feed-answer-comment-group'
import { FeedCommentThread, ContractCommentInput } from '../feed/feed-comments' import { FeedCommentThread, ContractCommentInput } from '../feed/feed-comments'
import { groupBy, sortBy, sum } from 'lodash' import { groupBy, sortBy, sum } from 'lodash'
import { Bet } from 'common/bet' import { Bet } from 'common/bet'
@ -25,7 +24,6 @@ import {
import { buildArray } from 'common/util/array' import { buildArray } from 'common/util/array'
import { ContractComment } from 'common/comment' import { ContractComment } from 'common/comment'
import { Button } from 'web/components/button'
import { MINUTE_MS } from 'common/util/time' import { MINUTE_MS } from 'common/util/time'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { Tooltip } from 'web/components/tooltip' import { Tooltip } from 'web/components/tooltip'
@ -36,14 +34,27 @@ import {
usePersistentState, usePersistentState,
} from 'web/hooks/use-persistent-state' } from 'web/hooks/use-persistent-state'
import { safeLocalStorage } from 'web/lib/util/local' import { safeLocalStorage } from 'web/lib/util/local'
import TriangleDownFillIcon from 'web/lib/icons/triangle-down-fill-icon'
import Curve from 'web/public/custom-components/curve'
import { Answer } from 'common/answer'
import { AnswerCommentInput } from '../comment-input'
export function ContractTabs(props: { export function ContractTabs(props: {
contract: Contract contract: Contract
bets: Bet[] bets: Bet[]
userBets: Bet[] userBets: Bet[]
comments: ContractComment[] comments: ContractComment[]
answerResponse?: Answer | undefined
onCancelAnswerResponse?: () => void
}) { }) {
const { contract, bets, userBets, comments } = props const {
contract,
bets,
userBets,
comments,
answerResponse,
onCancelAnswerResponse,
} = props
const yourTrades = ( const yourTrades = (
<div> <div>
@ -56,7 +67,14 @@ export function ContractTabs(props: {
const tabs = buildArray( const tabs = buildArray(
{ {
title: 'Comments', title: 'Comments',
content: <CommentsTabContent contract={contract} comments={comments} />, content: (
<CommentsTabContent
contract={contract}
comments={comments}
answerResponse={answerResponse}
onCancelAnswerResponse={onCancelAnswerResponse}
/>
),
}, },
bets.length > 0 && { bets.length > 0 && {
title: capitalize(PAST_BETS), title: capitalize(PAST_BETS),
@ -76,8 +94,10 @@ export function ContractTabs(props: {
const CommentsTabContent = memo(function CommentsTabContent(props: { const CommentsTabContent = memo(function CommentsTabContent(props: {
contract: Contract contract: Contract
comments: ContractComment[] comments: ContractComment[]
answerResponse?: Answer
onCancelAnswerResponse?: () => void
}) { }) {
const { contract } = props const { contract, answerResponse, onCancelAnswerResponse } = props
const tips = useTipTxns({ contractId: contract.id }) const tips = useTipTxns({ contractId: contract.id })
const comments = useComments(contract.id) ?? props.comments const comments = useComments(contract.id) ?? props.comments
const [sort, setSort] = usePersistentState<'Newest' | 'Best'>('Newest', { const [sort, setSort] = usePersistentState<'Newest' | 'Best'>('Newest', {
@ -95,10 +115,7 @@ const CommentsTabContent = memo(function CommentsTabContent(props: {
// replied to answers/comments are NOT newest, otherwise newest first // replied to answers/comments are NOT newest, otherwise newest first
const shouldBeNewestFirst = (c: ContractComment) => const shouldBeNewestFirst = (c: ContractComment) =>
c.replyToCommentId == undefined && 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 // TODO: links to comments are broken because tips load after render and
// comments will reorganize themselves if there are tips/bounties awarded // comments will reorganize themselves if there are tips/bounties awarded
@ -123,73 +140,85 @@ const CommentsTabContent = memo(function CommentsTabContent(props: {
const topLevelComments = commentsByParent['_'] ?? [] const topLevelComments = commentsByParent['_'] ?? []
const sortRow = comments.length > 0 && ( const sortRow = comments.length > 0 && (
<Row className="mb-4 items-center"> <Row className="mb-4 items-center justify-end gap-4">
<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 /> <BountiedContractSmallBadge contract={contract} showAmount />
<Row className="items-center gap-1">
<div className="text-greyscale-4 text-sm">Sort by:</div>
<button
className="text-greyscale-6 w-20 text-sm"
onClick={() => setSort(sort === 'Newest' ? 'Best' : 'Newest')}
>
<Tooltip
text={sort === 'Best' ? 'Highest tips + bounties first.' : ''}
>
<Row className="items-center gap-1">
{sort}
<TriangleDownFillIcon className=" h-2 w-2" />
</Row>
</Tooltip>
</button>
</Row>
</Row> </Row>
) )
if (contract.outcomeType === 'FREE_RESPONSE') { if (contract.outcomeType === 'FREE_RESPONSE') {
const sortedAnswers = sortBy(
contract.answers,
(a) => -getOutcomeProbability(contract, a.id)
)
const commentsByOutcome = groupBy(
sortedComments,
(c) => c.answerOutcome ?? c.betOutcome ?? '_'
)
const generalTopLevelComments = topLevelComments.filter(
(c) => c.answerOutcome === undefined && c.betId === undefined
)
return ( return (
<> <>
<ContractCommentInput className="mb-5" contract={contract} />
{sortRow} {sortRow}
{sortedAnswers.map((answer) => ( {answerResponse && (
<div key={answer.id} className="relative pb-4"> <AnswerCommentInput
<span contract={contract}
className="absolute top-5 left-5 -ml-px h-[calc(100%-2rem)] w-0.5 bg-gray-200" answerResponse={answerResponse}
aria-hidden="true" onCancelAnswerResponse={onCancelAnswerResponse}
/> />
<FeedAnswerCommentGroup )}
contract={contract} {topLevelComments.map((parent) => {
answer={answer} if (parent.answerOutcome === undefined) {
answerComments={commentsByOutcome[answer.number.toString()] ?? []} return (
tips={tips} <FeedCommentThread
/> key={parent.id}
</div> contract={contract}
))} parentComment={parent}
<Col className="mt-8 flex w-full"> threadComments={sortBy(
<div className="text-md mt-8 mb-2 text-left">General Comments</div> commentsByParent[parent.id] ?? [],
<div className="mb-4 w-full border-b border-gray-200" /> (c) => c.createdTime
<ContractCommentInput className="mb-5" contract={contract} /> )}
{sortRow} tips={tips}
/>
{generalTopLevelComments.map((comment) => ( )
<FeedCommentThread }
key={comment.id} const answer = contract.answers.find(
contract={contract} (answer) => answer.id === parent.answerOutcome
parentComment={comment} )
threadComments={commentsByParent[comment.id] ?? []} if (answer === undefined) {
tips={tips} console.error('Could not find answer that matches ID')
/> return <></>
))} }
</Col> return (
<>
<Row className="gap-2">
<CommentsAnswer answer={answer} contract={contract} />
</Row>
<Row>
<div className="ml-1">
<Curve size={28} strokeWidth={1} color="#D8D8EB" />
</div>
<div className="w-full pt-1">
<FeedCommentThread
key={parent.id}
contract={contract}
parentComment={parent}
threadComments={sortBy(
commentsByParent[parent.id] ?? [],
(c) => c.createdTime
)}
tips={tips}
/>
</div>
</Row>
</>
)
})}
</> </>
) )
} else { } else {

View File

@ -44,7 +44,7 @@ export function TipButton(props: {
<Col className={'relative items-center sm:flex-row'}> <Col className={'relative items-center sm:flex-row'}>
<HeartIcon <HeartIcon
className={clsx( className={clsx(
'h-5 w-5 sm:h-6 sm:w-6', 'h-5 w-5',
totalTipped > 0 ? 'mr-2' : '', totalTipped > 0 ? 'mr-2' : '',
userTipped ? 'fill-teal-500 text-teal-500' : '' userTipped ? 'fill-teal-500 text-teal-500' : ''
)} )}

View File

@ -33,7 +33,7 @@ export function CopyLinkDateTimeComponent(props: {
<a <a
onClick={copyLinkToComment} onClick={copyLinkToComment}
className={ className={
'mx-1 whitespace-nowrap rounded-sm px-1 text-gray-400 hover:bg-gray-100' 'text-greyscale-4 hover:bg-greyscale-1.5 mx-1 whitespace-nowrap rounded-sm px-1 text-xs transition-colors'
} }
> >
{fromNow(createdTime)} {fromNow(createdTime)}

View File

@ -1,46 +1,21 @@
import { Answer } from 'common/answer' import { Answer } from 'common/answer'
import { FreeResponseContract } from 'common/contract' import { Contract } from 'common/contract'
import { ContractComment } from 'common/comment' import React, { useEffect, useRef } from 'react'
import React, { useEffect, useRef, useState } from 'react'
import { sum } from 'lodash'
import { Col } from 'web/components/layout/col' import { Col } from 'web/components/layout/col'
import { Row } from 'web/components/layout/row' import { Row } from 'web/components/layout/row'
import { Avatar } from 'web/components/avatar' import { Avatar } from 'web/components/avatar'
import { Linkify } from 'web/components/linkify'
import clsx from 'clsx'
import {
ContractCommentInput,
FeedComment,
ReplyTo,
} from 'web/components/feed/feed-comments'
import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time' import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { useUser } from 'web/hooks/use-user'
import { useEvent } from 'web/hooks/use-event'
import { CommentTipMap } from 'web/hooks/use-tip-txns'
import { UserLink } from 'web/components/user-link' import { UserLink } from 'web/components/user-link'
export function FeedAnswerCommentGroup(props: { export function CommentsAnswer(props: { answer: Answer; contract: Contract }) {
contract: FreeResponseContract const { answer, contract } = props
answer: Answer
answerComments: ContractComment[]
tips: CommentTipMap
}) {
const { answer, contract, answerComments, tips } = props
const { username, avatarUrl, name, text } = answer const { username, avatarUrl, name, text } = answer
const [replyTo, setReplyTo] = useState<ReplyTo>()
const user = useUser()
const router = useRouter()
const answerElementId = `answer-${answer.id}` const answerElementId = `answer-${answer.id}`
const router = useRouter()
const highlighted = router.asPath.endsWith(`#${answerElementId}`) const highlighted = router.asPath.endsWith(`#${answerElementId}`)
const answerRef = useRef<HTMLDivElement>(null) const answerRef = useRef<HTMLDivElement>(null)
const onSubmitComment = useEvent(() => setReplyTo(undefined))
const onReplyClick = useEvent((comment: ContractComment) => {
setReplyTo({ id: comment.id, username: comment.userUsername })
})
useEffect(() => { useEffect(() => {
if (highlighted && answerRef.current != null) { if (highlighted && answerRef.current != null) {
answerRef.current.scrollIntoView(true) answerRef.current.scrollIntoView(true)
@ -48,83 +23,20 @@ export function FeedAnswerCommentGroup(props: {
}, [highlighted]) }, [highlighted])
return ( return (
<Col className="relative flex-1 items-stretch gap-3"> <Col className="bg-greyscale-2 w-fit gap-1 rounded-t-xl rounded-bl-xl py-2 px-4">
<Row <Row className="gap-2">
className={clsx( <Avatar username={username} avatarUrl={avatarUrl} size="xxs" />
'gap-3 space-x-3 pt-4 transition-all duration-1000', <div className="text-greyscale-6 text-xs">
highlighted ? `-m-2 my-3 rounded bg-indigo-500/[0.2] p-2` : '' <UserLink username={username} name={name} /> answered
)} <CopyLinkDateTimeComponent
ref={answerRef} prefix={contract.creatorUsername}
id={answerElementId} slug={contract.slug}
> createdTime={answer.createdTime}
<Avatar username={username} avatarUrl={avatarUrl} /> elementId={answerElementId}
<Col className="min-w-0 flex-1 lg:gap-1">
<div className="text-sm text-gray-500">
<UserLink username={username} name={name} /> answered
<CopyLinkDateTimeComponent
prefix={contract.creatorUsername}
slug={contract.slug}
createdTime={answer.createdTime}
elementId={answerElementId}
/>
</div>
<Col className="align-items justify-between gap-2 sm:flex-row">
<span className="whitespace-pre-line text-lg">
<Linkify text={text} />
</span>
<div className="sm:hidden">
<button
className="text-xs font-bold text-gray-500 hover:underline"
onClick={() =>
setReplyTo({ id: answer.id, username: answer.username })
}
>
Reply
</button>
</div>
</Col>
<div className="justify-initial hidden sm:block">
<button
className="text-xs font-bold text-gray-500 hover:underline"
onClick={() =>
setReplyTo({ id: answer.id, username: answer.username })
}
>
Reply
</button>
</div>
</Col>
</Row>
<Col className="gap-3 pl-1">
{answerComments.map((comment) => (
<FeedComment
key={comment.id}
indent={true}
contract={contract}
comment={comment}
myTip={user ? tips[comment.id]?.[user.id] : undefined}
totalTip={sum(Object.values(tips[comment.id] ?? {}))}
showTip={true}
onReplyClick={onReplyClick}
/>
))}
</Col>
{replyTo && (
<div className="relative ml-7">
<span
className="absolute -left-1 -ml-[1px] mt-[1.25rem] h-2 w-0.5 rotate-90 bg-gray-200"
aria-hidden="true"
/>
<ContractCommentInput
contract={contract}
parentAnswerOutcome={answer.number.toString()}
replyTo={replyTo}
onSubmitComment={onSubmitComment}
/> />
</div> </div>
)} </Row>
<div className="text-sm">{text}</div>
</Col> </Col>
) )
} }

View File

@ -23,6 +23,9 @@ import { Content } from '../editor'
import { UserLink } from 'web/components/user-link' import { UserLink } from 'web/components/user-link'
import { CommentInput } from '../comment-input' import { CommentInput } from '../comment-input'
import { AwardBountyButton } from 'web/components/award-bounty-button' import { AwardBountyButton } from 'web/components/award-bounty-button'
import { ReplyIcon } from '@heroicons/react/solid'
import { Button } from '../button'
import { ReplyToggle } from '../comments/reply-toggle'
export type ReplyTo = { id: string; username: string } export type ReplyTo = { id: string; username: string }
@ -34,6 +37,7 @@ export function FeedCommentThread(props: {
}) { }) {
const { contract, threadComments, tips, parentComment } = props const { contract, threadComments, tips, parentComment } = props
const [replyTo, setReplyTo] = useState<ReplyTo>() const [replyTo, setReplyTo] = useState<ReplyTo>()
const [seeReplies, setSeeReplies] = useState(false)
const user = useUser() const user = useUser()
const onSubmitComment = useEvent(() => setReplyTo(undefined)) const onSubmitComment = useEvent(() => setReplyTo(undefined))
@ -43,28 +47,37 @@ export function FeedCommentThread(props: {
return ( return (
<Col className="relative w-full items-stretch gap-3 pb-4"> <Col className="relative w-full items-stretch gap-3 pb-4">
<span <ParentFeedComment
className="absolute top-5 left-4 -ml-px h-[calc(100%-2rem)] w-0.5 bg-gray-200" key={parentComment.id}
aria-hidden="true" contract={contract}
comment={parentComment}
myTip={user ? tips[parentComment.id]?.[user.id] : undefined}
totalTip={sum(Object.values(tips[parentComment.id] ?? {}))}
showTip={true}
seeReplies={seeReplies}
numComments={threadComments.length}
onSeeReplyClick={() => setSeeReplies(!seeReplies)}
onReplyClick={() =>
setReplyTo({
id: parentComment.id,
username: parentComment.userUsername,
})
}
/> />
{[parentComment].concat(threadComments).map((comment, commentIdx) => ( {seeReplies &&
<FeedComment threadComments.map((comment, _commentIdx) => (
key={comment.id} <FeedComment
indent={commentIdx != 0} key={comment.id}
contract={contract} contract={contract}
comment={comment} comment={comment}
myTip={user ? tips[comment.id]?.[user.id] : undefined} myTip={user ? tips[comment.id]?.[user.id] : undefined}
totalTip={sum(Object.values(tips[comment.id] ?? {}))} totalTip={sum(Object.values(tips[comment.id] ?? {}))}
showTip={true} showTip={true}
onReplyClick={onReplyClick} onReplyClick={onReplyClick}
/> />
))} ))}
{replyTo && ( {replyTo && (
<Col className="-pb-2 relative ml-6"> <Col className="-pb-2 relative ml-6">
<span
className="absolute -left-1 -ml-[1px] mt-[0.8rem] h-2 w-0.5 rotate-90 bg-gray-200"
aria-hidden="true"
/>
<ContractCommentInput <ContractCommentInput
contract={contract} contract={contract}
parentCommentId={parentComment.id} parentCommentId={parentComment.id}
@ -77,38 +90,110 @@ export function FeedCommentThread(props: {
) )
} }
export function ParentFeedComment(props: {
contract: Contract
comment: ContractComment
showTip?: boolean
myTip?: number
totalTip?: number
seeReplies: boolean
numComments: number
onReplyClick?: (comment: ContractComment) => void
onSeeReplyClick: () => void
}) {
const {
contract,
comment,
myTip,
totalTip,
showTip,
onReplyClick,
onSeeReplyClick,
seeReplies,
numComments,
} = props
const { text, content, userUsername, userAvatarUrl } = comment
const { isReady, asPath } = useRouter()
const [highlighted, setHighlighted] = useState(false)
const commentRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (isReady && asPath.endsWith(`#${comment.id}`)) {
setHighlighted(true)
}
}, [isReady, asPath, comment.id])
useEffect(() => {
if (highlighted && commentRef.current) {
commentRef.current.scrollIntoView(true)
}
}, [highlighted])
return (
<Row
ref={commentRef}
id={comment.id}
className={clsx(
'hover:bg-greyscale-1 ml-3 gap-2 transition-colors',
highlighted ? `-m-1.5 rounded bg-indigo-500/[0.2] p-1.5` : ''
)}
>
<Col className="-ml-3.5">
<Avatar size="sm" username={userUsername} avatarUrl={userAvatarUrl} />
</Col>
<Col className="w-full">
<FeedCommentHeader comment={comment} contract={contract} />
<Content
className="text-greyscale-7 mt-2 grow text-[14px]"
content={content || text}
smallImage
/>
<Row className="justify-between">
<ReplyToggle
seeReplies={seeReplies}
numComments={numComments}
onClick={onSeeReplyClick}
/>
<Row className="grow justify-end gap-2">
{onReplyClick && (
<Button
size={'sm'}
className={clsx(
'hover:bg-greyscale-2 mt-0 mb-1 max-w-xs px-0 py-0'
)}
color={'gray-white'}
onClick={() => onReplyClick(comment)}
>
<ReplyIcon className="h-5 w-5" />
</Button>
)}
{showTip && (
<Tipper
comment={comment}
myTip={myTip ?? 0}
totalTip={totalTip ?? 0}
/>
)}
{(contract.openCommentBounties ?? 0) > 0 && (
<AwardBountyButton comment={comment} contract={contract} />
)}
</Row>
</Row>
</Col>
</Row>
)
}
export const FeedComment = memo(function FeedComment(props: { export const FeedComment = memo(function FeedComment(props: {
contract: Contract contract: Contract
comment: ContractComment comment: ContractComment
showTip?: boolean showTip?: boolean
myTip?: number myTip?: number
totalTip?: number totalTip?: number
indent?: boolean
onReplyClick?: (comment: ContractComment) => void onReplyClick?: (comment: ContractComment) => void
}) { }) {
const { contract, comment, myTip, totalTip, showTip, indent, onReplyClick } = const { contract, comment, myTip, totalTip, showTip, onReplyClick } = props
props const { text, content, userUsername, userAvatarUrl } = comment
const {
text,
content,
userUsername,
userName,
userAvatarUrl,
commenterPositionProb,
commenterPositionShares,
commenterPositionOutcome,
createdTime,
bountiesAwarded,
} = comment
const betOutcome = comment.betOutcome
let bought: string | undefined
let money: string | undefined
if (comment.betAmount != null) {
bought = comment.betAmount >= 0 ? 'bought' : 'sold'
money = formatMoney(Math.abs(comment.betAmount))
}
const totalAwarded = bountiesAwarded ?? 0
const { isReady, asPath } = useRouter() const { isReady, asPath } = useRouter()
const [highlighted, setHighlighted] = useState(false) const [highlighted, setHighlighted] = useState(false)
const commentRef = useRef<HTMLDivElement>(null) const commentRef = useRef<HTMLDivElement>(null)
@ -130,78 +215,36 @@ export const FeedComment = memo(function FeedComment(props: {
ref={commentRef} ref={commentRef}
id={comment.id} id={comment.id}
className={clsx( className={clsx(
'relative', 'hover:bg-greyscale-1 ml-10 gap-2 transition-colors',
indent ? 'ml-6' : '', highlighted ? `-m-1.5 rounded bg-indigo-500/[0.2] p-1.5` : ''
highlighted ? `-m-1.5 rounded bg-indigo-500/[0.2] px-2 py-4` : ''
)} )}
> >
{/*draw a gray line from the comment to the left:*/} <Col className="-ml-3">
{indent ? ( <Avatar size="xs" username={userUsername} avatarUrl={userAvatarUrl} />
<span <span
className="absolute -left-1 -ml-[1px] mt-[0.8rem] h-2 w-0.5 rotate-90 bg-gray-200" className="bg-greyscale-3 mx-auto h-full w-[1.5px]"
aria-hidden="true" aria-hidden="true"
/> />
) : null} </Col>
<Avatar size="sm" username={userUsername} avatarUrl={userAvatarUrl} /> <Col className="w-full">
<div className="ml-1.5 min-w-0 flex-1 pl-0.5 sm:ml-3"> <FeedCommentHeader comment={comment} contract={contract} />
<div className="mt-0.5 text-sm text-gray-500">
<UserLink
className="text-gray-500"
username={userUsername}
name={userName}
/>{' '}
{comment.betId == null &&
commenterPositionProb != null &&
commenterPositionOutcome != null &&
commenterPositionShares != null &&
commenterPositionShares > 0 &&
contract.outcomeType !== 'NUMERIC' && (
<>
{'is '}
<CommentStatus
prob={commenterPositionProb}
outcome={commenterPositionOutcome}
contract={contract}
/>
</>
)}
{bought} {money}
{contract.outcomeType !== 'FREE_RESPONSE' && betOutcome && (
<>
{' '}
of{' '}
<OutcomeLabel
outcome={betOutcome ? betOutcome : ''}
contract={contract}
truncate="short"
/>
</>
)}
<CopyLinkDateTimeComponent
prefix={contract.creatorUsername}
slug={contract.slug}
createdTime={createdTime}
elementId={comment.id}
/>
{totalAwarded > 0 && (
<span className=" text-primary ml-2 text-sm">
+{formatMoney(totalAwarded)}
</span>
)}
</div>
<Content <Content
className="mt-2 text-[15px] text-gray-700" className="text-greyscale-7 mt-2 grow text-[14px]"
content={content || text} content={content || text}
smallImage smallImage
/> />
<Row className="mt-2 items-center gap-6 text-xs text-gray-500"> <Row className="grow justify-end gap-2">
{onReplyClick && ( {onReplyClick && (
<button <Button
className="font-bold hover:underline" size={'sm'}
className={clsx(
'hover:bg-greyscale-2 mt-0 mb-1 max-w-xs px-0 py-0'
)}
color={'gray-white'}
onClick={() => onReplyClick(comment)} onClick={() => onReplyClick(comment)}
> >
Reply <ReplyIcon className="h-5 w-5" />
</button> </Button>
)} )}
{showTip && ( {showTip && (
<Tipper <Tipper
@ -214,7 +257,7 @@ export const FeedComment = memo(function FeedComment(props: {
<AwardBountyButton comment={comment} contract={contract} /> <AwardBountyButton comment={comment} contract={contract} />
)} )}
</Row> </Row>
</div> </Col>
</Row> </Row>
) )
}) })
@ -273,3 +316,74 @@ export function ContractCommentInput(props: {
/> />
) )
} }
export function FeedCommentHeader(props: {
comment: ContractComment
contract: Contract
}) {
const { comment, contract } = props
const {
userUsername,
userName,
commenterPositionProb,
commenterPositionShares,
commenterPositionOutcome,
createdTime,
bountiesAwarded,
} = comment
const betOutcome = comment.betOutcome
let bought: string | undefined
let money: string | undefined
if (comment.betAmount != null) {
bought = comment.betAmount >= 0 ? 'bought' : 'sold'
money = formatMoney(Math.abs(comment.betAmount))
}
const totalAwarded = bountiesAwarded ?? 0
return (
<Row>
<div className="text-greyscale-6 mt-0.5 text-xs">
<UserLink username={userUsername} name={userName} />{' '}
<span className="text-greyscale-4">
{comment.betId == null &&
commenterPositionProb != null &&
commenterPositionOutcome != null &&
commenterPositionShares != null &&
commenterPositionShares > 0 &&
contract.outcomeType !== 'NUMERIC' && (
<>
{'is '}
<CommentStatus
prob={commenterPositionProb}
outcome={commenterPositionOutcome}
contract={contract}
/>
</>
)}
{bought} {money}
{contract.outcomeType !== 'FREE_RESPONSE' && betOutcome && (
<>
{' '}
of{' '}
<OutcomeLabel
outcome={betOutcome ? betOutcome : ''}
contract={contract}
truncate="short"
/>
</>
)}
</span>
<CopyLinkDateTimeComponent
prefix={contract.creatorUsername}
slug={contract.slug}
createdTime={createdTime}
elementId={comment.id}
/>
{totalAwarded > 0 && (
<span className=" text-primary ml-2 text-sm">
+{formatMoney(totalAwarded)}
</span>
)}
</div>
</Row>
)
}

View File

@ -1,4 +1,4 @@
import React, { memo, useEffect, useMemo, useState } from 'react' import React, { memo, useEffect, useMemo, useRef, useState } from 'react'
import { ArrowLeftIcon } from '@heroicons/react/outline' import { ArrowLeftIcon } from '@heroicons/react/outline'
import dayjs from 'dayjs' import dayjs from 'dayjs'
@ -46,6 +46,8 @@ import { BetsSummary } from 'web/components/bet-summary'
import { listAllComments } from 'web/lib/firebase/comments' import { listAllComments } from 'web/lib/firebase/comments'
import { ContractComment } from 'common/comment' import { ContractComment } from 'common/comment'
import { ScrollToTopButton } from 'web/components/scroll-to-top-button' import { ScrollToTopButton } from 'web/components/scroll-to-top-button'
import { Answer } from 'common/answer'
import { useEvent } from 'web/hooks/use-event'
export const getStaticProps = fromPropz(getStaticPropz) export const getStaticProps = fromPropz(getStaticPropz)
export async function getStaticPropz(props: { export async function getStaticPropz(props: {
@ -204,6 +206,20 @@ export function ContractPageContent(
contractId: contract.id, contractId: contract.id,
}) })
const [answerResponse, setAnswerResponse] = useState<Answer | undefined>(
undefined
)
const tabsContainerRef = useRef<null | HTMLDivElement>(null)
const onAnswerCommentClick = useEvent((answer: Answer) => {
setAnswerResponse(answer)
if (tabsContainerRef.current) {
tabsContainerRef.current.scrollIntoView({ behavior: 'smooth' })
} else {
console.error('no ref to scroll to')
}
})
const onCancelAnswerResponse = useEvent(() => setAnswerResponse(undefined))
return ( return (
<Page <Page
rightSidebar={ rightSidebar={
@ -257,7 +273,10 @@ export function ContractPageContent(
outcomeType === 'MULTIPLE_CHOICE') && ( outcomeType === 'MULTIPLE_CHOICE') && (
<> <>
<Spacer h={4} /> <Spacer h={4} />
<AnswersPanel contract={contract} /> <AnswersPanel
contract={contract}
onAnswerCommentClick={onAnswerCommentClick}
/>
<Spacer h={4} /> <Spacer h={4} />
</> </>
)} )}
@ -286,12 +305,16 @@ export function ContractPageContent(
userBets={userBets} userBets={userBets}
/> />
<ContractTabs <div ref={tabsContainerRef}>
contract={contract} <ContractTabs
bets={bets} contract={contract}
userBets={userBets} bets={bets}
comments={comments} userBets={userBets}
/> comments={comments}
answerResponse={answerResponse}
onCancelAnswerResponse={onCancelAnswerResponse}
/>
</div>
</Col> </Col>
{!isCreator && <RecommendedContractsWidget contract={contract} />} {!isCreator && <RecommendedContractsWidget contract={contract} />}
<ScrollToTopButton className="fixed bottom-16 right-2 z-20 lg:bottom-2 xl:hidden" /> <ScrollToTopButton className="fixed bottom-16 right-2 z-20 lg:bottom-2 xl:hidden" />

View File

@ -0,0 +1,19 @@
export default function Curve({
size = 24,
color = '#B1B1C7',
strokeWidth = 2,
}) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 18 18"
width={size}
height={size}
fill="none"
stroke={color}
strokeWidth={strokeWidth}
>
<path d="M5.02,0V5.24c0,4.3,3.49,7.79,7.79,7.79h5.2" />
</svg>
)
}