From 02ed9bf7e1764c45426776a10d2010c14afd5934 Mon Sep 17 00:00:00 2001 From: Boa Date: Wed, 11 May 2022 15:11:46 -0600 Subject: [PATCH] Single threaded comments (#175) * Remove unused hideOutcome in comments * Remove unused hideOutcome in comments * Add replyToComment fields to Comment * Add 1 threaded replies to comments & answers * Allow smooth scrolling within pages via # * remove yarn-error log * correct spelling * Remove smooth-scroll-to-hashtag component * Cleanup & show user position/bets in replies --- .gitignore | 1 + common/comment.ts | 1 + web/components/feed/activity-items.ts | 78 ++-- web/components/feed/feed-items.tsx | 471 +++++++++++++++++------- web/lib/firebase/comments.ts | 17 +- web/pages/[username]/[contractSlug].tsx | 1 - web/pages/_app.tsx | 1 - 7 files changed, 380 insertions(+), 190 deletions(-) diff --git a/.gitignore b/.gitignore index 6cb1e610..10f5d982 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ .idea/ .vercel node_modules +yarn-error.log diff --git a/common/comment.ts b/common/comment.ts index 15cfbcb5..1f420b64 100644 --- a/common/comment.ts +++ b/common/comment.ts @@ -5,6 +5,7 @@ export type Comment = { contractId: string betId?: string answerOutcome?: string + replyToCommentId?: string userId: string text: string diff --git a/web/components/feed/activity-items.ts b/web/components/feed/activity-items.ts index 21baa322..039073a5 100644 --- a/web/components/feed/activity-items.ts +++ b/web/components/feed/activity-items.ts @@ -1,4 +1,4 @@ -import _ from 'lodash' +import _, { Dictionary } from 'lodash' import { Answer } from 'common/answer' import { Bet } from 'common/bet' @@ -18,6 +18,7 @@ export type ActivityItem = | CloseItem | ResolveItem | CommentInputItem + | CommentThreadItem type BaseActivityItem = { id: string @@ -53,9 +54,15 @@ export type CommentItem = BaseActivityItem & { type: 'comment' comment: Comment betsBySameUser: Bet[] - hideOutcome: boolean - truncate: boolean - smallAvatar: boolean + truncate?: boolean + smallAvatar?: boolean +} + +export type CommentThreadItem = BaseActivityItem & { + type: 'commentThread' + parentComment: Comment + comments: Comment[] + betsByUserId: Dictionary<[Bet, ...Bet[]]> } export type BetGroupItem = BaseActivityItem & { @@ -68,6 +75,8 @@ export type AnswerGroupItem = BaseActivityItem & { type: 'answergroup' | 'answer' answer: Answer items: ActivityItem[] + betsByCurrentUser?: Bet[] + comments?: Comment[] } export type CloseItem = BaseActivityItem & { @@ -131,7 +140,6 @@ function groupBets( comment, betsBySameUser: [bet], contract, - hideOutcome, truncate: abbreviated, smallAvatar, } @@ -273,41 +281,23 @@ function getAnswerAndCommentInputGroups( 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) + 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( + answerBets, + answerComments, + contract + ).reverse() return { id: outcome, @@ -316,6 +306,8 @@ function getAnswerAndCommentInputGroups( answer, items, user, + betsByCurrentUser: answerBets.filter((bet) => bet.userId === user?.id), + comments: answerComments, } }) .filter((group) => group.answer) as ActivityItem[] @@ -344,7 +336,6 @@ function groupBetsAndComments( comment, betsBySameUser: [], truncate: abbreviated, - hideOutcome: true, smallAvatar, })) @@ -370,22 +361,21 @@ function groupBetsAndComments( return abbrItems } -function getCommentsWithPositions( +function getCommentThreads( bets: Bet[], comments: Comment[], contract: Contract ) { const betsByUserId = _.groupBy(bets, (bet) => bet.userId) + const parentComments = comments.filter((comment) => !comment.replyToCommentId) - const items = comments.map((comment) => ({ - type: 'comment' as const, + const items = parentComments.map((comment) => ({ + type: 'commentThread' as const, id: comment.id, contract: contract, - comment, - betsBySameUser: bets.length === 0 ? [] : betsByUserId[comment.userId] ?? [], - truncate: false, - hideOutcome: false, - smallAvatar: false, + comments: comments, + parentComment: comment, + betsByUserId: betsByUserId, })) return items @@ -566,7 +556,7 @@ export function getSpecificContractActivityItems( const nonFreeResponseBets = contract.outcomeType === 'FREE_RESPONSE' ? [] : bets items.push( - ...getCommentsWithPositions( + ...getCommentThreads( nonFreeResponseBets, nonFreeResponseComments, contract diff --git a/web/components/feed/feed-items.tsx b/web/components/feed/feed-items.tsx index 0a690d1b..8e5c83ed 100644 --- a/web/components/feed/feed-items.tsx +++ b/web/components/feed/feed-items.tsx @@ -1,6 +1,7 @@ // From https://tailwindui.com/components/application-ui/lists/feeds -import React, { Fragment, useRef, useState } from 'react' +import React, { Fragment, useEffect, useRef, useState } from 'react' import * as _ from 'lodash' +import { Dictionary } from 'lodash' import { BanIcon, CheckIcon, @@ -15,8 +16,8 @@ import Textarea from 'react-expanding-textarea' import { OutcomeLabel } from '../outcome-label' import { - contractMetrics, Contract, + contractMetrics, contractPath, tradingAllowed, } from 'web/lib/firebase/contracts' @@ -30,15 +31,13 @@ import { BinaryResolutionOrChance } from '../contract/contract-card' import { SiteLink } from '../site-link' import { Col } from '../layout/col' import { UserLink } from '../user-page' -import { DateTimeTooltip } from '../datetime-tooltip' import { Bet } from 'web/lib/firebase/bets' import { JoinSpans } from '../join-spans' -import { fromNow } from 'web/lib/util/time' import BetRow from '../bet-row' import { Avatar } from '../avatar' import { Answer } from 'common/answer' import { ActivityItem, GENERAL_COMMENTS_OUTCOME_ID } from './activity-items' -import { Binary, CPMM, DPM, FreeResponse, FullContract } from 'common/contract' +import { Binary, CPMM, FreeResponse, FullContract } from 'common/contract' import { BuyButton } from '../yes-no-selector' import { getDpmOutcomeProbability } from 'common/calculate-dpm' import { AnswerBetPanel } from '../answers/answer-bet-panel' @@ -51,6 +50,7 @@ import { DAY_MS } from 'common/util/time' import NewContractBadge from '../new-contract-badge' import { RelativeTimestamp } from '../relative-timestamp' import { calculateCpmmSale } from 'common/calculate-cpmm' +import { useWindowSize } from 'web/hooks/use-window-size' export function FeedItems(props: { contract: Contract @@ -118,24 +118,97 @@ function FeedItem(props: { item: ActivityItem }) { return case 'commentInput': return + case 'commentThread': + return } } +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[] - hideOutcome: boolean - truncate: boolean - smallAvatar: boolean + truncate?: boolean + smallAvatar?: boolean + onReplyClick?: (comment: Comment) => void }) { const { contract, comment, betsBySameUser, - hideOutcome, truncate, smallAvatar, + onReplyClick, } = props const { text, userUsername, userName, userAvatarUrl, createdTime } = comment let outcome: string | undefined, @@ -187,7 +260,7 @@ export function FeedComment(props: { )} <> {bought} {money} - {outcome && !hideOutcome && ( + {contract.outcomeType !== 'FREE_RESPONSE' && outcome && ( <> {' '} of{' '} @@ -206,6 +279,16 @@ export function FeedComment(props: { moreHref={contractPath(contract)} shouldTruncate={truncate} /> + {onReplyClick && ( + + )} ) @@ -215,133 +298,163 @@ export function CommentInput(props: { contract: Contract betsByCurrentUser: Bet[] comments: Comment[] - // Only for free response comment inputs + // Tie a comment to an free response answer outcome answerOutcome?: string + // Tie a comment to another comment + parentComment?: Comment + replyToUsername?: string + setRef?: (ref: any) => void }) { - const { contract, betsByCurrentUser, comments, answerOutcome } = props + const { + contract, + betsByCurrentUser, + comments, + answerOutcome, + parentComment, + replyToUsername, + setRef, + } = props const user = useUser() const [comment, setComment] = useState('') const [focused, setFocused] = useState(false) + const { width } = useWindowSize() - // Should this be oldest bet or most recent bet? - const mostRecentCommentableBet = betsByCurrentUser - .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 mostRecentCommentableBet = getMostRecentCommentableBet( + betsByCurrentUser, + comments, + 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) return - await createComment(contract.id, comment, user, betId, answerOutcome) + + // Update state asap to avoid double submission. + const commentValue = comment.toString() setComment('') + await createComment( + contract.id, + commentValue, + user, + betId, + answerOutcome, + parentComment?.id + ) } const { userPosition, userPositionMoney, yesFloorShares, noFloorShares } = getBettorsPosition(contract, Date.now(), betsByCurrentUser) + const shouldCollapseAfterClickOutside = false + + function isMobile() { + return width ? width < 768 : false + } + return ( <> - - + +
+ +
- {mostRecentCommentableBet && ( - - )} - {!mostRecentCommentableBet && user && userPosition > 0 && ( - <> - {'You have ' + userPositionMoney + ' '} - <> - {' of '} - noFloorShares ? 'YES' : 'NO'} - contract={contract} - truncate="short" - /> - - - )} - {(answerOutcome === undefined || focused) && ( -
-