Single threaded comments (#175)
* Remove unused hideOutcome in comments * Remove unused hideOutcome in comments * Add replyToComment fields to Comment * Add 1 threaded replies to comments & answers * Allow smooth scrolling within pages via # * remove yarn-error log * correct spelling * Remove smooth-scroll-to-hashtag component * Cleanup & show user position/bets in replies
This commit is contained in:
parent
aa433e309c
commit
02ed9bf7e1
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -2,3 +2,4 @@
|
||||||
.idea/
|
.idea/
|
||||||
.vercel
|
.vercel
|
||||||
node_modules
|
node_modules
|
||||||
|
yarn-error.log
|
||||||
|
|
|
@ -5,6 +5,7 @@ export type Comment = {
|
||||||
contractId: string
|
contractId: string
|
||||||
betId?: string
|
betId?: string
|
||||||
answerOutcome?: string
|
answerOutcome?: string
|
||||||
|
replyToCommentId?: string
|
||||||
userId: string
|
userId: string
|
||||||
|
|
||||||
text: string
|
text: string
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import _ from 'lodash'
|
import _, { Dictionary } from 'lodash'
|
||||||
|
|
||||||
import { Answer } from 'common/answer'
|
import { Answer } from 'common/answer'
|
||||||
import { Bet } from 'common/bet'
|
import { Bet } from 'common/bet'
|
||||||
|
@ -18,6 +18,7 @@ export type ActivityItem =
|
||||||
| CloseItem
|
| CloseItem
|
||||||
| ResolveItem
|
| ResolveItem
|
||||||
| CommentInputItem
|
| CommentInputItem
|
||||||
|
| CommentThreadItem
|
||||||
|
|
||||||
type BaseActivityItem = {
|
type BaseActivityItem = {
|
||||||
id: string
|
id: string
|
||||||
|
@ -53,9 +54,15 @@ export type CommentItem = BaseActivityItem & {
|
||||||
type: 'comment'
|
type: 'comment'
|
||||||
comment: Comment
|
comment: Comment
|
||||||
betsBySameUser: Bet[]
|
betsBySameUser: Bet[]
|
||||||
hideOutcome: boolean
|
truncate?: boolean
|
||||||
truncate: boolean
|
smallAvatar?: boolean
|
||||||
smallAvatar: boolean
|
}
|
||||||
|
|
||||||
|
export type CommentThreadItem = BaseActivityItem & {
|
||||||
|
type: 'commentThread'
|
||||||
|
parentComment: Comment
|
||||||
|
comments: Comment[]
|
||||||
|
betsByUserId: Dictionary<[Bet, ...Bet[]]>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BetGroupItem = BaseActivityItem & {
|
export type BetGroupItem = BaseActivityItem & {
|
||||||
|
@ -68,6 +75,8 @@ export type AnswerGroupItem = BaseActivityItem & {
|
||||||
type: 'answergroup' | 'answer'
|
type: 'answergroup' | 'answer'
|
||||||
answer: Answer
|
answer: Answer
|
||||||
items: ActivityItem[]
|
items: ActivityItem[]
|
||||||
|
betsByCurrentUser?: Bet[]
|
||||||
|
comments?: Comment[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CloseItem = BaseActivityItem & {
|
export type CloseItem = BaseActivityItem & {
|
||||||
|
@ -131,7 +140,6 @@ function groupBets(
|
||||||
comment,
|
comment,
|
||||||
betsBySameUser: [bet],
|
betsBySameUser: [bet],
|
||||||
contract,
|
contract,
|
||||||
hideOutcome,
|
|
||||||
truncate: abbreviated,
|
truncate: abbreviated,
|
||||||
smallAvatar,
|
smallAvatar,
|
||||||
}
|
}
|
||||||
|
@ -273,41 +281,23 @@ function getAnswerAndCommentInputGroups(
|
||||||
getOutcomeProbability(contract, outcome)
|
getOutcomeProbability(contract, outcome)
|
||||||
)
|
)
|
||||||
|
|
||||||
function collateCommentsSectionForOutcome(outcome: string) {
|
|
||||||
const answerBets = bets.filter((bet) => bet.outcome === outcome)
|
|
||||||
const answerComments = comments.filter(
|
|
||||||
(comment) =>
|
|
||||||
comment.answerOutcome === outcome ||
|
|
||||||
answerBets.some((bet) => bet.id === comment.betId)
|
|
||||||
)
|
|
||||||
let items = []
|
|
||||||
items.push({
|
|
||||||
type: 'commentInput' as const,
|
|
||||||
id: 'commentInputFor' + outcome,
|
|
||||||
contract,
|
|
||||||
betsByCurrentUser: user
|
|
||||||
? bets.filter((bet) => bet.userId === user.id)
|
|
||||||
: [],
|
|
||||||
comments: comments,
|
|
||||||
answerOutcome: outcome,
|
|
||||||
})
|
|
||||||
items.push(
|
|
||||||
...getCommentsWithPositions(
|
|
||||||
answerBets,
|
|
||||||
answerComments,
|
|
||||||
contract
|
|
||||||
).reverse()
|
|
||||||
)
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
|
|
||||||
const answerGroups = outcomes
|
const answerGroups = outcomes
|
||||||
.map((outcome) => {
|
.map((outcome) => {
|
||||||
const answer = contract.answers?.find(
|
const answer = contract.answers?.find(
|
||||||
(answer) => answer.id === outcome
|
(answer) => answer.id === outcome
|
||||||
) as Answer
|
) as Answer
|
||||||
|
|
||||||
const items = collateCommentsSectionForOutcome(outcome)
|
const answerBets = bets.filter((bet) => bet.outcome === outcome)
|
||||||
|
const answerComments = comments.filter(
|
||||||
|
(comment) =>
|
||||||
|
comment.answerOutcome === outcome ||
|
||||||
|
answerBets.some((bet) => bet.id === comment.betId)
|
||||||
|
)
|
||||||
|
const items = getCommentThreads(
|
||||||
|
answerBets,
|
||||||
|
answerComments,
|
||||||
|
contract
|
||||||
|
).reverse()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: outcome,
|
id: outcome,
|
||||||
|
@ -316,6 +306,8 @@ function getAnswerAndCommentInputGroups(
|
||||||
answer,
|
answer,
|
||||||
items,
|
items,
|
||||||
user,
|
user,
|
||||||
|
betsByCurrentUser: answerBets.filter((bet) => bet.userId === user?.id),
|
||||||
|
comments: answerComments,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.filter((group) => group.answer) as ActivityItem[]
|
.filter((group) => group.answer) as ActivityItem[]
|
||||||
|
@ -344,7 +336,6 @@ function groupBetsAndComments(
|
||||||
comment,
|
comment,
|
||||||
betsBySameUser: [],
|
betsBySameUser: [],
|
||||||
truncate: abbreviated,
|
truncate: abbreviated,
|
||||||
hideOutcome: true,
|
|
||||||
smallAvatar,
|
smallAvatar,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
@ -370,22 +361,21 @@ function groupBetsAndComments(
|
||||||
return abbrItems
|
return abbrItems
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCommentsWithPositions(
|
function getCommentThreads(
|
||||||
bets: Bet[],
|
bets: Bet[],
|
||||||
comments: Comment[],
|
comments: Comment[],
|
||||||
contract: Contract
|
contract: Contract
|
||||||
) {
|
) {
|
||||||
const betsByUserId = _.groupBy(bets, (bet) => bet.userId)
|
const betsByUserId = _.groupBy(bets, (bet) => bet.userId)
|
||||||
|
const parentComments = comments.filter((comment) => !comment.replyToCommentId)
|
||||||
|
|
||||||
const items = comments.map((comment) => ({
|
const items = parentComments.map((comment) => ({
|
||||||
type: 'comment' as const,
|
type: 'commentThread' as const,
|
||||||
id: comment.id,
|
id: comment.id,
|
||||||
contract: contract,
|
contract: contract,
|
||||||
comment,
|
comments: comments,
|
||||||
betsBySameUser: bets.length === 0 ? [] : betsByUserId[comment.userId] ?? [],
|
parentComment: comment,
|
||||||
truncate: false,
|
betsByUserId: betsByUserId,
|
||||||
hideOutcome: false,
|
|
||||||
smallAvatar: false,
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return items
|
return items
|
||||||
|
@ -566,7 +556,7 @@ export function getSpecificContractActivityItems(
|
||||||
const nonFreeResponseBets =
|
const nonFreeResponseBets =
|
||||||
contract.outcomeType === 'FREE_RESPONSE' ? [] : bets
|
contract.outcomeType === 'FREE_RESPONSE' ? [] : bets
|
||||||
items.push(
|
items.push(
|
||||||
...getCommentsWithPositions(
|
...getCommentThreads(
|
||||||
nonFreeResponseBets,
|
nonFreeResponseBets,
|
||||||
nonFreeResponseComments,
|
nonFreeResponseComments,
|
||||||
contract
|
contract
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
// From https://tailwindui.com/components/application-ui/lists/feeds
|
// From https://tailwindui.com/components/application-ui/lists/feeds
|
||||||
import React, { Fragment, useRef, useState } from 'react'
|
import React, { Fragment, useEffect, useRef, useState } from 'react'
|
||||||
import * as _ from 'lodash'
|
import * as _ from 'lodash'
|
||||||
|
import { Dictionary } from 'lodash'
|
||||||
import {
|
import {
|
||||||
BanIcon,
|
BanIcon,
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
|
@ -15,8 +16,8 @@ import Textarea from 'react-expanding-textarea'
|
||||||
|
|
||||||
import { OutcomeLabel } from '../outcome-label'
|
import { OutcomeLabel } from '../outcome-label'
|
||||||
import {
|
import {
|
||||||
contractMetrics,
|
|
||||||
Contract,
|
Contract,
|
||||||
|
contractMetrics,
|
||||||
contractPath,
|
contractPath,
|
||||||
tradingAllowed,
|
tradingAllowed,
|
||||||
} from 'web/lib/firebase/contracts'
|
} from 'web/lib/firebase/contracts'
|
||||||
|
@ -30,15 +31,13 @@ import { BinaryResolutionOrChance } from '../contract/contract-card'
|
||||||
import { SiteLink } from '../site-link'
|
import { SiteLink } from '../site-link'
|
||||||
import { Col } from '../layout/col'
|
import { Col } from '../layout/col'
|
||||||
import { UserLink } from '../user-page'
|
import { UserLink } from '../user-page'
|
||||||
import { DateTimeTooltip } from '../datetime-tooltip'
|
|
||||||
import { Bet } from 'web/lib/firebase/bets'
|
import { Bet } from 'web/lib/firebase/bets'
|
||||||
import { JoinSpans } from '../join-spans'
|
import { JoinSpans } from '../join-spans'
|
||||||
import { fromNow } from 'web/lib/util/time'
|
|
||||||
import BetRow from '../bet-row'
|
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, GENERAL_COMMENTS_OUTCOME_ID } from './activity-items'
|
import { ActivityItem, GENERAL_COMMENTS_OUTCOME_ID } from './activity-items'
|
||||||
import { Binary, CPMM, DPM, FreeResponse, FullContract } from 'common/contract'
|
import { Binary, CPMM, 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'
|
||||||
|
@ -51,6 +50,7 @@ import { DAY_MS } from 'common/util/time'
|
||||||
import NewContractBadge from '../new-contract-badge'
|
import NewContractBadge from '../new-contract-badge'
|
||||||
import { RelativeTimestamp } from '../relative-timestamp'
|
import { RelativeTimestamp } from '../relative-timestamp'
|
||||||
import { calculateCpmmSale } from 'common/calculate-cpmm'
|
import { calculateCpmmSale } from 'common/calculate-cpmm'
|
||||||
|
import { useWindowSize } from 'web/hooks/use-window-size'
|
||||||
|
|
||||||
export function FeedItems(props: {
|
export function FeedItems(props: {
|
||||||
contract: Contract
|
contract: Contract
|
||||||
|
@ -118,24 +118,97 @@ function FeedItem(props: { item: ActivityItem }) {
|
||||||
return <FeedResolve {...item} />
|
return <FeedResolve {...item} />
|
||||||
case 'commentInput':
|
case 'commentInput':
|
||||||
return <CommentInput {...item} />
|
return <CommentInput {...item} />
|
||||||
|
case 'commentThread':
|
||||||
|
return <FeedCommentThread {...item} />
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function FeedCommentThread(props: {
|
||||||
|
contract: Contract
|
||||||
|
comments: Comment[]
|
||||||
|
parentComment: Comment
|
||||||
|
betsByUserId: Dictionary<[Bet, ...Bet[]]>
|
||||||
|
truncate?: boolean
|
||||||
|
smallAvatar?: boolean
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
contract,
|
||||||
|
comments,
|
||||||
|
betsByUserId,
|
||||||
|
truncate,
|
||||||
|
smallAvatar,
|
||||||
|
parentComment,
|
||||||
|
} = props
|
||||||
|
const [showReply, setShowReply] = useState(false)
|
||||||
|
const [replyToUsername, setReplyToUsername] = useState('')
|
||||||
|
const user = useUser()
|
||||||
|
const commentsList = comments.filter(
|
||||||
|
(comment) => comment.replyToCommentId === parentComment.id
|
||||||
|
)
|
||||||
|
commentsList.unshift(parentComment)
|
||||||
|
const [inputRef, setInputRef] = useState<HTMLTextAreaElement | null>(null)
|
||||||
|
function scrollAndOpenReplyInput(comment: Comment) {
|
||||||
|
setReplyToUsername(comment.userUsername)
|
||||||
|
setShowReply(true)
|
||||||
|
inputRef?.focus()
|
||||||
|
}
|
||||||
|
useEffect(() => {
|
||||||
|
if (showReply && inputRef) inputRef.focus()
|
||||||
|
}, [inputRef, showReply])
|
||||||
|
return (
|
||||||
|
<div className={'w-full flex-col flex-col pr-6'}>
|
||||||
|
{commentsList.map((comment, commentIdx) => (
|
||||||
|
<div
|
||||||
|
key={comment.id}
|
||||||
|
id={comment.id}
|
||||||
|
className={clsx(
|
||||||
|
'flex space-x-3',
|
||||||
|
commentIdx === 0 ? '' : 'mt-4 ml-8'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<FeedComment
|
||||||
|
contract={contract}
|
||||||
|
comment={comment}
|
||||||
|
betsBySameUser={betsByUserId[comment.userId] ?? []}
|
||||||
|
onReplyClick={scrollAndOpenReplyInput}
|
||||||
|
smallAvatar={smallAvatar}
|
||||||
|
truncate={truncate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{showReply && (
|
||||||
|
<div className={' ml-8 w-full pt-6'}>
|
||||||
|
<CommentInput
|
||||||
|
contract={contract}
|
||||||
|
// Should we allow replies to contain recent bet info?
|
||||||
|
betsByCurrentUser={(user && betsByUserId[user.id]) ?? []}
|
||||||
|
comments={comments}
|
||||||
|
parentComment={parentComment}
|
||||||
|
replyToUsername={replyToUsername}
|
||||||
|
answerOutcome={comments[0].answerOutcome}
|
||||||
|
setRef={setInputRef}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function FeedComment(props: {
|
export function FeedComment(props: {
|
||||||
contract: Contract
|
contract: Contract
|
||||||
comment: Comment
|
comment: Comment
|
||||||
betsBySameUser: Bet[]
|
betsBySameUser: Bet[]
|
||||||
hideOutcome: boolean
|
truncate?: boolean
|
||||||
truncate: boolean
|
smallAvatar?: boolean
|
||||||
smallAvatar: boolean
|
onReplyClick?: (comment: Comment) => void
|
||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
contract,
|
contract,
|
||||||
comment,
|
comment,
|
||||||
betsBySameUser,
|
betsBySameUser,
|
||||||
hideOutcome,
|
|
||||||
truncate,
|
truncate,
|
||||||
smallAvatar,
|
smallAvatar,
|
||||||
|
onReplyClick,
|
||||||
} = props
|
} = props
|
||||||
const { text, userUsername, userName, userAvatarUrl, createdTime } = comment
|
const { text, userUsername, userName, userAvatarUrl, createdTime } = comment
|
||||||
let outcome: string | undefined,
|
let outcome: string | undefined,
|
||||||
|
@ -187,7 +260,7 @@ export function FeedComment(props: {
|
||||||
)}
|
)}
|
||||||
<>
|
<>
|
||||||
{bought} {money}
|
{bought} {money}
|
||||||
{outcome && !hideOutcome && (
|
{contract.outcomeType !== 'FREE_RESPONSE' && outcome && (
|
||||||
<>
|
<>
|
||||||
{' '}
|
{' '}
|
||||||
of{' '}
|
of{' '}
|
||||||
|
@ -206,6 +279,16 @@ export function FeedComment(props: {
|
||||||
moreHref={contractPath(contract)}
|
moreHref={contractPath(contract)}
|
||||||
shouldTruncate={truncate}
|
shouldTruncate={truncate}
|
||||||
/>
|
/>
|
||||||
|
{onReplyClick && (
|
||||||
|
<button
|
||||||
|
className={
|
||||||
|
'btn btn-ghost btn-xs text-transform: text-xs capitalize text-gray-500'
|
||||||
|
}
|
||||||
|
onClick={() => onReplyClick(comment)}
|
||||||
|
>
|
||||||
|
Reply
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
@ -215,133 +298,163 @@ export function CommentInput(props: {
|
||||||
contract: Contract
|
contract: Contract
|
||||||
betsByCurrentUser: Bet[]
|
betsByCurrentUser: Bet[]
|
||||||
comments: Comment[]
|
comments: Comment[]
|
||||||
// Only for free response comment inputs
|
// Tie a comment to an free response answer outcome
|
||||||
answerOutcome?: string
|
answerOutcome?: string
|
||||||
|
// Tie a comment to another comment
|
||||||
|
parentComment?: Comment
|
||||||
|
replyToUsername?: string
|
||||||
|
setRef?: (ref: any) => void
|
||||||
}) {
|
}) {
|
||||||
const { contract, betsByCurrentUser, comments, answerOutcome } = props
|
const {
|
||||||
|
contract,
|
||||||
|
betsByCurrentUser,
|
||||||
|
comments,
|
||||||
|
answerOutcome,
|
||||||
|
parentComment,
|
||||||
|
replyToUsername,
|
||||||
|
setRef,
|
||||||
|
} = props
|
||||||
const user = useUser()
|
const user = useUser()
|
||||||
const [comment, setComment] = useState('')
|
const [comment, setComment] = useState('')
|
||||||
const [focused, setFocused] = useState(false)
|
const [focused, setFocused] = useState(false)
|
||||||
|
const { width } = useWindowSize()
|
||||||
|
|
||||||
// Should this be oldest bet or most recent bet?
|
const mostRecentCommentableBet = getMostRecentCommentableBet(
|
||||||
const mostRecentCommentableBet = betsByCurrentUser
|
betsByCurrentUser,
|
||||||
.filter((bet) => {
|
comments,
|
||||||
if (
|
user,
|
||||||
canCommentOnBet(bet, user) &&
|
answerOutcome
|
||||||
// The bet doesn't already have a comment
|
)
|
||||||
!comments.some((comment) => comment.betId == bet.id)
|
|
||||||
) {
|
|
||||||
if (!answerOutcome) return true
|
|
||||||
// If we're in free response, don't allow commenting on ante bet
|
|
||||||
return (
|
|
||||||
bet.outcome !== GENERAL_COMMENTS_OUTCOME_ID &&
|
|
||||||
answerOutcome === bet.outcome
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
.sort((b1, b2) => b1.createdTime - b2.createdTime)
|
|
||||||
.pop()
|
|
||||||
|
|
||||||
const { id } = mostRecentCommentableBet || { id: undefined }
|
const { id } = mostRecentCommentableBet || { id: undefined }
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!replyToUsername || !user || replyToUsername === user.username) return
|
||||||
|
const replacement = `@${replyToUsername} `
|
||||||
|
setComment(replacement + comment.replace(replacement, ''))
|
||||||
|
}, [user, replyToUsername])
|
||||||
|
|
||||||
async function submitComment(betId: string | undefined) {
|
async function submitComment(betId: string | undefined) {
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return await firebaseLogin()
|
return await firebaseLogin()
|
||||||
}
|
}
|
||||||
if (!comment) return
|
if (!comment) return
|
||||||
await createComment(contract.id, comment, user, betId, answerOutcome)
|
|
||||||
|
// Update state asap to avoid double submission.
|
||||||
|
const commentValue = comment.toString()
|
||||||
setComment('')
|
setComment('')
|
||||||
|
await createComment(
|
||||||
|
contract.id,
|
||||||
|
commentValue,
|
||||||
|
user,
|
||||||
|
betId,
|
||||||
|
answerOutcome,
|
||||||
|
parentComment?.id
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const { userPosition, userPositionMoney, yesFloorShares, noFloorShares } =
|
const { userPosition, userPositionMoney, yesFloorShares, noFloorShares } =
|
||||||
getBettorsPosition(contract, Date.now(), betsByCurrentUser)
|
getBettorsPosition(contract, Date.now(), betsByCurrentUser)
|
||||||
|
|
||||||
|
const shouldCollapseAfterClickOutside = false
|
||||||
|
|
||||||
|
function isMobile() {
|
||||||
|
return width ? width < 768 : false
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Row className={'flex w-full gap-2'}>
|
<Row className={'mb-2 flex w-full gap-2'}>
|
||||||
<Avatar avatarUrl={user?.avatarUrl} username={user?.username} />
|
<div className={'mt-1'}>
|
||||||
|
<Avatar avatarUrl={user?.avatarUrl} username={user?.username} />
|
||||||
|
</div>
|
||||||
<div className={'min-w-0 flex-1'}>
|
<div className={'min-w-0 flex-1'}>
|
||||||
<div className="text-sm text-gray-500">
|
<div className="text-sm text-gray-500">
|
||||||
{mostRecentCommentableBet && (
|
<div className={'mb-1'}>
|
||||||
<BetStatusText
|
{mostRecentCommentableBet && (
|
||||||
contract={contract}
|
<BetStatusText
|
||||||
bet={mostRecentCommentableBet}
|
contract={contract}
|
||||||
isSelf={true}
|
bet={mostRecentCommentableBet}
|
||||||
/>
|
isSelf={true}
|
||||||
)}
|
|
||||||
{!mostRecentCommentableBet && user && userPosition > 0 && (
|
|
||||||
<>
|
|
||||||
{'You have ' + userPositionMoney + ' '}
|
|
||||||
<>
|
|
||||||
{' of '}
|
|
||||||
<OutcomeLabel
|
|
||||||
outcome={yesFloorShares > noFloorShares ? 'YES' : 'NO'}
|
|
||||||
contract={contract}
|
|
||||||
truncate="short"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{(answerOutcome === undefined || focused) && (
|
|
||||||
<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..."
|
|
||||||
autoFocus={focused}
|
|
||||||
rows={answerOutcome == undefined || focused ? 3 : 1}
|
|
||||||
onFocus={() => setFocused(true)}
|
|
||||||
onBlur={() => !comment && setFocused(false)}
|
|
||||||
maxLength={MAX_COMMENT_LENGTH}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
|
||||||
submitComment(id)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
)}
|
||||||
)}
|
{!mostRecentCommentableBet && user && userPosition > 0 && (
|
||||||
</div>
|
<>
|
||||||
{!user && (
|
{'You have ' + userPositionMoney + ' '}
|
||||||
<button
|
<>
|
||||||
className={
|
{' of '}
|
||||||
'btn btn-outline btn-sm text-transform: mt-1 capitalize'
|
<OutcomeLabel
|
||||||
}
|
outcome={yesFloorShares > noFloorShares ? 'YES' : 'NO'}
|
||||||
onClick={() => submitComment(id)}
|
contract={contract}
|
||||||
>
|
truncate="short"
|
||||||
Sign in to Comment
|
/>
|
||||||
</button>
|
</>
|
||||||
)}
|
</>
|
||||||
{user && answerOutcome === undefined && (
|
)}
|
||||||
<button
|
</div>
|
||||||
className={
|
|
||||||
'btn btn-outline btn-sm text-transform: mt-1 capitalize'
|
<Row className="gap-1.5">
|
||||||
}
|
<Textarea
|
||||||
onClick={() => submitComment(id)}
|
ref={(ref) => setRef?.(ref)}
|
||||||
>
|
value={comment}
|
||||||
Comment
|
onChange={(e) => setComment(e.target.value)}
|
||||||
</button>
|
className="textarea textarea-bordered w-full resize-none"
|
||||||
)}
|
placeholder={
|
||||||
{user && answerOutcome !== undefined && (
|
parentComment || answerOutcome
|
||||||
<button
|
? 'Write a reply... '
|
||||||
className={
|
: 'Write a comment...'
|
||||||
focused
|
|
||||||
? 'btn btn-outline btn-sm text-transform: mt-1 capitalize'
|
|
||||||
: 'btn btn-ghost btn-sm text-transform: mt-1 capitalize'
|
|
||||||
}
|
|
||||||
onClick={() => {
|
|
||||||
if (!focused) setFocused(true)
|
|
||||||
else {
|
|
||||||
submitComment(id)
|
|
||||||
setFocused(false)
|
|
||||||
}
|
}
|
||||||
}}
|
autoFocus={focused}
|
||||||
>
|
rows={focused ? 3 : 1}
|
||||||
{!focused ? 'Add Comment' : 'Comment'}
|
onFocus={() => setFocused(true)}
|
||||||
</button>
|
onBlur={() =>
|
||||||
)}
|
shouldCollapseAfterClickOutside && setFocused(false)
|
||||||
|
}
|
||||||
|
maxLength={MAX_COMMENT_LENGTH}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey && !isMobile()) {
|
||||||
|
e.preventDefault()
|
||||||
|
submitComment(id)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'flex justify-center',
|
||||||
|
focused ? 'items-end' : 'items-center'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{!user && (
|
||||||
|
<button
|
||||||
|
className={
|
||||||
|
'btn btn-outline btn-sm text-transform: capitalize'
|
||||||
|
}
|
||||||
|
onClick={() => submitComment(id)}
|
||||||
|
>
|
||||||
|
Sign in to Comment
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{user && (
|
||||||
|
<button
|
||||||
|
className={clsx(
|
||||||
|
'btn text-transform: block capitalize md:hidden lg:hidden',
|
||||||
|
focused && comment
|
||||||
|
? 'btn-outline btn-sm '
|
||||||
|
: 'btn-ghost btn-sm text-gray-500'
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
if (!focused) return
|
||||||
|
else {
|
||||||
|
submitComment(id)
|
||||||
|
setFocused(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{parentComment || answerOutcome ? 'Reply' : 'Comment'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Row>
|
</Row>
|
||||||
</>
|
</>
|
||||||
|
@ -435,7 +548,6 @@ export function FeedBet(props: {
|
||||||
bet={bet}
|
bet={bet}
|
||||||
contract={contract}
|
contract={contract}
|
||||||
isSelf={isSelf}
|
isSelf={isSelf}
|
||||||
hideOutcome={hideOutcome}
|
|
||||||
bettor={bettor}
|
bettor={bettor}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -448,10 +560,9 @@ function BetStatusText(props: {
|
||||||
contract: Contract
|
contract: Contract
|
||||||
bet: Bet
|
bet: Bet
|
||||||
isSelf: boolean
|
isSelf: boolean
|
||||||
hideOutcome?: boolean
|
|
||||||
bettor?: User
|
bettor?: User
|
||||||
}) {
|
}) {
|
||||||
const { bet, contract, hideOutcome, bettor, isSelf } = props
|
const { bet, contract, bettor, isSelf } = props
|
||||||
const { amount, outcome, createdTime } = bet
|
const { amount, outcome, createdTime } = bet
|
||||||
|
|
||||||
const bought = amount >= 0 ? 'bought' : 'sold'
|
const bought = amount >= 0 ? 'bought' : 'sold'
|
||||||
|
@ -461,7 +572,7 @@ function BetStatusText(props: {
|
||||||
<div className="text-sm text-gray-500">
|
<div className="text-sm text-gray-500">
|
||||||
<span>{isSelf ? 'You' : bettor ? bettor.name : 'A trader'}</span> {bought}{' '}
|
<span>{isSelf ? 'You' : bettor ? bettor.name : 'A trader'}</span> {bought}{' '}
|
||||||
{money}
|
{money}
|
||||||
{!hideOutcome && (
|
{contract.outcomeType !== 'FREE_RESPONSE' && (
|
||||||
<>
|
<>
|
||||||
{' '}
|
{' '}
|
||||||
of{' '}
|
of{' '}
|
||||||
|
@ -581,6 +692,32 @@ export function FeedQuestion(props: {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getMostRecentCommentableBet(
|
||||||
|
betsByCurrentUser: Bet[],
|
||||||
|
comments: Comment[],
|
||||||
|
user?: User | null,
|
||||||
|
answerOutcome?: string
|
||||||
|
) {
|
||||||
|
return betsByCurrentUser
|
||||||
|
.filter((bet) => {
|
||||||
|
if (
|
||||||
|
canCommentOnBet(bet, user) &&
|
||||||
|
// The bet doesn't already have a comment
|
||||||
|
!comments.some((comment) => comment.betId == bet.id)
|
||||||
|
) {
|
||||||
|
if (!answerOutcome) return true
|
||||||
|
// If we're in free response, don't allow commenting on ante bet
|
||||||
|
return (
|
||||||
|
bet.outcome !== GENERAL_COMMENTS_OUTCOME_ID &&
|
||||||
|
answerOutcome === bet.outcome
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
.sort((b1, b2) => b1.createdTime - b2.createdTime)
|
||||||
|
.pop()
|
||||||
|
}
|
||||||
|
|
||||||
function canCommentOnBet(bet: Bet, user?: User | null) {
|
function canCommentOnBet(bet: Bet, user?: User | null) {
|
||||||
const { userId, createdTime, isRedemption } = bet
|
const { userId, createdTime, isRedemption } = bet
|
||||||
const isSelf = user?.id === userId
|
const isSelf = user?.id === userId
|
||||||
|
@ -768,13 +905,35 @@ function FeedAnswerGroup(props: {
|
||||||
answer: Answer
|
answer: Answer
|
||||||
items: ActivityItem[]
|
items: ActivityItem[]
|
||||||
type: string
|
type: string
|
||||||
|
betsByCurrentUser?: Bet[]
|
||||||
|
comments?: Comment[]
|
||||||
}) {
|
}) {
|
||||||
const { answer, items, contract, type } = props
|
const { answer, items, contract, type, betsByCurrentUser, comments } = props
|
||||||
const { username, avatarUrl, name, text } = answer
|
const { username, avatarUrl, name, text } = answer
|
||||||
|
const user = useUser()
|
||||||
|
const mostRecentCommentableBet = getMostRecentCommentableBet(
|
||||||
|
betsByCurrentUser ?? [],
|
||||||
|
comments ?? [],
|
||||||
|
user,
|
||||||
|
answer.number + ''
|
||||||
|
)
|
||||||
const prob = getDpmOutcomeProbability(contract.totalShares, answer.id)
|
const prob = getDpmOutcomeProbability(contract.totalShares, answer.id)
|
||||||
const probPercent = formatPercent(prob)
|
const probPercent = formatPercent(prob)
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
const [showReply, setShowReply] = useState(false)
|
||||||
|
const isFreeResponseContractPage = type === 'answergroup' && comments
|
||||||
|
if (mostRecentCommentableBet && !showReply) setShowReplyAndFocus(true)
|
||||||
|
const [inputRef, setInputRef] = useState<HTMLTextAreaElement | null>(null)
|
||||||
|
|
||||||
|
// If they've already opened the input box, focus it once again
|
||||||
|
function setShowReplyAndFocus(show: boolean) {
|
||||||
|
setShowReply(show)
|
||||||
|
inputRef?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (showReply && inputRef) inputRef.focus()
|
||||||
|
}, [inputRef, showReply])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Col
|
<Col
|
||||||
|
@ -798,7 +957,7 @@ function FeedAnswerGroup(props: {
|
||||||
<div className="px-1">
|
<div className="px-1">
|
||||||
<Avatar username={username} avatarUrl={avatarUrl} />
|
<Avatar username={username} avatarUrl={avatarUrl} />
|
||||||
</div>
|
</div>
|
||||||
<Col className="min-w-0 flex-1 gap-2">
|
<Col className="min-w-0 flex-1 lg:gap-1">
|
||||||
<div className="text-sm text-gray-500">
|
<div className="text-sm text-gray-500">
|
||||||
<UserLink username={username} name={name} /> answered
|
<UserLink username={username} name={name} /> answered
|
||||||
</div>
|
</div>
|
||||||
|
@ -808,27 +967,69 @@ function FeedAnswerGroup(props: {
|
||||||
<Linkify text={text} />
|
<Linkify text={text} />
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<Row className="align-items justify-end gap-4">
|
<Row className="items-center justify-center gap-4">
|
||||||
<span
|
{isFreeResponseContractPage && (
|
||||||
className={clsx(
|
<div className={'sm:hidden'}>
|
||||||
'text-2xl',
|
<button
|
||||||
tradingAllowed(contract) ? 'text-green-500' : 'text-gray-500'
|
className={
|
||||||
)}
|
'btn btn-ghost btn-xs text-transform: text-xs capitalize text-gray-500'
|
||||||
>
|
}
|
||||||
{probPercent}
|
onClick={() => setShowReplyAndFocus(true)}
|
||||||
</span>
|
>
|
||||||
<BuyButton
|
Reply
|
||||||
className={clsx(
|
</button>
|
||||||
'btn-sm flex-initial !px-6 sm:flex',
|
</div>
|
||||||
tradingAllowed(contract) ? '' : '!hidden'
|
)}
|
||||||
)}
|
|
||||||
onClick={() => setOpen(true)}
|
<div className={'align-items flex w-full justify-end gap-4 '}>
|
||||||
/>
|
<span
|
||||||
|
className={clsx(
|
||||||
|
'text-2xl',
|
||||||
|
tradingAllowed(contract)
|
||||||
|
? 'text-green-500'
|
||||||
|
: 'text-gray-500'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{probPercent}
|
||||||
|
</span>
|
||||||
|
<BuyButton
|
||||||
|
className={clsx(
|
||||||
|
'btn-sm flex-initial !px-6 sm:flex',
|
||||||
|
tradingAllowed(contract) ? '' : '!hidden'
|
||||||
|
)}
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</Row>
|
</Row>
|
||||||
</Col>
|
</Col>
|
||||||
|
{isFreeResponseContractPage && (
|
||||||
|
<div className={'justify-initial hidden sm:block'}>
|
||||||
|
<button
|
||||||
|
className={
|
||||||
|
'btn btn-ghost btn-xs text-transform: text-xs capitalize text-gray-500'
|
||||||
|
}
|
||||||
|
onClick={() => setShowReplyAndFocus(true)}
|
||||||
|
>
|
||||||
|
Reply
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
|
{showReply && (
|
||||||
|
<div className={'ml-8'}>
|
||||||
|
<CommentInput
|
||||||
|
contract={contract}
|
||||||
|
betsByCurrentUser={betsByCurrentUser ?? []}
|
||||||
|
comments={comments ?? []}
|
||||||
|
answerOutcome={answer.number + ''}
|
||||||
|
replyToUsername={answer.username}
|
||||||
|
setRef={setInputRef}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{items.map((item, index) => (
|
{items.map((item, index) => (
|
||||||
<div
|
<div
|
||||||
key={item.id}
|
key={item.id}
|
||||||
|
|
|
@ -13,6 +13,7 @@ import { getValues, listenForValues } from './utils'
|
||||||
import { db } from './init'
|
import { db } from './init'
|
||||||
import { User } from 'common/user'
|
import { User } from 'common/user'
|
||||||
import { Comment } from 'common/comment'
|
import { Comment } from 'common/comment'
|
||||||
|
import { removeUndefinedProps } from 'common/util/object'
|
||||||
|
|
||||||
export type { Comment }
|
export type { Comment }
|
||||||
|
|
||||||
|
@ -23,12 +24,13 @@ export async function createComment(
|
||||||
text: string,
|
text: string,
|
||||||
commenter: User,
|
commenter: User,
|
||||||
betId?: string,
|
betId?: string,
|
||||||
answerOutcome?: string
|
answerOutcome?: string,
|
||||||
|
replyToCommentId?: string
|
||||||
) {
|
) {
|
||||||
const ref = betId
|
const ref = betId
|
||||||
? doc(getCommentsCollection(contractId), betId)
|
? doc(getCommentsCollection(contractId), betId)
|
||||||
: doc(getCommentsCollection(contractId))
|
: doc(getCommentsCollection(contractId))
|
||||||
const comment: Comment = {
|
const comment: Comment = removeUndefinedProps({
|
||||||
id: ref.id,
|
id: ref.id,
|
||||||
contractId,
|
contractId,
|
||||||
userId: commenter.id,
|
userId: commenter.id,
|
||||||
|
@ -37,13 +39,10 @@ export async function createComment(
|
||||||
userName: commenter.name,
|
userName: commenter.name,
|
||||||
userUsername: commenter.username,
|
userUsername: commenter.username,
|
||||||
userAvatarUrl: commenter.avatarUrl,
|
userAvatarUrl: commenter.avatarUrl,
|
||||||
}
|
betId: betId,
|
||||||
if (betId) {
|
answerOutcome: answerOutcome,
|
||||||
comment.betId = betId
|
replyToCommentId: replyToCommentId,
|
||||||
}
|
})
|
||||||
if (answerOutcome) {
|
|
||||||
comment.answerOutcome = answerOutcome
|
|
||||||
}
|
|
||||||
return await setDoc(ref, comment)
|
return await setDoc(ref, comment)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -290,7 +290,6 @@ function ContractTopTrades(props: {
|
||||||
contract={contract}
|
contract={contract}
|
||||||
comment={commentsById[topCommentId]}
|
comment={commentsById[topCommentId]}
|
||||||
betsBySameUser={[betsById[topCommentId]]}
|
betsBySameUser={[betsById[topCommentId]]}
|
||||||
hideOutcome={false}
|
|
||||||
truncate={false}
|
truncate={false}
|
||||||
smallAvatar={false}
|
smallAvatar={false}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -76,7 +76,6 @@ function MyApp({ Component, pageProps }: AppProps) {
|
||||||
content="width=device-width, initial-scale=1, maximum-scale=1"
|
content="width=device-width, initial-scale=1, maximum-scale=1"
|
||||||
/>
|
/>
|
||||||
</Head>
|
</Head>
|
||||||
|
|
||||||
<Component {...pageProps} />
|
<Component {...pageProps} />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
Loading…
Reference in New Issue
Block a user