import { Bet } from 'common/bet' import { ContractComment } from 'common/comment' import { PRESENT_BET, User } from 'common/user' import { Contract } from 'common/contract' import React, { useEffect, useState } from 'react' import { minBy, maxBy, partition, sumBy, Dictionary } from 'lodash' import { useUser } from 'web/hooks/use-user' import { formatMoney } from 'common/util/format' import { useRouter } from 'next/router' import { Row } from 'web/components/layout/row' import clsx from 'clsx' import { Avatar } from 'web/components/avatar' import { OutcomeLabel } from 'web/components/outcome-label' import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time' import { firebaseLogin } from 'web/lib/firebase/users' import { createCommentOnContract } from 'web/lib/firebase/comments' import { Col } from 'web/components/layout/col' import { track } from 'web/lib/service/analytics' import { Tipper } from '../tipper' import { CommentTipMap, CommentTips } from 'web/hooks/use-tip-txns' import { Content } from '../editor' import { Editor } from '@tiptap/react' import { UserLink } from 'web/components/user-link' import { CommentInput } from '../comment-input' export function FeedCommentThread(props: { user: User | null | undefined contract: Contract threadComments: ContractComment[] tips: CommentTipMap parentComment: ContractComment bets: Bet[] betsByUserId: Dictionary<Bet[]> commentsByUserId: Dictionary<ContractComment[]> }) { const { user, contract, threadComments, commentsByUserId, bets, betsByUserId, tips, parentComment, } = props const [showReply, setShowReply] = useState(false) const [replyTo, setReplyTo] = useState<{ id: string; username: string }>() function scrollAndOpenReplyInput(comment: ContractComment) { setReplyTo({ id: comment.userId, username: comment.userUsername }) setShowReply(true) } return ( <Col className="relative w-full items-stretch gap-3 pb-4"> <span className="absolute top-5 left-4 -ml-px h-[calc(100%-2rem)] w-0.5 bg-gray-200" aria-hidden="true" /> {[parentComment].concat(threadComments).map((comment, commentIdx) => ( <FeedComment key={comment.id} indent={commentIdx != 0} contract={contract} comment={comment} tips={tips[comment.id]} betsBySameUser={betsByUserId[comment.userId] ?? []} onReplyClick={scrollAndOpenReplyInput} probAtCreatedTime={ contract.outcomeType === 'BINARY' ? minBy(bets, (bet) => { return bet.createdTime < comment.createdTime ? comment.createdTime - bet.createdTime : comment.createdTime })?.probAfter : undefined } /> ))} {showReply && ( <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 contract={contract} betsByCurrentUser={(user && betsByUserId[user.id]) ?? []} commentsByCurrentUser={(user && commentsByUserId[user.id]) ?? []} parentCommentId={parentComment.id} replyToUser={replyTo} parentAnswerOutcome={parentComment.answerOutcome} onSubmitComment={() => { setShowReply(false) }} /> </Col> )} </Col> ) } export function FeedComment(props: { contract: Contract comment: ContractComment tips: CommentTips betsBySameUser: Bet[] indent?: boolean probAtCreatedTime?: number onReplyClick?: (comment: ContractComment) => void }) { const { contract, comment, tips, betsBySameUser, indent, probAtCreatedTime, onReplyClick, } = props const { text, content, userUsername, userName, userAvatarUrl, createdTime } = 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 [highlighted, setHighlighted] = useState(false) const router = useRouter() useEffect(() => { if (router.asPath.endsWith(`#${comment.id}`)) { setHighlighted(true) } }, [comment.id, router.asPath]) // Only calculated if they don't have a matching bet const { userPosition, outcome } = getBettorsLargestPositionBeforeTime( contract, comment.createdTime, comment.betId ? [] : betsBySameUser ) return ( <Row id={comment.id} className={clsx( 'relative', indent ? 'ml-6' : '', highlighted ? `-m-1.5 rounded bg-indigo-500/[0.2] p-1.5` : '' )} > {/*draw a gray line from the comment to the left:*/} {indent ? ( <span className="absolute -left-1 -ml-[1px] mt-[0.8rem] h-2 w-0.5 rotate-90 bg-gray-200" aria-hidden="true" /> ) : null} <Avatar size="sm" username={userUsername} avatarUrl={userAvatarUrl} /> <div className="ml-1.5 min-w-0 flex-1 pl-0.5 sm:ml-3"> <div className="mt-0.5 text-sm text-gray-500"> <UserLink className="text-gray-500" username={userUsername} name={userName} />{' '} {!comment.betId != null && userPosition > 0 && contract.outcomeType !== 'NUMERIC' && ( <> {'is '} <CommentStatus prob={probAtCreatedTime} outcome={outcome} 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} /> </div> <Content className="mt-2 text-[15px] text-gray-700" content={content || text} smallImage /> <Row className="mt-2 items-center gap-6 text-xs text-gray-500"> <Tipper comment={comment} tips={tips ?? {}} /> {onReplyClick && ( <button className="font-bold hover:underline" onClick={() => onReplyClick(comment)} > Reply </button> )} </Row> </div> </Row> ) } export function getMostRecentCommentableBet( betsByCurrentUser: Bet[], commentsByCurrentUser: ContractComment[], user?: User | null, answerOutcome?: string ) { let sortedBetsByCurrentUser = betsByCurrentUser.sort( (a, b) => b.createdTime - a.createdTime ) if (answerOutcome) { sortedBetsByCurrentUser = sortedBetsByCurrentUser.slice(0, 1) } return sortedBetsByCurrentUser .filter((bet) => { if ( canCommentOnBet(bet, user) && !commentsByCurrentUser.some( (comment) => comment.createdTime > bet.createdTime ) ) { if (!answerOutcome) return true return answerOutcome === bet.outcome } return false }) .pop() } function CommentStatus(props: { contract: Contract outcome: string prob?: number }) { const { contract, outcome, prob } = props return ( <> {` ${PRESENT_BET}ing `} <OutcomeLabel outcome={outcome} contract={contract} truncate="short" /> {prob && ' at ' + Math.round(prob * 100) + '%'} </> ) } export function ContractCommentInput(props: { contract: Contract betsByCurrentUser: Bet[] commentsByCurrentUser: ContractComment[] className?: string parentAnswerOutcome?: string | undefined replyToUser?: { id: string; username: string } parentCommentId?: string onSubmitComment?: () => void }) { const user = useUser() async function onSubmitComment(editor: Editor, betId: string | undefined) { if (!user) { track('sign in to comment') return await firebaseLogin() } await createCommentOnContract( props.contract.id, editor.getJSON(), user, betId, props.parentAnswerOutcome, props.parentCommentId ) props.onSubmitComment?.() } const mostRecentCommentableBet = getMostRecentCommentableBet( props.betsByCurrentUser, props.commentsByCurrentUser, user, props.parentAnswerOutcome ) const { id } = mostRecentCommentableBet || { id: undefined } return ( <CommentInput replyToUser={props.replyToUser} parentAnswerOutcome={props.parentAnswerOutcome} parentCommentId={props.parentCommentId} onSubmitComment={onSubmitComment} className={props.className} presetId={id} /> ) } function getBettorsLargestPositionBeforeTime( contract: Contract, createdTime: number, bets: Bet[] ) { let yesFloorShares = 0, yesShares = 0, noShares = 0, noFloorShares = 0 const previousBets = bets.filter( (prevBet) => prevBet.createdTime < createdTime && !prevBet.isAnte ) if (contract.outcomeType === 'FREE_RESPONSE') { const answerCounts: { [outcome: string]: number } = {} for (const bet of previousBets) { if (bet.outcome) { if (!answerCounts[bet.outcome]) { answerCounts[bet.outcome] = bet.amount } else { answerCounts[bet.outcome] += bet.amount } } } const majorityAnswer = maxBy(Object.keys(answerCounts), (outcome) => answerCounts[outcome]) ?? '' return { userPosition: answerCounts[majorityAnswer] || 0, outcome: majorityAnswer, } } if (bets.length === 0) { return { userPosition: 0, outcome: '' } } const [yesBets, noBets] = partition( previousBets ?? [], (bet) => bet.outcome === 'YES' ) yesShares = sumBy(yesBets, (bet) => bet.shares) noShares = sumBy(noBets, (bet) => bet.shares) yesFloorShares = Math.floor(yesShares) noFloorShares = Math.floor(noShares) const userPosition = yesFloorShares || noFloorShares const outcome = yesFloorShares > noFloorShares ? 'YES' : 'NO' return { userPosition, outcome } } function canCommentOnBet(bet: Bet, user?: User | null) { const { userId, createdTime, isRedemption } = bet const isSelf = user?.id === userId // You can comment if your bet was posted in the last hour return !isRedemption && isSelf && Date.now() - createdTime < 60 * 60 * 1000 }