Merge branch 'main' into new-home

This commit is contained in:
James Grugett 2022-09-08 01:01:04 -05:00
parent bddc499981
commit c38d389aff
39 changed files with 687 additions and 290 deletions

View File

@ -1,6 +1,6 @@
import type { JSONContent } from '@tiptap/core' import type { JSONContent } from '@tiptap/core'
export type AnyCommentType = OnContract | OnGroup export type AnyCommentType = OnContract | OnGroup | OnPost
// Currently, comments are created after the bet, not atomically with the bet. // Currently, comments are created after the bet, not atomically with the bet.
// They're uniquely identified by the pair contractId/betId. // They're uniquely identified by the pair contractId/betId.
@ -20,7 +20,7 @@ export type Comment<T extends AnyCommentType = AnyCommentType> = {
userAvatarUrl?: string userAvatarUrl?: string
} & T } & T
type OnContract = { export type OnContract = {
commentType: 'contract' commentType: 'contract'
contractId: string contractId: string
answerOutcome?: string answerOutcome?: string
@ -35,10 +35,16 @@ type OnContract = {
betOutcome?: string betOutcome?: string
} }
type OnGroup = { export type OnGroup = {
commentType: 'group' commentType: 'group'
groupId: string groupId: string
} }
export type OnPost = {
commentType: 'post'
postId: string
}
export type ContractComment = Comment<OnContract> export type ContractComment = Comment<OnContract>
export type GroupComment = Comment<OnGroup> export type GroupComment = Comment<OnGroup>
export type PostComment = Comment<OnPost>

View File

@ -203,6 +203,10 @@ service cloud.firestore {
.affectedKeys() .affectedKeys()
.hasOnly(['name', 'content']); .hasOnly(['name', 'content']);
allow delete: if isAdmin() || request.auth.uid == resource.data.creatorId; allow delete: if isAdmin() || request.auth.uid == resource.data.creatorId;
match /comments/{commentId} {
allow read;
allow create: if request.auth != null && commentMatchesUser(request.auth.uid, request.resource.data) ;
}
} }
} }
} }

View File

@ -135,7 +135,7 @@ export const placebet = newEndpoint({}, async (req, auth) => {
!isFinite(newP) || !isFinite(newP) ||
Math.min(...Object.values(newPool ?? {})) < CPMM_MIN_POOL_QTY) Math.min(...Object.values(newPool ?? {})) < CPMM_MIN_POOL_QTY)
) { ) {
throw new APIError(400, 'Bet too large for current liquidity pool.') throw new APIError(400, 'Trade too large for current liquidity pool.')
} }
const betDoc = contractDoc.collection('bets').doc() const betDoc = contractDoc.collection('bets').doc()

View File

@ -120,7 +120,7 @@ export function AnswerBetPanel(props: {
<Col className={clsx('px-2 pb-2 pt-4 sm:pt-0', className)}> <Col className={clsx('px-2 pb-2 pt-4 sm:pt-0', className)}>
<Row className="items-center justify-between self-stretch"> <Row className="items-center justify-between self-stretch">
<div className="text-xl"> <div className="text-xl">
Bet on {isModal ? `"${answer.text}"` : 'this answer'} Buy answer: {isModal ? `"${answer.text}"` : 'this answer'}
</div> </div>
{!isModal && ( {!isModal && (

View File

@ -111,7 +111,7 @@ export const getHomeItems = (
{ label: 'Trending', id: 'score' }, { label: 'Trending', id: 'score' },
{ label: 'Newest', id: 'newest' }, { label: 'Newest', id: 'newest' },
{ label: 'Close date', id: 'close-date' }, { label: 'Close date', id: 'close-date' },
{ label: 'Your bets', id: 'your-bets' }, { label: 'Your trades', id: 'your-bets' },
...groups.map((g) => ({ ...groups.map((g) => ({
label: g.name, label: g.name,
id: g.id, id: g.id,

View File

@ -38,7 +38,7 @@ export default function BetButton(props: {
className={clsx('my-auto inline-flex min-w-[75px] ', btnClassName)} className={clsx('my-auto inline-flex min-w-[75px] ', btnClassName)}
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
> >
Bet Trade
</Button> </Button>
) : ( ) : (
<BetSignUpPrompt /> <BetSignUpPrompt />

View File

@ -281,7 +281,7 @@ function BuyPanel(props: {
title="Whoa, there!" title="Whoa, there!"
text={`You might not want to spend ${formatPercent( text={`You might not want to spend ${formatPercent(
bankrollFraction bankrollFraction
)} of your balance on a single bet. \n\nCurrent balance: ${formatMoney( )} of your balance on a single trade. \n\nCurrent balance: ${formatMoney(
user?.balance ?? 0 user?.balance ?? 0
)}`} )}`}
/> />
@ -379,11 +379,11 @@ function BuyPanel(props: {
)} )}
onClick={betDisabled ? undefined : submitBet} onClick={betDisabled ? undefined : submitBet}
> >
{isSubmitting ? 'Submitting...' : 'Submit bet'} {isSubmitting ? 'Submitting...' : 'Submit trade'}
</button> </button>
)} )}
{wasSubmitted && <div className="mt-4">Bet submitted!</div>} {wasSubmitted && <div className="mt-4">Trade submitted!</div>}
</Col> </Col>
) )
} }
@ -569,7 +569,7 @@ function LimitOrderPanel(props: {
<Row className="mt-1 items-center gap-4"> <Row className="mt-1 items-center gap-4">
<Col className="gap-2"> <Col className="gap-2">
<div className="relative ml-1 text-sm text-gray-500"> <div className="relative ml-1 text-sm text-gray-500">
Bet {isPseudoNumeric ? <HigherLabel /> : <YesLabel />} up to Buy {isPseudoNumeric ? <HigherLabel /> : <YesLabel />} up to
</div> </div>
<ProbabilityOrNumericInput <ProbabilityOrNumericInput
contract={contract} contract={contract}
@ -580,7 +580,7 @@ function LimitOrderPanel(props: {
</Col> </Col>
<Col className="gap-2"> <Col className="gap-2">
<div className="ml-1 text-sm text-gray-500"> <div className="ml-1 text-sm text-gray-500">
Bet {isPseudoNumeric ? <LowerLabel /> : <NoLabel />} down to Buy {isPseudoNumeric ? <LowerLabel /> : <NoLabel />} down to
</div> </div>
<ProbabilityOrNumericInput <ProbabilityOrNumericInput
contract={contract} contract={contract}
@ -750,7 +750,7 @@ function QuickOrLimitBet(props: {
return ( return (
<Row className="align-center mb-4 justify-between"> <Row className="align-center mb-4 justify-between">
<div className="text-4xl">Bet</div> <div className="text-4xl">Trade</div>
{!hideToggle && ( {!hideToggle && (
<Row className="mt-1 items-center gap-2"> <Row className="mt-1 items-center gap-2">
<PillButton <PillButton

View File

@ -0,0 +1,175 @@
import { PaperAirplaneIcon } from '@heroicons/react/solid'
import { Editor } from '@tiptap/react'
import clsx from 'clsx'
import { User } from 'common/user'
import { useEffect, useState } from 'react'
import { useUser } from 'web/hooks/use-user'
import { useWindowSize } from 'web/hooks/use-window-size'
import { MAX_COMMENT_LENGTH } from 'web/lib/firebase/comments'
import { Avatar } from './avatar'
import { TextEditor, useTextEditor } from './editor'
import { Row } from './layout/row'
import { LoadingIndicator } from './loading-indicator'
export function CommentInput(props: {
replyToUser?: { id: string; username: string }
// Reply to a free response answer
parentAnswerOutcome?: string
// Reply to another comment
parentCommentId?: string
onSubmitComment?: (editor: Editor, betId: string | undefined) => void
className?: string
presetId?: string
}) {
const {
parentAnswerOutcome,
parentCommentId,
replyToUser,
onSubmitComment,
presetId,
} = props
const user = useUser()
const { editor, upload } = useTextEditor({
simple: true,
max: MAX_COMMENT_LENGTH,
placeholder:
!!parentCommentId || !!parentAnswerOutcome
? 'Write a reply...'
: 'Write a comment...',
})
const [isSubmitting, setIsSubmitting] = useState(false)
async function submitComment(betId: string | undefined) {
if (!editor || editor.isEmpty || isSubmitting) return
setIsSubmitting(true)
onSubmitComment?.(editor, betId)
setIsSubmitting(false)
}
if (user?.isBannedFromPosting) return <></>
return (
<Row className={clsx(props.className, 'mb-2 gap-1 sm:gap-2')}>
<Avatar
avatarUrl={user?.avatarUrl}
username={user?.username}
size="sm"
className="mt-2"
/>
<div className="min-w-0 flex-1 pl-0.5 text-sm">
<CommentInputTextArea
editor={editor}
upload={upload}
replyToUser={replyToUser}
user={user}
submitComment={submitComment}
isSubmitting={isSubmitting}
presetId={presetId}
/>
</div>
</Row>
)
}
export function CommentInputTextArea(props: {
user: User | undefined | null
replyToUser?: { id: string; username: string }
editor: Editor | null
upload: Parameters<typeof TextEditor>[0]['upload']
submitComment: (id?: string) => void
isSubmitting: boolean
submitOnEnter?: boolean
presetId?: string
}) {
const {
user,
editor,
upload,
submitComment,
presetId,
isSubmitting,
submitOnEnter,
replyToUser,
} = props
const isMobile = (useWindowSize().width ?? 0) < 768 // TODO: base off input device (keybord vs touch)
useEffect(() => {
editor?.setEditable(!isSubmitting)
}, [isSubmitting, editor])
const submit = () => {
submitComment(presetId)
editor?.commands?.clearContent()
}
useEffect(() => {
if (!editor) {
return
}
// submit on Enter key
editor.setOptions({
editorProps: {
handleKeyDown: (view, event) => {
if (
submitOnEnter &&
event.key === 'Enter' &&
!event.shiftKey &&
(!isMobile || event.ctrlKey || event.metaKey) &&
// mention list is closed
!(view.state as any).mention$.active
) {
submit()
event.preventDefault()
return true
}
return false
},
},
})
// insert at mention and focus
if (replyToUser) {
editor
.chain()
.setContent({
type: 'mention',
attrs: { label: replyToUser.username, id: replyToUser.id },
})
.insertContent(' ')
.focus()
.run()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editor])
return (
<>
<TextEditor editor={editor} upload={upload}>
{user && !isSubmitting && (
<button
className="btn btn-ghost btn-sm px-2 disabled:bg-inherit disabled:text-gray-300"
disabled={!editor || editor.isEmpty}
onClick={submit}
>
<PaperAirplaneIcon className="m-0 h-[25px] min-w-[22px] rotate-90 p-0" />
</button>
)}
{isSubmitting && (
<LoadingIndicator spinnerClassName={'border-gray-500'} />
)}
</TextEditor>
<Row>
{!user && (
<button
className={'btn btn-outline btn-sm mt-2 normal-case'}
onClick={() => submitComment(presetId)}
>
Add my comment
</button>
)}
</Row>
</>
)
}

View File

@ -444,7 +444,7 @@ function ContractSearchControls(props: {
selected={state.pillFilter === 'your-bets'} selected={state.pillFilter === 'your-bets'}
onSelect={selectPill('your-bets')} onSelect={selectPill('your-bets')}
> >
Your bets Your trades
</PillButton> </PillButton>
)} )}

View File

@ -294,7 +294,7 @@ export function ExtraMobileContractDetails(props: {
<Tooltip <Tooltip
text={`${formatMoney( text={`${formatMoney(
volume volume
)} bet - ${uniqueBettors} unique bettors`} )} bet - ${uniqueBettors} unique traders`}
> >
{volumeTranslation} {volumeTranslation}
</Tooltip> </Tooltip>

View File

@ -135,7 +135,7 @@ export function ContractInfoDialog(props: {
</tr> */} </tr> */}
<tr> <tr>
<td>Bettors</td> <td>Traders</td>
<td>{bettorsCount}</td> <td>{bettorsCount}</td>
</tr> </tr>

View File

@ -49,7 +49,7 @@ export function ContractLeaderboard(props: {
return users && users.length > 0 ? ( return users && users.length > 0 ? (
<Leaderboard <Leaderboard
title="🏅 Top bettors" title="🏅 Top traders"
users={users || []} users={users || []}
columns={[ columns={[
{ {

View File

@ -116,13 +116,13 @@ export function ContractTabs(props: {
badge: `${comments.length}`, badge: `${comments.length}`,
}, },
{ {
title: 'Bets', title: 'Trades',
content: betActivity, content: betActivity,
badge: `${visibleBets.length}`, badge: `${visibleBets.length}`,
}, },
...(!user || !userBets?.length ...(!user || !userBets?.length
? [] ? []
: [{ title: 'Your bets', content: yourTrades }]), : [{ title: 'Your trades', content: yourTrades }]),
]} ]}
/> />
{!user ? ( {!user ? (

View File

@ -6,7 +6,7 @@ import { getOutcomeProbability } from 'common/calculate'
import { FeedBet } from './feed-bets' import { FeedBet } from './feed-bets'
import { FeedLiquidity } from './feed-liquidity' import { FeedLiquidity } from './feed-liquidity'
import { FeedAnswerCommentGroup } from './feed-answer-comment-group' import { FeedAnswerCommentGroup } from './feed-answer-comment-group'
import { FeedCommentThread, CommentInput } from './feed-comments' import { FeedCommentThread, ContractCommentInput } from './feed-comments'
import { User } from 'common/user' import { User } from 'common/user'
import { CommentTipMap } from 'web/hooks/use-tip-txns' import { CommentTipMap } from 'web/hooks/use-tip-txns'
import { LiquidityProvision } from 'common/liquidity-provision' import { LiquidityProvision } from 'common/liquidity-provision'
@ -72,7 +72,7 @@ export function ContractCommentsActivity(props: {
return ( return (
<> <>
<CommentInput <ContractCommentInput
className="mb-5" className="mb-5"
contract={contract} contract={contract}
betsByCurrentUser={(user && betsByUserId[user.id]) ?? []} betsByCurrentUser={(user && betsByUserId[user.id]) ?? []}

View File

@ -9,7 +9,7 @@ import { Avatar } from 'web/components/avatar'
import { Linkify } from 'web/components/linkify' import { Linkify } from 'web/components/linkify'
import clsx from 'clsx' import clsx from 'clsx'
import { import {
CommentInput, ContractCommentInput,
FeedComment, FeedComment,
getMostRecentCommentableBet, getMostRecentCommentableBet,
} from 'web/components/feed/feed-comments' } from 'web/components/feed/feed-comments'
@ -177,7 +177,7 @@ export function FeedAnswerCommentGroup(props: {
className="absolute -left-1 -ml-[1px] mt-[1.25rem] h-2 w-0.5 rotate-90 bg-gray-200" className="absolute -left-1 -ml-[1px] mt-[1.25rem] h-2 w-0.5 rotate-90 bg-gray-200"
aria-hidden="true" aria-hidden="true"
/> />
<CommentInput <ContractCommentInput
contract={contract} contract={contract}
betsByCurrentUser={betsByCurrentUser} betsByCurrentUser={betsByCurrentUser}
commentsByCurrentUser={commentsByCurrentUser} commentsByCurrentUser={commentsByCurrentUser}

View File

@ -13,22 +13,18 @@ import { Avatar } from 'web/components/avatar'
import { OutcomeLabel } from 'web/components/outcome-label' import { OutcomeLabel } from 'web/components/outcome-label'
import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time' import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time'
import { firebaseLogin } from 'web/lib/firebase/users' import { firebaseLogin } from 'web/lib/firebase/users'
import { import { createCommentOnContract } from 'web/lib/firebase/comments'
createCommentOnContract,
MAX_COMMENT_LENGTH,
} from 'web/lib/firebase/comments'
import { BetStatusText } from 'web/components/feed/feed-bets' import { BetStatusText } from 'web/components/feed/feed-bets'
import { Col } from 'web/components/layout/col' import { Col } from 'web/components/layout/col'
import { getProbability } from 'common/calculate' import { getProbability } from 'common/calculate'
import { LoadingIndicator } from 'web/components/loading-indicator'
import { PaperAirplaneIcon } from '@heroicons/react/outline'
import { track } from 'web/lib/service/analytics' import { track } from 'web/lib/service/analytics'
import { Tipper } from '../tipper' import { Tipper } from '../tipper'
import { CommentTipMap, CommentTips } from 'web/hooks/use-tip-txns' import { CommentTipMap, CommentTips } from 'web/hooks/use-tip-txns'
import { useWindowSize } from 'web/hooks/use-window-size'
import { Content, TextEditor, useTextEditor } from '../editor' import { Content } from '../editor'
import { Editor } from '@tiptap/react' import { Editor } from '@tiptap/react'
import { UserLink } from 'web/components/user-link' import { UserLink } from 'web/components/user-link'
import { CommentInput } from '../comment-input'
export function FeedCommentThread(props: { export function FeedCommentThread(props: {
user: User | null | undefined user: User | null | undefined
@ -90,14 +86,16 @@ export function FeedCommentThread(props: {
className="absolute -left-1 -ml-[1px] mt-[0.8rem] h-2 w-0.5 rotate-90 bg-gray-200" className="absolute -left-1 -ml-[1px] mt-[0.8rem] h-2 w-0.5 rotate-90 bg-gray-200"
aria-hidden="true" aria-hidden="true"
/> />
<CommentInput <ContractCommentInput
contract={contract} contract={contract}
betsByCurrentUser={(user && betsByUserId[user.id]) ?? []} betsByCurrentUser={(user && betsByUserId[user.id]) ?? []}
commentsByCurrentUser={(user && commentsByUserId[user.id]) ?? []} commentsByCurrentUser={(user && commentsByUserId[user.id]) ?? []}
parentCommentId={parentComment.id} parentCommentId={parentComment.id}
replyToUser={replyTo} replyToUser={replyTo}
parentAnswerOutcome={parentComment.answerOutcome} parentAnswerOutcome={parentComment.answerOutcome}
onSubmitComment={() => setShowReply(false)} onSubmitComment={() => {
setShowReply(false)
}}
/> />
</Col> </Col>
)} )}
@ -267,67 +265,76 @@ function CommentStatus(props: {
) )
} }
//TODO: move commentinput and comment input text area into their own files export function ContractCommentInput(props: {
export function CommentInput(props: {
contract: Contract contract: Contract
betsByCurrentUser: Bet[] betsByCurrentUser: Bet[]
commentsByCurrentUser: ContractComment[] commentsByCurrentUser: ContractComment[]
className?: string className?: string
parentAnswerOutcome?: string | undefined
replyToUser?: { id: string; username: string } replyToUser?: { id: string; username: string }
// Reply to a free response answer
parentAnswerOutcome?: string
// Reply to another comment
parentCommentId?: string parentCommentId?: string
onSubmitComment?: () => void onSubmitComment?: () => void
}) { }) {
const {
contract,
betsByCurrentUser,
commentsByCurrentUser,
className,
parentAnswerOutcome,
parentCommentId,
replyToUser,
onSubmitComment,
} = props
const user = useUser() const user = useUser()
const { editor, upload } = useTextEditor({ async function onSubmitComment(editor: Editor, betId: string | undefined) {
simple: true,
max: MAX_COMMENT_LENGTH,
placeholder:
!!parentCommentId || !!parentAnswerOutcome
? 'Write a reply...'
: 'Write a comment...',
})
const [isSubmitting, setIsSubmitting] = useState(false)
const mostRecentCommentableBet = getMostRecentCommentableBet(
betsByCurrentUser,
commentsByCurrentUser,
user,
parentAnswerOutcome
)
const { id } = mostRecentCommentableBet || { id: undefined }
async function submitComment(betId: string | undefined) {
if (!user) { if (!user) {
track('sign in to comment') track('sign in to comment')
return await firebaseLogin() return await firebaseLogin()
} }
if (!editor || editor.isEmpty || isSubmitting) return
setIsSubmitting(true)
await createCommentOnContract( await createCommentOnContract(
contract.id, props.contract.id,
editor.getJSON(), editor.getJSON(),
user, user,
betId, betId,
parentAnswerOutcome, props.parentAnswerOutcome,
parentCommentId props.parentCommentId
) )
onSubmitComment?.() props.onSubmitComment?.()
setIsSubmitting(false)
} }
const mostRecentCommentableBet = getMostRecentCommentableBet(
props.betsByCurrentUser,
props.commentsByCurrentUser,
user,
props.parentAnswerOutcome
)
const { id } = mostRecentCommentableBet || { id: undefined }
return (
<Col>
<CommentBetArea
betsByCurrentUser={props.betsByCurrentUser}
contract={props.contract}
commentsByCurrentUser={props.commentsByCurrentUser}
parentAnswerOutcome={props.parentAnswerOutcome}
user={useUser()}
className={props.className}
mostRecentCommentableBet={mostRecentCommentableBet}
/>
<CommentInput
replyToUser={props.replyToUser}
parentAnswerOutcome={props.parentAnswerOutcome}
parentCommentId={props.parentCommentId}
onSubmitComment={onSubmitComment}
className={props.className}
presetId={id}
/>
</Col>
)
}
function CommentBetArea(props: {
betsByCurrentUser: Bet[]
contract: Contract
commentsByCurrentUser: ContractComment[]
parentAnswerOutcome?: string
user?: User | null
className?: string
mostRecentCommentableBet?: Bet
}) {
const { betsByCurrentUser, contract, user, mostRecentCommentableBet } = props
const { userPosition, outcome } = getBettorsLargestPositionBeforeTime( const { userPosition, outcome } = getBettorsLargestPositionBeforeTime(
contract, contract,
Date.now(), Date.now(),
@ -336,158 +343,36 @@ export function CommentInput(props: {
const isNumeric = contract.outcomeType === 'NUMERIC' const isNumeric = contract.outcomeType === 'NUMERIC'
if (user?.isBannedFromPosting) return <></>
return ( return (
<Row className={clsx(className, 'mb-2 gap-1 sm:gap-2')}> <Row className={clsx(props.className, 'mb-2 gap-1 sm:gap-2')}>
<Avatar <div className="mb-1 text-gray-500">
avatarUrl={user?.avatarUrl} {mostRecentCommentableBet && (
username={user?.username} <BetStatusText
size="sm" contract={contract}
className="mt-2" bet={mostRecentCommentableBet}
/> isSelf={true}
<div className="min-w-0 flex-1 pl-0.5 text-sm"> hideOutcome={isNumeric || contract.outcomeType === 'FREE_RESPONSE'}
<div className="mb-1 text-gray-500"> />
{mostRecentCommentableBet && ( )}
<BetStatusText {!mostRecentCommentableBet && user && userPosition > 0 && !isNumeric && (
<>
{"You're"}
<CommentStatus
outcome={outcome}
contract={contract} contract={contract}
bet={mostRecentCommentableBet} prob={
isSelf={true} contract.outcomeType === 'BINARY'
hideOutcome={ ? getProbability(contract)
isNumeric || contract.outcomeType === 'FREE_RESPONSE' : undefined
} }
/> />
)} </>
{!mostRecentCommentableBet && user && userPosition > 0 && !isNumeric && ( )}
<>
{"You're"}
<CommentStatus
outcome={outcome}
contract={contract}
prob={
contract.outcomeType === 'BINARY'
? getProbability(contract)
: undefined
}
/>
</>
)}
</div>
<CommentInputTextArea
editor={editor}
upload={upload}
replyToUser={replyToUser}
user={user}
submitComment={submitComment}
isSubmitting={isSubmitting}
presetId={id}
/>
</div> </div>
</Row> </Row>
) )
} }
export function CommentInputTextArea(props: {
user: User | undefined | null
replyToUser?: { id: string; username: string }
editor: Editor | null
upload: Parameters<typeof TextEditor>[0]['upload']
submitComment: (id?: string) => void
isSubmitting: boolean
submitOnEnter?: boolean
presetId?: string
}) {
const {
user,
editor,
upload,
submitComment,
presetId,
isSubmitting,
submitOnEnter,
replyToUser,
} = props
const isMobile = (useWindowSize().width ?? 0) < 768 // TODO: base off input device (keybord vs touch)
useEffect(() => {
editor?.setEditable(!isSubmitting)
}, [isSubmitting, editor])
const submit = () => {
submitComment(presetId)
editor?.commands?.clearContent()
}
useEffect(() => {
if (!editor) {
return
}
// submit on Enter key
editor.setOptions({
editorProps: {
handleKeyDown: (view, event) => {
if (
submitOnEnter &&
event.key === 'Enter' &&
!event.shiftKey &&
(!isMobile || event.ctrlKey || event.metaKey) &&
// mention list is closed
!(view.state as any).mention$.active
) {
submit()
event.preventDefault()
return true
}
return false
},
},
})
// insert at mention and focus
if (replyToUser) {
editor
.chain()
.setContent({
type: 'mention',
attrs: { label: replyToUser.username, id: replyToUser.id },
})
.insertContent(' ')
.focus()
.run()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editor])
return (
<>
<TextEditor editor={editor} upload={upload}>
{user && !isSubmitting && (
<button
className="btn btn-ghost btn-sm px-2 disabled:bg-inherit disabled:text-gray-300"
disabled={!editor || editor.isEmpty}
onClick={submit}
>
<PaperAirplaneIcon className="m-0 h-[25px] min-w-[22px] rotate-90 p-0" />
</button>
)}
{isSubmitting && (
<LoadingIndicator spinnerClassName={'border-gray-500'} />
)}
</TextEditor>
<Row>
{!user && (
<button
className={'btn btn-outline btn-sm mt-2 normal-case'}
onClick={() => submitComment(presetId)}
>
Add my comment
</button>
)}
</Row>
</>
)
}
function getBettorsLargestPositionBeforeTime( function getBettorsLargestPositionBeforeTime(
contract: Contract, contract: Contract,
createdTime: number, createdTime: number,

View File

@ -27,23 +27,18 @@ export function LandingPagePanel(props: { hotContracts: Contract[] }) {
<div className="m-4 max-w-[550px] self-center"> <div className="m-4 max-w-[550px] self-center">
<h1 className="text-3xl sm:text-6xl xl:text-6xl"> <h1 className="text-3xl sm:text-6xl xl:text-6xl">
<div className="font-semibold sm:mb-2"> <div className="font-semibold sm:mb-2">
Predict{' '} A{' '}
<span className="bg-gradient-to-r from-indigo-500 to-blue-500 bg-clip-text font-bold text-transparent"> <span className="bg-gradient-to-r from-indigo-500 to-blue-500 bg-clip-text font-bold text-transparent">
anything! market
</span> </span>{' '}
for every question
</div> </div>
</h1> </h1>
<Spacer h={6} /> <Spacer h={6} />
<div className="mb-4 px-2 "> <div className="mb-4 px-2 ">
Create a play-money prediction market on any topic you care about Create a play-money prediction market on any topic you care about.
and bet with your friends on what will happen! Trade with your friends to forecast the future.
<br /> <br />
{/* <br />
Sign up and get {formatMoney(1000)} - worth $10 to your{' '}
<SiteLink className="font-semibold" href="/charity">
favorite charity.
</SiteLink>
<br /> */}
</div> </div>
</div> </div>
<Spacer h={6} /> <Spacer h={6} />

View File

@ -64,7 +64,7 @@ export function BottomNavBar() {
item={{ item={{
name: formatMoney(user.balance), name: formatMoney(user.balance),
trackingEventName: 'profile', trackingEventName: 'profile',
href: `/${user.username}?tab=bets`, href: `/${user.username}?tab=trades`,
icon: () => ( icon: () => (
<Avatar <Avatar
className="mx-auto my-1" className="mx-auto my-1"

View File

@ -8,7 +8,7 @@ import { trackCallback } from 'web/lib/service/analytics'
export function ProfileSummary(props: { user: User }) { export function ProfileSummary(props: { user: User }) {
const { user } = props const { user } = props
return ( return (
<Link href={`/${user.username}?tab=bets`}> <Link href={`/${user.username}?tab=trades`}>
<a <a
onClick={trackCallback('sidebar: profile')} onClick={trackCallback('sidebar: profile')}
className="group mb-3 flex flex-row items-center gap-4 truncate rounded-md py-3 text-gray-500 hover:bg-gray-100 hover:text-gray-700" className="group mb-3 flex flex-row items-center gap-4 truncate rounded-md py-3 text-gray-500 hover:bg-gray-100 hover:text-gray-700"

View File

@ -2,8 +2,8 @@ import { InfoBox } from './info-box'
export const PlayMoneyDisclaimer = () => ( export const PlayMoneyDisclaimer = () => (
<InfoBox <InfoBox
title="Play-money betting" title="Play-money trading"
className="mt-4 max-w-md" className="mt-4 max-w-md"
text="Mana (M$) is the play-money used by our platform to keep track of your bets. It's completely free for you and your friends to get started!" text="Mana (M$) is the play-money used by our platform to keep track of your trades. It's completely free for you and your friends to get started!"
/> />
) )

View File

@ -44,6 +44,7 @@ export const PortfolioValueSection = memo(
}} }}
> >
<option value="allTime">All time</option> <option value="allTime">All time</option>
<option value="monthly">Last Month</option>
<option value="weekly">Last 7d</option> <option value="weekly">Last 7d</option>
<option value="daily">Last 24h</option> <option value="daily">Last 24h</option>
</select> </select>

View File

@ -11,7 +11,7 @@ export function LoansModal(props: {
<Modal open={isOpen} setOpen={setOpen}> <Modal open={isOpen} setOpen={setOpen}>
<Col className="items-center gap-4 rounded-md bg-white px-8 py-6"> <Col className="items-center gap-4 rounded-md bg-white px-8 py-6">
<span className={'text-8xl'}>🏦</span> <span className={'text-8xl'}>🏦</span>
<span className="text-xl">Daily loans on your bets</span> <span className="text-xl">Daily loans on your trades</span>
<Col className={'gap-2'}> <Col className={'gap-2'}>
<span className={'text-indigo-700'}> What are daily loans?</span> <span className={'text-indigo-700'}> What are daily loans?</span>
<span className={'ml-2'}> <span className={'ml-2'}>

View File

@ -83,14 +83,14 @@ export function ResolutionPanel(props: {
<div> <div>
{outcome === 'YES' ? ( {outcome === 'YES' ? (
<> <>
Winnings will be paid out to YES bettors. Winnings will be paid out to traders who bought YES.
{/* <br /> {/* <br />
<br /> <br />
You will earn {earnedFees}. */} You will earn {earnedFees}. */}
</> </>
) : outcome === 'NO' ? ( ) : outcome === 'NO' ? (
<> <>
Winnings will be paid out to NO bettors. Winnings will be paid out to traders who bought NO.
{/* <br /> {/* <br />
<br /> <br />
You will earn {earnedFees}. */} You will earn {earnedFees}. */}

View File

@ -19,7 +19,7 @@ export function BetSignUpPrompt(props: {
size={size} size={size}
color="gradient" color="gradient"
> >
{label ?? 'Sign up to bet!'} {label ?? 'Sign up to trade!'}
</Button> </Button>
) : null ) : null
} }

View File

@ -46,6 +46,7 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) {
comment.commentType === 'contract' ? comment.contractId : undefined comment.commentType === 'contract' ? comment.contractId : undefined
const groupId = const groupId =
comment.commentType === 'group' ? comment.groupId : undefined comment.commentType === 'group' ? comment.groupId : undefined
const postId = comment.commentType === 'post' ? comment.postId : undefined
await transact({ await transact({
amount: change, amount: change,
fromId: user.id, fromId: user.id,
@ -54,7 +55,7 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) {
toType: 'USER', toType: 'USER',
token: 'M$', token: 'M$',
category: 'TIP', category: 'TIP',
data: { commentId: comment.id, contractId, groupId }, data: { commentId: comment.id, contractId, groupId, postId },
description: `${user.name} tipped M$ ${change} to ${comment.userName} for a comment`, description: `${user.name} tipped M$ ${change} to ${comment.userName} for a comment`,
}) })
@ -62,6 +63,7 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) {
commentId: comment.id, commentId: comment.id,
contractId, contractId,
groupId, groupId,
postId,
amount: change, amount: change,
fromId: user.id, fromId: user.id,
toId: comment.userId, toId: comment.userId,

View File

@ -260,7 +260,7 @@ export function UserPage(props: { user: User }) {
), ),
}, },
{ {
title: 'Bets', title: 'Trades',
content: ( content: (
<> <>
<BetsList user={user} /> <BetsList user={user} />

View File

@ -193,7 +193,7 @@ export function BuyButton(props: { className?: string; onClick?: () => void }) {
)} )}
onClick={onClick} onClick={onClick}
> >
Bet Buy
</button> </button>
) )
} }

View File

@ -1,8 +1,14 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Comment, ContractComment, GroupComment } from 'common/comment' import {
Comment,
ContractComment,
GroupComment,
PostComment,
} from 'common/comment'
import { import {
listenForCommentsOnContract, listenForCommentsOnContract,
listenForCommentsOnGroup, listenForCommentsOnGroup,
listenForCommentsOnPost,
listenForRecentComments, listenForRecentComments,
} from 'web/lib/firebase/comments' } from 'web/lib/firebase/comments'
@ -25,6 +31,16 @@ export const useCommentsOnGroup = (groupId: string | undefined) => {
return comments return comments
} }
export const useCommentsOnPost = (postId: string | undefined) => {
const [comments, setComments] = useState<PostComment[] | undefined>()
useEffect(() => {
if (postId) return listenForCommentsOnPost(postId, setComments)
}, [postId])
return comments
}
export const useRecentComments = () => { export const useRecentComments = () => {
const [recentComments, setRecentComments] = useState<Comment[] | undefined>() const [recentComments, setRecentComments] = useState<Comment[] | undefined>()
useEffect(() => listenForRecentComments(setRecentComments), []) useEffect(() => listenForRecentComments(setRecentComments), [])

View File

@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from 'react'
import { import {
listenForTipTxns, listenForTipTxns,
listenForTipTxnsOnGroup, listenForTipTxnsOnGroup,
listenForTipTxnsOnPost,
} from 'web/lib/firebase/txns' } from 'web/lib/firebase/txns'
export type CommentTips = { [userId: string]: number } export type CommentTips = { [userId: string]: number }
@ -12,14 +13,16 @@ export type CommentTipMap = { [commentId: string]: CommentTips }
export function useTipTxns(on: { export function useTipTxns(on: {
contractId?: string contractId?: string
groupId?: string groupId?: string
postId?: string
}): CommentTipMap { }): CommentTipMap {
const [txns, setTxns] = useState<TipTxn[]>([]) const [txns, setTxns] = useState<TipTxn[]>([])
const { contractId, groupId } = on const { contractId, groupId, postId } = on
useEffect(() => { useEffect(() => {
if (contractId) return listenForTipTxns(contractId, setTxns) if (contractId) return listenForTipTxns(contractId, setTxns)
if (groupId) return listenForTipTxnsOnGroup(groupId, setTxns) if (groupId) return listenForTipTxnsOnGroup(groupId, setTxns)
}, [contractId, groupId, setTxns]) if (postId) return listenForTipTxnsOnPost(postId, setTxns)
}, [contractId, groupId, postId, setTxns])
return useMemo(() => { return useMemo(() => {
const byComment = groupBy(txns, 'data.commentId') const byComment = groupBy(txns, 'data.commentId')

View File

@ -7,12 +7,22 @@ import {
query, query,
setDoc, setDoc,
where, where,
DocumentData,
DocumentReference,
} from 'firebase/firestore' } from 'firebase/firestore'
import { getValues, listenForValues } from './utils' import { getValues, listenForValues } from './utils'
import { db } from './init' import { db } from './init'
import { User } from 'common/user' import { User } from 'common/user'
import { Comment, ContractComment, GroupComment } from 'common/comment' import {
Comment,
ContractComment,
GroupComment,
OnContract,
OnGroup,
OnPost,
PostComment,
} from 'common/comment'
import { removeUndefinedProps } from 'common/util/object' import { removeUndefinedProps } from 'common/util/object'
import { track } from '@amplitude/analytics-browser' import { track } from '@amplitude/analytics-browser'
import { JSONContent } from '@tiptap/react' import { JSONContent } from '@tiptap/react'
@ -24,7 +34,7 @@ export const MAX_COMMENT_LENGTH = 10000
export async function createCommentOnContract( export async function createCommentOnContract(
contractId: string, contractId: string,
content: JSONContent, content: JSONContent,
commenter: User, user: User,
betId?: string, betId?: string,
answerOutcome?: string, answerOutcome?: string,
replyToCommentId?: string replyToCommentId?: string
@ -32,28 +42,20 @@ export async function createCommentOnContract(
const ref = betId const ref = betId
? doc(getCommentsCollection(contractId), betId) ? doc(getCommentsCollection(contractId), betId)
: doc(getCommentsCollection(contractId)) : doc(getCommentsCollection(contractId))
// contract slug and question are set via trigger const onContract = {
const comment = removeUndefinedProps({
id: ref.id,
commentType: 'contract', commentType: 'contract',
contractId, contractId,
userId: commenter.id, betId,
content: content, answerOutcome,
createdTime: Date.now(), } as OnContract
userName: commenter.name, return await createComment(
userUsername: commenter.username,
userAvatarUrl: commenter.avatarUrl,
betId: betId,
answerOutcome: answerOutcome,
replyToCommentId: replyToCommentId,
})
track('comment', {
contractId, contractId,
commentId: ref.id, onContract,
betId: betId, content,
replyToCommentId: replyToCommentId, user,
}) ref,
return await setDoc(ref, comment) replyToCommentId
)
} }
export async function createCommentOnGroup( export async function createCommentOnGroup(
groupId: string, groupId: string,
@ -62,10 +64,45 @@ export async function createCommentOnGroup(
replyToCommentId?: string replyToCommentId?: string
) { ) {
const ref = doc(getCommentsOnGroupCollection(groupId)) const ref = doc(getCommentsOnGroupCollection(groupId))
const onGroup = { commentType: 'group', groupId: groupId } as OnGroup
return await createComment(
groupId,
onGroup,
content,
user,
ref,
replyToCommentId
)
}
export async function createCommentOnPost(
postId: string,
content: JSONContent,
user: User,
replyToCommentId?: string
) {
const ref = doc(getCommentsOnPostCollection(postId))
const onPost = { postId: postId, commentType: 'post' } as OnPost
return await createComment(
postId,
onPost,
content,
user,
ref,
replyToCommentId
)
}
async function createComment(
surfaceId: string,
extraFields: OnContract | OnGroup | OnPost,
content: JSONContent,
user: User,
ref: DocumentReference<DocumentData>,
replyToCommentId?: string
) {
const comment = removeUndefinedProps({ const comment = removeUndefinedProps({
id: ref.id, id: ref.id,
commentType: 'group',
groupId,
userId: user.id, userId: user.id,
content: content, content: content,
createdTime: Date.now(), createdTime: Date.now(),
@ -73,11 +110,13 @@ export async function createCommentOnGroup(
userUsername: user.username, userUsername: user.username,
userAvatarUrl: user.avatarUrl, userAvatarUrl: user.avatarUrl,
replyToCommentId: replyToCommentId, replyToCommentId: replyToCommentId,
...extraFields,
}) })
track('group message', {
track(`${extraFields.commentType} message`, {
user, user,
commentId: ref.id, commentId: ref.id,
groupId, surfaceId,
replyToCommentId: replyToCommentId, replyToCommentId: replyToCommentId,
}) })
return await setDoc(ref, comment) return await setDoc(ref, comment)
@ -91,6 +130,10 @@ function getCommentsOnGroupCollection(groupId: string) {
return collection(db, 'groups', groupId, 'comments') return collection(db, 'groups', groupId, 'comments')
} }
function getCommentsOnPostCollection(postId: string) {
return collection(db, 'posts', postId, 'comments')
}
export async function listAllComments(contractId: string) { export async function listAllComments(contractId: string) {
return await getValues<Comment>( return await getValues<Comment>(
query(getCommentsCollection(contractId), orderBy('createdTime', 'desc')) query(getCommentsCollection(contractId), orderBy('createdTime', 'desc'))
@ -103,6 +146,12 @@ export async function listAllCommentsOnGroup(groupId: string) {
) )
} }
export async function listAllCommentsOnPost(postId: string) {
return await getValues<PostComment>(
query(getCommentsOnPostCollection(postId), orderBy('createdTime', 'desc'))
)
}
export function listenForCommentsOnContract( export function listenForCommentsOnContract(
contractId: string, contractId: string,
setComments: (comments: ContractComment[]) => void setComments: (comments: ContractComment[]) => void
@ -126,6 +175,16 @@ export function listenForCommentsOnGroup(
) )
} }
export function listenForCommentsOnPost(
postId: string,
setComments: (comments: PostComment[]) => void
) {
return listenForValues<PostComment>(
query(getCommentsOnPostCollection(postId), orderBy('createdTime', 'desc')),
setComments
)
}
const DAY_IN_MS = 24 * 60 * 60 * 1000 const DAY_IN_MS = 24 * 60 * 60 * 1000
// Define "recent" as "<3 days ago" for now // Define "recent" as "<3 days ago" for now

View File

@ -86,9 +86,10 @@ export async function listGroupContracts(groupId: string) {
contractId: string contractId: string
createdTime: number createdTime: number
}>(groupContracts(groupId)) }>(groupContracts(groupId))
return Promise.all( const contracts = await Promise.all(
contractDocs.map((doc) => getContractFromId(doc.contractId)) contractDocs.map((doc) => getContractFromId(doc.contractId))
) )
return filterDefined(contracts)
} }
export function listenForOpenGroups(setGroups: (groups: Group[]) => void) { export function listenForOpenGroups(setGroups: (groups: Group[]) => void) {

View File

@ -41,6 +41,13 @@ const getTipsOnGroupQuery = (groupId: string) =>
where('data.groupId', '==', groupId) where('data.groupId', '==', groupId)
) )
const getTipsOnPostQuery = (postId: string) =>
query(
txns,
where('category', '==', 'TIP'),
where('data.postId', '==', postId)
)
export function listenForTipTxns( export function listenForTipTxns(
contractId: string, contractId: string,
setTxns: (txns: TipTxn[]) => void setTxns: (txns: TipTxn[]) => void
@ -54,6 +61,13 @@ export function listenForTipTxnsOnGroup(
return listenForValues<TipTxn>(getTipsOnGroupQuery(groupId), setTxns) return listenForValues<TipTxn>(getTipsOnGroupQuery(groupId), setTxns)
} }
export function listenForTipTxnsOnPost(
postId: string,
setTxns: (txns: TipTxn[]) => void
) {
return listenForValues<TipTxn>(getTipsOnPostQuery(postId), setTxns)
}
// Find all manalink Txns that are from or to this user // Find all manalink Txns that are from or to this user
export function useManalinkTxns(userId: string) { export function useManalinkTxns(userId: string) {
const [fromTxns, setFromTxns] = useState<ManalinkTxn[]>([]) const [fromTxns, setFromTxns] = useState<ManalinkTxn[]>([])

View File

@ -1,6 +1,7 @@
import { NextApiRequest, NextApiResponse } from 'next' import { NextApiRequest, NextApiResponse } from 'next'
import { applyCorsHeaders, CORS_UNRESTRICTED } from 'web/lib/api/cors' import { applyCorsHeaders, CORS_UNRESTRICTED } from 'web/lib/api/cors'
import { listGroupContracts } from 'web/lib/firebase/groups' import { listGroupContracts } from 'web/lib/firebase/groups'
import { toLiteMarket } from 'web/pages/api/v0/_types'
export default async function handler( export default async function handler(
req: NextApiRequest, req: NextApiRequest,
@ -8,7 +9,9 @@ export default async function handler(
) { ) {
await applyCorsHeaders(req, res, CORS_UNRESTRICTED) await applyCorsHeaders(req, res, CORS_UNRESTRICTED)
const { id } = req.query const { id } = req.query
const contracts = await listGroupContracts(id as string) const contracts = (await listGroupContracts(id as string)).map((contract) =>
toLiteMarket(contract)
)
if (!contracts) { if (!contracts) {
res.status(404).json({ error: 'Group not found' }) res.status(404).json({ error: 'Group not found' })
return return

View File

@ -52,7 +52,7 @@ const Home = () => {
<EditButton /> <EditButton />
</Row> </Row>
<DailyProfitAndBalance className="self-end" userId={user?.id} /> <DailyProfitAndBalance userId={user?.id} />
<div className="text-xl text-gray-800">Daily movers</div> <div className="text-xl text-gray-800">Daily movers</div>
<ProbChangeTable userId={user?.id} /> <ProbChangeTable userId={user?.id} />
@ -63,7 +63,7 @@ const Home = () => {
return ( return (
<SearchSection <SearchSection
key={id} key={id}
label={'Your bets'} label={'Your trades'}
sort={'prob-change-day'} sort={'prob-change-day'}
user={user} user={user}
yourBets yourBets

View File

@ -227,6 +227,7 @@ export default function GroupPage(props: {
defaultSort={'newest'} defaultSort={'newest'}
defaultFilter={suggestedFilter} defaultFilter={suggestedFilter}
additionalFilter={{ groupSlug: group.slug }} additionalFilter={{ groupSlug: group.slug }}
persistPrefix={`group-${group.slug}`}
/> />
) )

View File

@ -135,21 +135,20 @@ export default function Leaderboards(_props: {
defaultIndex={1} defaultIndex={1}
tabs={[ tabs={[
{ {
title: 'All Time', title: 'Daily',
content: LeaderboardWithPeriod('allTime'), content: LeaderboardWithPeriod('daily'),
}, },
// TODO: Enable this near the end of July!
// {
// title: 'Monthly',
// content: LeaderboardWithPeriod('monthly'),
// },
{ {
title: 'Weekly', title: 'Weekly',
content: LeaderboardWithPeriod('weekly'), content: LeaderboardWithPeriod('weekly'),
}, },
{ {
title: 'Daily', title: 'Monthly',
content: LeaderboardWithPeriod('daily'), content: LeaderboardWithPeriod('monthly'),
},
{
title: 'All Time',
content: LeaderboardWithPeriod('allTime'),
}, },
]} ]}
/> />

View File

@ -390,7 +390,7 @@ function IncomeNotificationItem(props: {
reasonText = !simple reasonText = !simple
? `Bonus for ${ ? `Bonus for ${
parseInt(sourceText) / UNIQUE_BETTOR_BONUS_AMOUNT parseInt(sourceText) / UNIQUE_BETTOR_BONUS_AMOUNT
} new bettors on` } new traders on`
: 'bonus on' : 'bonus on'
} else if (sourceType === 'tip') { } else if (sourceType === 'tip') {
reasonText = !simple ? `tipped you on` : `in tips on` reasonText = !simple ? `tipped you on` : `in tips on`
@ -508,7 +508,7 @@ function IncomeNotificationItem(props: {
{(isTip || isUniqueBettorBonus) && ( {(isTip || isUniqueBettorBonus) && (
<MultiUserTransactionLink <MultiUserTransactionLink
userInfos={userLinks} userInfos={userLinks}
modalLabel={isTip ? 'Who tipped you' : 'Unique bettors'} modalLabel={isTip ? 'Who tipped you' : 'Unique traders'}
/> />
)} )}
<Row className={'line-clamp-2 flex max-w-xl'}> <Row className={'line-clamp-2 flex max-w-xl'}>

View File

@ -16,17 +16,25 @@ import { Col } from 'web/components/layout/col'
import { ENV_CONFIG } from 'common/envs/constants' import { ENV_CONFIG } from 'common/envs/constants'
import Custom404 from 'web/pages/404' import Custom404 from 'web/pages/404'
import { UserLink } from 'web/components/user-link' import { UserLink } from 'web/components/user-link'
import { listAllCommentsOnPost } from 'web/lib/firebase/comments'
import { PostComment } from 'common/comment'
import { CommentTipMap, useTipTxns } from 'web/hooks/use-tip-txns'
import { groupBy, sortBy } from 'lodash'
import { PostCommentInput, PostCommentThread } from 'web/posts/post-comments'
import { useCommentsOnPost } from 'web/hooks/use-comments'
export async function getStaticProps(props: { params: { slugs: string[] } }) { export async function getStaticProps(props: { params: { slugs: string[] } }) {
const { slugs } = props.params const { slugs } = props.params
const post = await getPostBySlug(slugs[0]) const post = await getPostBySlug(slugs[0])
const creator = post ? await getUser(post.creatorId) : null const creator = post ? await getUser(post.creatorId) : null
const comments = post && (await listAllCommentsOnPost(post.id))
return { return {
props: { props: {
post: post, post: post,
creator: creator, creator: creator,
comments: comments,
}, },
revalidate: 60, // regenerate after a minute revalidate: 60, // regenerate after a minute
@ -37,28 +45,36 @@ export async function getStaticPaths() {
return { paths: [], fallback: 'blocking' } return { paths: [], fallback: 'blocking' }
} }
export default function PostPage(props: { post: Post; creator: User }) { export default function PostPage(props: {
post: Post
creator: User
comments: PostComment[]
}) {
const [isShareOpen, setShareOpen] = useState(false) const [isShareOpen, setShareOpen] = useState(false)
const { post, creator } = props
if (props.post == null) { const tips = useTipTxns({ postId: post.id })
const shareUrl = `https://${ENV_CONFIG.domain}${postPath(post.slug)}`
const updatedComments = useCommentsOnPost(post.id)
const comments = updatedComments ?? props.comments
if (post == null) {
return <Custom404 /> return <Custom404 />
} }
const shareUrl = `https://${ENV_CONFIG.domain}${postPath(props?.post.slug)}`
return ( return (
<Page> <Page>
<div className="mx-auto w-full max-w-3xl "> <div className="mx-auto w-full max-w-3xl ">
<Spacer h={1} /> <Spacer h={1} />
<Title className="!mt-0" text={props.post.title} /> <Title className="!mt-0" text={post.title} />
<Row> <Row>
<Col className="flex-1"> <Col className="flex-1">
<div className={'inline-flex'}> <div className={'inline-flex'}>
<div className="mr-1 text-gray-500">Created by</div> <div className="mr-1 text-gray-500">Created by</div>
<UserLink <UserLink
className="text-neutral" className="text-neutral"
name={props.creator.name} name={creator.name}
username={props.creator.username} username={creator.username}
/> />
</div> </div>
</Col> </Col>
@ -88,10 +104,55 @@ export default function PostPage(props: { post: Post; creator: User }) {
<Spacer h={2} /> <Spacer h={2} />
<div className="rounded-lg bg-white px-6 py-4 sm:py-0"> <div className="rounded-lg bg-white px-6 py-4 sm:py-0">
<div className="form-control w-full py-2"> <div className="form-control w-full py-2">
<Content content={props.post.content} /> <Content content={post.content} />
</div> </div>
</div> </div>
<Spacer h={2} />
<div className="rounded-lg bg-white px-6 py-4 sm:py-0">
<PostCommentsActivity
post={post}
comments={comments}
tips={tips}
user={creator}
/>
</div>
</div> </div>
</Page> </Page>
) )
} }
export function PostCommentsActivity(props: {
post: Post
comments: PostComment[]
tips: CommentTipMap
user: User | null | undefined
}) {
const { post, comments, user, tips } = props
const commentsByUserId = groupBy(comments, (c) => c.userId)
const commentsByParentId = groupBy(comments, (c) => c.replyToCommentId ?? '_')
const topLevelComments = sortBy(
commentsByParentId['_'] ?? [],
(c) => -c.createdTime
)
return (
<>
<PostCommentInput post={post} />
{topLevelComments.map((parent) => (
<PostCommentThread
key={parent.id}
user={user}
post={post}
parentComment={parent}
threadComments={sortBy(
commentsByParentId[parent.id] ?? [],
(c) => c.createdTime
)}
tips={tips}
commentsByUserId={commentsByUserId}
/>
))}
</>
)
}

172
web/posts/post-comments.tsx Normal file
View File

@ -0,0 +1,172 @@
import { track } from '@amplitude/analytics-browser'
import { Editor } from '@tiptap/core'
import clsx from 'clsx'
import { PostComment } from 'common/comment'
import { Post } from 'common/post'
import { User } from 'common/user'
import { Dictionary } from 'lodash'
import { useRouter } from 'next/router'
import { useEffect, useState } from 'react'
import { Avatar } from 'web/components/avatar'
import { CommentInput } from 'web/components/comment-input'
import { Content } from 'web/components/editor'
import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time'
import { Col } from 'web/components/layout/col'
import { Row } from 'web/components/layout/row'
import { Tipper } from 'web/components/tipper'
import { UserLink } from 'web/components/user-link'
import { CommentTipMap, CommentTips } from 'web/hooks/use-tip-txns'
import { useUser } from 'web/hooks/use-user'
import { createCommentOnPost } from 'web/lib/firebase/comments'
import { firebaseLogin } from 'web/lib/firebase/users'
export function PostCommentThread(props: {
user: User | null | undefined
post: Post
threadComments: PostComment[]
tips: CommentTipMap
parentComment: PostComment
commentsByUserId: Dictionary<PostComment[]>
}) {
const { post, threadComments, tips, parentComment } = props
const [showReply, setShowReply] = useState(false)
const [replyTo, setReplyTo] = useState<{ id: string; username: string }>()
function scrollAndOpenReplyInput(comment: PostComment) {
setReplyTo({ id: comment.userId, username: comment.userUsername })
setShowReply(true)
}
return (
<Col className="relative w-full items-stretch gap-3 pb-4">
<span
className="absolute top-5 left-4 -ml-px h-[calc(100%-2rem)] w-0.5 bg-gray-200"
aria-hidden="true"
/>
{[parentComment].concat(threadComments).map((comment, commentIdx) => (
<PostComment
key={comment.id}
indent={commentIdx != 0}
post={post}
comment={comment}
tips={tips[comment.id]}
onReplyClick={scrollAndOpenReplyInput}
/>
))}
{showReply && (
<Col className="-pb-2 relative ml-6">
<span
className="absolute -left-1 -ml-[1px] mt-[0.8rem] h-2 w-0.5 rotate-90 bg-gray-200"
aria-hidden="true"
/>
<PostCommentInput
post={post}
parentCommentId={parentComment.id}
replyToUser={replyTo}
onSubmitComment={() => setShowReply(false)}
/>
</Col>
)}
</Col>
)
}
export function PostCommentInput(props: {
post: Post
parentCommentId?: string
replyToUser?: { id: string; username: string }
onSubmitComment?: () => void
}) {
const user = useUser()
const { post, parentCommentId, replyToUser } = props
async function onSubmitComment(editor: Editor) {
if (!user) {
track('sign in to comment')
return await firebaseLogin()
}
await createCommentOnPost(post.id, editor.getJSON(), user, parentCommentId)
props.onSubmitComment?.()
}
return (
<CommentInput
replyToUser={replyToUser}
parentCommentId={parentCommentId}
onSubmitComment={onSubmitComment}
/>
)
}
export function PostComment(props: {
post: Post
comment: PostComment
tips: CommentTips
indent?: boolean
probAtCreatedTime?: number
onReplyClick?: (comment: PostComment) => void
}) {
const { post, comment, tips, indent, onReplyClick } = props
const { text, content, userUsername, userName, userAvatarUrl, createdTime } =
comment
const [highlighted, setHighlighted] = useState(false)
const router = useRouter()
useEffect(() => {
if (router.asPath.endsWith(`#${comment.id}`)) {
setHighlighted(true)
}
}, [comment.id, router.asPath])
return (
<Row
id={comment.id}
className={clsx(
'relative',
indent ? 'ml-6' : '',
highlighted ? `-m-1.5 rounded bg-indigo-500/[0.2] p-1.5` : ''
)}
>
{/*draw a gray line from the comment to the left:*/}
{indent ? (
<span
className="absolute -left-1 -ml-[1px] mt-[0.8rem] h-2 w-0.5 rotate-90 bg-gray-200"
aria-hidden="true"
/>
) : null}
<Avatar size="sm" username={userUsername} avatarUrl={userAvatarUrl} />
<div className="ml-1.5 min-w-0 flex-1 pl-0.5 sm:ml-3">
<div className="mt-0.5 text-sm text-gray-500">
<UserLink
className="text-gray-500"
username={userUsername}
name={userName}
/>{' '}
<CopyLinkDateTimeComponent
prefix={comment.userName}
slug={post.slug}
createdTime={createdTime}
elementId={comment.id}
/>
</div>
<Content
className="mt-2 text-[15px] text-gray-700"
content={content || text}
smallImage
/>
<Row className="mt-2 items-center gap-6 text-xs text-gray-500">
<Tipper comment={comment} tips={tips ?? {}} />
{onReplyClick && (
<button
className="font-bold hover:underline"
onClick={() => onReplyClick(comment)}
>
Reply
</button>
)}
</Row>
</div>
</Row>
)
}