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

View File

@ -1,8 +1,6 @@
import { Answer } from 'common/answer'
import { ActivityItem } from 'web/components/feed/activity-items'
import { Bet } from 'common/bet'
import { Comment } from 'common/comment'
import { useUser } from 'web/hooks/use-user'
import { getDpmOutcomeProbability } from 'common/calculate-dpm'
import { formatPercent } from 'common/util/format'
import React, { useEffect, useState } from 'react'
@ -16,44 +14,80 @@ import { Linkify } from 'web/components/linkify'
import clsx from 'clsx'
import { tradingAllowed } from 'web/lib/firebase/contracts'
import { BuyButton } from 'web/components/yes-no-selector'
import { FeedItem } from 'web/components/feed/feed-items'
import {
CommentInput,
CommentRepliesList,
getMostRecentCommentableBet,
} from 'web/components/feed/feed-comments'
import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time'
import { useRouter } from 'next/router'
import { groupBy } from 'lodash'
import { User } from 'common/user'
export function FeedAnswerCommentGroup(props: {
contract: any
user: User | undefined | null
answer: Answer
items: ActivityItem[]
type: string
betsByCurrentUser?: Bet[]
commentsByCurrentUser?: Comment[]
comments: Comment[]
bets: Bet[]
}) {
const { answer, items, contract, betsByCurrentUser, commentsByCurrentUser } =
props
const { answer, contract, comments, bets, user } = props
const { username, avatarUrl, name, text } = answer
const answerElementId = `answer-${answer.id}`
const user = useUser()
const mostRecentCommentableBet = getMostRecentCommentableBet(
betsByCurrentUser ?? [],
commentsByCurrentUser ?? [],
user,
answer.number + ''
)
const prob = getDpmOutcomeProbability(contract.totalShares, answer.id)
const probPercent = formatPercent(prob)
const [replyToUsername, setReplyToUsername] = useState('')
const [open, setOpen] = useState(false)
const [showReply, setShowReply] = useState(false)
const isFreeResponseContractPage = !!commentsByCurrentUser
if (mostRecentCommentableBet && !showReply) setShowReplyAndFocus(true)
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
function setShowReplyAndFocus(show: boolean) {
setShowReply(show)
const answerElementId = `answer-${answer.id}`
const betsByUserId = groupBy(bets, (bet) => bet.userId)
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()
}
@ -61,8 +95,6 @@ export function FeedAnswerCommentGroup(props: {
if (showReply && inputRef) inputRef.focus()
}, [inputRef, showReply])
const [highlighted, setHighlighted] = useState(false)
const router = useRouter()
useEffect(() => {
if (router.asPath.endsWith(`#${answerElementId}`)) {
setHighlighted(true)
@ -70,7 +102,7 @@ export function FeedAnswerCommentGroup(props: {
}, [answerElementId, router.asPath])
return (
<Col className={'flex-1 gap-2'}>
<Col className={'relative flex-1 gap-2'}>
<Modal open={open} setOpen={setOpen}>
<AnswerBetPanel
answer={answer}
@ -113,7 +145,7 @@ export function FeedAnswerCommentGroup(props: {
className={
'text-xs font-bold text-gray-500 hover:underline'
}
onClick={() => setShowReplyAndFocus(true)}
onClick={() => scrollAndOpenReplyInput(undefined, answer)}
>
Reply
</button>
@ -143,7 +175,7 @@ export function FeedAnswerCommentGroup(props: {
<div className={'justify-initial hidden sm:block'}>
<button
className={'text-xs font-bold text-gray-500 hover:underline'}
onClick={() => setShowReplyAndFocus(true)}
onClick={() => scrollAndOpenReplyInput(undefined, answer)}
>
Reply
</button>
@ -151,36 +183,31 @@ export function FeedAnswerCommentGroup(props: {
)}
</Col>
</Row>
{items.map((item, index) => (
<div
key={item.id}
className={clsx(
'relative ml-8',
index !== items.length - 1 && 'pb-4'
)}
>
{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>
))}
<CommentRepliesList
contract={contract}
commentsList={commentsList}
betsByUserId={betsByUserId}
smallAvatar={true}
truncate={false}
bets={bets}
scrollAndOpenReplyInput={scrollAndOpenReplyInput}
treatFirstIndexEqually={true}
/>
{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
contract={contract}
betsByCurrentUser={betsByCurrentUser ?? []}
commentsByCurrentUser={commentsByCurrentUser ?? []}
answerOutcome={answer.number + ''}
replyToUsername={answer.username}
betsByCurrentUser={betsByCurrentUser}
commentsByCurrentUser={commentsByCurrentUser}
parentAnswerOutcome={answer.number.toString()}
replyToUsername={replyToUsername}
setRef={setInputRef}
onSubmitComment={() => setShowReply(false)}
/>
</div>
)}

View File

@ -3,7 +3,7 @@ import { Comment } from 'common/comment'
import { User } from 'common/user'
import { Contract } from 'common/contract'
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 { formatMoney } from 'common/util/format'
import { useRouter } from 'next/router'
@ -54,15 +54,77 @@ export function FeedCommentThread(props: {
if (showReply && inputRef) inputRef.focus()
}, [inputRef, showReply])
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) => (
<div
key={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:*/}
{commentIdx != 0 && (
{(treatFirstIndexEqually || commentIdx != 0) && (
<span
className="absolute -ml-[1px] mt-[0.8rem] h-2 w-0.5 rotate-90 bg-gray-200"
aria-hidden="true"
@ -87,24 +149,7 @@ export function FeedCommentThread(props: {
/>
</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,
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) => {
if (
canCommentOnBet(bet, user) &&
@ -238,12 +289,10 @@ export function getMostRecentCommentableBet(
)
) {
if (!answerOutcome) return true
// If we're in free response, don't allow commenting on ante bet
return answerOutcome === bet.outcome
}
return false
})
.sort((b1, b2) => b1.createdTime - b2.createdTime)
.pop()
}
@ -266,20 +315,22 @@ export function CommentInput(props: {
contract: Contract
betsByCurrentUser: Bet[]
commentsByCurrentUser: Comment[]
// Tie a comment to an free response answer outcome
answerOutcome?: string
// Tie a comment to another comment
parentComment?: Comment
replyToUsername?: string
setRef?: (ref: HTMLTextAreaElement) => void
// Reply to a free response answer
parentAnswerOutcome?: string
// Reply to another comment
parentCommentId?: string
onSubmitComment?: () => void
}) {
const {
contract,
betsByCurrentUser,
commentsByCurrentUser,
answerOutcome,
parentComment,
parentAnswerOutcome,
parentCommentId,
replyToUsername,
onSubmitComment,
setRef,
} = props
const user = useUser()
@ -291,7 +342,7 @@ export function CommentInput(props: {
betsByCurrentUser,
commentsByCurrentUser,
user,
answerOutcome
parentAnswerOutcome
)
const { id } = mostRecentCommentableBet || { id: undefined }
@ -312,9 +363,10 @@ export function CommentInput(props: {
comment,
user,
betId,
answerOutcome,
parentComment?.id
parentAnswerOutcome,
parentCommentId
)
onSubmitComment?.()
setComment('')
setFocused(false)
setIsSubmitting(false)
@ -326,7 +378,7 @@ export function CommentInput(props: {
betsByCurrentUser
)
const shouldCollapseAfterClickOutside = false
const shouldCollapseAfterClickOutside = !comment
const isNumeric = contract.outcomeType === 'NUMERIC'
@ -373,74 +425,45 @@ export function CommentInput(props: {
)}
</div>
<Row className="grid grid-cols-8 gap-1.5 text-gray-700">
<Col
<Row className="gap-1.5 text-gray-700">
<Textarea
ref={setRef}
value={comment}
onChange={(e) => setComment(e.target.value)}
className={clsx(
'col-span-8 sm:col-span-6',
!user && 'col-span-8'
'textarea textarea-bordered w-full resize-none'
)}
>
<Textarea
ref={setRef}
value={comment}
onChange={(e) => setComment(e.target.value)}
className={clsx('textarea textarea-bordered resize-none')}
placeholder={
parentComment || answerOutcome
? 'Write a reply... '
: 'Write a comment...'
placeholder={
parentCommentId || parentAnswerOutcome
? 'Write a reply... '
: 'Write a comment...'
}
autoFocus={true}
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()
}
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
className={clsx(
'col-span-1 sm:col-span-2',
focused ? 'justify-end' : 'justify-center'
)}
>
<Col className={clsx(focused ? 'justify-end' : 'justify-center')}>
{user && !isSubmitting && (
<button
className={clsx(
'btn btn-ghost btn-sm block flex flex-row capitalize',
'absolute bottom-4 right-1 col-span-1',
parentComment ? ' bottom-6 right-2.5' : '',
'sm:relative sm:bottom-0 sm:right-0 sm:col-span-2',
focused && comment
? 'sm:btn-outline'
: 'pointer-events-none text-gray-500'
'btn btn-ghost btn-sm absolute right-2 block flex flex-row capitalize',
parentCommentId || parentAnswerOutcome
? ' bottom-4'
: ' bottom-2',
(!focused || !comment) &&
'pointer-events-none text-gray-500'
)}
onClick={() => {
if (!focused) return
@ -449,12 +472,9 @@ export function CommentInput(props: {
}
}}
>
<span className={'hidden sm:block'}>
{parentComment || answerOutcome ? 'Reply' : 'Comment'}
</span>
{focused && (
<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}
/>
)}
@ -465,6 +485,16 @@ export function CommentInput(props: {
)}
</Col>
</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>
</Row>