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/
|
||||
.vercel
|
||||
node_modules
|
||||
yarn-error.log
|
||||
|
|
|
@ -5,6 +5,7 @@ export type Comment = {
|
|||
contractId: string
|
||||
betId?: string
|
||||
answerOutcome?: string
|
||||
replyToCommentId?: string
|
||||
userId: string
|
||||
|
||||
text: string
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import _ from 'lodash'
|
||||
import _, { Dictionary } from 'lodash'
|
||||
|
||||
import { Answer } from 'common/answer'
|
||||
import { Bet } from 'common/bet'
|
||||
|
@ -18,6 +18,7 @@ export type ActivityItem =
|
|||
| CloseItem
|
||||
| ResolveItem
|
||||
| CommentInputItem
|
||||
| CommentThreadItem
|
||||
|
||||
type BaseActivityItem = {
|
||||
id: string
|
||||
|
@ -53,9 +54,15 @@ export type CommentItem = BaseActivityItem & {
|
|||
type: 'comment'
|
||||
comment: Comment
|
||||
betsBySameUser: Bet[]
|
||||
hideOutcome: boolean
|
||||
truncate: boolean
|
||||
smallAvatar: boolean
|
||||
truncate?: boolean
|
||||
smallAvatar?: boolean
|
||||
}
|
||||
|
||||
export type CommentThreadItem = BaseActivityItem & {
|
||||
type: 'commentThread'
|
||||
parentComment: Comment
|
||||
comments: Comment[]
|
||||
betsByUserId: Dictionary<[Bet, ...Bet[]]>
|
||||
}
|
||||
|
||||
export type BetGroupItem = BaseActivityItem & {
|
||||
|
@ -68,6 +75,8 @@ export type AnswerGroupItem = BaseActivityItem & {
|
|||
type: 'answergroup' | 'answer'
|
||||
answer: Answer
|
||||
items: ActivityItem[]
|
||||
betsByCurrentUser?: Bet[]
|
||||
comments?: Comment[]
|
||||
}
|
||||
|
||||
export type CloseItem = BaseActivityItem & {
|
||||
|
@ -131,7 +140,6 @@ function groupBets(
|
|||
comment,
|
||||
betsBySameUser: [bet],
|
||||
contract,
|
||||
hideOutcome,
|
||||
truncate: abbreviated,
|
||||
smallAvatar,
|
||||
}
|
||||
|
@ -273,41 +281,23 @@ function getAnswerAndCommentInputGroups(
|
|||
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
|
||||
.map((outcome) => {
|
||||
const answer = contract.answers?.find(
|
||||
(answer) => answer.id === outcome
|
||||
) 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 {
|
||||
id: outcome,
|
||||
|
@ -316,6 +306,8 @@ function getAnswerAndCommentInputGroups(
|
|||
answer,
|
||||
items,
|
||||
user,
|
||||
betsByCurrentUser: answerBets.filter((bet) => bet.userId === user?.id),
|
||||
comments: answerComments,
|
||||
}
|
||||
})
|
||||
.filter((group) => group.answer) as ActivityItem[]
|
||||
|
@ -344,7 +336,6 @@ function groupBetsAndComments(
|
|||
comment,
|
||||
betsBySameUser: [],
|
||||
truncate: abbreviated,
|
||||
hideOutcome: true,
|
||||
smallAvatar,
|
||||
}))
|
||||
|
||||
|
@ -370,22 +361,21 @@ function groupBetsAndComments(
|
|||
return abbrItems
|
||||
}
|
||||
|
||||
function getCommentsWithPositions(
|
||||
function getCommentThreads(
|
||||
bets: Bet[],
|
||||
comments: Comment[],
|
||||
contract: Contract
|
||||
) {
|
||||
const betsByUserId = _.groupBy(bets, (bet) => bet.userId)
|
||||
const parentComments = comments.filter((comment) => !comment.replyToCommentId)
|
||||
|
||||
const items = comments.map((comment) => ({
|
||||
type: 'comment' as const,
|
||||
const items = parentComments.map((comment) => ({
|
||||
type: 'commentThread' as const,
|
||||
id: comment.id,
|
||||
contract: contract,
|
||||
comment,
|
||||
betsBySameUser: bets.length === 0 ? [] : betsByUserId[comment.userId] ?? [],
|
||||
truncate: false,
|
||||
hideOutcome: false,
|
||||
smallAvatar: false,
|
||||
comments: comments,
|
||||
parentComment: comment,
|
||||
betsByUserId: betsByUserId,
|
||||
}))
|
||||
|
||||
return items
|
||||
|
@ -566,7 +556,7 @@ export function getSpecificContractActivityItems(
|
|||
const nonFreeResponseBets =
|
||||
contract.outcomeType === 'FREE_RESPONSE' ? [] : bets
|
||||
items.push(
|
||||
...getCommentsWithPositions(
|
||||
...getCommentThreads(
|
||||
nonFreeResponseBets,
|
||||
nonFreeResponseComments,
|
||||
contract
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
// 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 { Dictionary } from 'lodash'
|
||||
import {
|
||||
BanIcon,
|
||||
CheckIcon,
|
||||
|
@ -15,8 +16,8 @@ import Textarea from 'react-expanding-textarea'
|
|||
|
||||
import { OutcomeLabel } from '../outcome-label'
|
||||
import {
|
||||
contractMetrics,
|
||||
Contract,
|
||||
contractMetrics,
|
||||
contractPath,
|
||||
tradingAllowed,
|
||||
} from 'web/lib/firebase/contracts'
|
||||
|
@ -30,15 +31,13 @@ import { BinaryResolutionOrChance } from '../contract/contract-card'
|
|||
import { SiteLink } from '../site-link'
|
||||
import { Col } from '../layout/col'
|
||||
import { UserLink } from '../user-page'
|
||||
import { DateTimeTooltip } from '../datetime-tooltip'
|
||||
import { Bet } from 'web/lib/firebase/bets'
|
||||
import { JoinSpans } from '../join-spans'
|
||||
import { fromNow } from 'web/lib/util/time'
|
||||
import BetRow from '../bet-row'
|
||||
import { Avatar } from '../avatar'
|
||||
import { Answer } from 'common/answer'
|
||||
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 { getDpmOutcomeProbability } from 'common/calculate-dpm'
|
||||
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 { RelativeTimestamp } from '../relative-timestamp'
|
||||
import { calculateCpmmSale } from 'common/calculate-cpmm'
|
||||
import { useWindowSize } from 'web/hooks/use-window-size'
|
||||
|
||||
export function FeedItems(props: {
|
||||
contract: Contract
|
||||
|
@ -118,24 +118,97 @@ function FeedItem(props: { item: ActivityItem }) {
|
|||
return <FeedResolve {...item} />
|
||||
case 'commentInput':
|
||||
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: {
|
||||
contract: Contract
|
||||
comment: Comment
|
||||
betsBySameUser: Bet[]
|
||||
hideOutcome: boolean
|
||||
truncate: boolean
|
||||
smallAvatar: boolean
|
||||
truncate?: boolean
|
||||
smallAvatar?: boolean
|
||||
onReplyClick?: (comment: Comment) => void
|
||||
}) {
|
||||
const {
|
||||
contract,
|
||||
comment,
|
||||
betsBySameUser,
|
||||
hideOutcome,
|
||||
truncate,
|
||||
smallAvatar,
|
||||
onReplyClick,
|
||||
} = props
|
||||
const { text, userUsername, userName, userAvatarUrl, createdTime } = comment
|
||||
let outcome: string | undefined,
|
||||
|
@ -187,7 +260,7 @@ export function FeedComment(props: {
|
|||
)}
|
||||
<>
|
||||
{bought} {money}
|
||||
{outcome && !hideOutcome && (
|
||||
{contract.outcomeType !== 'FREE_RESPONSE' && outcome && (
|
||||
<>
|
||||
{' '}
|
||||
of{' '}
|
||||
|
@ -206,6 +279,16 @@ export function FeedComment(props: {
|
|||
moreHref={contractPath(contract)}
|
||||
shouldTruncate={truncate}
|
||||
/>
|
||||
{onReplyClick && (
|
||||
<button
|
||||
className={
|
||||
'btn btn-ghost btn-xs text-transform: text-xs capitalize text-gray-500'
|
||||
}
|
||||
onClick={() => onReplyClick(comment)}
|
||||
>
|
||||
Reply
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
@ -215,133 +298,163 @@ export function CommentInput(props: {
|
|||
contract: Contract
|
||||
betsByCurrentUser: Bet[]
|
||||
comments: Comment[]
|
||||
// Only for free response comment inputs
|
||||
// Tie a comment to an free response answer outcome
|
||||
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 [comment, setComment] = useState('')
|
||||
const [focused, setFocused] = useState(false)
|
||||
const { width } = useWindowSize()
|
||||
|
||||
// Should this be oldest bet or most recent bet?
|
||||
const mostRecentCommentableBet = 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()
|
||||
|
||||
const mostRecentCommentableBet = getMostRecentCommentableBet(
|
||||
betsByCurrentUser,
|
||||
comments,
|
||||
user,
|
||||
answerOutcome
|
||||
)
|
||||
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) {
|
||||
if (!user) {
|
||||
return await firebaseLogin()
|
||||
}
|
||||
if (!comment) return
|
||||
await createComment(contract.id, comment, user, betId, answerOutcome)
|
||||
|
||||
// Update state asap to avoid double submission.
|
||||
const commentValue = comment.toString()
|
||||
setComment('')
|
||||
await createComment(
|
||||
contract.id,
|
||||
commentValue,
|
||||
user,
|
||||
betId,
|
||||
answerOutcome,
|
||||
parentComment?.id
|
||||
)
|
||||
}
|
||||
|
||||
const { userPosition, userPositionMoney, yesFloorShares, noFloorShares } =
|
||||
getBettorsPosition(contract, Date.now(), betsByCurrentUser)
|
||||
|
||||
const shouldCollapseAfterClickOutside = false
|
||||
|
||||
function isMobile() {
|
||||
return width ? width < 768 : false
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Row className={'flex w-full gap-2'}>
|
||||
<Avatar avatarUrl={user?.avatarUrl} username={user?.username} />
|
||||
<Row className={'mb-2 flex w-full gap-2'}>
|
||||
<div className={'mt-1'}>
|
||||
<Avatar avatarUrl={user?.avatarUrl} username={user?.username} />
|
||||
</div>
|
||||
<div className={'min-w-0 flex-1'}>
|
||||
<div className="text-sm text-gray-500">
|
||||
{mostRecentCommentableBet && (
|
||||
<BetStatusText
|
||||
contract={contract}
|
||||
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 className={'mb-1'}>
|
||||
{mostRecentCommentableBet && (
|
||||
<BetStatusText
|
||||
contract={contract}
|
||||
bet={mostRecentCommentableBet}
|
||||
isSelf={true}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!user && (
|
||||
<button
|
||||
className={
|
||||
'btn btn-outline btn-sm text-transform: mt-1 capitalize'
|
||||
}
|
||||
onClick={() => submitComment(id)}
|
||||
>
|
||||
Sign in to Comment
|
||||
</button>
|
||||
)}
|
||||
{user && answerOutcome === undefined && (
|
||||
<button
|
||||
className={
|
||||
'btn btn-outline btn-sm text-transform: mt-1 capitalize'
|
||||
}
|
||||
onClick={() => submitComment(id)}
|
||||
>
|
||||
Comment
|
||||
</button>
|
||||
)}
|
||||
{user && answerOutcome !== undefined && (
|
||||
<button
|
||||
className={
|
||||
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)
|
||||
)}
|
||||
{!mostRecentCommentableBet && user && userPosition > 0 && (
|
||||
<>
|
||||
{'You have ' + userPositionMoney + ' '}
|
||||
<>
|
||||
{' of '}
|
||||
<OutcomeLabel
|
||||
outcome={yesFloorShares > noFloorShares ? 'YES' : 'NO'}
|
||||
contract={contract}
|
||||
truncate="short"
|
||||
/>
|
||||
</>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Row className="gap-1.5">
|
||||
<Textarea
|
||||
ref={(ref) => setRef?.(ref)}
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
className="textarea textarea-bordered w-full resize-none"
|
||||
placeholder={
|
||||
parentComment || answerOutcome
|
||||
? 'Write a reply... '
|
||||
: 'Write a comment...'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{!focused ? 'Add Comment' : 'Comment'}
|
||||
</button>
|
||||
)}
|
||||
autoFocus={focused}
|
||||
rows={focused ? 3 : 1}
|
||||
onFocus={() => setFocused(true)}
|
||||
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>
|
||||
</Row>
|
||||
</>
|
||||
|
@ -435,7 +548,6 @@ export function FeedBet(props: {
|
|||
bet={bet}
|
||||
contract={contract}
|
||||
isSelf={isSelf}
|
||||
hideOutcome={hideOutcome}
|
||||
bettor={bettor}
|
||||
/>
|
||||
</div>
|
||||
|
@ -448,10 +560,9 @@ function BetStatusText(props: {
|
|||
contract: Contract
|
||||
bet: Bet
|
||||
isSelf: boolean
|
||||
hideOutcome?: boolean
|
||||
bettor?: User
|
||||
}) {
|
||||
const { bet, contract, hideOutcome, bettor, isSelf } = props
|
||||
const { bet, contract, bettor, isSelf } = props
|
||||
const { amount, outcome, createdTime } = bet
|
||||
|
||||
const bought = amount >= 0 ? 'bought' : 'sold'
|
||||
|
@ -461,7 +572,7 @@ function BetStatusText(props: {
|
|||
<div className="text-sm text-gray-500">
|
||||
<span>{isSelf ? 'You' : bettor ? bettor.name : 'A trader'}</span> {bought}{' '}
|
||||
{money}
|
||||
{!hideOutcome && (
|
||||
{contract.outcomeType !== 'FREE_RESPONSE' && (
|
||||
<>
|
||||
{' '}
|
||||
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) {
|
||||
const { userId, createdTime, isRedemption } = bet
|
||||
const isSelf = user?.id === userId
|
||||
|
@ -768,13 +905,35 @@ function FeedAnswerGroup(props: {
|
|||
answer: Answer
|
||||
items: ActivityItem[]
|
||||
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 user = useUser()
|
||||
const mostRecentCommentableBet = getMostRecentCommentableBet(
|
||||
betsByCurrentUser ?? [],
|
||||
comments ?? [],
|
||||
user,
|
||||
answer.number + ''
|
||||
)
|
||||
const prob = getDpmOutcomeProbability(contract.totalShares, answer.id)
|
||||
const probPercent = formatPercent(prob)
|
||||
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 (
|
||||
<Col
|
||||
|
@ -798,7 +957,7 @@ function FeedAnswerGroup(props: {
|
|||
<div className="px-1">
|
||||
<Avatar username={username} avatarUrl={avatarUrl} />
|
||||
</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">
|
||||
<UserLink username={username} name={name} /> answered
|
||||
</div>
|
||||
|
@ -808,27 +967,69 @@ function FeedAnswerGroup(props: {
|
|||
<Linkify text={text} />
|
||||
</span>
|
||||
|
||||
<Row className="align-items 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)}
|
||||
/>
|
||||
<Row className="items-center justify-center gap-4">
|
||||
{isFreeResponseContractPage && (
|
||||
<div className={'sm:hidden'}>
|
||||
<button
|
||||
className={
|
||||
'btn btn-ghost btn-xs text-transform: text-xs capitalize text-gray-500'
|
||||
}
|
||||
onClick={() => setShowReplyAndFocus(true)}
|
||||
>
|
||||
Reply
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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>
|
||||
</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>
|
||||
</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) => (
|
||||
<div
|
||||
key={item.id}
|
||||
|
|
|
@ -13,6 +13,7 @@ import { getValues, listenForValues } from './utils'
|
|||
import { db } from './init'
|
||||
import { User } from 'common/user'
|
||||
import { Comment } from 'common/comment'
|
||||
import { removeUndefinedProps } from 'common/util/object'
|
||||
|
||||
export type { Comment }
|
||||
|
||||
|
@ -23,12 +24,13 @@ export async function createComment(
|
|||
text: string,
|
||||
commenter: User,
|
||||
betId?: string,
|
||||
answerOutcome?: string
|
||||
answerOutcome?: string,
|
||||
replyToCommentId?: string
|
||||
) {
|
||||
const ref = betId
|
||||
? doc(getCommentsCollection(contractId), betId)
|
||||
: doc(getCommentsCollection(contractId))
|
||||
const comment: Comment = {
|
||||
const comment: Comment = removeUndefinedProps({
|
||||
id: ref.id,
|
||||
contractId,
|
||||
userId: commenter.id,
|
||||
|
@ -37,13 +39,10 @@ export async function createComment(
|
|||
userName: commenter.name,
|
||||
userUsername: commenter.username,
|
||||
userAvatarUrl: commenter.avatarUrl,
|
||||
}
|
||||
if (betId) {
|
||||
comment.betId = betId
|
||||
}
|
||||
if (answerOutcome) {
|
||||
comment.answerOutcome = answerOutcome
|
||||
}
|
||||
betId: betId,
|
||||
answerOutcome: answerOutcome,
|
||||
replyToCommentId: replyToCommentId,
|
||||
})
|
||||
return await setDoc(ref, comment)
|
||||
}
|
||||
|
||||
|
|
|
@ -290,7 +290,6 @@ function ContractTopTrades(props: {
|
|||
contract={contract}
|
||||
comment={commentsById[topCommentId]}
|
||||
betsBySameUser={[betsById[topCommentId]]}
|
||||
hideOutcome={false}
|
||||
truncate={false}
|
||||
smallAvatar={false}
|
||||
/>
|
||||
|
|
|
@ -76,7 +76,6 @@ function MyApp({ Component, pageProps }: AppProps) {
|
|||
content="width=device-width, initial-scale=1, maximum-scale=1"
|
||||
/>
|
||||
</Head>
|
||||
|
||||
<Component {...pageProps} />
|
||||
</>
|
||||
)
|
||||
|
|
Loading…
Reference in New Issue
Block a user