Show comments position (#110)

* Add betting activity back to feed

* Show position in bin. markets, no comments on bets

* Degroup bets on Bets tab

* Show users position or recent bet with comments

* Add tooltip on answer to FR comments

* Style improvements

* Only use bets by current user for comment input
This commit is contained in:
Boa 2022-04-29 15:11:04 -06:00 committed by GitHub
parent 5cb6ee3bca
commit 78997c1e45
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 308 additions and 147 deletions

View File

@ -31,6 +31,8 @@ type BaseActivityItem = {
export type CommentInputItem = BaseActivityItem & { export type CommentInputItem = BaseActivityItem & {
type: 'commentInput' type: 'commentInput'
betsByCurrentUser: Bet[]
comments: Comment[]
} }
export type DescriptionItem = BaseActivityItem & { export type DescriptionItem = BaseActivityItem & {
@ -48,12 +50,13 @@ export type BetItem = BaseActivityItem & {
bet: Bet bet: Bet
hideOutcome: boolean hideOutcome: boolean
smallAvatar: boolean smallAvatar: boolean
hideComment?: boolean
} }
export type CommentItem = BaseActivityItem & { export type CommentItem = BaseActivityItem & {
type: 'comment' type: 'comment'
comment: Comment comment: Comment
bet: Bet | undefined betsBySameUser: Bet[]
hideOutcome: boolean hideOutcome: boolean
truncate: boolean truncate: boolean
smallAvatar: boolean smallAvatar: boolean
@ -129,7 +132,7 @@ function groupBets(
type: 'comment' as const, type: 'comment' as const,
id: bet.id, id: bet.id,
comment, comment,
bet, betsBySameUser: [bet],
contract, contract,
hideOutcome, hideOutcome,
truncate: abbreviated, truncate: abbreviated,
@ -280,7 +283,7 @@ function groupBetsAndComments(
id: comment.id, id: comment.id,
contract: contract, contract: contract,
comment, comment,
bet: undefined, betsBySameUser: [],
truncate: abbreviated, truncate: abbreviated,
hideOutcome: true, hideOutcome: true,
smallAvatar, smallAvatar,
@ -308,6 +311,27 @@ function groupBetsAndComments(
return abbrItems return abbrItems
} }
function getCommentsWithPositions(
bets: Bet[],
comments: Comment[],
contract: Contract
) {
const betsByUserId = _.groupBy(bets, (bet) => bet.userId)
const items = comments.map((comment) => ({
type: 'comment' as const,
id: comment.id,
contract: contract,
comment,
betsBySameUser: bets.length === 0 ? [] : betsByUserId[comment.userId] ?? [],
truncate: true,
hideOutcome: false,
smallAvatar: false,
}))
return items
}
export function getAllContractActivityItems( export function getAllContractActivityItems(
contract: Contract, contract: Contract,
bets: Bet[], bets: Bet[],
@ -361,6 +385,8 @@ export function getAllContractActivityItems(
type: 'commentInput', type: 'commentInput',
id: 'commentInput', id: 'commentInput',
contract, contract,
betsByCurrentUser: [],
comments: [],
}) })
} else { } else {
items.push( items.push(
@ -385,6 +411,8 @@ export function getAllContractActivityItems(
type: 'commentInput', type: 'commentInput',
id: 'commentInput', id: 'commentInput',
contract, contract,
betsByCurrentUser: [],
comments: [],
}) })
} }
@ -432,24 +460,13 @@ export function getRecentContractActivityItems(
) )
) )
} else { } else {
const onlyUsersBetsOrBetsWithComments = bets.filter((bet) =>
comments.some(
(comment) => comment.betId === bet.id || bet.userId === user?.id
)
)
items.push( items.push(
...groupBetsAndComments( ...groupBetsAndComments(bets, comments, contract, user?.id, {
onlyUsersBetsOrBetsWithComments, hideOutcome: false,
comments, abbreviated: true,
contract, smallAvatar: false,
user?.id, reversed: true,
{ })
hideOutcome: false,
abbreviated: true,
smallAvatar: false,
reversed: true,
}
)
) )
} }
@ -471,37 +488,29 @@ export function getSpecificContractActivityItems(
switch (mode) { switch (mode) {
case 'bets': case 'bets':
items.push( items.push(
...groupBets(bets, comments, contract, user?.id, { ...bets.map((bet) => ({
type: 'bet' as const,
id: bet.id,
bet,
contract,
hideOutcome: false, hideOutcome: false,
abbreviated: false,
smallAvatar: false, smallAvatar: false,
reversed: false, hideComment: true,
}) }))
) )
break break
case 'comments': case 'comments':
const onlyBetsWithComments = bets.filter((bet) => items.push(...getCommentsWithPositions(bets, comments, contract))
comments.some((comment) => comment.betId === bet.id)
)
items.push(
...groupBetsAndComments(
onlyBetsWithComments,
comments,
contract,
user?.id,
{
hideOutcome: false,
abbreviated: false,
smallAvatar: false,
reversed: false,
}
)
)
items.push({ items.push({
type: 'commentInput', type: 'commentInput',
id: 'commentInput', id: 'commentInput',
contract, contract,
betsByCurrentUser: user
? bets.filter((bet) => bet.userId === user.id)
: [],
comments: comments,
}) })
break break
} }

View File

@ -39,7 +39,13 @@ import BetRow from '../bet-row'
import { Avatar } from '../avatar' import { Avatar } from '../avatar'
import { Answer } from '../../../common/answer' import { Answer } from '../../../common/answer'
import { ActivityItem } from './activity-items' import { ActivityItem } from './activity-items'
import { FreeResponse, FullContract } from '../../../common/contract' import {
Binary,
CPMM,
DPM,
FreeResponse,
FullContract,
} from '../../../common/contract'
import { BuyButton } from '../yes-no-selector' import { BuyButton } from '../yes-no-selector'
import { getDpmOutcomeProbability } from '../../../common/calculate-dpm' import { getDpmOutcomeProbability } from '../../../common/calculate-dpm'
import { AnswerBetPanel } from '../answers/answer-bet-panel' import { AnswerBetPanel } from '../answers/answer-bet-panel'
@ -50,6 +56,7 @@ import { trackClick } from '../../lib/firebase/tracking'
import { firebaseLogin } from '../../lib/firebase/users' import { firebaseLogin } from '../../lib/firebase/users'
import { DAY_MS } from '../../../common/util/time' import { DAY_MS } from '../../../common/util/time'
import NewContractBadge from '../new-contract-badge' import NewContractBadge from '../new-contract-badge'
import { calculateCpmmSale } from '../../../common/calculate-cpmm'
export function FeedItems(props: { export function FeedItems(props: {
contract: Contract contract: Contract
@ -123,21 +130,38 @@ function FeedItem(props: { item: ActivityItem }) {
export function FeedComment(props: { export function FeedComment(props: {
contract: Contract contract: Contract
comment: Comment comment: Comment
bet: Bet | undefined betsBySameUser: Bet[]
hideOutcome: boolean hideOutcome: boolean
truncate: boolean truncate: boolean
smallAvatar: boolean smallAvatar: boolean
}) { }) {
const { contract, comment, bet, hideOutcome, truncate, smallAvatar } = props const {
let money: string | undefined contract,
let outcome: string | undefined comment,
let bought: string | undefined betsBySameUser,
if (bet) { hideOutcome,
outcome = bet.outcome truncate,
bought = bet.amount >= 0 ? 'bought' : 'sold' smallAvatar,
money = formatMoney(Math.abs(bet.amount)) } = props
}
const { text, userUsername, userName, userAvatarUrl, createdTime } = comment const { text, userUsername, userName, userAvatarUrl, createdTime } = comment
let outcome: string | undefined,
bought: string | undefined,
money: string | undefined
const matchedBet = betsBySameUser.find((bet) => bet.id === comment.betId)
if (matchedBet) {
outcome = matchedBet.outcome
bought = matchedBet.amount >= 0 ? 'bought' : 'sold'
money = formatMoney(Math.abs(matchedBet.amount))
}
// Only calculated if they don't have a matching bet
const { userPosition, userPositionMoney, yesFloorShares, noFloorShares } =
getBettorsPosition(
contract,
comment.createdTime,
matchedBet ? [] : betsBySameUser
)
return ( return (
<> <>
@ -155,18 +179,33 @@ export function FeedComment(props: {
username={userUsername} username={userUsername}
name={userName} name={userName}
/>{' '} />{' '}
{bought} {money} {!matchedBet && userPosition > 0 && (
{!hideOutcome && (
<> <>
{' '} {'with ' + userPositionMoney + ' '}
of{' '} <>
<OutcomeLabel {' of '}
outcome={outcome ? outcome : ''} <OutcomeLabel
contract={contract} outcome={yesFloorShares > noFloorShares ? 'YES' : 'NO'}
truncate="short" contract={contract}
/> truncate="short"
/>
</>
</> </>
)} )}
<>
{bought} {money}
{outcome && !hideOutcome && (
<>
{' '}
of{' '}
<OutcomeLabel
outcome={outcome ? outcome : ''}
contract={contract}
truncate="short"
/>
</>
)}
</>
<RelativeTimestamp time={createdTime} /> <RelativeTimestamp time={createdTime} />
</p> </p>
</div> </div>
@ -180,20 +219,12 @@ export function FeedComment(props: {
) )
} }
function RelativeTimestamp(props: { time: number }) { export function CommentInput(props: {
const { time } = props contract: Contract
return ( betsByCurrentUser: Bet[]
<DateTimeTooltip time={time}> comments: Comment[]
<span className="ml-1 whitespace-nowrap text-gray-400"> }) {
{fromNow(time)} const { contract, betsByCurrentUser, comments } = props
</span>
</DateTimeTooltip>
)
}
export function CommentInput(props: { contract: Contract }) {
// see if we can comment input on any bet:
const { contract } = props
const user = useUser() const user = useUser()
const [comment, setComment] = useState('') const [comment, setComment] = useState('')
@ -206,14 +237,50 @@ export function CommentInput(props: { contract: Contract }) {
setComment('') setComment('')
} }
// Should this be oldest bet or most recent bet?
const mostRecentCommentableBet = betsByCurrentUser
.filter(
(bet) =>
canCommentOnBet(bet.userId, bet.createdTime, user) &&
!comments.some((comment) => comment.betId == bet.id)
)
.sort((b1, b2) => b1.createdTime - b2.createdTime)
.pop()
if (mostRecentCommentableBet) {
return (
<FeedBet
contract={contract}
bet={mostRecentCommentableBet}
hideOutcome={false}
smallAvatar={false}
/>
)
}
const { userPosition, userPositionMoney, yesFloorShares, noFloorShares } =
getBettorsPosition(contract, Date.now(), betsByCurrentUser)
return ( return (
<> <>
<Row className={'flex w-full gap-2 pt-5'}> <Row className={'flex w-full gap-2 pt-3'}>
<div> <div>
<Avatar avatarUrl={user?.avatarUrl} username={user?.username} /> <Avatar avatarUrl={user?.avatarUrl} username={user?.username} />
</div> </div>
<div className={'min-w-0 flex-1 py-1.5'}> <div className={'min-w-0 flex-1 py-1.5'}>
<div className="text-sm text-gray-500"> <div className="text-sm text-gray-500">
{user && userPosition > 0 && (
<>
{'You with ' + userPositionMoney + ' '}
<>
{' of '}
<OutcomeLabel
outcome={yesFloorShares > noFloorShares ? 'YES' : 'NO'}
contract={contract}
truncate="short"
/>
</>
</>
)}
<div className="mt-2"> <div className="mt-2">
<Textarea <Textarea
value={comment} value={comment}
@ -244,18 +311,76 @@ export function CommentInput(props: { contract: Contract }) {
) )
} }
function RelativeTimestamp(props: { time: number }) {
const { time } = props
return (
<DateTimeTooltip time={time}>
<span className="ml-1 whitespace-nowrap text-gray-400">
{fromNow(time)}
</span>
</DateTimeTooltip>
)
}
function getBettorsPosition(
contract: Contract,
createdTime: number,
bets: Bet[]
) {
let yesFloorShares = 0,
yesShares = 0,
noShares = 0,
noFloorShares = 0
const emptyReturn = {
userPosition: 0,
userPositionMoney: 0,
yesFloorShares,
noFloorShares,
}
// TODO: show which of the answers was their majority stake at time of comment for FR?
if (contract.outcomeType != 'BINARY') {
return emptyReturn
}
if (bets.length === 0) {
return emptyReturn
}
// Calculate the majority shares they had when they made the comment
const betsBefore = bets.filter((prevBet) => prevBet.createdTime < createdTime)
const [yesBets, noBets] = _.partition(
betsBefore ?? [],
(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 { saleValue } = calculateCpmmSale(
contract as FullContract<CPMM, Binary>,
yesShares || noShares,
yesFloorShares > noFloorShares ? 'YES' : 'NO'
)
const userPositionMoney = formatMoney(Math.abs(saleValue))
return { userPosition, userPositionMoney, yesFloorShares, noFloorShares }
}
export function FeedBet(props: { export function FeedBet(props: {
contract: Contract contract: Contract
bet: Bet bet: Bet
hideOutcome: boolean hideOutcome: boolean
smallAvatar: boolean smallAvatar: boolean
hideComment?: boolean
bettor?: User // If set: reveal bettor identity bettor?: User // If set: reveal bettor identity
}) { }) {
const { contract, bet, hideOutcome, smallAvatar, bettor } = props const { contract, bet, hideOutcome, smallAvatar, bettor, hideComment } = props
const { id, amount, outcome, createdTime, userId } = bet const { id, amount, outcome, createdTime, userId } = bet
const user = useUser() const user = useUser()
const isSelf = user?.id === userId const isSelf = user?.id === userId
const canComment = canCommentOnBet(userId, createdTime, user) const canComment = canCommentOnBet(userId, createdTime, user) && !hideComment
const [comment, setComment] = useState('') const [comment, setComment] = useState('')
async function submitComment() { async function submitComment() {
@ -268,71 +393,76 @@ export function FeedBet(props: {
return ( return (
<> <>
<div> <Row className={'flex w-full gap-2 pt-3'}>
{isSelf ? ( <div>
<Avatar {isSelf ? (
className={clsx(smallAvatar && 'ml-1')} <Avatar
size={smallAvatar ? 'sm' : undefined} className={clsx(smallAvatar && 'ml-1')}
avatarUrl={user.avatarUrl} size={smallAvatar ? 'sm' : undefined}
username={user.username} avatarUrl={user.avatarUrl}
/> username={user.username}
) : bettor ? ( />
<Avatar ) : bettor ? (
className={clsx(smallAvatar && 'ml-1')} <Avatar
size={smallAvatar ? 'sm' : undefined} className={clsx(smallAvatar && 'ml-1')}
avatarUrl={bettor.avatarUrl} size={smallAvatar ? 'sm' : undefined}
username={bettor.username} avatarUrl={bettor.avatarUrl}
/> username={bettor.username}
) : ( />
<div className="relative px-1"> ) : (
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-gray-200"> <div className="relative px-1">
<UserIcon className="h-5 w-5 text-gray-500" aria-hidden="true" /> <div className="flex h-8 w-8 items-center justify-center rounded-full bg-gray-200">
</div> <UserIcon
</div> className="h-5 w-5 text-gray-500"
)} aria-hidden="true"
</div> />
<div className={'min-w-0 flex-1 py-1.5'}> </div>
<div className="text-sm text-gray-500">
<span>{isSelf ? 'You' : bettor ? bettor.name : 'A trader'}</span>{' '}
{bought} {money}
{!hideOutcome && (
<>
{' '}
of{' '}
<OutcomeLabel
outcome={outcome}
contract={contract}
truncate="short"
/>
</>
)}
<RelativeTimestamp time={createdTime} />
{(canComment || comment) && (
<div className="mt-2">
<Textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
className="textarea textarea-bordered w-full resize-none"
placeholder="Add a comment..."
rows={3}
maxLength={MAX_COMMENT_LENGTH}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
submitComment()
}
}}
/>
<button
className="btn btn-outline btn-sm text-transform: mt-1 capitalize"
onClick={submitComment}
disabled={!canComment}
>
Comment
</button>
</div> </div>
)} )}
</div> </div>
</div> <div className={'min-w-0 flex-1 py-1.5'}>
<div className="text-sm text-gray-500">
<span>{isSelf ? 'You' : bettor ? bettor.name : 'A trader'}</span>{' '}
{bought} {money}
{!hideOutcome && (
<>
{' '}
of{' '}
<OutcomeLabel
outcome={outcome}
contract={contract}
truncate="short"
/>
</>
)}
<RelativeTimestamp time={createdTime} />
{(canComment || comment) && (
<div className="mt-2">
<Textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
className="textarea textarea-bordered w-full resize-none"
placeholder="Add a comment..."
rows={3}
maxLength={MAX_COMMENT_LENGTH}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
submitComment()
}
}}
/>
<button
className="btn btn-outline btn-sm text-transform: mt-1 capitalize"
onClick={submitComment}
disabled={!canComment}
>
Comment
</button>
</div>
)}
</div>
</div>
</Row>
</> </>
) )
} }

View File

@ -1,4 +1,3 @@
import clsx from 'clsx'
import { Answer } from '../../common/answer' import { Answer } from '../../common/answer'
import { getProbability } from '../../common/calculate' import { getProbability } from '../../common/calculate'
import { import {
@ -11,6 +10,7 @@ import {
FullContract, FullContract,
} from '../../common/contract' } from '../../common/contract'
import { formatPercent } from '../../common/util/format' import { formatPercent } from '../../common/util/format'
import { ClientRender } from './client-render'
export function OutcomeLabel(props: { export function OutcomeLabel(props: {
contract: Contract contract: Contract
@ -72,11 +72,13 @@ export function FreeResponseOutcomeLabel(props: {
const chosen = answers?.find((answer) => answer.id === resolution) const chosen = answers?.find((answer) => answer.id === resolution)
if (!chosen) return <AnswerNumberLabel number={resolution} /> if (!chosen) return <AnswerNumberLabel number={resolution} />
return ( return (
<AnswerLabel <FreeResponseAnswerToolTip text={chosen.text}>
answer={chosen} <AnswerLabel
truncate={truncate} answer={chosen}
className={answerClassName} truncate={truncate}
/> className={answerClassName}
/>
</FreeResponseAnswerToolTip>
) )
} }
@ -126,3 +128,23 @@ export function AnswerLabel(props: {
return <span className={className}>{truncated}</span> return <span className={className}>{truncated}</span>
} }
function FreeResponseAnswerToolTip(props: {
text: string
children?: React.ReactNode
}) {
const { text } = props
return (
<>
<ClientRender>
<span
className="tooltip hidden cursor-default sm:inline-block"
data-tip={text}
>
{props.children}
</span>
</ClientRender>
<span className="whitespace-nowrap sm:hidden">{props.children}</span>
</>
)
}

View File

@ -287,7 +287,7 @@ function ContractTopTrades(props: {
<FeedComment <FeedComment
contract={contract} contract={contract}
comment={commentsById[topCommentId]} comment={commentsById[topCommentId]}
bet={betsById[topCommentId]} betsBySameUser={[betsById[topCommentId]]}
hideOutcome={false} hideOutcome={false}
truncate={false} truncate={false}
smallAvatar={false} smallAvatar={false}