diff --git a/web/components/answers/answer-item.tsx b/web/components/answers/answer-item.tsx index 602c9d2e..ccda34ea 100644 --- a/web/components/answers/answer-item.tsx +++ b/web/components/answers/answer-item.tsx @@ -46,7 +46,7 @@ export function AnswerItem(props: { wasResolvedTo ? resolution === 'MKT' ? 'mb-2 bg-blue-50' - : 'mb-8 bg-green-50' + : 'mb-10 bg-green-50' : chosenProb === undefined ? 'bg-gray-50' : showChoice === 'radio' diff --git a/web/components/answers/answers-panel.tsx b/web/components/answers/answers-panel.tsx index ae2c0100..b132b3cb 100644 --- a/web/components/answers/answers-panel.tsx +++ b/web/components/answers/answers-panel.tsx @@ -1,5 +1,5 @@ import _ from 'lodash' -import { useLayoutEffect, useState } from 'react' +import React, { useLayoutEffect, useState } from 'react' import { DPM, FreeResponse, FullContract } from 'common/contract' import { Col } from '../layout/col' @@ -11,11 +11,19 @@ import { AnswerItem } from './answer-item' import { CreateAnswerPanel } from './create-answer-panel' import { AnswerResolvePanel } from './answer-resolve-panel' import { Spacer } from '../layout/spacer' -import { FeedItems } from '../feed/feed-items' import { ActivityItem } from '../feed/activity-items' import { User } from 'common/user' import { getOutcomeProbability } from 'common/calculate' import { Answer } from 'common/answer' +import clsx from 'clsx' +import { formatPercent } from 'common/util/format' +import { Modal } from 'web/components/layout/modal' +import { AnswerBetPanel } from 'web/components/answers/answer-bet-panel' +import { Row } from 'web/components/layout/row' +import { Avatar } from 'web/components/avatar' +import { UserLink } from 'web/components/user-page' +import { Linkify } from 'web/components/linkify' +import { BuyButton } from 'web/components/yes-no-selector' export function AnswersPanel(props: { contract: FullContract @@ -108,12 +116,17 @@ export function AnswersPanel(props: { ))} {!resolveOption && ( - +
+
+ {answerItems.map((item, activityItemIdx) => ( +
+
+ +
+
+ ))} +
+
)} {answers.length <= 1 && ( @@ -167,3 +180,72 @@ function getAnswerItems( }) .filter((group) => group.answer) } + +function OpenAnswer(props: { + contract: FullContract + answer: Answer + items: ActivityItem[] + type: string +}) { + const { answer, contract } = props + const { username, avatarUrl, name, text } = answer + const prob = getDpmOutcomeProbability(contract.totalShares, answer.id) + const probPercent = formatPercent(prob) + const [open, setOpen] = useState(false) + + return ( + + + setOpen(false)} + className="sm:max-w-84 !rounded-md bg-white !px-8 !py-6" + isModal={true} + /> + + +
+ + +
+ +
+ +
+ answered +
+ + + + + + + +
+ + {probPercent} + + setOpen(true)} + /> +
+
+ + +
+ + ) +} diff --git a/web/components/contract/contract-info-dialog.tsx b/web/components/contract/contract-info-dialog.tsx index 68921347..73858c6f 100644 --- a/web/components/contract/contract-info-dialog.tsx +++ b/web/components/contract/contract-info-dialog.tsx @@ -49,12 +49,15 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
Share
- + - +
diff --git a/web/components/copy-link-button.tsx b/web/components/copy-link-button.tsx index 6ad22893..9366af8f 100644 --- a/web/components/copy-link-button.tsx +++ b/web/components/copy-link-button.tsx @@ -1,4 +1,4 @@ -import { Fragment } from 'react' +import React, { Fragment } from 'react' import { LinkIcon } from '@heroicons/react/outline' import { Menu, Transition } from '@headlessui/react' import clsx from 'clsx' @@ -6,6 +6,7 @@ import { Contract } from 'common/contract' import { copyToClipboard } from 'web/lib/util/copy' import { contractPath } from 'web/lib/firebase/contracts' import { ENV_CONFIG } from 'common/envs/constants' +import { ToastClipboard } from 'web/components/toast-clipboard' function copyContractUrl(contract: Contract) { copyToClipboard(`https://${ENV_CONFIG.domain}${contractPath(contract)}`) @@ -14,8 +15,9 @@ function copyContractUrl(contract: Contract) { export function CopyLinkButton(props: { contract: Contract buttonClassName?: string + toastClassName?: string }) { - const { contract, buttonClassName } = props + const { contract, buttonClassName, toastClassName } = props return ( - + -
Link copied!
+
diff --git a/web/components/feed/activity-items.ts b/web/components/feed/activity-items.ts index 0497451c..af7ac696 100644 --- a/web/components/feed/activity-items.ts +++ b/web/components/feed/activity-items.ts @@ -72,7 +72,7 @@ export type BetGroupItem = BaseActivityItem & { } export type AnswerGroupItem = BaseActivityItem & { - type: 'answergroup' | 'answer' + type: 'answergroup' answer: Answer items: ActivityItem[] betsByCurrentUser?: Bet[] diff --git a/web/components/feed/copy-link-date-time.tsx b/web/components/feed/copy-link-date-time.tsx new file mode 100644 index 00000000..674ca655 --- /dev/null +++ b/web/components/feed/copy-link-date-time.tsx @@ -0,0 +1,61 @@ +import { Contract } from 'common/contract' +import React, { useState } from 'react' +import { ENV_CONFIG } from 'common/envs/constants' +import { contractPath } from 'web/lib/firebase/contracts' +import { copyToClipboard } from 'web/lib/util/copy' +import { DateTimeTooltip } from 'web/components/datetime-tooltip' +import Link from 'next/link' +import { fromNow } from 'web/lib/util/time' +import { ToastClipboard } from 'web/components/toast-clipboard' +import { LinkIcon } from '@heroicons/react/outline' + +export function CopyLinkDateTimeComponent(props: { + contract: Contract + createdTime: number + elementId: string +}) { + const { contract, elementId, createdTime } = props + const [showToast, setShowToast] = useState(false) + + function copyLinkToComment( + event: React.MouseEvent + ) { + event.preventDefault() + + let currentLocation = window.location.href.includes('/home?u=') + ? `https://${ENV_CONFIG.domain}${contractPath(contract)}#${elementId}` + : window.location.href + if (currentLocation.includes('#')) { + currentLocation = currentLocation.split('#')[0] + } + copyToClipboard(`${currentLocation}#${elementId}`) + setShowToast(true) + setTimeout(() => setShowToast(false), 2000) + } + return ( + <> + + + copyLinkToComment(event)} + className={'mx-1 cursor-pointer'} + > + + {fromNow(createdTime)} + {showToast && ( + + )} + + + + + + + ) +} diff --git a/web/components/feed/feed-answer-comment-group.tsx b/web/components/feed/feed-answer-comment-group.tsx new file mode 100644 index 00000000..ec3b3433 --- /dev/null +++ b/web/components/feed/feed-answer-comment-group.tsx @@ -0,0 +1,186 @@ +import { FreeResponse, FullContract } from 'common/contract' +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' +import { Col } from 'web/components/layout/col' +import { Modal } from 'web/components/layout/modal' +import { AnswerBetPanel } from 'web/components/answers/answer-bet-panel' +import { Row } from 'web/components/layout/row' +import { Avatar } from 'web/components/avatar' +import { UserLink } from 'web/components/user-page' +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 { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time' +import { useRouter } from 'next/router' + +export function FeedAnswerCommentGroup(props: { + contract: FullContract + answer: Answer + items: ActivityItem[] + type: string + betsByCurrentUser?: Bet[] + comments?: Comment[] +}) { + const { answer, items, contract, betsByCurrentUser, comments } = props + const { username, avatarUrl, name, text } = answer + const answerElementId = `answer-${answer.id}` + const user = useUser() + const mostRecentCommentableBet = getMostRecentCommentableBet( + betsByCurrentUser ?? [], + comments ?? [], + user, + answer.number + '' + ) + const prob = getDpmOutcomeProbability(contract.totalShares, answer.id) + const probPercent = formatPercent(prob) + const [open, setOpen] = useState(false) + const [showReply, setShowReply] = useState(false) + const isFreeResponseContractPage = comments + if (mostRecentCommentableBet && !showReply) setShowReplyAndFocus(true) + const [inputRef, setInputRef] = useState(null) + + // If they've already opened the input box, focus it once again + function setShowReplyAndFocus(show: boolean) { + setShowReply(show) + inputRef?.focus() + } + + useEffect(() => { + if (showReply && inputRef) inputRef.focus() + }, [inputRef, showReply]) + + const [highlighted, setHighlighted] = useState(false) + const router = useRouter() + useEffect(() => { + if (router.asPath.includes(`#${answerElementId}`)) { + setHighlighted(true) + } + }, [router.asPath]) + + return ( + + + setOpen(false)} + className="sm:max-w-84 !rounded-md bg-white !px-8 !py-6" + isModal={true} + /> + + + +
+ +
+ +
+ answered + +
+ + + + + + + + {isFreeResponseContractPage && ( +
+ +
+ )} + +
+ + {probPercent} + + setOpen(true)} + /> +
+
+ + {isFreeResponseContractPage && ( + + )} + +
+ + {items.map((item, index) => ( +
+ {index !== items.length - 1 ? ( +
+ ))} + + {showReply && ( +
+ +
+ )} + + ) +} diff --git a/web/components/feed/feed-comments.tsx b/web/components/feed/feed-comments.tsx new file mode 100644 index 00000000..f9cf8a96 --- /dev/null +++ b/web/components/feed/feed-comments.tsx @@ -0,0 +1,38 @@ +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' + +// 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[], + user?: User | null, + answerOutcome?: string +) { + return 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() +} + +function canCommentOnBet(bet: Bet, user?: User | null) { + const { userId, createdTime, isRedemption } = bet + const isSelf = user?.id === userId + // You can comment if your bet was posted in the last hour + return !isRedemption && isSelf && Date.now() - createdTime < 60 * 60 * 1000 +} diff --git a/web/components/feed/feed-items.tsx b/web/components/feed/feed-items.tsx index f6c62130..38a3ac3c 100644 --- a/web/components/feed/feed-items.tsx +++ b/web/components/feed/feed-items.tsx @@ -25,7 +25,7 @@ import { useUser } from 'web/hooks/use-user' import { Linkify } from '../linkify' import { Row } from '../layout/row' import { createComment, MAX_COMMENT_LENGTH } from 'web/lib/firebase/comments' -import { formatMoney, formatPercent } from 'common/util/format' +import { formatMoney } from 'common/util/format' import { Comment } from 'common/comment' import { BinaryResolutionOrChance } from '../contract/contract-card' import { SiteLink } from '../site-link' @@ -35,21 +35,20 @@ import { Bet } from 'web/lib/firebase/bets' import { JoinSpans } from '../join-spans' 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, FreeResponse, FullContract } from 'common/contract' -import { BuyButton } from '../yes-no-selector' -import { getDpmOutcomeProbability } from 'common/calculate-dpm' -import { AnswerBetPanel } from '../answers/answer-bet-panel' +import { ActivityItem } from './activity-items' +import { Binary, CPMM, FullContract } from 'common/contract' import { useSaveSeenContract } from 'web/hooks/use-seen-contracts' import { User } from 'common/user' -import { Modal } from '../layout/modal' import { trackClick } from 'web/lib/firebase/tracking' import { firebaseLogin } from 'web/lib/firebase/users' 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 { useRouter } from 'next/router' +import { FeedAnswerCommentGroup } from 'web/components/feed/feed-answer-comment-group' +import { getMostRecentCommentableBet } from 'web/components/feed/feed-comments' +import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time' export function FeedItems(props: { contract: Contract @@ -67,12 +66,7 @@ export function FeedItems(props: {
{items.map((item, activityItemIdx) => ( -
+
{activityItemIdx !== items.length - 1 || item.type === 'answergroup' ? ( case 'answergroup': - return - case 'answer': - return + return case 'close': return case 'resolve': @@ -160,10 +152,7 @@ export function FeedCommentThread(props: {
{ + if (router.asPath.includes(`#${comment.id}`)) { + setHighlighted(true) + } + }, [router.asPath]) + // Only calculated if they don't have a matching bet const { userPosition, userPositionMoney, yesFloorShares, noFloorShares } = getBettorsPosition( @@ -230,7 +227,12 @@ export function FeedComment(props: { ) return ( - <> + )} - +

)}
- + ) } @@ -687,39 +693,6 @@ export function FeedQuestion(props: { ) } -function getMostRecentCommentableBet( - betsByCurrentUser: Bet[], - comments: Comment[], - user?: User | null, - answerOutcome?: string -) { - return 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() -} - -function canCommentOnBet(bet: Bet, user?: User | null) { - const { userId, createdTime, isRedemption } = bet - const isSelf = user?.id === userId - // You can comment if your bet was posted in the last hour - return !isRedemption && isSelf && Date.now() - createdTime < 60 * 60 * 1000 -} - function FeedDescription(props: { contract: Contract }) { const { contract } = props const { creatorName, creatorUsername } = contract @@ -895,161 +868,6 @@ function FeedBetGroup(props: { ) } -function FeedAnswerGroup(props: { - contract: FullContract - answer: Answer - items: ActivityItem[] - type: string - betsByCurrentUser?: Bet[] - comments?: Comment[] -}) { - const { answer, items, contract, type, betsByCurrentUser, comments } = props - const { username, avatarUrl, name, text } = answer - const user = useUser() - const mostRecentCommentableBet = getMostRecentCommentableBet( - betsByCurrentUser ?? [], - comments ?? [], - user, - answer.number + '' - ) - const prob = getDpmOutcomeProbability(contract.totalShares, answer.id) - const probPercent = formatPercent(prob) - const [open, setOpen] = useState(false) - const [showReply, setShowReply] = useState(false) - const isFreeResponseContractPage = type === 'answergroup' && comments - if (mostRecentCommentableBet && !showReply) setShowReplyAndFocus(true) - const [inputRef, setInputRef] = useState(null) - - // If they've already opened the input box, focus it once again - function setShowReplyAndFocus(show: boolean) { - setShowReply(show) - inputRef?.focus() - } - - useEffect(() => { - if (showReply && inputRef) inputRef.focus() - }, [inputRef, showReply]) - - return ( - - - setOpen(false)} - className="sm:max-w-84 !rounded-md bg-white !px-8 !py-6" - isModal={true} - /> - - - {type == 'answer' && ( -
- )} - -
- -
- -
- answered -
- - - - - - - - {isFreeResponseContractPage && ( -
- -
- )} - -
- - {probPercent} - - setOpen(true)} - /> -
-
- - {isFreeResponseContractPage && ( - - )} - -
- - {items.map((item, index) => ( -
- {index !== items.length - 1 ? ( -
- ))} - - {showReply && ( -
- -
- )} - - ) -} - // TODO: Should highlight the entire Feed segment function FeedExpand(props: { setExpanded: (expanded: boolean) => void }) { const { setExpanded } = props diff --git a/web/components/layout/row.tsx b/web/components/layout/row.tsx index 6e4c9e91..5ccccd7b 100644 --- a/web/components/layout/row.tsx +++ b/web/components/layout/row.tsx @@ -1,7 +1,15 @@ import clsx from 'clsx' -export function Row(props: { children?: any; className?: string }) { - const { children, className } = props +export function Row(props: { + children?: any + className?: string + id?: string +}) { + const { children, className, id } = props - return
{children}
+ return ( +
+ {children} +
+ ) } diff --git a/web/components/share-embed-button.tsx b/web/components/share-embed-button.tsx index 1023b169..e21c4cfe 100644 --- a/web/components/share-embed-button.tsx +++ b/web/components/share-embed-button.tsx @@ -1,10 +1,11 @@ -import { Fragment } from 'react' +import React, { Fragment } from 'react' import { CodeIcon } from '@heroicons/react/outline' import { Menu, Transition } from '@headlessui/react' import { Contract } from 'common/contract' import { contractPath } from 'web/lib/firebase/contracts' import { DOMAIN } from 'common/envs/constants' import { copyToClipboard } from 'web/lib/util/copy' +import { ToastClipboard } from 'web/components/toast-clipboard' function copyEmbedCode(contract: Contract) { const title = contract.question @@ -15,8 +16,11 @@ function copyEmbedCode(contract: Contract) { copyToClipboard(embedCode) } -export function ShareEmbedButton(props: { contract: Contract }) { - const { contract } = props +export function ShareEmbedButton(props: { + contract: Contract + toastClassName?: string +}) { + const { contract, toastClassName } = props return ( - + -
Embed code copied!
+
diff --git a/web/components/share-market.tsx b/web/components/share-market.tsx index 01977fbd..a5da585f 100644 --- a/web/components/share-market.tsx +++ b/web/components/share-market.tsx @@ -20,6 +20,7 @@ export function ShareMarket(props: { contract: Contract; className?: string }) { diff --git a/web/components/toast-clipboard.tsx b/web/components/toast-clipboard.tsx new file mode 100644 index 00000000..7a909c51 --- /dev/null +++ b/web/components/toast-clipboard.tsx @@ -0,0 +1,21 @@ +import { ClipboardCopyIcon } from '@heroicons/react/outline' +import React from 'react' +import clsx from 'clsx' +import { Row } from 'web/components/layout/row' + +export function ToastClipboard(props: { className?: string }) { + const { className } = props + return ( + + +
Link copied to clipboard!
+
+ ) +}