De-feedify contract tab contents (#808)

* De-feedify contract bets list

* De-feedify contract comments lists

* Clean up a bunch of duplicated work in the comments list stuff

* Remove wrapper markup from comment replies list

* Fix sort order on comments I broke

* Kill now unhelpful `CommentRepliesList` wrapper component

* More random cleanup

* More cleanup and fix some styling I had broken

* Make bet calculations less wrong

* Keep up to date with master

* Make copy link component copy better URL

* Make highlighted comments align properly

* Make user header left align with content on comments

* Fix some free response UI stuff up
This commit is contained in:
Marshall Polaris 2022-08-30 02:41:47 -07:00 committed by GitHub
parent 1e3a0ca3d9
commit 7debc4925e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 426 additions and 899 deletions

View File

@ -11,7 +11,6 @@ import { AnswerItem } from './answer-item'
import { CreateAnswerPanel } from './create-answer-panel'
import { AnswerResolvePanel } from './answer-resolve-panel'
import { Spacer } from '../layout/spacer'
import { ActivityItem } from '../feed/activity-items'
import { User } from 'common/user'
import { getOutcomeProbability } from 'common/calculate'
import { Answer } from 'common/answer'
@ -176,7 +175,6 @@ function getAnswerItems(
type: 'answer' as const,
contract,
answer,
items: [] as ActivityItem[],
user,
}
})
@ -186,7 +184,6 @@ function getAnswerItems(
function OpenAnswer(props: {
contract: FreeResponseContract | MultipleChoiceContract
answer: Answer
items: ActivityItem[]
type: string
}) {
const { answer, contract } = props

View File

@ -394,13 +394,11 @@ export function BetsSummary(props: {
const { hasShares, invested, profitPercent, payout, profit, totalShares } =
getContractBetMetrics(contract, bets)
const excludeSalesAndAntes = bets.filter(
(b) => !b.isAnte && !b.isSold && !b.sale
)
const yesWinnings = sumBy(excludeSalesAndAntes, (bet) =>
const excludeSales = bets.filter((b) => !b.isSold && !b.sale)
const yesWinnings = sumBy(excludeSales, (bet) =>
calculatePayout(contract, bet, 'YES')
)
const noWinnings = sumBy(excludeSalesAndAntes, (bet) =>
const noWinnings = sumBy(excludeSales, (bet) =>
calculatePayout(contract, bet, 'NO')
)

View File

@ -107,7 +107,6 @@ export function ContractTopTrades(props: {
comment={commentsById[topCommentId]}
tips={tips[topCommentId]}
betsBySameUser={[betsById[topCommentId]]}
smallAvatar={false}
/>
</div>
<div className="mt-2 text-sm text-gray-500">
@ -123,12 +122,7 @@ export function ContractTopTrades(props: {
<>
<Title text="💸 Smartest money" className="!mt-0" />
<div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4">
<FeedBet
contract={contract}
bet={betsById[topBetId]}
hideOutcome={false}
smallAvatar={false}
/>
<FeedBet contract={contract} bet={betsById[topBetId]} />
</div>
<div className="mt-2 text-sm text-gray-500">
{topBettor?.name} made {formatMoney(profitById[topBetId] || 0)}!

View File

@ -1,15 +1,24 @@
import { Bet } from 'common/bet'
import { Contract } from 'common/contract'
import { Contract, CPMMBinaryContract } from 'common/contract'
import { ContractComment } from 'common/comment'
import { User } from 'common/user'
import { ContractActivity } from '../feed/contract-activity'
import {
ContractCommentsActivity,
ContractBetsActivity,
FreeResponseContractCommentsActivity,
} from '../feed/contract-activity'
import { ContractBetsTable, BetsSummary } from '../bets-list'
import { Spacer } from '../layout/spacer'
import { Tabs } from '../layout/tabs'
import { Col } from '../layout/col'
import { tradingAllowed } from 'web/lib/firebase/contracts'
import { CommentTipMap } from 'web/hooks/use-tip-txns'
import { useBets } from 'web/hooks/use-bets'
import { useComments } from 'web/hooks/use-comments'
import { useLiquidity } from 'web/hooks/use-liquidity'
import { BetSignUpPrompt } from '../sign-up-prompt'
import { PlayMoneyDisclaimer } from '../play-money-disclaimer'
import BetButton from '../bet-button'
export function ContractTabs(props: {
contract: Contract
@ -18,68 +27,69 @@ export function ContractTabs(props: {
comments: ContractComment[]
tips: CommentTipMap
}) {
const { contract, user, bets, tips } = props
const { contract, user, tips } = props
const { outcomeType } = contract
const userBets = user && bets.filter((bet) => bet.userId === user.id)
const bets = useBets(contract.id) ?? props.bets
const lps = useLiquidity(contract.id) ?? []
const userBets =
user && bets.filter((bet) => !bet.isAnte && bet.userId === user.id)
const visibleBets = bets.filter(
(bet) => !bet.isAnte && !bet.isRedemption && bet.amount !== 0
)
const liquidityProvisions =
useLiquidity(contract.id)?.filter((l) => !l.isAnte && l.amount > 0) ?? []
const visibleLps = lps.filter((l) => !l.isAnte && l.amount > 0)
// Load comments here, so the badge count will be correct
const updatedComments = useComments(contract.id)
const comments = updatedComments ?? props.comments
const betActivity = (
<ContractActivity
<ContractBetsActivity
contract={contract}
bets={bets}
liquidityProvisions={liquidityProvisions}
comments={comments}
tips={tips}
user={user}
mode="bets"
betRowClassName="!mt-0 xl:hidden"
bets={visibleBets}
lps={visibleLps}
/>
)
const commentActivity = (
<>
<ContractActivity
contract={contract}
bets={bets}
liquidityProvisions={liquidityProvisions}
comments={comments}
tips={tips}
user={user}
mode={
contract.outcomeType === 'FREE_RESPONSE'
? 'free-response-comment-answer-groups'
: 'comments'
}
betRowClassName="!mt-0 xl:hidden"
/>
{outcomeType === 'FREE_RESPONSE' && (
const generalBets = outcomeType === 'FREE_RESPONSE' ? [] : visibleBets
const generalComments = comments.filter(
(comment) =>
comment.answerOutcome === undefined &&
(outcomeType === 'FREE_RESPONSE' ? comment.betId === undefined : true)
)
const commentActivity =
outcomeType === 'FREE_RESPONSE' ? (
<>
<FreeResponseContractCommentsActivity
contract={contract}
bets={visibleBets}
comments={comments}
tips={tips}
user={user}
/>
<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'} />
<ContractActivity
<ContractCommentsActivity
contract={contract}
bets={bets}
liquidityProvisions={liquidityProvisions}
comments={comments}
bets={generalBets}
comments={generalComments}
tips={tips}
user={user}
mode={'comments'}
betRowClassName="!mt-0 xl:hidden"
/>
</Col>
)}
</>
)
</>
) : (
<ContractCommentsActivity
contract={contract}
bets={visibleBets}
comments={comments}
tips={tips}
user={user}
/>
)
const yourTrades = (
<div>
@ -96,19 +106,39 @@ export function ContractTabs(props: {
)
return (
<Tabs
currentPageForAnalytics={'contract'}
tabs={[
{
title: 'Comments',
content: commentActivity,
badge: `${comments.length}`,
},
{ title: 'Bets', content: betActivity, badge: `${visibleBets.length}` },
...(!user || !userBets?.length
? []
: [{ title: 'Your bets', content: yourTrades }]),
]}
/>
<>
<Tabs
currentPageForAnalytics={'contract'}
tabs={[
{
title: 'Comments',
content: commentActivity,
badge: `${comments.length}`,
},
{
title: 'Bets',
content: betActivity,
badge: `${visibleBets.length}`,
},
...(!user || !userBets?.length
? []
: [{ title: 'Your bets', content: yourTrades }]),
]}
/>
{!user ? (
<Col className="mt-4 max-w-sm items-center xl:hidden">
<BetSignUpPrompt />
<PlayMoneyDisclaimer />
</Col>
) : (
outcomeType === 'BINARY' &&
tradingAllowed(contract) && (
<BetButton
contract={contract as CPMMBinaryContract}
className="mb-2 !mt-0 xl:hidden"
/>
)
)}
</>
)
}

View File

@ -236,9 +236,10 @@ const useUploadMutation = (editor: Editor | null) =>
export function RichContent(props: {
content: JSONContent | string
className?: string
smallImage?: boolean
}) {
const { content, smallImage } = props
const { className, content, smallImage } = props
const editor = useEditor({
editorProps: { attributes: { class: proseClass } },
extensions: [
@ -254,19 +255,24 @@ export function RichContent(props: {
})
useEffect(() => void editor?.commands?.setContent(content), [editor, content])
return <EditorContent editor={editor} />
return <EditorContent className={className} editor={editor} />
}
// backwards compatibility: we used to store content as strings
export function Content(props: {
content: JSONContent | string
className?: string
smallImage?: boolean
}) {
const { content } = props
const { className, content } = props
return typeof content === 'string' ? (
<div className="whitespace-pre-line font-light leading-relaxed">
<Linkify text={content} />
</div>
<Linkify
className={clsx(
className,
'whitespace-pre-line font-light leading-relaxed'
)}
text={content}
/>
) : (
<RichContent {...props} />
)

View File

@ -1,237 +0,0 @@
import { uniq, sortBy } from 'lodash'
import { Answer } from 'common/answer'
import { Bet } from 'common/bet'
import { getOutcomeProbability } from 'common/calculate'
import { ContractComment } from 'common/comment'
import { Contract, FreeResponseContract } from 'common/contract'
import { User } from 'common/user'
import { CommentTipMap } from 'web/hooks/use-tip-txns'
import { LiquidityProvision } from 'common/liquidity-provision'
export type ActivityItem =
| DescriptionItem
| QuestionItem
| BetItem
| AnswerGroupItem
| CloseItem
| ResolveItem
| CommentInputItem
| CommentThreadItem
| LiquidityItem
type BaseActivityItem = {
id: string
contract: Contract
}
export type CommentInputItem = BaseActivityItem & {
type: 'commentInput'
betsByCurrentUser: Bet[]
commentsByCurrentUser: ContractComment[]
}
export type DescriptionItem = BaseActivityItem & {
type: 'description'
}
export type QuestionItem = BaseActivityItem & {
type: 'question'
contractPath?: string
}
export type BetItem = BaseActivityItem & {
type: 'bet'
bet: Bet
hideOutcome: boolean
smallAvatar: boolean
hideComment?: boolean
}
export type CommentThreadItem = BaseActivityItem & {
type: 'commentThread'
parentComment: ContractComment
comments: ContractComment[]
tips: CommentTipMap
bets: Bet[]
}
export type AnswerGroupItem = BaseActivityItem & {
type: 'answergroup'
user: User | undefined | null
answer: Answer
comments: ContractComment[]
tips: CommentTipMap
bets: Bet[]
}
export type CloseItem = BaseActivityItem & {
type: 'close'
}
export type ResolveItem = BaseActivityItem & {
type: 'resolve'
}
export type LiquidityItem = BaseActivityItem & {
type: 'liquidity'
liquidity: LiquidityProvision
hideOutcome: boolean
smallAvatar: boolean
hideComment?: boolean
}
function getAnswerAndCommentInputGroups(
contract: FreeResponseContract,
bets: Bet[],
comments: ContractComment[],
tips: CommentTipMap,
user: User | undefined | null
) {
let outcomes = uniq(bets.map((bet) => bet.outcome))
outcomes = sortBy(outcomes, (outcome) =>
getOutcomeProbability(contract, outcome)
)
const answerGroups = outcomes
.map((outcome) => {
const answer = contract.answers?.find(
(answer) => answer.id === outcome
) as Answer
return {
id: outcome,
type: 'answergroup' as const,
contract,
user,
answer,
comments,
tips,
bets,
}
})
.filter((group) => group.answer) as ActivityItem[]
return answerGroups
}
function getCommentThreads(
bets: Bet[],
comments: ContractComment[],
tips: CommentTipMap,
contract: Contract
) {
const parentComments = comments.filter((comment) => !comment.replyToCommentId)
const items = parentComments.map((comment) => ({
type: 'commentThread' as const,
id: comment.id,
contract: contract,
comments: comments,
parentComment: comment,
bets: bets,
tips,
}))
return items
}
function commentIsGeneralComment(comment: ContractComment, contract: Contract) {
return (
comment.answerOutcome === undefined &&
(contract.outcomeType === 'FREE_RESPONSE'
? comment.betId === undefined
: true)
)
}
export function getSpecificContractActivityItems(
contract: Contract,
bets: Bet[],
comments: ContractComment[],
liquidityProvisions: LiquidityProvision[],
tips: CommentTipMap,
user: User | null | undefined,
options: {
mode: 'comments' | 'bets' | 'free-response-comment-answer-groups'
}
) {
const { mode } = options
let items = [] as ActivityItem[]
switch (mode) {
case 'bets':
// Remove first bet (which is the ante):
if (contract.outcomeType === 'FREE_RESPONSE') bets = bets.slice(1)
items.push(
...bets.map((bet) => ({
type: 'bet' as const,
id: bet.id + '-' + bet.isSold,
bet,
contract,
hideOutcome: false,
smallAvatar: false,
hideComment: true,
}))
)
items.push(
...liquidityProvisions.map((liquidity) => ({
type: 'liquidity' as const,
id: liquidity.id,
contract,
liquidity,
hideOutcome: false,
smallAvatar: false,
}))
)
items = sortBy(items, (item) =>
item.type === 'bet'
? item.bet.createdTime
: item.type === 'liquidity'
? item.liquidity.createdTime
: undefined
)
break
case 'comments': {
const nonFreeResponseComments = comments.filter((comment) =>
commentIsGeneralComment(comment, contract)
)
const nonFreeResponseBets =
contract.outcomeType === 'FREE_RESPONSE' ? [] : bets
items.push(
...getCommentThreads(
nonFreeResponseBets,
nonFreeResponseComments,
tips,
contract
)
)
items.push({
type: 'commentInput',
id: 'commentInput',
contract,
betsByCurrentUser: nonFreeResponseBets.filter(
(bet) => bet.userId === user?.id
),
commentsByCurrentUser: nonFreeResponseComments.filter(
(comment) => comment.userId === user?.id
),
})
break
}
case 'free-response-comment-answer-groups':
items.push(
...getAnswerAndCommentInputGroups(
contract as FreeResponseContract,
bets,
comments,
tips,
user
)
)
break
}
return items.reverse()
}

View File

@ -1,55 +1,144 @@
import { Contract } from 'web/lib/firebase/contracts'
import { Contract, FreeResponseContract } from 'common/contract'
import { ContractComment } from 'common/comment'
import { Answer } from 'common/answer'
import { Bet } from 'common/bet'
import { useBets } from 'web/hooks/use-bets'
import { getSpecificContractActivityItems } from './activity-items'
import { FeedItems } from './feed-items'
import { getOutcomeProbability } from 'common/calculate'
import { FeedBet } from './feed-bets'
import { FeedLiquidity } from './feed-liquidity'
import { FeedAnswerCommentGroup } from './feed-answer-comment-group'
import { FeedCommentThread, CommentInput } from './feed-comments'
import { User } from 'common/user'
import { useContractWithPreload } from 'web/hooks/use-contract'
import { CommentTipMap } from 'web/hooks/use-tip-txns'
import { LiquidityProvision } from 'common/liquidity-provision'
import { groupBy, sortBy, uniq } from 'lodash'
import { Col } from 'web/components/layout/col'
export function ContractActivity(props: {
export function ContractBetsActivity(props: {
contract: Contract
bets: Bet[]
comments: ContractComment[]
liquidityProvisions: LiquidityProvision[]
tips: CommentTipMap
user: User | null | undefined
mode: 'comments' | 'bets' | 'free-response-comment-answer-groups'
contractPath?: string
className?: string
betRowClassName?: string
lps: LiquidityProvision[]
}) {
const { user, mode, tips, className, betRowClassName, liquidityProvisions } =
props
const { contract, bets, lps } = props
const contract = useContractWithPreload(props.contract) ?? props.contract
const comments = props.comments
const updatedBets = useBets(contract.id, {
filterChallenges: false,
filterRedemptions: true,
})
const bets = (updatedBets ?? props.bets).filter(
(bet) => !bet.isRedemption && bet.amount !== 0
)
const items = getSpecificContractActivityItems(
contract,
bets,
comments,
liquidityProvisions,
tips,
user,
{ mode }
const items = [
...bets.map((bet) => ({
type: 'bet' as const,
id: bet.id + '-' + bet.isSold,
bet,
})),
...lps.map((lp) => ({
type: 'liquidity' as const,
id: lp.id,
lp,
})),
]
const sortedItems = sortBy(items, (item) =>
item.type === 'bet'
? -item.bet.createdTime
: item.type === 'liquidity'
? -item.lp.createdTime
: undefined
)
return (
<FeedItems
contract={contract}
items={items}
className={className}
betRowClassName={betRowClassName}
user={user}
/>
<Col className="gap-4">
{sortedItems.map((item) =>
item.type === 'bet' ? (
<FeedBet key={item.id} contract={contract} bet={item.bet} />
) : (
<FeedLiquidity key={item.id} liquidity={item.lp} />
)
)}
</Col>
)
}
export function ContractCommentsActivity(props: {
contract: Contract
bets: Bet[]
comments: ContractComment[]
tips: CommentTipMap
user: User | null | undefined
}) {
const { bets, contract, comments, user, tips } = props
const betsByUserId = groupBy(bets, (bet) => bet.userId)
const commentsByUserId = groupBy(comments, (c) => c.userId)
const commentsByParentId = groupBy(comments, (c) => c.replyToCommentId ?? '_')
const topLevelComments = sortBy(
commentsByParentId['_'] ?? [],
(c) => -c.createdTime
)
return (
<>
<CommentInput
className="mb-5"
contract={contract}
betsByCurrentUser={(user && betsByUserId[user.id]) ?? []}
commentsByCurrentUser={(user && commentsByUserId[user.id]) ?? []}
/>
{topLevelComments.map((parent) => (
<FeedCommentThread
key={parent.id}
user={user}
contract={contract}
parentComment={parent}
threadComments={commentsByParentId[parent.id] ?? []}
tips={tips}
bets={bets}
betsByUserId={betsByUserId}
commentsByUserId={commentsByUserId}
/>
))}
</>
)
}
export function FreeResponseContractCommentsActivity(props: {
contract: FreeResponseContract
bets: Bet[]
comments: ContractComment[]
tips: CommentTipMap
user: User | null | undefined
}) {
const { bets, contract, comments, user, tips } = props
let outcomes = uniq(bets.map((bet) => bet.outcome))
outcomes = sortBy(
outcomes,
(outcome) => -getOutcomeProbability(contract, outcome)
)
const answers = outcomes
.map((outcome) => {
return contract.answers.find((answer) => answer.id === outcome) as Answer
})
.filter((answer) => answer != null)
const betsByUserId = groupBy(bets, (bet) => bet.userId)
const commentsByUserId = groupBy(comments, (c) => c.userId)
const commentsByOutcome = groupBy(comments, (c) => c.answerOutcome ?? '_')
return (
<>
{answers.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}
user={user}
answer={answer}
answerComments={commentsByOutcome[answer.number.toString()] ?? []}
tips={tips}
betsByUserId={betsByUserId}
commentsByUserId={commentsByUserId}
/>
</div>
))}
</>
)
}

View File

@ -1,5 +1,4 @@
import React, { useState } from 'react'
import { ENV_CONFIG } from 'common/envs/constants'
import { copyToClipboard } from 'web/lib/util/copy'
import { DateTimeTooltip } from 'web/components/datetime-tooltip'
import Link from 'next/link'
@ -21,9 +20,10 @@ export function CopyLinkDateTimeComponent(props: {
event: React.MouseEvent<HTMLAnchorElement, MouseEvent>
) {
event.preventDefault()
const elementLocation = `https://${ENV_CONFIG.domain}/${prefix}/${slug}#${elementId}`
copyToClipboard(elementLocation)
const commentUrl = new URL(window.location.href)
commentUrl.pathname = `/${prefix}/${slug}`
commentUrl.hash = elementId
copyToClipboard(commentUrl.toString())
setShowToast(true)
setTimeout(() => setShowToast(false), 2000)
}

View File

@ -1,5 +1,6 @@
import { Answer } from 'common/answer'
import { Bet } from 'common/bet'
import { FreeResponseContract } from 'common/contract'
import { ContractComment } from 'common/comment'
import React, { useEffect, useState } from 'react'
import { Col } from 'web/components/layout/col'
@ -10,25 +11,34 @@ import { Linkify } from 'web/components/linkify'
import clsx from 'clsx'
import {
CommentInput,
CommentRepliesList,
FeedComment,
getMostRecentCommentableBet,
} from 'web/components/feed/feed-comments'
import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time'
import { useRouter } from 'next/router'
import { groupBy } from 'lodash'
import { Dictionary } from 'lodash'
import { User } from 'common/user'
import { useEvent } from 'web/hooks/use-event'
import { CommentTipMap } from 'web/hooks/use-tip-txns'
export function FeedAnswerCommentGroup(props: {
contract: any
contract: FreeResponseContract
user: User | undefined | null
answer: Answer
comments: ContractComment[]
answerComments: ContractComment[]
tips: CommentTipMap
bets: Bet[]
betsByUserId: Dictionary<Bet[]>
commentsByUserId: Dictionary<ContractComment[]>
}) {
const { answer, contract, comments, tips, bets, user } = props
const {
answer,
contract,
answerComments,
tips,
betsByUserId,
commentsByUserId,
user,
} = props
const { username, avatarUrl, name, text } = answer
const [replyToUser, setReplyToUser] =
@ -38,11 +48,6 @@ export function FeedAnswerCommentGroup(props: {
const router = useRouter()
const answerElementId = `answer-${answer.id}`
const betsByUserId = groupBy(bets, (bet) => bet.userId)
const commentsByUserId = groupBy(comments, (comment) => comment.userId)
const commentsList = comments.filter(
(comment) => comment.answerOutcome === answer.number.toString()
)
const betsByCurrentUser = (user && betsByUserId[user.id]) ?? []
const commentsByCurrentUser = (user && commentsByUserId[user.id]) ?? []
const isFreeResponseContractPage = !!commentsByCurrentUser
@ -101,10 +106,13 @@ export function FeedAnswerCommentGroup(props: {
}, [answerElementId, router.asPath])
return (
<Col className={'relative flex-1 gap-3'} key={answer.id + 'comment'}>
<Col
className={'relative flex-1 items-stretch gap-3'}
key={answer.id + 'comment'}
>
<Row
className={clsx(
'flex gap-3 space-x-3 pt-4 transition-all duration-1000',
'gap-3 space-x-3 pt-4 transition-all duration-1000',
highlighted ? `-m-2 my-3 rounded bg-indigo-500/[0.2] p-2` : ''
)}
id={answerElementId}
@ -150,21 +158,23 @@ export function FeedAnswerCommentGroup(props: {
)}
</Col>
</Row>
<CommentRepliesList
contract={contract}
commentsList={commentsList}
betsByUserId={betsByUserId}
smallAvatar={true}
bets={bets}
tips={tips}
scrollAndOpenReplyInput={scrollAndOpenReplyInput}
treatFirstIndexEqually={true}
/>
<Col className="gap-3 pl-1">
{answerComments.map((comment) => (
<FeedComment
key={comment.id}
indent={true}
contract={contract}
comment={comment}
tips={tips[comment.id]}
betsBySameUser={betsByUserId[comment.userId] ?? []}
onReplyClick={scrollAndOpenReplyInput}
/>
))}
</Col>
{showReply && (
<div className={'ml-6'}>
<div className={'relative ml-7'}>
<span
className="absolute -ml-[1px] mt-[1.25rem] h-2 w-0.5 rotate-90 bg-gray-200"
className="absolute -left-1 -ml-[1px] mt-[1.25rem] h-2 w-0.5 rotate-90 bg-gray-200"
aria-hidden="true"
/>
<CommentInput

View File

@ -16,13 +16,8 @@ import { SiteLink } from 'web/components/site-link'
import { getChallenge, getChallengeUrl } from 'web/lib/firebase/challenges'
import { Challenge } from 'common/challenge'
export function FeedBet(props: {
contract: Contract
bet: Bet
hideOutcome: boolean
smallAvatar: boolean
}) {
const { contract, bet, hideOutcome, smallAvatar } = props
export function FeedBet(props: { contract: Contract; bet: Bet }) {
const { contract, bet } = props
const { userId, createdTime } = bet
const isBeforeJune2022 = dayjs(createdTime).isBefore('2022-06-01')
@ -33,21 +28,11 @@ export function FeedBet(props: {
const isSelf = user?.id === userId
return (
<Row className={'flex w-full items-center gap-2 pt-3'}>
<Row className="items-center gap-2 pt-3">
{isSelf ? (
<Avatar
className={clsx(smallAvatar && 'ml-1')}
size={smallAvatar ? 'sm' : undefined}
avatarUrl={user.avatarUrl}
username={user.username}
/>
<Avatar avatarUrl={user.avatarUrl} username={user.username} />
) : bettor ? (
<Avatar
className={clsx(smallAvatar && 'ml-1')}
size={smallAvatar ? 'sm' : undefined}
avatarUrl={bettor.avatarUrl}
username={bettor.username}
/>
<Avatar avatarUrl={bettor.avatarUrl} username={bettor.username} />
) : (
<EmptyAvatar className="mx-1" />
)}
@ -56,7 +41,6 @@ export function FeedBet(props: {
contract={contract}
isSelf={isSelf}
bettor={bettor}
hideOutcome={hideOutcome}
className="flex-1"
/>
</Row>

View File

@ -3,7 +3,7 @@ import { ContractComment } from 'common/comment'
import { User } from 'common/user'
import { Contract } from 'common/contract'
import React, { useEffect, useState } from 'react'
import { minBy, maxBy, groupBy, partition, sumBy, Dictionary } from 'lodash'
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'
@ -31,62 +31,72 @@ import { Content, TextEditor, useTextEditor } from '../editor'
import { Editor } from '@tiptap/react'
export function FeedCommentThread(props: {
user: User | null | undefined
contract: Contract
comments: ContractComment[]
threadComments: ContractComment[]
tips: CommentTipMap
parentComment: ContractComment
bets: Bet[]
smallAvatar?: boolean
betsByUserId: Dictionary<Bet[]>
commentsByUserId: Dictionary<ContractComment[]>
}) {
const { contract, comments, bets, tips, smallAvatar, parentComment } = props
const {
user,
contract,
threadComments,
commentsByUserId,
bets,
betsByUserId,
tips,
parentComment,
} = props
const [showReply, setShowReply] = useState(false)
const [replyToUser, setReplyToUser] = useState<{
id: string
username: string
}>()
const betsByUserId = groupBy(bets, (bet) => bet.userId)
const user = useUser()
const commentsList = comments.filter(
(comment) =>
parentComment.id && comment.replyToCommentId === parentComment.id
)
commentsList.unshift(parentComment)
const [replyTo, setReplyTo] = useState<{ id: string; username: string }>()
function scrollAndOpenReplyInput(comment: ContractComment) {
setReplyToUser({ id: comment.userId, username: comment.userUsername })
setReplyTo({ id: comment.userId, username: comment.userUsername })
setShowReply(true)
}
return (
<Col className={'w-full gap-3 pr-1'}>
<Col className="relative w-full items-stretch gap-3 pb-4">
<span
className="absolute top-5 left-5 -ml-px h-[calc(100%-2rem)] w-0.5 bg-gray-200"
className="absolute top-5 left-4 -ml-px h-[calc(100%-2rem)] w-0.5 bg-gray-200"
aria-hidden="true"
/>
<CommentRepliesList
contract={contract}
commentsList={commentsList}
betsByUserId={betsByUserId}
tips={tips}
smallAvatar={smallAvatar}
bets={bets}
scrollAndOpenReplyInput={scrollAndOpenReplyInput}
/>
{[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 ml-6'}>
<Col className="-pb-2 relative ml-6">
<span
className="absolute -ml-[1px] mt-[0.8rem] h-2 w-0.5 rotate-90 bg-gray-200"
className="absolute -left-1 -ml-[1px] mt-[0.8rem] h-2 w-0.5 rotate-90 bg-gray-200"
aria-hidden="true"
/>
<CommentInput
contract={contract}
betsByCurrentUser={(user && betsByUserId[user.id]) ?? []}
commentsByCurrentUser={comments.filter(
(c) => c.userId === user?.id
)}
commentsByCurrentUser={(user && commentsByUserId[user.id]) ?? []}
parentCommentId={parentComment.id}
replyToUser={replyToUser}
parentAnswerOutcome={comments[0].answerOutcome}
replyToUser={replyTo}
parentAnswerOutcome={parentComment.answerOutcome}
onSubmitComment={() => setShowReply(false)}
/>
</Col>
@ -95,74 +105,13 @@ export function FeedCommentThread(props: {
)
}
export function CommentRepliesList(props: {
contract: Contract
commentsList: ContractComment[]
betsByUserId: Dictionary<Bet[]>
tips: CommentTipMap
scrollAndOpenReplyInput: (comment: ContractComment) => void
bets: Bet[]
treatFirstIndexEqually?: boolean
smallAvatar?: boolean
}) {
const {
contract,
commentsList,
betsByUserId,
tips,
smallAvatar,
bets,
scrollAndOpenReplyInput,
treatFirstIndexEqually,
} = props
return (
<>
{commentsList.map((comment, commentIdx) => (
<div
key={comment.id}
id={comment.id}
className={clsx(
'relative',
!treatFirstIndexEqually && commentIdx === 0 ? '' : 'ml-6'
)}
>
{/*draw a gray line from the comment to the left:*/}
{(treatFirstIndexEqually || commentIdx != 0) && (
<span
className="absolute -ml-[1px] mt-[0.8rem] h-2 w-0.5 rotate-90 bg-gray-200"
aria-hidden="true"
/>
)}
<FeedComment
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
}
smallAvatar={smallAvatar}
/>
</div>
))}
</>
)
}
export function FeedComment(props: {
contract: Contract
comment: ContractComment
tips: CommentTips
betsBySameUser: Bet[]
indent?: boolean
probAtCreatedTime?: number
smallAvatar?: boolean
onReplyClick?: (comment: ContractComment) => void
}) {
const {
@ -170,6 +119,7 @@ export function FeedComment(props: {
comment,
tips,
betsBySameUser,
indent,
probAtCreatedTime,
onReplyClick,
} = props
@ -203,19 +153,23 @@ export function FeedComment(props: {
return (
<Row
id={comment.id}
className={clsx(
'flex space-x-1.5 sm:space-x-3',
highlighted ? `-m-1 rounded bg-indigo-500/[0.2] p-2` : ''
'relative',
indent ? 'ml-6' : '',
highlighted ? `-m-1.5 rounded bg-indigo-500/[0.2] p-1.5` : ''
)}
>
<Avatar
className={'ml-1'}
size={'sm'}
username={userUsername}
avatarUrl={userAvatarUrl}
/>
<div className="min-w-0 flex-1">
<div className="mt-0.5 pl-0.5 text-sm text-gray-500">
{/*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}
@ -233,21 +187,19 @@ export function FeedComment(props: {
/>
</>
)}
<>
{bought} {money}
{contract.outcomeType !== 'FREE_RESPONSE' && betOutcome && (
<>
{' '}
of{' '}
<OutcomeLabel
outcome={betOutcome ? betOutcome : ''}
value={(matchedBet as any).value}
contract={contract}
truncate="short"
/>
</>
)}
</>
{bought} {money}
{contract.outcomeType !== 'FREE_RESPONSE' && betOutcome && (
<>
{' '}
of{' '}
<OutcomeLabel
outcome={betOutcome ? betOutcome : ''}
value={(matchedBet as any).value}
contract={contract}
truncate="short"
/>
</>
)}
<CopyLinkDateTimeComponent
prefix={contract.creatorUsername}
slug={contract.slug}
@ -255,9 +207,11 @@ export function FeedComment(props: {
elementId={comment.id}
/>
</div>
<div className="mt-2 text-[15px] text-gray-700">
<Content content={content || text} smallImage />
</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 && (
@ -322,6 +276,7 @@ export function CommentInput(props: {
contract: Contract
betsByCurrentUser: Bet[]
commentsByCurrentUser: ContractComment[]
className?: string
replyToUser?: { id: string; username: string }
// Reply to a free response answer
parentAnswerOutcome?: string
@ -333,6 +288,7 @@ export function CommentInput(props: {
contract,
betsByCurrentUser,
commentsByCurrentUser,
className,
parentAnswerOutcome,
parentCommentId,
replyToUser,
@ -387,60 +343,51 @@ export function CommentInput(props: {
if (user?.isBannedFromPosting) return <></>
return (
<>
<Row className={'mb-2 gap-1 sm:gap-2'}>
<div className={'mt-2'}>
<Avatar
avatarUrl={user?.avatarUrl}
username={user?.username}
size={'sm'}
className={'ml-1'}
/>
</div>
<div className={'min-w-0 flex-1'}>
<div className="pl-0.5 text-sm">
<div className="mb-1 text-gray-500">
{mostRecentCommentableBet && (
<BetStatusText
contract={contract}
bet={mostRecentCommentableBet}
isSelf={true}
hideOutcome={
isNumeric || contract.outcomeType === 'FREE_RESPONSE'
}
/>
)}
{!mostRecentCommentableBet &&
user &&
userPosition > 0 &&
!isNumeric && (
<>
{"You're"}
<CommentStatus
outcome={outcome}
contract={contract}
prob={
contract.outcomeType === 'BINARY'
? getProbability(contract)
: undefined
}
/>
</>
)}
</div>
<CommentInputTextArea
editor={editor}
upload={upload}
replyToUser={replyToUser}
user={user}
submitComment={submitComment}
isSubmitting={isSubmitting}
presetId={id}
<Row className={clsx(className, 'mb-2 gap-1 sm:gap-2')}>
<Avatar
avatarUrl={user?.avatarUrl}
username={user?.username}
size="sm"
className="mt-2"
/>
<div className="min-w-0 flex-1 pl-0.5 text-sm">
<div className="mb-1 text-gray-500">
{mostRecentCommentableBet && (
<BetStatusText
contract={contract}
bet={mostRecentCommentableBet}
isSelf={true}
hideOutcome={
isNumeric || contract.outcomeType === 'FREE_RESPONSE'
}
/>
</div>
)}
{!mostRecentCommentableBet && user && userPosition > 0 && !isNumeric && (
<>
{"You're"}
<CommentStatus
outcome={outcome}
contract={contract}
prob={
contract.outcomeType === 'BINARY'
? getProbability(contract)
: undefined
}
/>
</>
)}
</div>
</Row>
</>
<CommentInputTextArea
editor={editor}
upload={upload}
replyToUser={replyToUser}
user={user}
submitComment={submitComment}
isSubmitting={isSubmitting}
presetId={id}
/>
</div>
</Row>
)
}
@ -516,23 +463,21 @@ export function CommentInputTextArea(props: {
return (
<>
<div>
<TextEditor editor={editor} upload={upload}>
{user && !isSubmitting && (
<button
className="btn btn-ghost btn-sm px-2 disabled:bg-inherit disabled:text-gray-300"
disabled={!editor || editor.isEmpty}
onClick={submit}
>
<PaperAirplaneIcon className="m-0 h-[25px] min-w-[22px] rotate-90 p-0" />
</button>
)}
<TextEditor editor={editor} upload={upload}>
{user && !isSubmitting && (
<button
className="btn btn-ghost btn-sm px-2 disabled:bg-inherit disabled:text-gray-300"
disabled={!editor || editor.isEmpty}
onClick={submit}
>
<PaperAirplaneIcon className="m-0 h-[25px] min-w-[22px] rotate-90 p-0" />
</button>
)}
{isSubmitting && (
<LoadingIndicator spinnerClassName={'border-gray-500'} />
)}
</TextEditor>
</div>
{isSubmitting && (
<LoadingIndicator spinnerClassName={'border-gray-500'} />
)}
</TextEditor>
<Row>
{!user && (
<button
@ -557,10 +502,6 @@ function getBettorsLargestPositionBeforeTime(
noShares = 0,
noFloorShares = 0
const emptyReturn = {
userPosition: 0,
outcome: '',
}
const previousBets = bets.filter(
(prevBet) => prevBet.createdTime < createdTime && !prevBet.isAnte
)
@ -584,7 +525,7 @@ function getBettorsLargestPositionBeforeTime(
}
}
if (bets.length === 0) {
return emptyReturn
return { userPosition: 0, outcome: '' }
}
const [yesBets, noBets] = partition(

View File

@ -1,279 +0,0 @@
// From https://tailwindui.com/components/application-ui/lists/feeds
import React from 'react'
import {
BanIcon,
CheckIcon,
LockClosedIcon,
XIcon,
} from '@heroicons/react/solid'
import clsx from 'clsx'
import { OutcomeLabel } from '../outcome-label'
import {
Contract,
contractPath,
tradingAllowed,
} from 'web/lib/firebase/contracts'
import { BinaryResolutionOrChance } from '../contract/contract-card'
import { SiteLink } from '../site-link'
import { Col } from '../layout/col'
import { UserLink } from '../user-page'
import BetButton from '../bet-button'
import { Avatar } from '../avatar'
import { ActivityItem } from './activity-items'
import { useUser } from 'web/hooks/use-user'
import { trackClick } from 'web/lib/firebase/tracking'
import { DAY_MS } from 'common/util/time'
import NewContractBadge from '../new-contract-badge'
import { RelativeTimestamp } from '../relative-timestamp'
import { FeedAnswerCommentGroup } from 'web/components/feed/feed-answer-comment-group'
import {
FeedCommentThread,
CommentInput,
} from 'web/components/feed/feed-comments'
import { FeedBet } from 'web/components/feed/feed-bets'
import { CPMMBinaryContract, NumericContract } from 'common/contract'
import { FeedLiquidity } from './feed-liquidity'
import { BetSignUpPrompt } from '../sign-up-prompt'
import { User } from 'common/user'
import { PlayMoneyDisclaimer } from '../play-money-disclaimer'
import { contractMetrics } from 'common/contract-details'
export function FeedItems(props: {
contract: Contract
items: ActivityItem[]
className?: string
betRowClassName?: string
user: User | null | undefined
}) {
const { contract, items, className, betRowClassName, user } = props
const { outcomeType } = contract
return (
<div className={clsx('flow-root', className)}>
<div className={clsx(tradingAllowed(contract) ? '' : '-mb-6')}>
{items.map((item, activityItemIdx) => (
<div key={item.id} className={'relative pb-4'}>
{activityItemIdx !== items.length - 1 ||
item.type === 'answergroup' ? (
<span
className="absolute top-5 left-5 -ml-px h-[calc(100%-2rem)] w-0.5 bg-gray-200"
aria-hidden="true"
/>
) : null}
<div className="relative flex-col items-start space-x-3">
<FeedItem item={item} />
</div>
</div>
))}
</div>
{!user ? (
<Col className="mt-4 max-w-sm items-center xl:hidden">
<BetSignUpPrompt />
<PlayMoneyDisclaimer />
</Col>
) : (
outcomeType === 'BINARY' &&
tradingAllowed(contract) && (
<BetButton
contract={contract as CPMMBinaryContract}
className={clsx('mb-2', betRowClassName)}
/>
)
)}
</div>
)
}
export function FeedItem(props: { item: ActivityItem }) {
const { item } = props
switch (item.type) {
case 'question':
return <FeedQuestion {...item} />
case 'description':
return <FeedDescription {...item} />
case 'bet':
return <FeedBet {...item} />
case 'liquidity':
return <FeedLiquidity {...item} />
case 'answergroup':
return <FeedAnswerCommentGroup {...item} />
case 'close':
return <FeedClose {...item} />
case 'resolve':
return <FeedResolve {...item} />
case 'commentInput':
return <CommentInput {...item} />
case 'commentThread':
return <FeedCommentThread {...item} />
}
}
export function FeedQuestion(props: {
contract: Contract
contractPath?: string
}) {
const { contract } = props
const {
creatorName,
creatorUsername,
question,
outcomeType,
volume,
createdTime,
isResolved,
} = contract
const { volumeLabel } = contractMetrics(contract)
const isBinary = outcomeType === 'BINARY'
const isNew = createdTime > Date.now() - DAY_MS && !isResolved
const user = useUser()
return (
<div className={'flex gap-2'}>
<Avatar
username={contract.creatorUsername}
avatarUrl={contract.creatorAvatarUrl}
/>
<div className="min-w-0 flex-1 py-1.5">
<div className="mb-2 text-sm text-gray-500">
<UserLink
className="text-gray-900"
name={creatorName}
username={creatorUsername}
/>{' '}
asked
{/* Currently hidden on mobile; ideally we'd fit this in somewhere. */}
<div className="relative -top-2 float-right ">
{isNew || volume === 0 ? (
<NewContractBadge />
) : (
<span className="hidden text-gray-400 sm:inline">
{volumeLabel}
</span>
)}
</div>
</div>
<Col className="items-start justify-between gap-2 sm:flex-row sm:gap-4">
<SiteLink
href={
props.contractPath ? props.contractPath : contractPath(contract)
}
onClick={() => user && trackClick(user.id, contract.id)}
className="text-lg text-indigo-700 sm:text-xl"
>
{question}
</SiteLink>
{isBinary && (
<BinaryResolutionOrChance
className="items-center"
contract={contract}
/>
)}
</Col>
</div>
</div>
)
}
function FeedDescription(props: { contract: Contract }) {
const { contract } = props
const { creatorName, creatorUsername } = contract
return (
<>
<Avatar
username={contract.creatorUsername}
avatarUrl={contract.creatorAvatarUrl}
/>
<div className="min-w-0 flex-1 py-1.5">
<div className="text-sm text-gray-500">
<UserLink
className="text-gray-900"
name={creatorName}
username={creatorUsername}
/>{' '}
created this market <RelativeTimestamp time={contract.createdTime} />
</div>
</div>
</>
)
}
function OutcomeIcon(props: { outcome?: string }) {
const { outcome } = props
switch (outcome) {
case 'YES':
return <CheckIcon className="h-5 w-5 text-gray-500" aria-hidden="true" />
case 'NO':
return <XIcon className="h-5 w-5 text-gray-500" aria-hidden="true" />
case 'CANCEL':
return <BanIcon className="h-5 w-5 text-gray-500" aria-hidden="true" />
default:
return <CheckIcon className="h-5 w-5 text-gray-500" aria-hidden="true" />
}
}
function FeedResolve(props: { contract: Contract }) {
const { contract } = props
const { creatorName, creatorUsername } = contract
const resolution = contract.resolution || 'CANCEL'
const resolutionValue = (contract as NumericContract).resolutionValue
return (
<>
<div>
<div className="relative px-1">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-gray-200">
<OutcomeIcon outcome={resolution} />
</div>
</div>
</div>
<div className="min-w-0 flex-1 py-1.5">
<div className="text-sm text-gray-500">
<UserLink
className="text-gray-900"
name={creatorName}
username={creatorUsername}
/>{' '}
resolved this market to{' '}
<OutcomeLabel
outcome={resolution}
value={resolutionValue}
contract={contract}
truncate="long"
/>{' '}
<RelativeTimestamp time={contract.resolutionTime || 0} />
</div>
</div>
</>
)
}
function FeedClose(props: { contract: Contract }) {
const { contract } = props
return (
<>
<div>
<div className="relative px-1">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-gray-200">
<LockClosedIcon
className="h-5 w-5 text-gray-500"
aria-hidden="true"
/>
</div>
</div>
</div>
<div className="min-w-0 flex-1 py-1.5">
<div className="text-sm text-gray-500">
Trading closed in this market{' '}
<RelativeTimestamp time={contract.closeTime || 0} />
</div>
</div>
</>
)
}

View File

@ -3,7 +3,6 @@ import { User } from 'common/user'
import { useUser, useUserById } from 'web/hooks/use-user'
import { Row } from 'web/components/layout/row'
import { Avatar, EmptyAvatar } from 'web/components/avatar'
import clsx from 'clsx'
import { formatMoney } from 'common/util/format'
import { RelativeTimestamp } from 'web/components/relative-timestamp'
import React from 'react'
@ -11,10 +10,10 @@ import { UserLink } from '../user-page'
import { LiquidityProvision } from 'common/liquidity-provision'
export function FeedLiquidity(props: {
className?: string
liquidity: LiquidityProvision
smallAvatar: boolean
}) {
const { liquidity, smallAvatar } = props
const { liquidity } = props
const { userId, createdTime } = liquidity
const isBeforeJune2022 = dayjs(createdTime).isBefore('2022-06-01')
@ -26,21 +25,11 @@ export function FeedLiquidity(props: {
return (
<>
<Row className={'flex w-full gap-2 pt-3'}>
<Row className="flex w-full gap-2 pt-3">
{isSelf ? (
<Avatar
className={clsx(smallAvatar && 'ml-1')}
size={smallAvatar ? 'sm' : undefined}
avatarUrl={user.avatarUrl}
username={user.username}
/>
<Avatar avatarUrl={user.avatarUrl} username={user.username} />
) : bettor ? (
<Avatar
className={clsx(smallAvatar && 'ml-1')}
size={smallAvatar ? 'sm' : undefined}
avatarUrl={bettor.avatarUrl}
username={bettor.username}
/>
<Avatar avatarUrl={bettor.avatarUrl} username={bettor.username} />
) : (
<div className="relative px-1">
<EmptyAvatar />

View File

@ -1,10 +1,15 @@
import clsx from 'clsx'
import { Fragment } from 'react'
import { SiteLink } from './site-link'
// Return a JSX span, linkifying @username, #hashtags, and https://...
// TODO: Use a markdown parser instead of rolling our own here.
export function Linkify(props: { text: string; gray?: boolean }) {
const { text, gray } = props
export function Linkify(props: {
text: string
className?: string
gray?: boolean
}) {
const { text, className, gray } = props
// Replace "m1234" with "ϻ1234"
// const mRegex = /(\W|^)m(\d+)/g
// text = text.replace(mRegex, (_, pre, num) => `${pre}ϻ${num}`)
@ -38,7 +43,7 @@ export function Linkify(props: { text: string; gray?: boolean }) {
)
})
return (
<span className="break-anywhere">
<span className={clsx(className, 'break-anywhere')}>
{text.split(regex).map((part, i) => (
<Fragment key={i}>
{part}