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
contractId: string
betId?: string
answerOutcome?: string
userId: string
text: string

View File

@ -93,7 +93,8 @@ export function getHtml(parsedReq: ParsedRequest) {
creatorAvatarUrl,
} = parsedReq
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
const hideAvatar = creatorAvatarUrl ? '' : 'hidden'

View File

@ -7,6 +7,7 @@ import { ContractActivity } from '../feed/contract-activity'
import { ContractBetsTable, MyBetsSummary } from '../bets-list'
import { Spacer } from '../layout/spacer'
import { Tabs } from '../layout/tabs'
import { Col } from '../layout/col'
export function ContractTabs(props: {
contract: Contract
@ -33,14 +34,34 @@ export function ContractTabs(props: {
)
const commentActivity = (
<>
<ContractActivity
contract={contract}
bets={bets}
comments={comments}
user={user}
mode="comments"
mode={
contract.outcomeType === 'FREE_RESPONSE'
? 'free-response-comment-answer-groups'
: 'comments'
}
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 = (

View File

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

View File

@ -16,7 +16,13 @@ export function ContractActivity(props: {
bets: Bet[]
comments: Comment[]
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
className?: string
betRowClassName?: string
@ -38,7 +44,9 @@ export function ContractActivity(props: {
? getRecentContractActivityItems(contract, bets, comments, user, {
contractPath,
})
: mode === 'comments' || mode === 'bets'
: mode === 'comments' ||
mode === 'bets' ||
mode === 'free-response-comment-answer-groups'
? getSpecificContractActivityItems(contract, bets, comments, user, {
mode,
})

View File

@ -37,7 +37,7 @@ import { fromNow } from '../../lib/util/time'
import BetRow from '../bet-row'
import { Avatar } from '../avatar'
import { Answer } from '../../../common/answer'
import { ActivityItem } from './activity-items'
import { ActivityItem, GENERAL_COMMENTS_OUTCOME_ID } from './activity-items'
import {
Binary,
CPMM,
@ -222,29 +222,42 @@ export function CommentInput(props: {
contract: Contract
betsByCurrentUser: Bet[]
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 [comment, setComment] = useState('')
const [focused, setFocused] = useState(false)
// Should this be oldest bet or most recent bet?
const mostRecentCommentableBet = betsByCurrentUser
.filter(
(bet) =>
canCommentOnBet(bet, bet.createdTime, user) &&
.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 { id } = mostRecentCommentableBet || { id: undefined }
async function submitComment(id: string | undefined) {
if (!comment) return
async function submitComment(betId: string | undefined) {
if (!user) {
return await firebaseLogin()
}
await createComment(contract.id, comment, user, id)
if (!comment) return
await createComment(contract.id, comment, user, betId, answerOutcome)
setComment('')
}
@ -253,11 +266,11 @@ export function CommentInput(props: {
return (
<>
<Row className={'flex w-full gap-2 pt-3'}>
<Row className={'flex w-full gap-2'}>
<div>
<Avatar avatarUrl={user?.avatarUrl} username={user?.username} />
</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">
{mostRecentCommentableBet && (
<BetStatusText
@ -279,13 +292,17 @@ export function CommentInput(props: {
</>
</>
)}
{(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..."
rows={3}
autoFocus={true}
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)) {
@ -293,16 +310,47 @@ export function CommentInput(props: {
}
}}
/>
</div>
)}
</div>
{!user && (
<button
className={
'btn btn-outline btn-sm text-transform: mt-1 capitalize'
}
onClick={() => submitComment(id)}
>
{user ? 'Comment' : 'Sign in to comment'}
Sign in to Comment
</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>
</Row>
</>
@ -560,12 +608,11 @@ export function FeedQuestion(props: {
)
}
function canCommentOnBet(bet: Bet, createdTime: number, user?: User | null) {
const isSelf = user?.id === bet.userId
function canCommentOnBet(bet: Bet, user?: User | null) {
const { userId, createdTime, isRedemption } = bet
const isSelf = user?.id === userId
// You can comment if your bet was posted in the last hour
return (
!bet.isRedemption && isSelf && Date.now() - createdTime < 60 * 60 * 1000
)
return !isRedemption && isSelf && Date.now() - createdTime < 60 * 60 * 1000
}
function FeedDescription(props: { contract: Contract }) {

View File

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