Single threaded comments ()

* 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:
Boa 2022-05-11 15:11:46 -06:00 committed by GitHub
parent aa433e309c
commit 02ed9bf7e1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 380 additions and 190 deletions

1
.gitignore vendored
View File

@ -2,3 +2,4 @@
.idea/
.vercel
node_modules
yarn-error.log

View File

@ -5,6 +5,7 @@ export type Comment = {
contractId: string
betId?: string
answerOutcome?: string
replyToCommentId?: string
userId: string
text: string

View File

@ -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

View File

@ -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}

View File

@ -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)
}

View File

@ -290,7 +290,6 @@ function ContractTopTrades(props: {
contract={contract}
comment={commentsById[topCommentId]}
betsBySameUser={[betsById[topCommentId]]}
hideOutcome={false}
truncate={false}
smallAvatar={false}
/>

View File

@ -76,7 +76,6 @@ function MyApp({ Component, pageProps }: AppProps) {
content="width=device-width, initial-scale=1, maximum-scale=1"
/>
</Head>
<Component {...pageProps} />
</>
)