Threaded free response comments & general comments sections (#121)

* Allow comments to reference answers

* Add comment inputs for free response answers

* condense comment logic in one component

* Add General Comments section to FR answers

* Prompt signin even if no comment

* Remove unused & refactor

* Simplify general comments logic, toggle comment boxes

* Clarify rendering logic
This commit is contained in:
Boa 2022-05-03 14:38:40 -06:00 committed by GitHub
parent 100821e34c
commit 3a33efa8db
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 224 additions and 57 deletions

View File

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

View File

@ -93,7 +93,8 @@ export function getHtml(parsedReq: ParsedRequest) {
creatorAvatarUrl, creatorAvatarUrl,
} = parsedReq } = parsedReq
const MAX_QUESTION_CHARS = 100 const MAX_QUESTION_CHARS = 100
const truncatedQuestion = question.length > MAX_QUESTION_CHARS const truncatedQuestion =
question.length > MAX_QUESTION_CHARS
? question.slice(0, MAX_QUESTION_CHARS) + '...' ? question.slice(0, MAX_QUESTION_CHARS) + '...'
: question : question
const hideAvatar = creatorAvatarUrl ? '' : 'hidden' const hideAvatar = creatorAvatarUrl ? '' : 'hidden'

View File

@ -7,6 +7,7 @@ import { ContractActivity } from '../feed/contract-activity'
import { ContractBetsTable, MyBetsSummary } from '../bets-list' import { ContractBetsTable, MyBetsSummary } from '../bets-list'
import { Spacer } from '../layout/spacer' import { Spacer } from '../layout/spacer'
import { Tabs } from '../layout/tabs' import { Tabs } from '../layout/tabs'
import { Col } from '../layout/col'
export function ContractTabs(props: { export function ContractTabs(props: {
contract: Contract contract: Contract
@ -33,14 +34,34 @@ export function ContractTabs(props: {
) )
const commentActivity = ( const commentActivity = (
<>
<ContractActivity <ContractActivity
contract={contract} contract={contract}
bets={bets} bets={bets}
comments={comments} comments={comments}
user={user} user={user}
mode="comments" mode={
contract.outcomeType === 'FREE_RESPONSE'
? 'free-response-comment-answer-groups'
: 'comments'
}
betRowClassName="!mt-0 xl:hidden" betRowClassName="!mt-0 xl:hidden"
/> />
{contract.outcomeType === 'FREE_RESPONSE' && (
<Col className={'mt-8 flex w-full '}>
<div className={'text-md mt-8 mb-2 text-left'}>General Comments</div>
<div className={'mb-4 w-full border-b border-gray-200'} />
<ContractActivity
contract={contract}
bets={bets}
comments={comments}
user={user}
mode={'comments'}
betRowClassName="!mt-0 xl:hidden"
/>
</Col>
)}
</>
) )
const yourTrades = ( const yourTrades = (

View File

@ -33,6 +33,7 @@ export type CommentInputItem = BaseActivityItem & {
type: 'commentInput' type: 'commentInput'
betsByCurrentUser: Bet[] betsByCurrentUser: Bet[]
comments: Comment[] comments: Comment[]
answerOutcome?: string
} }
export type DescriptionItem = BaseActivityItem & { export type DescriptionItem = BaseActivityItem & {
@ -82,6 +83,7 @@ export type ResolveItem = BaseActivityItem & {
type: 'resolve' type: 'resolve'
} }
export const GENERAL_COMMENTS_OUTCOME_ID = 'General Comments'
const DAY_IN_MS = 24 * 60 * 60 * 1000 const DAY_IN_MS = 24 * 60 * 60 * 1000
const ABBREVIATED_NUM_COMMENTS_OR_BETS_TO_SHOW = 3 const ABBREVIATED_NUM_COMMENTS_OR_BETS_TO_SHOW = 3
@ -263,6 +265,68 @@ function getAnswerGroups(
return answerGroups return answerGroups
} }
function getAnswerAndCommentInputGroups(
contract: FullContract<DPM, FreeResponse>,
bets: Bet[],
comments: Comment[],
user: User | undefined | null
) {
let outcomes = _.uniq(bets.map((bet) => bet.outcome)).filter(
(outcome) => getOutcomeProbability(contract, outcome) > 0.0001
)
outcomes = _.sortBy(outcomes, (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
.map((outcome) => {
const answer = contract.answers?.find(
(answer) => answer.id === outcome
) as Answer
const items = collateCommentsSectionForOutcome(outcome)
return {
id: outcome,
type: 'answergroup' as const,
contract,
answer,
items,
user,
}
})
.filter((group) => group.answer) as ActivityItem[]
return answerGroups
}
function groupBetsAndComments( function groupBetsAndComments(
bets: Bet[], bets: Bet[],
comments: Comment[], comments: Comment[],
@ -382,7 +446,7 @@ export function getAllContractActivityItems(
) )
) )
items.push({ items.push({
type: 'commentInput', type: 'commentInput' as const,
id: 'commentInput', id: 'commentInput',
contract, contract,
betsByCurrentUser: [], betsByCurrentUser: [],
@ -408,7 +472,7 @@ export function getAllContractActivityItems(
if (outcomeType === 'BINARY') { if (outcomeType === 'BINARY') {
items.push({ items.push({
type: 'commentInput', type: 'commentInput' as const,
id: 'commentInput', id: 'commentInput',
contract, contract,
betsByCurrentUser: [], betsByCurrentUser: [],
@ -479,7 +543,7 @@ export function getSpecificContractActivityItems(
comments: Comment[], comments: Comment[],
user: User | null | undefined, user: User | null | undefined,
options: { options: {
mode: 'comments' | 'bets' mode: 'comments' | 'bets' | 'free-response-comment-answer-groups'
} }
) { ) {
const { mode } = options const { mode } = options
@ -501,18 +565,39 @@ export function getSpecificContractActivityItems(
break break
case 'comments': case 'comments':
items.push(...getCommentsWithPositions(bets, comments, contract)) const nonFreeResponseComments = comments.filter(
(comment) => comment.answerOutcome === undefined
)
const nonFreeResponseBets =
contract.outcomeType === 'FREE_RESPONSE' ? [] : bets
items.push(
...getCommentsWithPositions(
nonFreeResponseBets,
nonFreeResponseComments,
contract
)
)
items.push({ items.push({
type: 'commentInput', type: 'commentInput',
id: 'commentInput', id: 'commentInput',
contract, contract,
betsByCurrentUser: user betsByCurrentUser: user
? bets.filter((bet) => bet.userId === user.id) ? nonFreeResponseBets.filter((bet) => bet.userId === user.id)
: [], : [],
comments: comments, comments: nonFreeResponseComments,
}) })
break break
case 'free-response-comment-answer-groups':
items.push(
...getAnswerAndCommentInputGroups(
contract as FullContract<DPM, FreeResponse>,
bets,
comments,
user
)
)
break
} }
return items.reverse() return items.reverse()

View File

@ -16,7 +16,13 @@ export function ContractActivity(props: {
bets: Bet[] bets: Bet[]
comments: Comment[] comments: Comment[]
user: User | null | undefined user: User | null | undefined
mode: 'only-recent' | 'abbreviated' | 'all' | 'comments' | 'bets' mode:
| 'only-recent'
| 'abbreviated'
| 'all'
| 'comments'
| 'bets'
| 'free-response-comment-answer-groups'
contractPath?: string contractPath?: string
className?: string className?: string
betRowClassName?: string betRowClassName?: string
@ -38,7 +44,9 @@ export function ContractActivity(props: {
? getRecentContractActivityItems(contract, bets, comments, user, { ? getRecentContractActivityItems(contract, bets, comments, user, {
contractPath, contractPath,
}) })
: mode === 'comments' || mode === 'bets' : mode === 'comments' ||
mode === 'bets' ||
mode === 'free-response-comment-answer-groups'
? getSpecificContractActivityItems(contract, bets, comments, user, { ? getSpecificContractActivityItems(contract, bets, comments, user, {
mode, mode,
}) })

View File

@ -37,7 +37,7 @@ import { fromNow } from '../../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 } from './activity-items' import { ActivityItem, GENERAL_COMMENTS_OUTCOME_ID } from './activity-items'
import { import {
Binary, Binary,
CPMM, CPMM,
@ -222,29 +222,42 @@ export function CommentInput(props: {
contract: Contract contract: Contract
betsByCurrentUser: Bet[] betsByCurrentUser: Bet[]
comments: Comment[] comments: Comment[]
// Only for free response comment inputs
answerOutcome?: string
}) { }) {
const { contract, betsByCurrentUser, comments } = props const { contract, betsByCurrentUser, comments, answerOutcome } = props
const user = useUser() const user = useUser()
const [comment, setComment] = useState('') const [comment, setComment] = useState('')
const [focused, setFocused] = useState(false)
// Should this be oldest bet or most recent bet? // Should this be oldest bet or most recent bet?
const mostRecentCommentableBet = betsByCurrentUser const mostRecentCommentableBet = betsByCurrentUser
.filter( .filter((bet) => {
(bet) => if (
canCommentOnBet(bet, bet.createdTime, user) && canCommentOnBet(bet, user) &&
// The bet doesn't already have a comment
!comments.some((comment) => comment.betId == bet.id) !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) .sort((b1, b2) => b1.createdTime - b2.createdTime)
.pop() .pop()
const { id } = mostRecentCommentableBet || { id: undefined } const { id } = mostRecentCommentableBet || { id: undefined }
async function submitComment(id: string | undefined) { async function submitComment(betId: string | undefined) {
if (!comment) return
if (!user) { if (!user) {
return await firebaseLogin() return await firebaseLogin()
} }
await createComment(contract.id, comment, user, id) if (!comment) return
await createComment(contract.id, comment, user, betId, answerOutcome)
setComment('') setComment('')
} }
@ -253,11 +266,11 @@ export function CommentInput(props: {
return ( return (
<> <>
<Row className={'flex w-full gap-2 pt-3'}> <Row className={'flex w-full gap-2'}>
<div> <div>
<Avatar avatarUrl={user?.avatarUrl} username={user?.username} /> <Avatar avatarUrl={user?.avatarUrl} username={user?.username} />
</div> </div>
<div className={'min-w-0 flex-1 py-1.5'}> <div className={'min-w-0 flex-1'}>
<div className="text-sm text-gray-500"> <div className="text-sm text-gray-500">
{mostRecentCommentableBet && ( {mostRecentCommentableBet && (
<BetStatusText <BetStatusText
@ -279,13 +292,17 @@ export function CommentInput(props: {
</> </>
</> </>
)} )}
{(answerOutcome === undefined || focused) && (
<div className="mt-2"> <div className="mt-2">
<Textarea <Textarea
value={comment} value={comment}
onChange={(e) => setComment(e.target.value)} onChange={(e) => setComment(e.target.value)}
className="textarea textarea-bordered w-full resize-none" className="textarea textarea-bordered w-full resize-none"
placeholder="Add a comment..." placeholder="Add a comment..."
rows={3} autoFocus={true}
rows={answerOutcome == undefined || focused ? 3 : 1}
onFocus={() => setFocused(true)}
onBlur={() => !comment && setFocused(false)}
maxLength={MAX_COMMENT_LENGTH} maxLength={MAX_COMMENT_LENGTH}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
@ -293,16 +310,47 @@ export function CommentInput(props: {
} }
}} }}
/> />
</div>
)}
</div>
{!user && (
<button <button
className={ className={
'btn btn-outline btn-sm text-transform: mt-1 capitalize' 'btn btn-outline btn-sm text-transform: mt-1 capitalize'
} }
onClick={() => submitComment(id)} onClick={() => submitComment(id)}
> >
{user ? 'Comment' : 'Sign in to comment'} Sign in to Comment
</button> </button>
</div> )}
</div> {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)
}
}}
>
{!focused ? 'Add Comment' : 'Comment'}
</button>
)}
</div> </div>
</Row> </Row>
</> </>
@ -560,12 +608,11 @@ export function FeedQuestion(props: {
) )
} }
function canCommentOnBet(bet: Bet, createdTime: number, user?: User | null) { function canCommentOnBet(bet: Bet, user?: User | null) {
const isSelf = user?.id === bet.userId const { userId, createdTime, isRedemption } = bet
const isSelf = user?.id === userId
// You can comment if your bet was posted in the last hour // You can comment if your bet was posted in the last hour
return ( return !isRedemption && isSelf && Date.now() - createdTime < 60 * 60 * 1000
!bet.isRedemption && isSelf && Date.now() - createdTime < 60 * 60 * 1000
)
} }
function FeedDescription(props: { contract: Contract }) { function FeedDescription(props: { contract: Contract }) {

View File

@ -21,7 +21,8 @@ export async function createComment(
contractId: string, contractId: string,
text: string, text: string,
commenter: User, commenter: User,
betId?: string betId?: string,
answerOutcome?: string
) { ) {
const ref = betId const ref = betId
? doc(getCommentsCollection(contractId), betId) ? doc(getCommentsCollection(contractId), betId)
@ -39,6 +40,9 @@ export async function createComment(
if (betId) { if (betId) {
comment.betId = betId comment.betId = betId
} }
if (answerOutcome) {
comment.answerOutcome = answerOutcome
}
return await setDoc(ref, comment) return await setDoc(ref, comment)
} }