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

View File

@ -39,7 +39,13 @@ import BetRow from '../bet-row'
import { Avatar } from '../avatar'
import { Answer } from '../../../common/answer'
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 { getDpmOutcomeProbability } from '../../../common/calculate-dpm'
import { AnswerBetPanel } from '../answers/answer-bet-panel'
@ -50,6 +56,7 @@ import { trackClick } from '../../lib/firebase/tracking'
import { firebaseLogin } from '../../lib/firebase/users'
import { DAY_MS } from '../../../common/util/time'
import NewContractBadge from '../new-contract-badge'
import { calculateCpmmSale } from '../../../common/calculate-cpmm'
export function FeedItems(props: {
contract: Contract
@ -123,21 +130,38 @@ function FeedItem(props: { item: ActivityItem }) {
export function FeedComment(props: {
contract: Contract
comment: Comment
bet: Bet | undefined
betsBySameUser: Bet[]
hideOutcome: boolean
truncate: boolean
smallAvatar: boolean
}) {
const { contract, comment, bet, hideOutcome, truncate, smallAvatar } = props
let money: string | undefined
let outcome: string | undefined
let bought: string | undefined
if (bet) {
outcome = bet.outcome
bought = bet.amount >= 0 ? 'bought' : 'sold'
money = formatMoney(Math.abs(bet.amount))
}
const {
contract,
comment,
betsBySameUser,
hideOutcome,
truncate,
smallAvatar,
} = props
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 (
<>
@ -155,18 +179,33 @@ export function FeedComment(props: {
username={userUsername}
name={userName}
/>{' '}
{bought} {money}
{!hideOutcome && (
{!matchedBet && userPosition > 0 && (
<>
{' '}
of{' '}
<OutcomeLabel
outcome={outcome ? outcome : ''}
contract={contract}
truncate="short"
/>
{'with ' + userPositionMoney + ' '}
<>
{' of '}
<OutcomeLabel
outcome={yesFloorShares > noFloorShares ? 'YES' : 'NO'}
contract={contract}
truncate="short"
/>
</>
</>
)}
<>
{bought} {money}
{outcome && !hideOutcome && (
<>
{' '}
of{' '}
<OutcomeLabel
outcome={outcome ? outcome : ''}
contract={contract}
truncate="short"
/>
</>
)}
</>
<RelativeTimestamp time={createdTime} />
</p>
</div>
@ -180,20 +219,12 @@ export function FeedComment(props: {
)
}
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>
)
}
export function CommentInput(props: { contract: Contract }) {
// see if we can comment input on any bet:
const { contract } = props
export function CommentInput(props: {
contract: Contract
betsByCurrentUser: Bet[]
comments: Comment[]
}) {
const { contract, betsByCurrentUser, comments } = props
const user = useUser()
const [comment, setComment] = useState('')
@ -206,14 +237,50 @@ export function CommentInput(props: { contract: Contract }) {
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 (
<>
<Row className={'flex w-full gap-2 pt-5'}>
<Row className={'flex w-full gap-2 pt-3'}>
<div>
<Avatar avatarUrl={user?.avatarUrl} username={user?.username} />
</div>
<div className={'min-w-0 flex-1 py-1.5'}>
<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">
<Textarea
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: {
contract: Contract
bet: Bet
hideOutcome: boolean
smallAvatar: boolean
hideComment?: boolean
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 user = useUser()
const isSelf = user?.id === userId
const canComment = canCommentOnBet(userId, createdTime, user)
const canComment = canCommentOnBet(userId, createdTime, user) && !hideComment
const [comment, setComment] = useState('')
async function submitComment() {
@ -268,71 +393,76 @@ export function FeedBet(props: {
return (
<>
<div>
{isSelf ? (
<Avatar
className={clsx(smallAvatar && 'ml-1')}
size={smallAvatar ? 'sm' : undefined}
avatarUrl={user.avatarUrl}
username={user.username}
/>
) : bettor ? (
<Avatar
className={clsx(smallAvatar && 'ml-1')}
size={smallAvatar ? 'sm' : undefined}
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">
<UserIcon 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">
<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>
<Row className={'flex w-full gap-2 pt-3'}>
<div>
{isSelf ? (
<Avatar
className={clsx(smallAvatar && 'ml-1')}
size={smallAvatar ? 'sm' : undefined}
avatarUrl={user.avatarUrl}
username={user.username}
/>
) : bettor ? (
<Avatar
className={clsx(smallAvatar && 'ml-1')}
size={smallAvatar ? 'sm' : undefined}
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">
<UserIcon
className="h-5 w-5 text-gray-500"
aria-hidden="true"
/>
</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 { getProbability } from '../../common/calculate'
import {
@ -11,6 +10,7 @@ import {
FullContract,
} from '../../common/contract'
import { formatPercent } from '../../common/util/format'
import { ClientRender } from './client-render'
export function OutcomeLabel(props: {
contract: Contract
@ -72,11 +72,13 @@ export function FreeResponseOutcomeLabel(props: {
const chosen = answers?.find((answer) => answer.id === resolution)
if (!chosen) return <AnswerNumberLabel number={resolution} />
return (
<AnswerLabel
answer={chosen}
truncate={truncate}
className={answerClassName}
/>
<FreeResponseAnswerToolTip text={chosen.text}>
<AnswerLabel
answer={chosen}
truncate={truncate}
className={answerClassName}
/>
</FreeResponseAnswerToolTip>
)
}
@ -126,3 +128,23 @@ export function AnswerLabel(props: {
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
contract={contract}
comment={commentsById[topCommentId]}
bet={betsById[topCommentId]}
betsBySameUser={[betsById[topCommentId]]}
hideOutcome={false}
truncate={false}
smallAvatar={false}