Fr comment ux improvements (#451)

* Extend comment input box, only use airplane

* Only 1 commentable bet, shrink input, fix feed lines

* Pad sign in to comment button

* Small changes
This commit is contained in:
Ian Philips 2022-06-08 07:24:12 -06:00 committed by GitHub
parent ad6594f0bc
commit 7e37fc776c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 221 additions and 206 deletions

View File

@ -29,7 +29,6 @@ export type CommentInputItem = BaseActivityItem & {
type: 'commentInput' type: 'commentInput'
betsByCurrentUser: Bet[] betsByCurrentUser: Bet[]
commentsByCurrentUser: Comment[] commentsByCurrentUser: Comment[]
answerOutcome?: string
} }
export type DescriptionItem = BaseActivityItem & { export type DescriptionItem = BaseActivityItem & {
@ -74,10 +73,10 @@ export type BetGroupItem = BaseActivityItem & {
export type AnswerGroupItem = BaseActivityItem & { export type AnswerGroupItem = BaseActivityItem & {
type: 'answergroup' type: 'answergroup'
user: User | undefined | null
answer: Answer answer: Answer
items: ActivityItem[] comments: Comment[]
betsByCurrentUser?: Bet[] bets: Bet[]
commentsByCurrentUser?: Comment[]
} }
export type CloseItem = BaseActivityItem & { export type CloseItem = BaseActivityItem & {
@ -232,31 +231,19 @@ function getAnswerGroups(
const answerGroups = outcomes const answerGroups = outcomes
.map((outcome) => { .map((outcome) => {
const answerBets = bets.filter((bet) => bet.outcome === outcome)
const answerComments = comments.filter((comment) =>
answerBets.some((bet) => bet.id === comment.betId)
)
const answer = contract.answers?.find( const answer = contract.answers?.find(
(answer) => answer.id === outcome (answer) => answer.id === outcome
) as Answer ) as Answer
let items = groupBets(answerBets, answerComments, contract, user?.id, { // TODO: this doesn't abbreviate these groups for activity feed anymore
hideOutcome: true,
abbreviated,
smallAvatar: true,
reversed,
})
if (abbreviated)
items = items.slice(-ABBREVIATED_NUM_COMMENTS_OR_BETS_TO_SHOW)
return { return {
id: outcome, id: outcome,
type: 'answergroup' as const, type: 'answergroup' as const,
contract, contract,
answer,
items,
user, user,
answer,
comments,
bets,
} }
}) })
.filter((group) => group.answer) .filter((group) => group.answer)
@ -276,7 +263,6 @@ function getAnswerAndCommentInputGroups(
outcomes = sortBy(outcomes, (outcome) => outcomes = sortBy(outcomes, (outcome) =>
getOutcomeProbability(contract, outcome) getOutcomeProbability(contract, outcome)
) )
const betsByCurrentUser = bets.filter((bet) => bet.userId === user?.id)
const answerGroups = outcomes const answerGroups = outcomes
.map((outcome) => { .map((outcome) => {
@ -284,25 +270,14 @@ function getAnswerAndCommentInputGroups(
(answer) => answer.id === outcome (answer) => answer.id === outcome
) as Answer ) as Answer
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(bets, answerComments, contract)
return { return {
id: outcome, id: outcome,
type: 'answergroup' as const, type: 'answergroup' as const,
contract, contract,
answer,
items,
user, user,
betsByCurrentUser, answer,
commentsByCurrentUser: answerComments.filter( comments,
(comment) => comment.userId === user?.id bets,
),
} }
}) })
.filter((group) => group.answer) as ActivityItem[] .filter((group) => group.answer) as ActivityItem[]
@ -425,13 +400,6 @@ export function getAllContractActivityItems(
} }
) )
) )
items.push({
type: 'commentInput' as const,
id: 'commentInput',
contract,
betsByCurrentUser: [],
commentsByCurrentUser: [],
})
} else { } else {
items.push( items.push(
...groupBetsAndComments(bets, comments, contract, user?.id, { ...groupBetsAndComments(bets, comments, contract, user?.id, {
@ -450,16 +418,6 @@ export function getAllContractActivityItems(
items.push({ type: 'resolve', id: `${contract.resolutionTime}`, contract }) items.push({ type: 'resolve', id: `${contract.resolutionTime}`, contract })
} }
if (outcomeType === 'BINARY') {
items.push({
type: 'commentInput' as const,
id: 'commentInput',
contract,
betsByCurrentUser: [],
commentsByCurrentUser: [],
})
}
if (reversed) items.reverse() if (reversed) items.reverse()
return items return items

View File

@ -1,8 +1,6 @@
import { Answer } from 'common/answer' import { Answer } from 'common/answer'
import { ActivityItem } from 'web/components/feed/activity-items'
import { Bet } from 'common/bet' import { Bet } from 'common/bet'
import { Comment } from 'common/comment' import { Comment } from 'common/comment'
import { useUser } from 'web/hooks/use-user'
import { getDpmOutcomeProbability } from 'common/calculate-dpm' import { getDpmOutcomeProbability } from 'common/calculate-dpm'
import { formatPercent } from 'common/util/format' import { formatPercent } from 'common/util/format'
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
@ -16,44 +14,80 @@ import { Linkify } from 'web/components/linkify'
import clsx from 'clsx' import clsx from 'clsx'
import { tradingAllowed } from 'web/lib/firebase/contracts' import { tradingAllowed } from 'web/lib/firebase/contracts'
import { BuyButton } from 'web/components/yes-no-selector' import { BuyButton } from 'web/components/yes-no-selector'
import { FeedItem } from 'web/components/feed/feed-items'
import { import {
CommentInput, CommentInput,
CommentRepliesList,
getMostRecentCommentableBet, getMostRecentCommentableBet,
} from 'web/components/feed/feed-comments' } from 'web/components/feed/feed-comments'
import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time' import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { groupBy } from 'lodash'
import { User } from 'common/user'
export function FeedAnswerCommentGroup(props: { export function FeedAnswerCommentGroup(props: {
contract: any contract: any
user: User | undefined | null
answer: Answer answer: Answer
items: ActivityItem[] comments: Comment[]
type: string bets: Bet[]
betsByCurrentUser?: Bet[]
commentsByCurrentUser?: Comment[]
}) { }) {
const { answer, items, contract, betsByCurrentUser, commentsByCurrentUser } = const { answer, contract, comments, bets, user } = props
props
const { username, avatarUrl, name, text } = answer const { username, avatarUrl, name, text } = answer
const answerElementId = `answer-${answer.id}`
const user = useUser() const [replyToUsername, setReplyToUsername] = useState('')
const mostRecentCommentableBet = getMostRecentCommentableBet(
betsByCurrentUser ?? [],
commentsByCurrentUser ?? [],
user,
answer.number + ''
)
const prob = getDpmOutcomeProbability(contract.totalShares, answer.id)
const probPercent = formatPercent(prob)
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [showReply, setShowReply] = useState(false) const [showReply, setShowReply] = useState(false)
const isFreeResponseContractPage = !!commentsByCurrentUser
if (mostRecentCommentableBet && !showReply) setShowReplyAndFocus(true)
const [inputRef, setInputRef] = useState<HTMLTextAreaElement | null>(null) const [inputRef, setInputRef] = useState<HTMLTextAreaElement | null>(null)
const [highlighted, setHighlighted] = useState(false)
const router = useRouter()
// If they've already opened the input box, focus it once again const answerElementId = `answer-${answer.id}`
function setShowReplyAndFocus(show: boolean) { const betsByUserId = groupBy(bets, (bet) => bet.userId)
setShowReply(show) const commentsByUserId = groupBy(comments, (comment) => comment.userId)
const answerComments = comments.filter(
(comment) => comment.answerOutcome === answer.number.toString()
)
const commentReplies = comments.filter(
(comment) =>
comment.replyToCommentId &&
!comment.answerOutcome &&
answerComments.map((c) => c.id).includes(comment.replyToCommentId)
)
const commentsList = answerComments.concat(commentReplies)
const prob = getDpmOutcomeProbability(contract.totalShares, answer.id)
const probPercent = formatPercent(prob)
const betsByCurrentUser = (user && betsByUserId[user.id]) ?? []
const commentsByCurrentUser = (user && commentsByUserId[user.id]) ?? []
const isFreeResponseContractPage = !!commentsByCurrentUser
useEffect(() => {
const mostRecentCommentableBet = getMostRecentCommentableBet(
betsByCurrentUser,
commentsByCurrentUser,
user,
answer.number.toString()
)
if (mostRecentCommentableBet && !showReply)
scrollAndOpenReplyInput(undefined, answer)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [betsByCurrentUser])
useEffect(() => {
// Only show one comment input for a bet at a time
const usersMostRecentBet = bets
.filter((b) => b.userId === user?.id)
.sort((a, b) => b.createdTime - a.createdTime)[0]
if (
usersMostRecentBet &&
usersMostRecentBet.outcome !== answer.number.toString()
) {
setShowReply(false)
}
}, [answer.number, bets, user])
function scrollAndOpenReplyInput(comment?: Comment, answer?: Answer) {
setReplyToUsername(comment?.userUsername ?? answer?.username ?? '')
setShowReply(true)
inputRef?.focus() inputRef?.focus()
} }
@ -61,8 +95,6 @@ export function FeedAnswerCommentGroup(props: {
if (showReply && inputRef) inputRef.focus() if (showReply && inputRef) inputRef.focus()
}, [inputRef, showReply]) }, [inputRef, showReply])
const [highlighted, setHighlighted] = useState(false)
const router = useRouter()
useEffect(() => { useEffect(() => {
if (router.asPath.endsWith(`#${answerElementId}`)) { if (router.asPath.endsWith(`#${answerElementId}`)) {
setHighlighted(true) setHighlighted(true)
@ -70,7 +102,7 @@ export function FeedAnswerCommentGroup(props: {
}, [answerElementId, router.asPath]) }, [answerElementId, router.asPath])
return ( return (
<Col className={'flex-1 gap-2'}> <Col className={'relative flex-1 gap-2'}>
<Modal open={open} setOpen={setOpen}> <Modal open={open} setOpen={setOpen}>
<AnswerBetPanel <AnswerBetPanel
answer={answer} answer={answer}
@ -113,7 +145,7 @@ export function FeedAnswerCommentGroup(props: {
className={ className={
'text-xs font-bold text-gray-500 hover:underline' 'text-xs font-bold text-gray-500 hover:underline'
} }
onClick={() => setShowReplyAndFocus(true)} onClick={() => scrollAndOpenReplyInput(undefined, answer)}
> >
Reply Reply
</button> </button>
@ -143,7 +175,7 @@ export function FeedAnswerCommentGroup(props: {
<div className={'justify-initial hidden sm:block'}> <div className={'justify-initial hidden sm:block'}>
<button <button
className={'text-xs font-bold text-gray-500 hover:underline'} className={'text-xs font-bold text-gray-500 hover:underline'}
onClick={() => setShowReplyAndFocus(true)} onClick={() => scrollAndOpenReplyInput(undefined, answer)}
> >
Reply Reply
</button> </button>
@ -151,36 +183,31 @@ export function FeedAnswerCommentGroup(props: {
)} )}
</Col> </Col>
</Row> </Row>
<CommentRepliesList
{items.map((item, index) => ( contract={contract}
<div commentsList={commentsList}
key={item.id} betsByUserId={betsByUserId}
className={clsx( smallAvatar={true}
'relative ml-8', truncate={false}
index !== items.length - 1 && 'pb-4' bets={bets}
)} scrollAndOpenReplyInput={scrollAndOpenReplyInput}
> treatFirstIndexEqually={true}
{index !== items.length - 1 ? ( />
<span
className="absolute top-5 left-5 -ml-px h-[calc(100%-1rem)] w-0.5 bg-gray-200"
aria-hidden="true"
/>
) : null}
<div className="relative flex items-start space-x-3">
<FeedItem item={item} />
</div>
</div>
))}
{showReply && ( {showReply && (
<div className={'ml-8 pt-4'}> <div className={'ml-6 pt-4'}>
<span
className="absolute -ml-[1px] mt-[0.8rem] h-2 w-0.5 rotate-90 bg-gray-200"
aria-hidden="true"
/>
<CommentInput <CommentInput
contract={contract} contract={contract}
betsByCurrentUser={betsByCurrentUser ?? []} betsByCurrentUser={betsByCurrentUser}
commentsByCurrentUser={commentsByCurrentUser ?? []} commentsByCurrentUser={commentsByCurrentUser}
answerOutcome={answer.number + ''} parentAnswerOutcome={answer.number.toString()}
replyToUsername={answer.username} replyToUsername={replyToUsername}
setRef={setInputRef} setRef={setInputRef}
onSubmitComment={() => setShowReply(false)}
/> />
</div> </div>
)} )}

View File

@ -3,7 +3,7 @@ import { Comment } from 'common/comment'
import { User } from 'common/user' import { User } from 'common/user'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { minBy, maxBy, groupBy, partition, sumBy } from 'lodash' import { minBy, maxBy, groupBy, partition, sumBy, Dictionary } from 'lodash'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { formatMoney } from 'common/util/format' import { formatMoney } from 'common/util/format'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
@ -54,15 +54,77 @@ export function FeedCommentThread(props: {
if (showReply && inputRef) inputRef.focus() if (showReply && inputRef) inputRef.focus()
}, [inputRef, showReply]) }, [inputRef, showReply])
return ( return (
<div className={'flex-col pr-1'}> <div className={'w-full flex-col pr-1'}>
<span
className="absolute top-5 left-5 -ml-px h-[calc(100%-2rem)] w-0.5 bg-gray-200"
aria-hidden="true"
/>
<CommentRepliesList
contract={contract}
commentsList={commentsList}
betsByUserId={betsByUserId}
smallAvatar={smallAvatar}
truncate={truncate}
bets={bets}
scrollAndOpenReplyInput={scrollAndOpenReplyInput}
/>
{showReply && (
<div className={'-pb-2 ml-6 flex flex-col pt-5'}>
<span
className="absolute -ml-[1px] mt-[0.8rem] h-2 w-0.5 rotate-90 bg-gray-200"
aria-hidden="true"
/>
<CommentInput
contract={contract}
betsByCurrentUser={(user && betsByUserId[user.id]) ?? []}
commentsByCurrentUser={comments.filter(
(c) => c.userId === user?.id
)}
parentCommentId={parentComment.id}
replyToUsername={replyToUsername}
parentAnswerOutcome={comments[0].answerOutcome}
setRef={setInputRef}
onSubmitComment={() => setShowReply(false)}
/>
</div>
)}
</div>
)
}
export function CommentRepliesList(props: {
contract: Contract
commentsList: Comment[]
betsByUserId: Dictionary<Bet[]>
scrollAndOpenReplyInput: (comment: Comment) => void
bets: Bet[]
treatFirstIndexEqually?: boolean
smallAvatar?: boolean
truncate?: boolean
}) {
const {
contract,
commentsList,
betsByUserId,
truncate,
smallAvatar,
bets,
scrollAndOpenReplyInput,
treatFirstIndexEqually,
} = props
return (
<>
{commentsList.map((comment, commentIdx) => ( {commentsList.map((comment, commentIdx) => (
<div <div
key={comment.id} key={comment.id}
id={comment.id} id={comment.id}
className={clsx('relative', commentIdx === 0 ? '' : 'mt-3 ml-6')} className={clsx(
'relative',
!treatFirstIndexEqually && commentIdx === 0 ? '' : 'mt-3 ml-6'
)}
> >
{/*draw a gray line from the comment to the left:*/} {/*draw a gray line from the comment to the left:*/}
{commentIdx != 0 && ( {(treatFirstIndexEqually || commentIdx != 0) && (
<span <span
className="absolute -ml-[1px] mt-[0.8rem] h-2 w-0.5 rotate-90 bg-gray-200" className="absolute -ml-[1px] mt-[0.8rem] h-2 w-0.5 rotate-90 bg-gray-200"
aria-hidden="true" aria-hidden="true"
@ -87,24 +149,7 @@ export function FeedCommentThread(props: {
/> />
</div> </div>
))} ))}
{showReply && ( </>
<div className={'-pb-2 ml-6 flex flex-col pt-5'}>
<span
className="absolute -ml-[1px] mt-[0.8rem] h-2 w-0.5 rotate-90 bg-gray-200"
aria-hidden="true"
/>
<CommentInput
contract={contract}
betsByCurrentUser={(user && betsByUserId[user.id]) ?? []}
commentsByCurrentUser={comments}
parentComment={parentComment}
replyToUsername={replyToUsername}
answerOutcome={comments[0].answerOutcome}
setRef={setInputRef}
/>
</div>
)}
</div>
) )
} }
@ -229,7 +274,13 @@ export function getMostRecentCommentableBet(
user?: User | null, user?: User | null,
answerOutcome?: string answerOutcome?: string
) { ) {
return betsByCurrentUser let sortedBetsByCurrentUser = betsByCurrentUser.sort(
(a, b) => b.createdTime - a.createdTime
)
if (answerOutcome) {
sortedBetsByCurrentUser = sortedBetsByCurrentUser.slice(0, 1)
}
return sortedBetsByCurrentUser
.filter((bet) => { .filter((bet) => {
if ( if (
canCommentOnBet(bet, user) && canCommentOnBet(bet, user) &&
@ -238,12 +289,10 @@ export function getMostRecentCommentableBet(
) )
) { ) {
if (!answerOutcome) return true if (!answerOutcome) return true
// If we're in free response, don't allow commenting on ante bet
return answerOutcome === bet.outcome return answerOutcome === bet.outcome
} }
return false return false
}) })
.sort((b1, b2) => b1.createdTime - b2.createdTime)
.pop() .pop()
} }
@ -266,20 +315,22 @@ export function CommentInput(props: {
contract: Contract contract: Contract
betsByCurrentUser: Bet[] betsByCurrentUser: Bet[]
commentsByCurrentUser: Comment[] commentsByCurrentUser: Comment[]
// Tie a comment to an free response answer outcome
answerOutcome?: string
// Tie a comment to another comment
parentComment?: Comment
replyToUsername?: string replyToUsername?: string
setRef?: (ref: HTMLTextAreaElement) => void setRef?: (ref: HTMLTextAreaElement) => void
// Reply to a free response answer
parentAnswerOutcome?: string
// Reply to another comment
parentCommentId?: string
onSubmitComment?: () => void
}) { }) {
const { const {
contract, contract,
betsByCurrentUser, betsByCurrentUser,
commentsByCurrentUser, commentsByCurrentUser,
answerOutcome, parentAnswerOutcome,
parentComment, parentCommentId,
replyToUsername, replyToUsername,
onSubmitComment,
setRef, setRef,
} = props } = props
const user = useUser() const user = useUser()
@ -291,7 +342,7 @@ export function CommentInput(props: {
betsByCurrentUser, betsByCurrentUser,
commentsByCurrentUser, commentsByCurrentUser,
user, user,
answerOutcome parentAnswerOutcome
) )
const { id } = mostRecentCommentableBet || { id: undefined } const { id } = mostRecentCommentableBet || { id: undefined }
@ -312,9 +363,10 @@ export function CommentInput(props: {
comment, comment,
user, user,
betId, betId,
answerOutcome, parentAnswerOutcome,
parentComment?.id parentCommentId
) )
onSubmitComment?.()
setComment('') setComment('')
setFocused(false) setFocused(false)
setIsSubmitting(false) setIsSubmitting(false)
@ -326,7 +378,7 @@ export function CommentInput(props: {
betsByCurrentUser betsByCurrentUser
) )
const shouldCollapseAfterClickOutside = false const shouldCollapseAfterClickOutside = !comment
const isNumeric = contract.outcomeType === 'NUMERIC' const isNumeric = contract.outcomeType === 'NUMERIC'
@ -373,74 +425,45 @@ export function CommentInput(props: {
)} )}
</div> </div>
<Row className="grid grid-cols-8 gap-1.5 text-gray-700"> <Row className="gap-1.5 text-gray-700">
<Col <Textarea
ref={setRef}
value={comment}
onChange={(e) => setComment(e.target.value)}
className={clsx( className={clsx(
'col-span-8 sm:col-span-6', 'textarea textarea-bordered w-full resize-none'
!user && 'col-span-8'
)} )}
> placeholder={
<Textarea parentCommentId || parentAnswerOutcome
ref={setRef} ? 'Write a reply... '
value={comment} : 'Write a comment...'
onChange={(e) => setComment(e.target.value)} }
className={clsx('textarea textarea-bordered resize-none')} autoFocus={true}
placeholder={ onFocus={() => setFocused(true)}
parentComment || answerOutcome onBlur={() =>
? 'Write a reply... ' shouldCollapseAfterClickOutside && setFocused(false)
: 'Write a comment...' }
maxLength={MAX_COMMENT_LENGTH}
disabled={isSubmitting}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault()
submitComment(id)
e.currentTarget.blur()
} }
autoFocus={focused} }}
rows={focused ? 3 : 1} />
onFocus={() => setFocused(true)}
onBlur={() =>
shouldCollapseAfterClickOutside && setFocused(false)
}
maxLength={MAX_COMMENT_LENGTH}
disabled={isSubmitting}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault()
submitComment(id)
e.currentTarget.blur()
}
}}
/>
</Col>
{!user && (
<Col
className={clsx(
'col-span-8 sm:col-span-2',
focused ? 'justify-end' : 'justify-center'
)}
>
<button
className={
'btn btn-outline btn-sm text-transform: capitalize'
}
onClick={() => submitComment(id)}
>
Sign in to Comment
</button>
</Col>
)}
<Col <Col className={clsx(focused ? 'justify-end' : 'justify-center')}>
className={clsx(
'col-span-1 sm:col-span-2',
focused ? 'justify-end' : 'justify-center'
)}
>
{user && !isSubmitting && ( {user && !isSubmitting && (
<button <button
className={clsx( className={clsx(
'btn btn-ghost btn-sm block flex flex-row capitalize', 'btn btn-ghost btn-sm absolute right-2 block flex flex-row capitalize',
'absolute bottom-4 right-1 col-span-1', parentCommentId || parentAnswerOutcome
parentComment ? ' bottom-6 right-2.5' : '', ? ' bottom-4'
'sm:relative sm:bottom-0 sm:right-0 sm:col-span-2', : ' bottom-2',
focused && comment (!focused || !comment) &&
? 'sm:btn-outline' 'pointer-events-none text-gray-500'
: 'pointer-events-none text-gray-500'
)} )}
onClick={() => { onClick={() => {
if (!focused) return if (!focused) return
@ -449,12 +472,9 @@ export function CommentInput(props: {
} }
}} }}
> >
<span className={'hidden sm:block'}>
{parentComment || answerOutcome ? 'Reply' : 'Comment'}
</span>
{focused && ( {focused && (
<PaperAirplaneIcon <PaperAirplaneIcon
className={'m-0 min-w-[22px] rotate-90 p-0 sm:hidden'} className={'m-0 min-w-[22px] rotate-90 p-0 '}
height={25} height={25}
/> />
)} )}
@ -465,6 +485,16 @@ export function CommentInput(props: {
)} )}
</Col> </Col>
</Row> </Row>
<Row>
{!user && (
<button
className={'btn btn-outline btn-sm mt-2 normal-case'}
onClick={() => submitComment(id)}
>
Sign in to comment
</button>
)}
</Row>
</div> </div>
</div> </div>
</Row> </Row>