From 7b3c21cf984ad1d1b80aae65b468308781a027aa Mon Sep 17 00:00:00 2001 From: Boa Date: Wed, 18 May 2022 08:42:13 -0600 Subject: [PATCH] Comments ux improvements and bugfixes (#246) * Show majority stake on comments * Darken comment input text * Fix old FR comments displayed in general section * Refactor feed comments and bets into files * Only allow user to comment on most recent bet * Fix overlapping sign in to comment * Only calculate current users bets once * Minor tweaks & is betting @ prob --- web/components/feed/activity-items.ts | 43 +- .../feed/feed-answer-comment-group.tsx | 18 +- web/components/feed/feed-bets.tsx | 173 +++++ web/components/feed/feed-comments.tsx | 474 +++++++++++++- web/components/feed/feed-items.tsx | 606 +----------------- web/pages/[username]/[contractSlug].tsx | 3 +- 6 files changed, 686 insertions(+), 631 deletions(-) create mode 100644 web/components/feed/feed-bets.tsx diff --git a/web/components/feed/activity-items.ts b/web/components/feed/activity-items.ts index af7ac696..bbf48fe7 100644 --- a/web/components/feed/activity-items.ts +++ b/web/components/feed/activity-items.ts @@ -28,7 +28,7 @@ type BaseActivityItem = { export type CommentInputItem = BaseActivityItem & { type: 'commentInput' betsByCurrentUser: Bet[] - comments: Comment[] + commentsByCurrentUser: Comment[] answerOutcome?: string } @@ -76,7 +76,7 @@ export type AnswerGroupItem = BaseActivityItem & { answer: Answer items: ActivityItem[] betsByCurrentUser?: Bet[] - comments?: Comment[] + commentsByCurrentUser?: Comment[] } export type CloseItem = BaseActivityItem & { @@ -87,7 +87,6 @@ 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 @@ -280,6 +279,7 @@ function getAnswerAndCommentInputGroups( outcomes = _.sortBy(outcomes, (outcome) => getOutcomeProbability(contract, outcome) ) + const betsByCurrentUser = bets.filter((bet) => bet.userId === user?.id) const answerGroups = outcomes .map((outcome) => { @@ -293,9 +293,7 @@ function getAnswerAndCommentInputGroups( comment.answerOutcome === outcome || answerBets.some((bet) => bet.id === comment.betId) ) - const items = getCommentThreads(answerBets, answerComments, contract) - - if (outcome === GENERAL_COMMENTS_OUTCOME_ID) items.reverse() + const items = getCommentThreads(bets, answerComments, contract) return { id: outcome, @@ -304,8 +302,10 @@ function getAnswerAndCommentInputGroups( answer, items, user, - betsByCurrentUser: answerBets.filter((bet) => bet.userId === user?.id), - comments: answerComments, + betsByCurrentUser, + commentsByCurrentUser: answerComments.filter( + (comment) => comment.userId === user?.id + ), } }) .filter((group) => group.answer) as ActivityItem[] @@ -433,7 +433,7 @@ export function getAllContractActivityItems( id: 'commentInput', contract, betsByCurrentUser: [], - comments: [], + commentsByCurrentUser: [], }) } else { items.push( @@ -459,7 +459,7 @@ export function getAllContractActivityItems( id: 'commentInput', contract, betsByCurrentUser: [], - comments: [], + commentsByCurrentUser: [], }) } @@ -520,6 +520,15 @@ export function getRecentContractActivityItems( return [questionItem, ...items] } +function commentIsGeneralComment(comment: Comment, contract: Contract) { + return ( + comment.answerOutcome === undefined && + (contract.outcomeType === 'FREE_RESPONSE' + ? comment.betId === undefined + : true) + ) +} + export function getSpecificContractActivityItems( contract: Contract, bets: Bet[], @@ -550,8 +559,8 @@ export function getSpecificContractActivityItems( break case 'comments': - const nonFreeResponseComments = comments.filter( - (comment) => comment.answerOutcome === undefined + const nonFreeResponseComments = comments.filter((comment) => + commentIsGeneralComment(comment, contract) ) const nonFreeResponseBets = contract.outcomeType === 'FREE_RESPONSE' ? [] : bets @@ -567,10 +576,12 @@ export function getSpecificContractActivityItems( type: 'commentInput', id: 'commentInput', contract, - betsByCurrentUser: user - ? nonFreeResponseBets.filter((bet) => bet.userId === user.id) - : [], - comments: nonFreeResponseComments, + betsByCurrentUser: nonFreeResponseBets.filter( + (bet) => bet.userId === user?.id + ), + commentsByCurrentUser: nonFreeResponseComments.filter( + (comment) => comment.userId === user?.id + ), }) break case 'free-response-comment-answer-groups': diff --git a/web/components/feed/feed-answer-comment-group.tsx b/web/components/feed/feed-answer-comment-group.tsx index a97cfafd..2665880e 100644 --- a/web/components/feed/feed-answer-comment-group.tsx +++ b/web/components/feed/feed-answer-comment-group.tsx @@ -17,8 +17,11 @@ 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 { CommentInput, FeedItem } from 'web/components/feed/feed-items' -import { getMostRecentCommentableBet } from 'web/components/feed/feed-comments' +import { FeedItem } from 'web/components/feed/feed-items' +import { + CommentInput, + getMostRecentCommentableBet, +} from 'web/components/feed/feed-comments' import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time' import { useRouter } from 'next/router' @@ -28,15 +31,16 @@ export function FeedAnswerCommentGroup(props: { items: ActivityItem[] type: string betsByCurrentUser?: Bet[] - comments?: Comment[] + commentsByCurrentUser?: Comment[] }) { - const { answer, items, contract, betsByCurrentUser, comments } = props + const { answer, items, contract, betsByCurrentUser, commentsByCurrentUser } = + props const { username, avatarUrl, name, text } = answer const answerElementId = `answer-${answer.id}` const user = useUser() const mostRecentCommentableBet = getMostRecentCommentableBet( betsByCurrentUser ?? [], - comments ?? [], + commentsByCurrentUser ?? [], user, answer.number + '' ) @@ -44,7 +48,7 @@ export function FeedAnswerCommentGroup(props: { const probPercent = formatPercent(prob) const [open, setOpen] = useState(false) const [showReply, setShowReply] = useState(false) - const isFreeResponseContractPage = comments + const isFreeResponseContractPage = !!commentsByCurrentUser if (mostRecentCommentableBet && !showReply) setShowReplyAndFocus(true) const [inputRef, setInputRef] = useState(null) @@ -174,7 +178,7 @@ export function FeedAnswerCommentGroup(props: { + + {isSelf ? ( + + ) : bettor ? ( + + ) : ( +
+
+
+
+ )} +
+ +
+
+ + ) +} + +export function BetStatusText(props: { + contract: Contract + bet: Bet + isSelf: boolean + bettor?: User + hideOutcome?: boolean +}) { + const { bet, contract, bettor, isSelf, hideOutcome } = props + const { amount, outcome, createdTime } = bet + + const bought = amount >= 0 ? 'bought' : 'sold' + const money = formatMoney(Math.abs(amount)) + + return ( +
+ {isSelf ? 'You' : bettor ? bettor.name : 'A trader'} {bought}{' '} + {money} + {!hideOutcome && ( + <> + {' '} + of{' '} + + + )} + +
+ ) +} + +function BetGroupSpan(props: { + contract: Contract + bets: Bet[] + outcome?: string +}) { + const { contract, bets, outcome } = props + + const numberTraders = _.uniqBy(bets, (b) => b.userId).length + + const [buys, sells] = _.partition(bets, (bet) => bet.amount >= 0) + const buyTotal = _.sumBy(buys, (b) => b.amount) + const sellTotal = _.sumBy(sells, (b) => -b.amount) + + return ( + + {numberTraders} {numberTraders > 1 ? 'traders' : 'trader'}{' '} + + {buyTotal > 0 && <>bought {formatMoney(buyTotal)} } + {sellTotal > 0 && <>sold {formatMoney(sellTotal)} } + + {outcome && ( + <> + {' '} + of{' '} + + + )}{' '} + + ) +} + +export function FeedBetGroup(props: { + contract: Contract + bets: Bet[] + hideOutcome: boolean +}) { + const { contract, bets, hideOutcome } = props + + const betGroups = _.groupBy(bets, (bet) => bet.outcome) + const outcomes = Object.keys(betGroups) + + // Use the time of the last bet for the entire group + const createdTime = bets[bets.length - 1].createdTime + + return ( + <> +
+
+
+
+
+
+
+
+ {outcomes.map((outcome, index) => ( + + + {index !== outcomes.length - 1 &&
} +
+ ))} + +
+
+ + ) +} diff --git a/web/components/feed/feed-comments.tsx b/web/components/feed/feed-comments.tsx index f9cf8a96..2d542172 100644 --- a/web/components/feed/feed-comments.tsx +++ b/web/components/feed/feed-comments.tsx @@ -1,12 +1,206 @@ import { Bet } from 'common/bet' import { Comment } from 'common/comment' import { User } from 'common/user' -import { GENERAL_COMMENTS_OUTCOME_ID } from 'web/components/feed/activity-items' +import { Contract } from 'common/contract' +import { Dictionary } from 'lodash' +import React, { useEffect, useState } from 'react' +import { useUser } from 'web/hooks/use-user' +import { formatMoney } from 'common/util/format' +import { useRouter } from 'next/router' +import { Row } from 'web/components/layout/row' +import clsx from 'clsx' +import { Avatar } from 'web/components/avatar' +import { UserLink } from 'web/components/user-page' +import { OutcomeLabel } from 'web/components/outcome-label' +import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time' +import { contractPath } from 'web/lib/firebase/contracts' +import { firebaseLogin } from 'web/lib/firebase/users' +import { createComment, MAX_COMMENT_LENGTH } from 'web/lib/firebase/comments' +import Textarea from 'react-expanding-textarea' +import * as _ from 'lodash' +import { Linkify } from 'web/components/linkify' +import { SiteLink } from 'web/components/site-link' +import { BetStatusText } from 'web/components/feed/feed-bets' +import { Col } from 'web/components/layout/col' +import { getOutcomeProbability } from 'common/calculate' + +export function FeedCommentThread(props: { + contract: Contract + comments: Comment[] + parentComment: Comment + betsByUserId: Dictionary<[Bet, ...Bet[]]> + truncate?: boolean + smallAvatar?: boolean +}) { + const { + contract, + comments, + betsByUserId, + truncate, + smallAvatar, + parentComment, + } = props + const [showReply, setShowReply] = useState(false) + const [replyToUsername, setReplyToUsername] = useState('') + const user = useUser() + const commentsList = comments.filter( + (comment) => comment.replyToCommentId === parentComment.id + ) + commentsList.unshift(parentComment) + const [inputRef, setInputRef] = useState(null) + function scrollAndOpenReplyInput(comment: Comment) { + setReplyToUsername(comment.userUsername) + setShowReply(true) + inputRef?.focus() + } + useEffect(() => { + if (showReply && inputRef) inputRef.focus() + }, [inputRef, showReply]) + return ( +
+ {commentsList.map((comment, commentIdx) => ( +
+ +
+ ))} + {showReply && ( +
+ +
+ )} +
+ ) +} + +export function FeedComment(props: { + contract: Contract + comment: Comment + betsBySameUser: Bet[] + truncate?: boolean + smallAvatar?: boolean + onReplyClick?: (comment: Comment) => void +}) { + const { + contract, + comment, + betsBySameUser, + truncate, + smallAvatar, + onReplyClick, + } = props + const { text, userUsername, userName, userAvatarUrl, createdTime } = comment + let betOutcome: string | undefined, + bought: string | undefined, + money: string | undefined + + const matchedBet = betsBySameUser.find((bet) => bet.id === comment.betId) + if (matchedBet) { + betOutcome = matchedBet.outcome + bought = matchedBet.amount >= 0 ? 'bought' : 'sold' + money = formatMoney(Math.abs(matchedBet.amount)) + } + + const [highlighted, setHighlighted] = useState(false) + const router = useRouter() + useEffect(() => { + if (router.asPath.endsWith(`#${comment.id}`)) { + setHighlighted(true) + } + }, [router.asPath]) + + // Only calculated if they don't have a matching bet + const { userPosition, outcome } = getBettorsPosition( + contract, + comment.createdTime, + matchedBet ? [] : betsBySameUser + ) + + return ( + + +
+

+ {' '} + {!matchedBet && userPosition > 0 && ( + <> + {'is '} + + + )} + <> + {bought} {money} + {contract.outcomeType !== 'FREE_RESPONSE' && betOutcome && ( + <> + {' '} + of{' '} + + + )} + + +

+ + {onReplyClick && ( + + )} +
+
+ ) +} -// TODO: move feed commment and comment thread in here when sinclair confirms they're not working on them rn export function getMostRecentCommentableBet( betsByCurrentUser: Bet[], - comments: Comment[], + commentsByCurrentUser: Comment[], user?: User | null, answerOutcome?: string ) { @@ -14,15 +208,13 @@ export function getMostRecentCommentableBet( .filter((bet) => { if ( canCommentOnBet(bet, user) && - // The bet doesn't already have a comment - !comments.some((comment) => comment.betId == bet.id) + !commentsByCurrentUser.some( + (comment) => comment.createdTime > bet.createdTime + ) ) { 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 answerOutcome === bet.outcome } return false }) @@ -30,6 +222,270 @@ export function getMostRecentCommentableBet( .pop() } +function CommentStatus(props: { contract: Contract; outcome: string }) { + const { contract, outcome } = props + return ( + <> + {' betting '} + + {' at ' + + Math.round(getOutcomeProbability(contract, outcome) * 100) + + '%'} + + ) +} + +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 +}) { + const { + contract, + betsByCurrentUser, + commentsByCurrentUser, + answerOutcome, + parentComment, + replyToUsername, + setRef, + } = props + const user = useUser() + const [comment, setComment] = useState('') + const [focused, setFocused] = useState(false) + const [isSubmitting, setIsSubmitting] = useState(false) + + const mostRecentCommentableBet = getMostRecentCommentableBet( + betsByCurrentUser, + commentsByCurrentUser, + user, + answerOutcome + ) + const { id } = mostRecentCommentableBet || { id: undefined } + + useEffect(() => { + if (!replyToUsername || !user || replyToUsername === user.username) return + const replacement = `@${replyToUsername} ` + setComment(replacement + comment.replace(replacement, '')) + }, [user, replyToUsername]) + + async function submitComment(betId: string | undefined) { + if (!user) { + return await firebaseLogin() + } + if (!comment || isSubmitting) return + setIsSubmitting(true) + await createComment( + contract.id, + comment, + user, + betId, + answerOutcome, + parentComment?.id + ) + setComment('') + setFocused(false) + setIsSubmitting(false) + } + + const { userPosition, outcome } = getBettorsPosition( + contract, + Date.now(), + betsByCurrentUser + ) + + const shouldCollapseAfterClickOutside = false + + return ( + <> + +
+ +
+
+
+
+ {mostRecentCommentableBet && ( + + )} + {!mostRecentCommentableBet && user && userPosition > 0 && ( + <> + {"You're"} + + + )} +
+ + + +