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:
parent
100821e34c
commit
3a33efa8db
|
@ -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
|
||||||
|
|
|
@ -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'
|
||||||
|
|
|
@ -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
|
<>
|
||||||
contract={contract}
|
<ContractActivity
|
||||||
bets={bets}
|
contract={contract}
|
||||||
comments={comments}
|
bets={bets}
|
||||||
user={user}
|
comments={comments}
|
||||||
mode="comments"
|
user={user}
|
||||||
betRowClassName="!mt-0 xl:hidden"
|
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 = (
|
const yourTrades = (
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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,
|
||||||
})
|
})
|
||||||
|
|
|
@ -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,30 +292,65 @@ export function CommentInput(props: {
|
||||||
</>
|
</>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<div className="mt-2">
|
{(answerOutcome === undefined || focused) && (
|
||||||
<Textarea
|
<div className="mt-2">
|
||||||
value={comment}
|
<Textarea
|
||||||
onChange={(e) => setComment(e.target.value)}
|
value={comment}
|
||||||
className="textarea textarea-bordered w-full resize-none"
|
onChange={(e) => setComment(e.target.value)}
|
||||||
placeholder="Add a comment..."
|
className="textarea textarea-bordered w-full resize-none"
|
||||||
rows={3}
|
placeholder="Add a comment..."
|
||||||
maxLength={MAX_COMMENT_LENGTH}
|
autoFocus={true}
|
||||||
onKeyDown={(e) => {
|
rows={answerOutcome == undefined || focused ? 3 : 1}
|
||||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
onFocus={() => setFocused(true)}
|
||||||
submitComment(id)
|
onBlur={() => !comment && setFocused(false)}
|
||||||
}
|
maxLength={MAX_COMMENT_LENGTH}
|
||||||
}}
|
onKeyDown={(e) => {
|
||||||
/>
|
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||||
<button
|
submitComment(id)
|
||||||
className={
|
}
|
||||||
'btn btn-outline btn-sm text-transform: mt-1 capitalize'
|
}}
|
||||||
}
|
/>
|
||||||
onClick={() => submitComment(id)}
|
</div>
|
||||||
>
|
)}
|
||||||
{user ? 'Comment' : 'Sign in to comment'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</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)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!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 }) {
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue
Block a user