Merge branch 'main' into trending-daily-movers

This commit is contained in:
James Grugett 2022-09-22 17:53:54 -04:00
commit 58f15fea02
42 changed files with 736 additions and 969 deletions

View File

@ -4,11 +4,7 @@
### Do I have to pay real money in order to participate? ### Do I have to pay real money in order to participate?
Nope! Each account starts with a free M$1000. If you invest it wisely, you can increase your total without ever needing to put any real money into the site. Nope! Each account starts with a free 1000 mana (or M$1000 for short). If you invest it wisely, you can increase your total without ever needing to put any real money into the site.
### What is the name for the currency Manifold uses, represented by M$?
Manifold Dollars, or mana for short.
### Can M$ be sold for real money? ### Can M$ be sold for real money?

View File

@ -30,10 +30,10 @@ export function AddFundsButton(props: { className?: string }) {
<div className="modal"> <div className="modal">
<div className="modal-box"> <div className="modal-box">
<div className="mb-6 text-xl">Get Manifold Dollars</div> <div className="mb-6 text-xl">Get Mana</div>
<div className="mb-6 text-gray-500"> <div className="mb-6 text-gray-500">
Use Manifold Dollars to trade in your favorite markets. <br /> (Not Buy mana (M$) to trade in your favorite markets. <br /> (Not
redeemable for cash.) redeemable for cash.)
</div> </div>

View File

@ -1,4 +1,4 @@
import { sortBy, partition, sum, uniq } from 'lodash' import { sortBy, partition, sum } from 'lodash'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { FreeResponseContract, MultipleChoiceContract } from 'common/contract' import { FreeResponseContract, MultipleChoiceContract } from 'common/contract'
@ -11,7 +11,6 @@ import { AnswerItem } from './answer-item'
import { CreateAnswerPanel } from './create-answer-panel' import { CreateAnswerPanel } from './create-answer-panel'
import { AnswerResolvePanel } from './answer-resolve-panel' import { AnswerResolvePanel } from './answer-resolve-panel'
import { Spacer } from '../layout/spacer' import { Spacer } from '../layout/spacer'
import { User } from 'common/user'
import { getOutcomeProbability } from 'common/calculate' import { getOutcomeProbability } from 'common/calculate'
import { Answer } from 'common/answer' import { Answer } from 'common/answer'
import clsx from 'clsx' import clsx from 'clsx'
@ -39,22 +38,14 @@ export function AnswersPanel(props: {
const answers = (useAnswers(contract.id) ?? contract.answers).filter( const answers = (useAnswers(contract.id) ?? contract.answers).filter(
(a) => a.number != 0 || contract.outcomeType === 'MULTIPLE_CHOICE' (a) => a.number != 0 || contract.outcomeType === 'MULTIPLE_CHOICE'
) )
const hasZeroBetAnswers = answers.some((answer) => totalBets[answer.id] < 1) const [winningAnswers, notWinningAnswers] = partition(
answers,
const [winningAnswers, losingAnswers] = partition( (a) => a.id === resolution || (resolutions && resolutions[a.id])
answers.filter((a) => (showAllAnswers ? true : totalBets[a.id] > 0)), )
(answer) => const [visibleAnswers, invisibleAnswers] = partition(
answer.id === resolution || (resolutions && resolutions[answer.id]) sortBy(notWinningAnswers, (a) => -getOutcomeProbability(contract, a.id)),
(a) => showAllAnswers || totalBets[a.id] > 0
) )
const sortedAnswers = [
...sortBy(winningAnswers, (answer) =>
resolutions ? -1 * resolutions[answer.id] : 0
),
...sortBy(
resolution ? [] : losingAnswers,
(answer) => -1 * getDpmOutcomeProbability(contract.totalShares, answer.id)
),
]
const user = useUser() const user = useUser()
@ -67,12 +58,6 @@ export function AnswersPanel(props: {
const chosenTotal = sum(Object.values(chosenAnswers)) const chosenTotal = sum(Object.values(chosenAnswers))
const answerItems = getAnswerItems(
contract,
losingAnswers.length > 0 ? losingAnswers : sortedAnswers,
user
)
const onChoose = (answerId: string, prob: number) => { const onChoose = (answerId: string, prob: number) => {
if (resolveOption === 'CHOOSE') { if (resolveOption === 'CHOOSE') {
setChosenAnswers({ [answerId]: prob }) setChosenAnswers({ [answerId]: prob })
@ -109,13 +94,13 @@ export function AnswersPanel(props: {
return ( return (
<Col className="gap-3"> <Col className="gap-3">
{(resolveOption || resolution) && {(resolveOption || resolution) &&
sortedAnswers.map((answer) => ( sortBy(winningAnswers, (a) => -(resolutions?.[a.id] ?? 0)).map((a) => (
<AnswerItem <AnswerItem
key={answer.id} key={a.id}
answer={answer} answer={a}
contract={contract} contract={contract}
showChoice={showChoice} showChoice={showChoice}
chosenProb={chosenAnswers[answer.id]} chosenProb={chosenAnswers[a.id]}
totalChosenProb={chosenTotal} totalChosenProb={chosenTotal}
onChoose={onChoose} onChoose={onChoose}
onDeselect={onDeselect} onDeselect={onDeselect}
@ -123,31 +108,29 @@ export function AnswersPanel(props: {
))} ))}
{!resolveOption && ( {!resolveOption && (
<div className={clsx('flow-root pr-2 md:pr-0')}> <Col
<div className={clsx(tradingAllowed(contract) ? '' : '-mb-6')}> className={clsx(
{answerItems.map((item) => ( 'gap-2 pr-2 md:pr-0',
<div key={item.id} className={'relative pb-2'}> tradingAllowed(contract) ? '' : '-mb-6'
<div className="relative flex items-start space-x-3"> )}
<OpenAnswer {...item} /> >
</div> {visibleAnswers.map((a) => (
</div> <OpenAnswer key={a.id} answer={a} contract={contract} />
))} ))}
<Row className={'justify-end'}> {invisibleAnswers.length > 0 && !showAllAnswers && (
{hasZeroBetAnswers && !showAllAnswers && (
<Button <Button
color={'gray-white'} className="self-end"
color="gray-white"
onClick={() => setShowAllAnswers(true)} onClick={() => setShowAllAnswers(true)}
size={'md'} size="md"
> >
Show More Show More
</Button> </Button>
)} )}
</Row> </Col>
</div>
</div>
)} )}
{answers.length <= 1 && ( {answers.length === 0 && (
<div className="pb-4 text-gray-500">No answers yet...</div> <div className="pb-4 text-gray-500">No answers yet...</div>
)} )}
@ -175,35 +158,9 @@ export function AnswersPanel(props: {
) )
} }
function getAnswerItems(
contract: FreeResponseContract | MultipleChoiceContract,
answers: Answer[],
user: User | undefined | null
) {
let outcomes = uniq(answers.map((answer) => answer.number.toString()))
outcomes = sortBy(outcomes, (outcome) =>
getOutcomeProbability(contract, outcome)
).reverse()
return outcomes
.map((outcome) => {
const answer = answers.find((answer) => answer.id === outcome) as Answer
//unnecessary
return {
id: outcome,
type: 'answer' as const,
contract,
answer,
user,
}
})
.filter((group) => group.answer)
}
function OpenAnswer(props: { function OpenAnswer(props: {
contract: FreeResponseContract | MultipleChoiceContract contract: FreeResponseContract | MultipleChoiceContract
answer: Answer answer: Answer
type: string
}) { }) {
const { answer, contract } = props const { answer, contract } = props
const { username, avatarUrl, name, text } = answer const { username, avatarUrl, name, text } = answer
@ -212,7 +169,7 @@ function OpenAnswer(props: {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
return ( return (
<Col className={'border-base-200 bg-base-200 flex-1 rounded-md px-2'}> <Col className="border-base-200 bg-base-200 relative flex-1 rounded-md px-2">
<Modal open={open} setOpen={setOpen} position="center"> <Modal open={open} setOpen={setOpen} position="center">
<AnswerBetPanel <AnswerBetPanel
answer={answer} answer={answer}
@ -229,21 +186,15 @@ function OpenAnswer(props: {
/> />
<Row className="my-4 gap-3"> <Row className="my-4 gap-3">
<div className="px-1"> <Avatar className="mx-1" username={username} avatarUrl={avatarUrl} />
<Avatar username={username} avatarUrl={avatarUrl} />
</div>
<Col className="min-w-0 flex-1 lg:gap-1"> <Col className="min-w-0 flex-1 lg:gap-1">
<div className="text-sm text-gray-500"> <div className="text-sm text-gray-500">
<UserLink username={username} name={name} /> answered <UserLink username={username} name={name} /> answered
</div> </div>
<Col className="align-items justify-between gap-4 sm:flex-row"> <Col className="align-items justify-between gap-4 sm:flex-row">
<span className="whitespace-pre-line text-lg"> <Linkify className="whitespace-pre-line text-lg" text={text} />
<Linkify text={text} /> <Row className="align-items items-center justify-end gap-4">
</span>
<Row className="items-center justify-center gap-4">
<div className={'align-items flex w-full justify-end gap-4 '}>
<span <span
className={clsx( className={clsx(
'text-2xl', 'text-2xl',
@ -259,7 +210,6 @@ function OpenAnswer(props: {
)} )}
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
/> />
</div>
</Row> </Row>
</Col> </Col>
</Col> </Col>

View File

@ -17,7 +17,7 @@ import { setCookie } from 'web/lib/util/cookie'
// Either we haven't looked up the logged in user yet (undefined), or we know // Either we haven't looked up the logged in user yet (undefined), or we know
// the user is not logged in (null), or we know the user is logged in. // the user is not logged in (null), or we know the user is logged in.
type AuthUser = undefined | null | UserAndPrivateUser export type AuthUser = undefined | null | UserAndPrivateUser
const TEN_YEARS_SECS = 60 * 60 * 24 * 365 * 10 const TEN_YEARS_SECS = 60 * 60 * 24 * 365 * 10
const CACHED_USER_KEY = 'CACHED_USER_KEY_V2' const CACHED_USER_KEY = 'CACHED_USER_KEY_V2'

View File

@ -40,7 +40,7 @@ export function Avatar(props: {
style={{ maxWidth: `${s * 0.25}rem` }} style={{ maxWidth: `${s * 0.25}rem` }}
src={avatarUrl} src={avatarUrl}
onClick={onClick} onClick={onClick}
alt={username} alt={`${username ?? 'Unknown user'} avatar`}
onError={() => { onError={() => {
// If the image doesn't load, clear the avatarUrl to show the default // If the image doesn't load, clear the avatarUrl to show the default
// Mostly for localhost, when getting a 403 from googleusercontent // Mostly for localhost, when getting a 403 from googleusercontent

View File

@ -11,7 +11,7 @@ import { Row } from './layout/row'
import { LoadingIndicator } from './loading-indicator' import { LoadingIndicator } from './loading-indicator'
export function CommentInput(props: { export function CommentInput(props: {
replyToUser?: { id: string; username: string } replyTo?: { id: string; username: string }
// Reply to a free response answer // Reply to a free response answer
parentAnswerOutcome?: string parentAnswerOutcome?: string
// Reply to another comment // Reply to another comment
@ -19,7 +19,7 @@ export function CommentInput(props: {
onSubmitComment?: (editor: Editor) => void onSubmitComment?: (editor: Editor) => void
className?: string className?: string
}) { }) {
const { parentAnswerOutcome, parentCommentId, replyToUser, onSubmitComment } = const { parentAnswerOutcome, parentCommentId, replyTo, onSubmitComment } =
props props
const user = useUser() const user = useUser()
@ -55,7 +55,7 @@ export function CommentInput(props: {
<CommentInputTextArea <CommentInputTextArea
editor={editor} editor={editor}
upload={upload} upload={upload}
replyToUser={replyToUser} replyTo={replyTo}
user={user} user={user}
submitComment={submitComment} submitComment={submitComment}
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
@ -67,14 +67,13 @@ export function CommentInput(props: {
export function CommentInputTextArea(props: { export function CommentInputTextArea(props: {
user: User | undefined | null user: User | undefined | null
replyToUser?: { id: string; username: string } replyTo?: { id: string; username: string }
editor: Editor | null editor: Editor | null
upload: Parameters<typeof TextEditor>[0]['upload'] upload: Parameters<typeof TextEditor>[0]['upload']
submitComment: () => void submitComment: () => void
isSubmitting: boolean isSubmitting: boolean
}) { }) {
const { user, editor, upload, submitComment, isSubmitting, replyToUser } = const { user, editor, upload, submitComment, isSubmitting, replyTo } = props
props
useEffect(() => { useEffect(() => {
editor?.setEditable(!isSubmitting) editor?.setEditable(!isSubmitting)
}, [isSubmitting, editor]) }, [isSubmitting, editor])
@ -108,12 +107,12 @@ export function CommentInputTextArea(props: {
}, },
}) })
// insert at mention and focus // insert at mention and focus
if (replyToUser) { if (replyTo) {
editor editor
.chain() .chain()
.setContent({ .setContent({
type: 'mention', type: 'mention',
attrs: { label: replyToUser.username, id: replyToUser.id }, attrs: { label: replyTo.username, id: replyTo.id },
}) })
.insertContent(' ') .insertContent(' ')
.focus() .focus()
@ -127,7 +126,7 @@ export function CommentInputTextArea(props: {
<TextEditor editor={editor} upload={upload}> <TextEditor editor={editor} upload={upload}>
{user && !isSubmitting && ( {user && !isSubmitting && (
<button <button
className="btn btn-ghost btn-sm px-2 disabled:bg-inherit disabled:text-gray-300" className="btn btn-ghost btn-sm disabled:bg-inherit! px-2 disabled:text-gray-300"
disabled={!editor || editor.isEmpty} disabled={!editor || editor.isEmpty}
onClick={submit} onClick={submit}
> >

View File

@ -9,7 +9,14 @@ import {
} from './contract/contracts-grid' } from './contract/contracts-grid'
import { ShowTime } from './contract/contract-details' import { ShowTime } from './contract/contract-details'
import { Row } from './layout/row' import { Row } from './layout/row'
import { useEffect, useLayoutEffect, useRef, useMemo, ReactNode } from 'react' import {
useEffect,
useLayoutEffect,
useRef,
useMemo,
ReactNode,
useState,
} from 'react'
import { IS_PRIVATE_MANIFOLD } from 'common/envs/constants' import { IS_PRIVATE_MANIFOLD } from 'common/envs/constants'
import { useFollows } from 'web/hooks/use-follows' import { useFollows } from 'web/hooks/use-follows'
import { import {
@ -32,22 +39,25 @@ import {
searchClient, searchClient,
searchIndexName, searchIndexName,
} from 'web/lib/service/algolia' } from 'web/lib/service/algolia'
import { useIsMobile } from 'web/hooks/use-is-mobile'
import { AdjustmentsIcon } from '@heroicons/react/solid'
import { Button } from './button'
import { Modal } from './layout/modal'
import { Title } from './title'
export const SORTS = [ export const SORTS = [
{ label: 'Newest', value: 'newest' }, { label: 'Newest', value: 'newest' },
{ label: 'Trending', value: 'score' }, { label: 'Trending', value: 'score' },
{ label: `Most traded`, value: 'most-traded' },
{ label: '24h volume', value: '24-hour-vol' }, { label: '24h volume', value: '24-hour-vol' },
{ label: '24h change', value: 'prob-change-day' },
{ label: 'Last updated', value: 'last-updated' }, { label: 'Last updated', value: 'last-updated' },
{ label: 'Subsidy', value: 'liquidity' }, { label: 'Closing soon', value: 'close-date' },
{ label: 'Close date', value: 'close-date' },
{ label: 'Resolve date', value: 'resolve-date' }, { label: 'Resolve date', value: 'resolve-date' },
{ label: 'Highest %', value: 'prob-descending' }, { label: 'Highest %', value: 'prob-descending' },
{ label: 'Lowest %', value: 'prob-ascending' }, { label: 'Lowest %', value: 'prob-ascending' },
] as const ] as const
export type Sort = typeof SORTS[number]['value'] export type Sort = typeof SORTS[number]['value']
export const PROB_SORTS = ['prob-descending', 'prob-ascending']
type filter = 'personal' | 'open' | 'closed' | 'resolved' | 'all' type filter = 'personal' | 'open' | 'closed' | 'resolved' | 'all'
@ -83,6 +93,7 @@ export function ContractSearch(props: {
persistPrefix?: string persistPrefix?: string
useQueryUrlParam?: boolean useQueryUrlParam?: boolean
isWholePage?: boolean isWholePage?: boolean
includeProbSorts?: boolean
noControls?: boolean noControls?: boolean
maxResults?: number maxResults?: number
renderContracts?: ( renderContracts?: (
@ -104,6 +115,7 @@ export function ContractSearch(props: {
headerClassName, headerClassName,
persistPrefix, persistPrefix,
useQueryUrlParam, useQueryUrlParam,
includeProbSorts,
isWholePage, isWholePage,
noControls, noControls,
maxResults, maxResults,
@ -209,6 +221,7 @@ export function ContractSearch(props: {
persistPrefix={persistPrefix} persistPrefix={persistPrefix}
hideOrderSelector={hideOrderSelector} hideOrderSelector={hideOrderSelector}
useQueryUrlParam={useQueryUrlParam} useQueryUrlParam={useQueryUrlParam}
includeProbSorts={includeProbSorts}
user={user} user={user}
onSearchParametersChanged={onSearchParametersChanged} onSearchParametersChanged={onSearchParametersChanged}
noControls={noControls} noControls={noControls}
@ -238,6 +251,7 @@ function ContractSearchControls(props: {
additionalFilter?: AdditionalFilter additionalFilter?: AdditionalFilter
persistPrefix?: string persistPrefix?: string
hideOrderSelector?: boolean hideOrderSelector?: boolean
includeProbSorts?: boolean
onSearchParametersChanged: (params: SearchParameters) => void onSearchParametersChanged: (params: SearchParameters) => void
useQueryUrlParam?: boolean useQueryUrlParam?: boolean
user?: User | null user?: User | null
@ -257,6 +271,7 @@ function ContractSearchControls(props: {
user, user,
noControls, noControls,
autoFocus, autoFocus,
includeProbSorts,
} = props } = props
const router = useRouter() const router = useRouter()
@ -270,6 +285,8 @@ function ContractSearchControls(props: {
} }
) )
const isMobile = useIsMobile()
const sortKey = `${persistPrefix}-search-sort` const sortKey = `${persistPrefix}-search-sort`
const savedSort = safeLocalStorage()?.getItem(sortKey) const savedSort = safeLocalStorage()?.getItem(sortKey)
@ -415,30 +432,33 @@ function ContractSearchControls(props: {
className="input input-bordered w-full" className="input input-bordered w-full"
autoFocus={autoFocus} autoFocus={autoFocus}
/> />
{!query && ( {!isMobile && (
<select <SearchFilters
className="select select-bordered" filter={filter}
value={filter} selectFilter={selectFilter}
onChange={(e) => selectFilter(e.target.value as filter)} hideOrderSelector={hideOrderSelector}
> selectSort={selectSort}
<option value="open">Open</option> sort={sort}
<option value="closed">Closed</option> className={'flex flex-row gap-2'}
<option value="resolved">Resolved</option> includeProbSorts={includeProbSorts}
<option value="all">All</option> />
</select>
)} )}
{!hideOrderSelector && !query && ( {isMobile && (
<select <>
className="select select-bordered" <MobileSearchBar
value={sort} children={
onChange={(e) => selectSort(e.target.value as Sort)} <SearchFilters
> filter={filter}
{SORTS.map((option) => ( selectFilter={selectFilter}
<option key={option.value} value={option.value}> hideOrderSelector={hideOrderSelector}
{option.label} selectSort={selectSort}
</option> sort={sort}
))} className={'flex flex-col gap-4'}
</select> includeProbSorts={includeProbSorts}
/>
}
/>
</>
)} )}
</Row> </Row>
@ -481,3 +501,78 @@ function ContractSearchControls(props: {
</Col> </Col>
) )
} }
export function SearchFilters(props: {
filter: string
selectFilter: (newFilter: filter) => void
hideOrderSelector: boolean | undefined
selectSort: (newSort: Sort) => void
sort: string
className?: string
includeProbSorts?: boolean
}) {
const {
filter,
selectFilter,
hideOrderSelector,
selectSort,
sort,
className,
includeProbSorts,
} = props
const sorts = includeProbSorts
? SORTS
: SORTS.filter((sort) => !PROB_SORTS.includes(sort.value))
return (
<div className={className}>
<select
className="select select-bordered"
value={filter}
onChange={(e) => selectFilter(e.target.value as filter)}
>
<option value="open">Open</option>
<option value="closed">Closed</option>
<option value="resolved">Resolved</option>
<option value="all">All</option>
</select>
{!hideOrderSelector && (
<select
className="select select-bordered"
value={sort}
onChange={(e) => selectSort(e.target.value as Sort)}
>
{sorts.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
)}
</div>
)
}
export function MobileSearchBar(props: { children: ReactNode }) {
const { children } = props
const [openFilters, setOpenFilters] = useState(false)
return (
<>
<Button color="gray-white" onClick={() => setOpenFilters(true)}>
<AdjustmentsIcon className="my-auto h-7" />
</Button>
<Modal
open={openFilters}
setOpen={setOpenFilters}
position="top"
className="rounded-lg bg-white px-4 pb-4"
>
<Col>
<Title text="Filter Markets" />
{children}
</Col>
</Modal>
</>
)
}

View File

@ -1,11 +1,10 @@
import { Bet } from 'common/bet' import { Bet } from 'common/bet'
import { ContractComment } from 'common/comment'
import { resolvedPayout } from 'common/calculate' import { resolvedPayout } from 'common/calculate'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
import { formatMoney } from 'common/util/format' import { formatMoney } from 'common/util/format'
import { groupBy, mapValues, sumBy, sortBy, keyBy } from 'lodash' import { groupBy, mapValues, sumBy, sortBy, keyBy } from 'lodash'
import { useState, useMemo, useEffect } from 'react' import { memo } from 'react'
import { listUsers, User } from 'web/lib/firebase/users' import { useComments } from 'web/hooks/use-comments'
import { FeedBet } from '../feed/feed-bets' import { FeedBet } from '../feed/feed-bets'
import { FeedComment } from '../feed/feed-comments' import { FeedComment } from '../feed/feed-comments'
import { Spacer } from '../layout/spacer' import { Spacer } from '../layout/spacer'
@ -13,61 +12,48 @@ import { Leaderboard } from '../leaderboard'
import { Title } from '../title' import { Title } from '../title'
import { BETTORS } from 'common/user' import { BETTORS } from 'common/user'
export function ContractLeaderboard(props: { export const ContractLeaderboard = memo(function ContractLeaderboard(props: {
contract: Contract contract: Contract
bets: Bet[] bets: Bet[]
}) { }) {
const { contract, bets } = props const { contract, bets } = props
const [users, setUsers] = useState<User[]>()
const { userProfits, top5Ids } = useMemo(() => {
// Create a map of userIds to total profits (including sales) // Create a map of userIds to total profits (including sales)
const openBets = bets.filter((bet) => !bet.isSold && !bet.sale) const openBets = bets.filter((bet) => !bet.isSold && !bet.sale)
const betsByUser = groupBy(openBets, 'userId') const betsByUser = groupBy(openBets, 'userId')
const userProfits = mapValues(betsByUser, (bets) => {
const userProfits = mapValues(betsByUser, (bets) => return {
sumBy(bets, (bet) => resolvedPayout(contract, bet) - bet.amount) name: bets[0].userName,
) username: bets[0].userUsername,
// Find the 5 users with the most profits avatarUrl: bets[0].userAvatarUrl,
const top5Ids = Object.entries(userProfits) total: sumBy(bets, (bet) => resolvedPayout(contract, bet) - bet.amount),
.sort(([_i1, p1], [_i2, p2]) => p2 - p1)
.filter(([, p]) => p > 0)
.slice(0, 5)
.map(([id]) => id)
return { userProfits, top5Ids }
}, [contract, bets])
useEffect(() => {
if (top5Ids.length > 0) {
listUsers(top5Ids).then((users) => {
const sortedUsers = sortBy(users, (user) => -userProfits[user.id])
setUsers(sortedUsers)
})
} }
}, [userProfits, top5Ids]) })
// Find the 5 users with the most profits
const top5 = Object.values(userProfits)
.sort((p1, p2) => p2.total - p1.total)
.filter((p) => p.total > 0)
.slice(0, 5)
return users && users.length > 0 ? ( return top5 && top5.length > 0 ? (
<Leaderboard <Leaderboard
title={`🏅 Top ${BETTORS}`} title={`🏅 Top ${BETTORS}`}
users={users || []} entries={top5 || []}
columns={[ columns={[
{ {
header: 'Total profit', header: 'Total profit',
renderCell: (user) => formatMoney(userProfits[user.id] || 0), renderCell: (entry) => formatMoney(entry.total),
}, },
]} ]}
className="mt-12 max-w-sm" className="mt-12 max-w-sm"
/> />
) : null ) : null
} })
export function ContractTopTrades(props: { export function ContractTopTrades(props: { contract: Contract; bets: Bet[] }) {
contract: Contract const { contract, bets } = props
bets: Bet[] // todo: this stuff should be calced in DB at resolve time
comments: ContractComment[] const comments = useComments(contract.id)
}) {
const { contract, bets, comments } = props
const commentsById = keyBy(comments, 'id')
const betsById = keyBy(bets, 'id') const betsById = keyBy(bets, 'id')
// If 'id2' is the sale of 'id1', both are logged with (id2 - id1) of profit // If 'id2' is the sale of 'id1', both are logged with (id2 - id1) of profit
@ -88,29 +74,23 @@ export function ContractTopTrades(props: {
const topBetId = sortBy(bets, (b) => -profitById[b.id])[0]?.id const topBetId = sortBy(bets, (b) => -profitById[b.id])[0]?.id
const topBettor = betsById[topBetId]?.userName const topBettor = betsById[topBetId]?.userName
// And also the commentId of the comment with the highest profit // And also the comment with the highest profit
const topCommentId = sortBy( const topComment = sortBy(comments, (c) => c.betId && -profitById[c.betId])[0]
comments,
(c) => c.betId && -profitById[c.betId]
)[0]?.id
return ( return (
<div className="mt-12 max-w-sm"> <div className="mt-12 max-w-sm">
{topCommentId && profitById[topCommentId] > 0 && ( {topComment && profitById[topComment.id] > 0 && (
<> <>
<Title text="💬 Proven correct" className="!mt-0" /> <Title text="💬 Proven correct" className="!mt-0" />
<div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4"> <div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4">
<FeedComment <FeedComment contract={contract} comment={topComment} />
contract={contract}
comment={commentsById[topCommentId]}
/>
</div> </div>
<Spacer h={16} /> <Spacer h={16} />
</> </>
)} )}
{/* If they're the same, only show the comment; otherwise show both */} {/* If they're the same, only show the comment; otherwise show both */}
{topBettor && topBetId !== topCommentId && profitById[topBetId] > 0 && ( {topBettor && topBetId !== topComment?.betId && profitById[topBetId] > 0 && (
<> <>
<Title text="💸 Best bet" className="!mt-0" /> <Title text="💸 Best bet" className="!mt-0" />
<div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4"> <div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4">

View File

@ -47,14 +47,14 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
times.push(latestTime.valueOf()) times.push(latestTime.valueOf())
probs.push(probs[probs.length - 1]) probs.push(probs[probs.length - 1])
const quartiles = [0, 25, 50, 75, 100] const { width } = useWindowSize()
const quartiles = !width || width < 800 ? [0, 50, 100] : [0, 25, 50, 75, 100]
const yTickValues = isBinary const yTickValues = isBinary
? quartiles ? quartiles
: quartiles.map((x) => x / 100).map(f) : quartiles.map((x) => x / 100).map(f)
const { width } = useWindowSize()
const numXTickValues = !width || width < 800 ? 2 : 5 const numXTickValues = !width || width < 800 ? 2 : 5
const startDate = dayjs(times[0]) const startDate = dayjs(times[0])
const endDate = startDate.add(1, 'hour').isAfter(latestTime) const endDate = startDate.add(1, 'hour').isAfter(latestTime)
@ -104,7 +104,7 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
return ( return (
<div <div
className="w-full overflow-visible" className="w-full overflow-visible"
style={{ height: height ?? (!width || width >= 800 ? 350 : 250) }} style={{ height: height ?? (!width || width >= 800 ? 250 : 150) }}
> >
<ResponsiveLine <ResponsiveLine
data={data} data={data}
@ -144,7 +144,7 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
pointBorderWidth={1} pointBorderWidth={1}
pointBorderColor="#fff" pointBorderColor="#fff"
enableSlices="x" enableSlices="x"
enableGridX={!!width && width >= 800} enableGridX={false}
enableArea enableArea
areaBaselineValue={isBinary || isLogScale ? 0 : contract.min} areaBaselineValue={isBinary || isLogScale ? 0 : contract.min}
margin={{ top: 20, right: 20, bottom: 25, left: 40 }} margin={{ top: 20, right: 20, bottom: 25, left: 40 }}

View File

@ -5,19 +5,19 @@ import { FeedBet } from '../feed/feed-bets'
import { FeedLiquidity } from '../feed/feed-liquidity' import { FeedLiquidity } from '../feed/feed-liquidity'
import { FeedAnswerCommentGroup } from '../feed/feed-answer-comment-group' import { FeedAnswerCommentGroup } from '../feed/feed-answer-comment-group'
import { FeedCommentThread, ContractCommentInput } from '../feed/feed-comments' import { FeedCommentThread, ContractCommentInput } from '../feed/feed-comments'
import { CommentTipMap } from 'web/hooks/use-tip-txns'
import { groupBy, sortBy } from 'lodash' import { groupBy, sortBy } from 'lodash'
import { Bet } from 'common/bet' import { Bet } from 'common/bet'
import { Contract, FreeResponseContract } from 'common/contract' import { Contract } from 'common/contract'
import { ContractComment } from 'common/comment' import { PAST_BETS } from 'common/user'
import { PAST_BETS, User } from 'common/user'
import { ContractBetsTable, BetsSummary } from '../bets-list' import { ContractBetsTable, BetsSummary } from '../bets-list'
import { Spacer } from '../layout/spacer' import { Spacer } from '../layout/spacer'
import { Tabs } from '../layout/tabs' import { Tabs } from '../layout/tabs'
import { Col } from '../layout/col' import { Col } from '../layout/col'
import { LoadingIndicator } from 'web/components/loading-indicator'
import { useComments } from 'web/hooks/use-comments' import { useComments } from 'web/hooks/use-comments'
import { useLiquidity } from 'web/hooks/use-liquidity' import { useLiquidity } from 'web/hooks/use-liquidity'
import { useTipTxns } from 'web/hooks/use-tip-txns' import { useTipTxns } from 'web/hooks/use-tip-txns'
import { useUser } from 'web/hooks/use-user'
import { capitalize } from 'lodash' import { capitalize } from 'lodash'
import { import {
DEV_HOUSE_LIQUIDITY_PROVIDER_ID, DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
@ -25,21 +25,13 @@ import {
} from 'common/antes' } from 'common/antes'
import { useIsMobile } from 'web/hooks/use-is-mobile' import { useIsMobile } from 'web/hooks/use-is-mobile'
export function ContractTabs(props: { export function ContractTabs(props: { contract: Contract; bets: Bet[] }) {
contract: Contract const { contract, bets } = props
user: User | null | undefined
bets: Bet[]
comments: ContractComment[]
}) {
const { contract, user, bets, comments } = props
const isMobile = useIsMobile() const isMobile = useIsMobile()
const user = useUser()
const userBets = const userBets =
user && bets.filter((bet) => !bet.isAnte && bet.userId === user.id) user && bets.filter((bet) => !bet.isAnte && bet.userId === user.id)
const visibleBets = bets.filter(
(bet) => !bet.isAnte && !bet.isRedemption && bet.amount !== 0
)
const yourTrades = ( const yourTrades = (
<div> <div>
@ -57,19 +49,16 @@ export function ContractTabs(props: {
return ( return (
<Tabs <Tabs
className="mb-4"
currentPageForAnalytics={'contract'} currentPageForAnalytics={'contract'}
tabs={[ tabs={[
{ {
title: 'Comments', title: 'Comments',
content: ( content: <CommentsTabContent contract={contract} />,
<CommentsTabContent contract={contract} comments={comments} />
),
}, },
{ {
title: capitalize(PAST_BETS), title: capitalize(PAST_BETS),
content: ( content: <BetsTabContent contract={contract} bets={bets} />,
<ContractBetsActivity contract={contract} bets={visibleBets} />
),
}, },
...(!user || !userBets?.length ...(!user || !userBets?.length
? [] ? []
@ -86,46 +75,87 @@ export function ContractTabs(props: {
const CommentsTabContent = memo(function CommentsTabContent(props: { const CommentsTabContent = memo(function CommentsTabContent(props: {
contract: Contract contract: Contract
comments: ContractComment[]
}) { }) {
const { contract, comments } = props const { contract } = props
const tips = useTipTxns({ contractId: contract.id }) const tips = useTipTxns({ contractId: contract.id })
const updatedComments = useComments(contract.id) ?? comments const comments = useComments(contract.id)
if (comments == null) {
return <LoadingIndicator />
}
if (contract.outcomeType === 'FREE_RESPONSE') { if (contract.outcomeType === 'FREE_RESPONSE') {
const generalComments = comments.filter(
(c) => c.answerOutcome === undefined && c.betId === undefined
)
const sortedAnswers = sortBy(
contract.answers,
(a) => -getOutcomeProbability(contract, a.id)
)
const commentsByOutcome = groupBy(
comments,
(c) => c.answerOutcome ?? c.betOutcome ?? '_'
)
return ( return (
<> <>
<FreeResponseContractCommentsActivity {sortedAnswers.map((answer) => (
contract={contract} <div key={answer.id} className="relative pb-4">
comments={updatedComments} <span
tips={tips} className="absolute top-5 left-5 -ml-px h-[calc(100%-2rem)] w-0.5 bg-gray-200"
aria-hidden="true"
/> />
<Col className="mt-8 flex w-full"> <FeedAnswerCommentGroup
<div className="text-md mt-8 mb-2 text-left">General Comments</div>
<div className="mb-4 w-full border-b border-gray-200" />
<ContractCommentsActivity
contract={contract} contract={contract}
comments={updatedComments.filter( answer={answer}
(comment) => answerComments={sortBy(
comment.answerOutcome === undefined && commentsByOutcome[answer.number.toString()] ?? [],
comment.betId === undefined (c) => c.createdTime
)} )}
tips={tips} tips={tips}
/> />
</div>
))}
<Col className="mt-8 flex w-full">
<div className="text-md mt-8 mb-2 text-left">General Comments</div>
<div className="mb-4 w-full border-b border-gray-200" />
<ContractCommentInput className="mb-5" contract={contract} />
{generalComments.map((comment) => (
<FeedCommentThread
key={comment.id}
contract={contract}
parentComment={comment}
threadComments={[]}
tips={tips}
/>
))}
</Col> </Col>
</> </>
) )
} else { } else {
const commentsByParent = groupBy(comments, (c) => c.replyToCommentId ?? '_')
const topLevelComments = commentsByParent['_'] ?? []
return ( return (
<ContractCommentsActivity <>
<ContractCommentInput className="mb-5" contract={contract} />
{sortBy(topLevelComments, (c) => -c.createdTime).map((parent) => (
<FeedCommentThread
key={parent.id}
contract={contract} contract={contract}
comments={comments} parentComment={parent}
threadComments={sortBy(
commentsByParent[parent.id] ?? [],
(c) => c.createdTime
)}
tips={tips} tips={tips}
/> />
))}
</>
) )
} }
}) })
function ContractBetsActivity(props: { contract: Contract; bets: Bet[] }) { const BetsTabContent = memo(function BetsTabContent(props: {
contract: Contract
bets: Bet[]
}) {
const { contract, bets } = props const { contract, bets } = props
const [page, setPage] = useState(0) const [page, setPage] = useState(0)
const ITEMS_PER_PAGE = 50 const ITEMS_PER_PAGE = 50
@ -133,6 +163,9 @@ function ContractBetsActivity(props: { contract: Contract; bets: Bet[] }) {
const end = start + ITEMS_PER_PAGE const end = start + ITEMS_PER_PAGE
const lps = useLiquidity(contract.id) ?? [] const lps = useLiquidity(contract.id) ?? []
const visibleBets = bets.filter(
(bet) => !bet.isAnte && !bet.isRedemption && bet.amount !== 0
)
const visibleLps = lps.filter( const visibleLps = lps.filter(
(l) => (l) =>
!l.isAnte && !l.isAnte &&
@ -142,7 +175,7 @@ function ContractBetsActivity(props: { contract: Contract; bets: Bet[] }) {
) )
const items = [ const items = [
...bets.map((bet) => ({ ...visibleBets.map((bet) => ({
type: 'bet' as const, type: 'bet' as const,
id: bet.id + '-' + bet.isSold, id: bet.id + '-' + bet.isSold,
bet, bet,
@ -184,74 +217,4 @@ function ContractBetsActivity(props: { contract: Contract; bets: Bet[] }) {
/> />
</> </>
) )
} })
function ContractCommentsActivity(props: {
contract: Contract
comments: ContractComment[]
tips: CommentTipMap
}) {
const { contract, comments, tips } = props
const commentsByParentId = groupBy(comments, (c) => c.replyToCommentId ?? '_')
const topLevelComments = sortBy(
commentsByParentId['_'] ?? [],
(c) => -c.createdTime
)
return (
<>
<ContractCommentInput className="mb-5" contract={contract} />
{topLevelComments.map((parent) => (
<FeedCommentThread
key={parent.id}
contract={contract}
parentComment={parent}
threadComments={sortBy(
commentsByParentId[parent.id] ?? [],
(c) => c.createdTime
)}
tips={tips}
/>
))}
</>
)
}
function FreeResponseContractCommentsActivity(props: {
contract: FreeResponseContract
comments: ContractComment[]
tips: CommentTipMap
}) {
const { contract, comments, tips } = props
const sortedAnswers = sortBy(
contract.answers,
(answer) => -getOutcomeProbability(contract, answer.number.toString())
)
const commentsByOutcome = groupBy(
comments,
(c) => c.answerOutcome ?? c.betOutcome ?? '_'
)
return (
<>
{sortedAnswers.map((answer) => (
<div key={answer.id} className="relative pb-4">
<span
className="absolute top-5 left-5 -ml-px h-[calc(100%-2rem)] w-0.5 bg-gray-200"
aria-hidden="true"
/>
<FeedAnswerCommentGroup
contract={contract}
answer={answer}
answerComments={sortBy(
commentsByOutcome[answer.number.toString()] ?? [],
(c) => c.createdTime
)}
tips={tips}
/>
</div>
))}
</>
)
}

View File

@ -1,6 +1,4 @@
import clsx from 'clsx'
import { ShareIcon } from '@heroicons/react/outline' import { ShareIcon } from '@heroicons/react/outline'
import { Row } from '../layout/row' import { Row } from '../layout/row'
import { Contract } from 'web/lib/firebase/contracts' import { Contract } from 'web/lib/firebase/contracts'
import React, { useState } from 'react' import React, { useState } from 'react'
@ -10,7 +8,7 @@ import { ShareModal } from './share-modal'
import { FollowMarketButton } from 'web/components/follow-market-button' import { FollowMarketButton } from 'web/components/follow-market-button'
import { LikeMarketButton } from 'web/components/contract/like-market-button' import { LikeMarketButton } from 'web/components/contract/like-market-button'
import { ContractInfoDialog } from 'web/components/contract/contract-info-dialog' import { ContractInfoDialog } from 'web/components/contract/contract-info-dialog'
import { Col } from 'web/components/layout/col' import { Tooltip } from '../tooltip'
export function ExtraContractActionsRow(props: { contract: Contract }) { export function ExtraContractActionsRow(props: { contract: Contract }) {
const { contract } = props const { contract } = props
@ -23,17 +21,14 @@ export function ExtraContractActionsRow(props: { contract: Contract }) {
{user?.id !== contract.creatorId && ( {user?.id !== contract.creatorId && (
<LikeMarketButton contract={contract} user={user} /> <LikeMarketButton contract={contract} user={user} />
)} )}
<Tooltip text="Share" placement="bottom" noTap noFade>
<Button <Button
size="sm" size="sm"
color="gray-white" color="gray-white"
className={'flex'} className={'flex'}
onClick={() => { onClick={() => setShareOpen(true)}
setShareOpen(true)
}}
> >
<Row> <ShareIcon className="h-5 w-5" aria-hidden />
<ShareIcon className={clsx('h-5 w-5')} aria-hidden="true" />
</Row>
<ShareModal <ShareModal
isOpen={isShareOpen} isOpen={isShareOpen}
setOpen={setShareOpen} setOpen={setShareOpen}
@ -41,9 +36,8 @@ export function ExtraContractActionsRow(props: { contract: Contract }) {
user={user} user={user}
/> />
</Button> </Button>
<Col className={'justify-center'}> </Tooltip>
<ContractInfoDialog contract={contract} /> <ContractInfoDialog contract={contract} />
</Col>
</Row> </Row>
) )
} }

View File

@ -13,6 +13,7 @@ import { Col } from 'web/components/layout/col'
import { firebaseLogin } from 'web/lib/firebase/users' import { firebaseLogin } from 'web/lib/firebase/users'
import { useMarketTipTxns } from 'web/hooks/use-tip-txns' import { useMarketTipTxns } from 'web/hooks/use-tip-txns'
import { sum } from 'lodash' import { sum } from 'lodash'
import { Tooltip } from '../tooltip'
export function LikeMarketButton(props: { export function LikeMarketButton(props: {
contract: Contract contract: Contract
@ -37,6 +38,12 @@ export function LikeMarketButton(props: {
} }
return ( return (
<Tooltip
text={`Tip ${formatMoney(LIKE_TIP_AMOUNT)}`}
placement="bottom"
noTap
noFade
>
<Button <Button
size={'sm'} size={'sm'}
className={'max-w-xs self-center'} className={'max-w-xs self-center'}
@ -69,5 +76,6 @@ export function LikeMarketButton(props: {
)} )}
</Col> </Col>
</Button> </Button>
</Tooltip>
) )
} }

View File

@ -21,6 +21,7 @@ import { CreateChallengeModal } from 'web/components/challenges/create-challenge
import { useState } from 'react' import { useState } from 'react'
import { CHALLENGES_ENABLED } from 'common/challenge' import { CHALLENGES_ENABLED } from 'common/challenge'
import ChallengeIcon from 'web/lib/icons/challenge-icon' import ChallengeIcon from 'web/lib/icons/challenge-icon'
import { QRCode } from '../qr-code'
export function ShareModal(props: { export function ShareModal(props: {
contract: Contract contract: Contract
@ -54,6 +55,12 @@ export function ShareModal(props: {
</SiteLink>{' '} </SiteLink>{' '}
if a new user signs up using the link! if a new user signs up using the link!
</p> </p>
<QRCode
url={shareUrl}
className="self-center"
width={150}
height={150}
/>
<Button <Button
size="2xl" size="2xl"
color="indigo" color="indigo"

View File

@ -1,7 +1,7 @@
import { Answer } from 'common/answer' import { Answer } from 'common/answer'
import { FreeResponseContract } from 'common/contract' import { FreeResponseContract } from 'common/contract'
import { ContractComment } from 'common/comment' import { ContractComment } from 'common/comment'
import React, { useEffect, useState } from 'react' import React, { useEffect, useRef, useState } from 'react'
import { Col } from 'web/components/layout/col' import { Col } from 'web/components/layout/col'
import { Row } from 'web/components/layout/row' import { Row } from 'web/components/layout/row'
import { Avatar } from 'web/components/avatar' import { Avatar } from 'web/components/avatar'
@ -10,11 +10,10 @@ import clsx from 'clsx'
import { import {
ContractCommentInput, ContractCommentInput,
FeedComment, FeedComment,
ReplyTo,
} from 'web/components/feed/feed-comments' } from 'web/components/feed/feed-comments'
import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time' import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { User } from 'common/user'
import { useEvent } from 'web/hooks/use-event'
import { CommentTipMap } from 'web/hooks/use-tip-txns' import { CommentTipMap } from 'web/hooks/use-tip-txns'
import { UserLink } from 'web/components/user-link' import { UserLink } from 'web/components/user-link'
@ -27,32 +26,17 @@ export function FeedAnswerCommentGroup(props: {
const { answer, contract, answerComments, tips } = props const { answer, contract, answerComments, tips } = props
const { username, avatarUrl, name, text } = answer const { username, avatarUrl, name, text } = answer
const [replyToUser, setReplyToUser] = const [replyTo, setReplyTo] = useState<ReplyTo>()
useState<Pick<User, 'id' | 'username'>>()
const [showReply, setShowReply] = useState(false)
const [highlighted, setHighlighted] = useState(false)
const router = useRouter() const router = useRouter()
const answerElementId = `answer-${answer.id}` const answerElementId = `answer-${answer.id}`
const highlighted = router.asPath.endsWith(`#${answerElementId}`)
const scrollAndOpenReplyInput = useEvent( const answerRef = useRef<HTMLDivElement>(null)
(comment?: ContractComment, answer?: Answer) => {
setReplyToUser(
comment
? { id: comment.userId, username: comment.userUsername }
: answer
? { id: answer.userId, username: answer.username }
: undefined
)
setShowReply(true)
}
)
useEffect(() => { useEffect(() => {
if (router.asPath.endsWith(`#${answerElementId}`)) { if (highlighted && answerRef.current != null) {
setHighlighted(true) answerRef.current.scrollIntoView(true)
} }
}, [answerElementId, router.asPath]) }, [highlighted])
return ( return (
<Col className="relative flex-1 items-stretch gap-3"> <Col className="relative flex-1 items-stretch gap-3">
@ -61,6 +45,7 @@ export function FeedAnswerCommentGroup(props: {
'gap-3 space-x-3 pt-4 transition-all duration-1000', 'gap-3 space-x-3 pt-4 transition-all duration-1000',
highlighted ? `-m-2 my-3 rounded bg-indigo-500/[0.2] p-2` : '' highlighted ? `-m-2 my-3 rounded bg-indigo-500/[0.2] p-2` : ''
)} )}
ref={answerRef}
id={answerElementId} id={answerElementId}
> >
<Avatar username={username} avatarUrl={avatarUrl} /> <Avatar username={username} avatarUrl={avatarUrl} />
@ -83,7 +68,9 @@ export function FeedAnswerCommentGroup(props: {
<div className="sm:hidden"> <div className="sm:hidden">
<button <button
className="text-xs font-bold text-gray-500 hover:underline" className="text-xs font-bold text-gray-500 hover:underline"
onClick={() => scrollAndOpenReplyInput(undefined, answer)} onClick={() =>
setReplyTo({ id: answer.id, username: answer.username })
}
> >
Reply Reply
</button> </button>
@ -92,7 +79,9 @@ export function FeedAnswerCommentGroup(props: {
<div className="justify-initial hidden sm:block"> <div className="justify-initial hidden sm:block">
<button <button
className="text-xs font-bold text-gray-500 hover:underline" className="text-xs font-bold text-gray-500 hover:underline"
onClick={() => scrollAndOpenReplyInput(undefined, answer)} onClick={() =>
setReplyTo({ id: answer.id, username: answer.username })
}
> >
Reply Reply
</button> </button>
@ -107,11 +96,13 @@ export function FeedAnswerCommentGroup(props: {
contract={contract} contract={contract}
comment={comment} comment={comment}
tips={tips[comment.id] ?? {}} tips={tips[comment.id] ?? {}}
onReplyClick={scrollAndOpenReplyInput} onReplyClick={() =>
setReplyTo({ id: comment.id, username: comment.userUsername })
}
/> />
))} ))}
</Col> </Col>
{showReply && ( {replyTo && (
<div className="relative ml-7"> <div className="relative ml-7">
<span <span
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"
@ -120,8 +111,8 @@ export function FeedAnswerCommentGroup(props: {
<ContractCommentInput <ContractCommentInput
contract={contract} contract={contract}
parentAnswerOutcome={answer.number.toString()} parentAnswerOutcome={answer.number.toString()}
replyToUser={replyToUser} replyTo={replyTo}
onSubmitComment={() => setShowReply(false)} onSubmitComment={() => setReplyTo(undefined)}
/> />
</div> </div>
)} )}

View File

@ -1,6 +1,6 @@
import { ContractComment } from 'common/comment' import { ContractComment } from 'common/comment'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
import React, { useEffect, useState } from 'react' import React, { useEffect, useRef, useState } from 'react'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { formatMoney } from 'common/util/format' import { formatMoney } from 'common/util/format'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
@ -20,6 +20,8 @@ import { Editor } from '@tiptap/react'
import { UserLink } from 'web/components/user-link' import { UserLink } from 'web/components/user-link'
import { CommentInput } from '../comment-input' import { CommentInput } from '../comment-input'
export type ReplyTo = { id: string; username: string }
export function FeedCommentThread(props: { export function FeedCommentThread(props: {
contract: Contract contract: Contract
threadComments: ContractComment[] threadComments: ContractComment[]
@ -27,13 +29,7 @@ export function FeedCommentThread(props: {
parentComment: ContractComment parentComment: ContractComment
}) { }) {
const { contract, threadComments, tips, parentComment } = props const { contract, threadComments, tips, parentComment } = props
const [showReply, setShowReply] = useState(false) const [replyTo, setReplyTo] = useState<ReplyTo>()
const [replyTo, setReplyTo] = useState<{ id: string; username: string }>()
function scrollAndOpenReplyInput(comment: ContractComment) {
setReplyTo({ id: comment.userId, username: comment.userUsername })
setShowReply(true)
}
return ( return (
<Col className="relative w-full items-stretch gap-3 pb-4"> <Col className="relative w-full items-stretch gap-3 pb-4">
@ -48,10 +44,12 @@ export function FeedCommentThread(props: {
contract={contract} contract={contract}
comment={comment} comment={comment}
tips={tips[comment.id] ?? {}} tips={tips[comment.id] ?? {}}
onReplyClick={scrollAndOpenReplyInput} onReplyClick={() =>
setReplyTo({ id: comment.id, username: comment.userUsername })
}
/> />
))} ))}
{showReply && ( {replyTo && (
<Col className="-pb-2 relative ml-6"> <Col className="-pb-2 relative ml-6">
<span <span
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"
@ -60,10 +58,8 @@ export function FeedCommentThread(props: {
<ContractCommentInput <ContractCommentInput
contract={contract} contract={contract}
parentCommentId={parentComment.id} parentCommentId={parentComment.id}
replyToUser={replyTo} replyTo={replyTo}
onSubmitComment={() => { onSubmitComment={() => setReplyTo(undefined)}
setShowReply(false)
}}
/> />
</Col> </Col>
)} )}
@ -76,7 +72,7 @@ export function FeedComment(props: {
comment: ContractComment comment: ContractComment
tips?: CommentTips tips?: CommentTips
indent?: boolean indent?: boolean
onReplyClick?: (comment: ContractComment) => void onReplyClick?: () => void
}) { }) {
const { contract, comment, tips, indent, onReplyClick } = props const { contract, comment, tips, indent, onReplyClick } = props
const { const {
@ -98,16 +94,19 @@ export function FeedComment(props: {
money = formatMoney(Math.abs(comment.betAmount)) money = formatMoney(Math.abs(comment.betAmount))
} }
const [highlighted, setHighlighted] = useState(false)
const router = useRouter() const router = useRouter()
const highlighted = router.asPath.endsWith(`#${comment.id}`)
const commentRef = useRef<HTMLDivElement>(null)
useEffect(() => { useEffect(() => {
if (router.asPath.endsWith(`#${comment.id}`)) { if (highlighted && commentRef.current != null) {
setHighlighted(true) commentRef.current.scrollIntoView(true)
} }
}, [comment.id, router.asPath]) }, [highlighted])
return ( return (
<Row <Row
ref={commentRef}
id={comment.id} id={comment.id}
className={clsx( className={clsx(
'relative', 'relative',
@ -174,7 +173,7 @@ export function FeedComment(props: {
{onReplyClick && ( {onReplyClick && (
<button <button
className="font-bold hover:underline" className="font-bold hover:underline"
onClick={() => onReplyClick(comment)} onClick={onReplyClick}
> >
Reply Reply
</button> </button>
@ -204,7 +203,7 @@ export function ContractCommentInput(props: {
contract: Contract contract: Contract
className?: string className?: string
parentAnswerOutcome?: string | undefined parentAnswerOutcome?: string | undefined
replyToUser?: { id: string; username: string } replyTo?: ReplyTo
parentCommentId?: string parentCommentId?: string
onSubmitComment?: () => void onSubmitComment?: () => void
}) { }) {
@ -226,7 +225,7 @@ export function ContractCommentInput(props: {
return ( return (
<CommentInput <CommentInput
replyToUser={props.replyToUser} replyTo={props.replyTo}
parentAnswerOutcome={props.parentAnswerOutcome} parentAnswerOutcome={props.parentAnswerOutcome}
parentCommentId={props.parentCommentId} parentCommentId={props.parentCommentId}
onSubmitComment={onSubmitComment} onSubmitComment={onSubmitComment}

View File

@ -14,6 +14,7 @@ import { track } from 'web/lib/service/analytics'
import { WatchMarketModal } from 'web/components/contract/watch-market-modal' import { WatchMarketModal } from 'web/components/contract/watch-market-modal'
import { useState } from 'react' import { useState } from 'react'
import { Col } from 'web/components/layout/col' import { Col } from 'web/components/layout/col'
import { Tooltip } from './tooltip'
export const FollowMarketButton = (props: { export const FollowMarketButton = (props: {
contract: Contract contract: Contract
@ -23,7 +24,15 @@ export const FollowMarketButton = (props: {
const followers = useContractFollows(contract.id) const followers = useContractFollows(contract.id)
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const watching = followers?.includes(user?.id ?? 'nope')
return ( return (
<Tooltip
text={watching ? 'Unfollow' : 'Follow'}
placement="bottom"
noTap
noFade
>
<Button <Button
size={'sm'} size={'sm'}
color={'gray-white'} color={'gray-white'}
@ -54,7 +63,7 @@ export const FollowMarketButton = (props: {
} }
}} }}
> >
{followers?.includes(user?.id ?? 'nope') ? ( {watching ? (
<Col className={'items-center gap-x-2 sm:flex-row'}> <Col className={'items-center gap-x-2 sm:flex-row'}>
<EyeOffIcon <EyeOffIcon
className={clsx('h-5 w-5 sm:h-6 sm:w-6')} className={clsx('h-5 w-5 sm:h-6 sm:w-6')}
@ -79,5 +88,6 @@ export const FollowMarketButton = (props: {
} a question!`} } a question!`}
/> />
</Button> </Button>
</Tooltip>
) )
} }

View File

@ -115,6 +115,7 @@ function FollowsDialog(props: {
<div className="p-2 pb-1 text-xl">{user.name}</div> <div className="p-2 pb-1 text-xl">{user.name}</div>
<div className="p-2 pt-0 text-sm text-gray-500">@{user.username}</div> <div className="p-2 pt-0 text-sm text-gray-500">@{user.username}</div>
<Tabs <Tabs
className="mb-4"
tabs={[ tabs={[
{ {
title: 'Following', title: 'Following',

View File

@ -23,6 +23,7 @@ export function LandingPagePanel(props: { hotContracts: Contract[] }) {
height={250} height={250}
width={250} width={250}
className="self-center" className="self-center"
alt="Manifold logo"
src="/flappy-logo.gif" src="/flappy-logo.gif"
/> />
<div className="m-4 max-w-[550px] self-center"> <div className="m-4 max-w-[550px] self-center">

View File

@ -31,7 +31,7 @@ export function ControlledTabs(props: TabProps & { activeIndex: number }) {
return ( return (
<> <>
<nav <nav
className={clsx('mb-4 space-x-8 border-b border-gray-200', className)} className={clsx('space-x-8 border-b border-gray-200', className)}
aria-label="Tabs" aria-label="Tabs"
> >
{tabs.map((tab, i) => ( {tabs.map((tab, i) => (

View File

@ -1,28 +1,33 @@
import clsx from 'clsx' import clsx from 'clsx'
import { User } from 'common/user'
import { Avatar } from './avatar' import { Avatar } from './avatar'
import { Row } from './layout/row' import { Row } from './layout/row'
import { SiteLink } from './site-link' import { SiteLink } from './site-link'
import { Title } from './title' import { Title } from './title'
export function Leaderboard(props: { interface LeaderboardEntry {
username: string
name: string
avatarUrl?: string
}
export function Leaderboard<T extends LeaderboardEntry>(props: {
title: string title: string
users: User[] entries: T[]
columns: { columns: {
header: string header: string
renderCell: (user: User) => any renderCell: (entry: T) => any
}[] }[]
className?: string className?: string
maxToShow?: number maxToShow?: number
}) { }) {
// TODO: Ideally, highlight your own entry on the leaderboard // TODO: Ideally, highlight your own entry on the leaderboard
const { title, columns, className } = props const { title, columns, className } = props
const maxToShow = props.maxToShow ?? props.users.length const maxToShow = props.maxToShow ?? props.entries.length
const users = props.users.slice(0, maxToShow) const entries = props.entries.slice(0, maxToShow)
return ( return (
<div className={clsx('w-full px-1', className)}> <div className={clsx('w-full px-1', className)}>
<Title text={title} className="!mt-0" /> <Title text={title} className="!mt-0" />
{users.length === 0 ? ( {entries.length === 0 ? (
<div className="ml-2 text-gray-500">None yet</div> <div className="ml-2 text-gray-500">None yet</div>
) : ( ) : (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
@ -37,19 +42,19 @@ export function Leaderboard(props: {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{users.map((user, index) => ( {entries.map((entry, index) => (
<tr key={user.id}> <tr key={index}>
<td>{index + 1}</td> <td>{index + 1}</td>
<td className="max-w-[190px]"> <td className="max-w-[190px]">
<SiteLink className="relative" href={`/${user.username}`}> <SiteLink className="relative" href={`/${entry.username}`}>
<Row className="items-center gap-4"> <Row className="items-center gap-4">
<Avatar avatarUrl={user.avatarUrl} size={8} /> <Avatar avatarUrl={entry.avatarUrl} size={8} />
<div className="truncate">{user.name}</div> <div className="truncate">{entry.name}</div>
</Row> </Row>
</SiteLink> </SiteLink>
</td> </td>
{columns.map((column) => ( {columns.map((column) => (
<td key={column.header}>{column.renderCell(user)}</td> <td key={column.header}>{column.renderCell(entry)}</td>
))} ))}
</tr> </tr>
))} ))}

View File

@ -1,94 +0,0 @@
import { ClipboardIcon, HomeIcon } from '@heroicons/react/outline'
import { Item } from './sidebar-item'
import clsx from 'clsx'
import { trackCallback } from 'web/lib/service/analytics'
import TrophyIcon from 'web/lib/icons/trophy-icon'
import { useUser } from 'web/hooks/use-user'
import NotificationsIcon from '../notifications-icon'
import router from 'next/router'
import { userProfileItem } from './bottom-nav-bar'
const mobileGroupNavigation = [
{ name: 'Markets', key: 'markets', icon: HomeIcon },
{ name: 'Leaderboard', key: 'leaderboards', icon: TrophyIcon },
{ name: 'About', key: 'about', icon: ClipboardIcon },
]
const mobileGeneralNavigation = [
{
name: 'Notifications',
key: 'notifications',
icon: NotificationsIcon,
href: '/notifications',
},
]
export function GroupNavBar(props: {
currentPage: string
onClick: (key: string) => void
}) {
const { currentPage } = props
const user = useUser()
return (
<nav className="z-20 flex justify-between border-t-2 bg-white text-xs text-gray-700 lg:hidden">
{mobileGroupNavigation.map((item) => (
<NavBarItem
key={item.name}
item={item}
currentPage={currentPage}
onClick={props.onClick}
/>
))}
{mobileGeneralNavigation.map((item) => (
<NavBarItem
key={item.name}
item={item}
currentPage={currentPage}
onClick={() => {
router.push(item.href)
}}
/>
))}
{user && (
<NavBarItem
key={'profile'}
currentPage={currentPage}
onClick={() => {
router.push(`/${user.username}?tab=trades`)
}}
item={userProfileItem(user)}
/>
)}
</nav>
)
}
function NavBarItem(props: {
item: Item
currentPage: string
onClick: (key: string) => void
}) {
const { item, currentPage } = props
const track = trackCallback(
`group navbar: ${item.trackingEventName ?? item.name}`
)
return (
<button onClick={() => props.onClick(item.key ?? '#')}>
<a
className={clsx(
'block w-full py-1 px-3 text-center hover:bg-indigo-200 hover:text-indigo-700',
currentPage === item.key && 'bg-gray-200 text-indigo-700'
)}
onClick={track}
>
{item.icon && <item.icon className="my-1 mx-auto h-6 w-6" />}
{item.name}
</a>
</button>
)
}

View File

@ -1,82 +0,0 @@
import { ClipboardIcon, HomeIcon } from '@heroicons/react/outline'
import clsx from 'clsx'
import { useUser } from 'web/hooks/use-user'
import { ManifoldLogo } from './manifold-logo'
import { ProfileSummary } from './profile-menu'
import React from 'react'
import TrophyIcon from 'web/lib/icons/trophy-icon'
import { SignInButton } from '../sign-in-button'
import NotificationsIcon from '../notifications-icon'
import { SidebarItem } from './sidebar-item'
import { buildArray } from 'common/util/array'
import { User } from 'common/user'
import { Row } from '../layout/row'
import { Spacer } from '../layout/spacer'
const groupNavigation = [
{ name: 'Markets', key: 'markets', icon: HomeIcon },
{ name: 'About', key: 'about', icon: ClipboardIcon },
{ name: 'Leaderboard', key: 'leaderboards', icon: TrophyIcon },
]
const generalNavigation = (user?: User | null) =>
buildArray(
user && {
name: 'Notifications',
href: `/notifications`,
key: 'notifications',
icon: NotificationsIcon,
}
)
export function GroupSidebar(props: {
groupName: string
className?: string
onClick: (key: string) => void
joinOrAddQuestionsButton: React.ReactNode
currentKey: string
}) {
const { className, groupName, currentKey } = props
const user = useUser()
return (
<nav
aria-label="Group Sidebar"
className={clsx('flex max-h-[100vh] flex-col', className)}
>
<ManifoldLogo className="pt-6" twoLine />
<Row className="pl-2 text-xl text-indigo-700 sm:mt-3">{groupName}</Row>
<div className=" min-h-0 shrink flex-col items-stretch gap-1 pt-6 lg:flex ">
{user ? (
<ProfileSummary user={user} />
) : (
<SignInButton className="mb-4" />
)}
</div>
{/* Desktop navigation */}
{groupNavigation.map((item) => (
<SidebarItem
key={item.key}
item={item}
currentPage={currentKey}
onClick={props.onClick}
/>
))}
{generalNavigation(user).map((item) => (
<SidebarItem
key={item.key}
item={item}
currentPage={currentKey}
onClick={props.onClick}
/>
))}
<Spacer h={2} />
{props.joinOrAddQuestionsButton}
</nav>
)
}

View File

@ -26,9 +26,14 @@ import TrophyIcon from 'web/lib/icons/trophy-icon'
import { SignInButton } from '../sign-in-button' import { SignInButton } from '../sign-in-button'
import { SidebarItem } from './sidebar-item' import { SidebarItem } from './sidebar-item'
import { MoreButton } from './more-button' import { MoreButton } from './more-button'
import { Row } from '../layout/row'
import { Spacer } from '../layout/spacer'
export default function Sidebar(props: { className?: string }) { export default function Sidebar(props: {
const { className } = props className?: string
logoSubheading?: string
}) {
const { className, logoSubheading } = props
const router = useRouter() const router = useRouter()
const currentPage = router.pathname const currentPage = router.pathname
@ -51,7 +56,13 @@ export default function Sidebar(props: { className?: string }) {
aria-label="Sidebar" aria-label="Sidebar"
className={clsx('flex max-h-[100vh] flex-col', className)} className={clsx('flex max-h-[100vh] flex-col', className)}
> >
<ManifoldLogo className="py-6" twoLine /> <ManifoldLogo className="pt-6" twoLine />
{logoSubheading && (
<Row className="pl-2 text-2xl text-indigo-700 sm:mt-3">
{logoSubheading}
</Row>
)}
<Spacer h={6} />
{!user && <SignInButton className="mb-4" />} {!user && <SignInButton className="mb-4" />}

View File

@ -99,8 +99,6 @@ const useIsTwitch = (user: User | null | undefined) => {
const isTwitch = router.pathname === '/twitch' const isTwitch = router.pathname === '/twitch'
useEffect(() => { useEffect(() => {
console.log('twich?', isTwitch)
if (isTwitch && user?.shouldShowWelcome) { if (isTwitch && user?.shouldShowWelcome) {
updateUser(user.id, { ['shouldShowWelcome']: false }) updateUser(user.id, { ['shouldShowWelcome']: false })
} }

View File

@ -9,8 +9,15 @@ export function Page(props: {
className?: string className?: string
rightSidebarClassName?: string rightSidebarClassName?: string
children?: ReactNode children?: ReactNode
logoSubheading?: string
}) { }) {
const { children, rightSidebar, className, rightSidebarClassName } = props const {
children,
rightSidebar,
className,
rightSidebarClassName,
logoSubheading,
} = props
const bottomBarPadding = 'pb-[58px] lg:pb-0 ' const bottomBarPadding = 'pb-[58px] lg:pb-0 '
return ( return (
@ -23,7 +30,10 @@ export function Page(props: {
)} )}
> >
<Toaster /> <Toaster />
<Sidebar className="sticky top-0 hidden divide-gray-300 self-start pl-2 lg:col-span-2 lg:flex" /> <Sidebar
logoSubheading={logoSubheading}
className="sticky top-0 hidden divide-gray-300 self-start pl-2 lg:col-span-2 lg:flex"
/>
<main <main
className={clsx( className={clsx(
'lg:col-span-8 lg:pt-6', 'lg:col-span-8 lg:pt-6',

View File

@ -64,6 +64,7 @@ function ReferralsDialog(props: {
<div className="p-2 pb-1 text-xl">{user.name}</div> <div className="p-2 pb-1 text-xl">{user.name}</div>
<div className="p-2 pt-0 text-sm text-gray-500">@{user.username}</div> <div className="p-2 pt-0 text-sm text-gray-500">@{user.username}</div>
<Tabs <Tabs
className="mb-4"
tabs={[ tabs={[
{ {
title: 'Referrals', title: 'Referrals',

View File

@ -16,6 +16,8 @@ import { track } from 'web/lib/service/analytics'
import { Row } from './layout/row' import { Row } from './layout/row'
import { Tooltip } from './tooltip' import { Tooltip } from './tooltip'
const TIP_SIZE = 10
export function Tipper(prop: { comment: Comment; tips: CommentTips }) { export function Tipper(prop: { comment: Comment; tips: CommentTips }) {
const { comment, tips } = prop const { comment, tips } = prop
@ -82,9 +84,12 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) {
const canUp = me && me.id !== comment.userId && me.balance >= localTip + 5 const canUp = me && me.id !== comment.userId && me.balance >= localTip + 5
return ( return (
<Row className="items-center gap-0.5"> <Row className="items-center gap-0.5">
<DownTip onClick={canDown ? () => addTip(-5) : undefined} /> <DownTip onClick={canDown ? () => addTip(-TIP_SIZE) : undefined} />
<span className="font-bold">{Math.floor(total)}</span> <span className="font-bold">{Math.floor(total)}</span>
<UpTip onClick={canUp ? () => addTip(+5) : undefined} value={localTip} /> <UpTip
onClick={canUp ? () => addTip(+TIP_SIZE) : undefined}
value={localTip}
/>
{localTip === 0 ? ( {localTip === 0 ? (
'' ''
) : ( ) : (
@ -107,7 +112,7 @@ function DownTip(props: { onClick?: () => void }) {
<Tooltip <Tooltip
className="h-6 w-6" className="h-6 w-6"
placement="bottom" placement="bottom"
text={onClick && `-${formatMoney(5)}`} text={onClick && `-${formatMoney(TIP_SIZE)}`}
noTap noTap
> >
<button <button
@ -128,7 +133,7 @@ function UpTip(props: { onClick?: () => void; value: number }) {
<Tooltip <Tooltip
className="h-6 w-6" className="h-6 w-6"
placement="bottom" placement="bottom"
text={onClick && `Tip ${formatMoney(5)}`} text={onClick && `Tip ${formatMoney(TIP_SIZE)}`}
noTap noTap
> >
<button <button

View File

@ -254,6 +254,7 @@ export function UserPage(props: { user: User }) {
</Row> </Row>
)} )}
<QueryUncontrolledTabs <QueryUncontrolledTabs
className="mb-4"
currentPageForAnalytics={'profile'} currentPageForAnalytics={'profile'}
labelClassName={'pb-2 pt-1 '} labelClassName={'pb-2 pt-1 '}
tabs={[ tabs={[
@ -283,7 +284,7 @@ export function UserPage(props: { user: User }) {
title: 'Stats', title: 'Stats',
content: ( content: (
<Col className="mb-8"> <Col className="mb-8">
<Row className={'mb-8 flex-wrap items-center gap-6'}> <Row className="mb-8 flex-wrap items-center gap-x-6 gap-y-2">
<FollowingButton user={user} /> <FollowingButton user={user} />
<FollowersButton user={user} /> <FollowersButton user={user} />
<ReferralsButton user={user} /> <ReferralsButton user={user} />

View File

@ -9,9 +9,6 @@ module.exports = {
reactStrictMode: true, reactStrictMode: true,
optimizeFonts: false, optimizeFonts: false,
experimental: { experimental: {
images: {
allowFutureImage: true,
},
scrollRestoration: true, scrollRestoration: true,
externalDir: true, externalDir: true,
modularizeImports: { modularizeImports: {

View File

@ -46,7 +46,7 @@
"gridjs-react": "5.0.2", "gridjs-react": "5.0.2",
"lodash": "4.17.21", "lodash": "4.17.21",
"nanoid": "^3.3.4", "nanoid": "^3.3.4",
"next": "12.2.5", "next": "12.3.1",
"node-fetch": "3.2.4", "node-fetch": "3.2.4",
"prosemirror-state": "1.4.1", "prosemirror-state": "1.4.1",
"react": "17.0.2", "react": "17.0.2",

View File

@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from 'react' import React, { memo, useEffect, useMemo, useState } from 'react'
import { ArrowLeftIcon } from '@heroicons/react/outline' import { ArrowLeftIcon } from '@heroicons/react/outline'
import { useContractWithPreload } from 'web/hooks/use-contract' import { useContractWithPreload } from 'web/hooks/use-contract'
@ -17,7 +17,6 @@ import {
import { SEO } from 'web/components/SEO' import { SEO } from 'web/components/SEO'
import { Page } from 'web/components/page' import { Page } from 'web/components/page'
import { Bet, listAllBets } from 'web/lib/firebase/bets' import { Bet, listAllBets } from 'web/lib/firebase/bets'
import { listAllComments } from 'web/lib/firebase/comments'
import Custom404 from '../404' import Custom404 from '../404'
import { AnswersPanel } from 'web/components/answers/answers-panel' import { AnswersPanel } from 'web/components/answers/answers-panel'
import { fromPropz, usePropz } from 'web/hooks/use-propz' import { fromPropz, usePropz } from 'web/hooks/use-propz'
@ -32,8 +31,6 @@ import { CPMMBinaryContract } from 'common/contract'
import { AlertBox } from 'web/components/alert-box' import { AlertBox } from 'web/components/alert-box'
import { useTracking } from 'web/hooks/use-tracking' import { useTracking } from 'web/hooks/use-tracking'
import { useSaveReferral } from 'web/hooks/use-save-referral' import { useSaveReferral } from 'web/hooks/use-save-referral'
import { User } from 'common/user'
import { ContractComment } from 'common/comment'
import { getOpenGraphProps } from 'common/contract-details' import { getOpenGraphProps } from 'common/contract-details'
import { ContractDescription } from 'web/components/contract/contract-description' import { ContractDescription } from 'web/components/contract/contract-description'
import { import {
@ -54,25 +51,14 @@ export const getStaticProps = fromPropz(getStaticPropz)
export async function getStaticPropz(props: { export async function getStaticPropz(props: {
params: { username: string; contractSlug: string } params: { username: string; contractSlug: string }
}) { }) {
const { username, contractSlug } = props.params const { contractSlug } = props.params
const contract = (await getContractFromSlug(contractSlug)) || null const contract = (await getContractFromSlug(contractSlug)) || null
const contractId = contract?.id const contractId = contract?.id
const bets = contractId ? await listAllBets(contractId) : []
const [bets, comments] = await Promise.all([
contractId ? listAllBets(contractId) : [],
contractId ? listAllComments(contractId) : [],
])
return { return {
props: { // Limit the data sent to the client. Client will still load all bets directly.
contract, props: { contract, bets: bets.slice(0, 5000) },
username,
slug: contractSlug,
// Limit the data sent to the client. Client will still load all bets and comments directly.
bets: bets.slice(0, 5000),
comments: comments.slice(0, 1000),
},
revalidate: 5, // regenerate after five seconds revalidate: 5, // regenerate after five seconds
} }
} }
@ -83,21 +69,11 @@ export async function getStaticPaths() {
export default function ContractPage(props: { export default function ContractPage(props: {
contract: Contract | null contract: Contract | null
username: string
bets: Bet[] bets: Bet[]
comments: ContractComment[]
slug: string
backToHome?: () => void backToHome?: () => void
}) { }) {
props = usePropz(props, getStaticPropz) ?? { props = usePropz(props, getStaticPropz) ?? { contract: null, bets: [] }
contract: null,
username: '',
comments: [],
bets: [],
slug: '',
}
const user = useUser()
const inIframe = useIsIframe() const inIframe = useIsIframe()
if (inIframe) { if (inIframe) {
return <ContractEmbedPage {...props} /> return <ContractEmbedPage {...props} />
@ -109,9 +85,7 @@ export default function ContractPage(props: {
return <Custom404 /> return <Custom404 />
} }
return ( return <ContractPageContent key={contract.id} {...{ ...props, contract }} />
<ContractPageContent key={contract.id} {...{ ...props, contract, user }} />
)
} }
// requires an admin to resolve a week after market closes // requires an admin to resolve a week after market closes
@ -119,12 +93,10 @@ export function needsAdminToResolve(contract: Contract) {
return !contract.isResolved && dayjs().diff(contract.closeTime, 'day') > 7 return !contract.isResolved && dayjs().diff(contract.closeTime, 'day') > 7
} }
export function ContractPageSidebar(props: { export function ContractPageSidebar(props: { contract: Contract }) {
user: User | null | undefined const { contract } = props
contract: Contract
}) {
const { contract, user } = props
const { creatorId, isResolved, outcomeType } = contract const { creatorId, isResolved, outcomeType } = contract
const user = useUser()
const isCreator = user?.id === creatorId const isCreator = user?.id === creatorId
const isBinary = outcomeType === 'BINARY' const isBinary = outcomeType === 'BINARY'
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
@ -173,11 +145,11 @@ export function ContractPageSidebar(props: {
export function ContractPageContent( export function ContractPageContent(
props: Parameters<typeof ContractPage>[0] & { props: Parameters<typeof ContractPage>[0] & {
contract: Contract contract: Contract
user?: User | null
} }
) { ) {
const { backToHome, comments, user } = props const { backToHome } = props
const contract = useContractWithPreload(props.contract) ?? props.contract const contract = useContractWithPreload(props.contract) ?? props.contract
const user = useUser()
usePrefetch(user?.id) usePrefetch(user?.id)
useTracking( useTracking(
'view market', 'view market',
@ -217,9 +189,8 @@ export function ContractPageContent(
contractId: contract.id, contractId: contract.id,
}) })
const rightSidebar = <ContractPageSidebar user={user} contract={contract} />
return ( return (
<Page rightSidebar={rightSidebar}> <Page rightSidebar={<ContractPageSidebar contract={contract} />}>
{showConfetti && ( {showConfetti && (
<FullscreenConfetti recycle={false} numberOfPieces={300} /> <FullscreenConfetti recycle={false} numberOfPieces={300} />
)} )}
@ -228,7 +199,7 @@ export function ContractPageContent(
<SEO <SEO
title={question} title={question}
description={ogCardProps.description} description={ogCardProps.description}
url={`/${props.username}/${props.slug}`} url={`/${contract.creatorUsername}/${contract.slug}`}
ogCardProps={ogCardProps} ogCardProps={ogCardProps}
/> />
)} )}
@ -271,22 +242,13 @@ export function ContractPageContent(
<> <>
<div className="grid grid-cols-1 sm:grid-cols-2"> <div className="grid grid-cols-1 sm:grid-cols-2">
<ContractLeaderboard contract={contract} bets={bets} /> <ContractLeaderboard contract={contract} bets={bets} />
<ContractTopTrades <ContractTopTrades contract={contract} bets={bets} />
contract={contract}
bets={bets}
comments={comments}
/>
</div> </div>
<Spacer h={12} /> <Spacer h={12} />
</> </>
)} )}
<ContractTabs <ContractTabs contract={contract} bets={bets} />
contract={contract}
user={user}
bets={bets}
comments={comments}
/>
{!user ? ( {!user ? (
<Col className="mt-4 max-w-sm items-center xl:hidden"> <Col className="mt-4 max-w-sm items-center xl:hidden">
<BetSignUpPrompt /> <BetSignUpPrompt />
@ -307,6 +269,7 @@ export function ContractPageContent(
) )
} }
const RecommendedContractsWidget = memo(
function RecommendedContractsWidget(props: { contract: Contract }) { function RecommendedContractsWidget(props: { contract: Contract }) {
const { contract } = props const { contract } = props
const user = useUser() const user = useUser()
@ -330,3 +293,4 @@ function RecommendedContractsWidget(props: { contract: Contract }) {
</Col> </Col>
) )
} }
)

View File

@ -4,7 +4,7 @@ import { useEffect } from 'react'
import Head from 'next/head' import Head from 'next/head'
import Script from 'next/script' import Script from 'next/script'
import { QueryClient, QueryClientProvider } from 'react-query' import { QueryClient, QueryClientProvider } from 'react-query'
import { AuthProvider } from 'web/components/auth-context' import { AuthProvider, AuthUser } from 'web/components/auth-context'
import Welcome from 'web/components/onboarding/welcome' import Welcome from 'web/components/onboarding/welcome'
function firstLine(msg: string) { function firstLine(msg: string) {
@ -24,7 +24,10 @@ function printBuildInfo() {
} }
} }
function MyApp({ Component, pageProps }: AppProps) { // specially treated props that may be present in the server/static props
type ManifoldPageProps = { auth?: AuthUser }
function MyApp({ Component, pageProps }: AppProps<ManifoldPageProps>) {
useEffect(printBuildInfo, []) useEffect(printBuildInfo, [])
return ( return (
@ -78,7 +81,7 @@ function MyApp({ Component, pageProps }: AppProps) {
</Head> </Head>
<AuthProvider serverUser={pageProps.auth}> <AuthProvider serverUser={pageProps.auth}>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<Welcome {...pageProps} /> <Welcome />
<Component {...pageProps} /> <Component {...pageProps} />
</QueryClientProvider> </QueryClientProvider>
</AuthProvider> </AuthProvider>

View File

@ -24,14 +24,14 @@ export default function AddFundsPage() {
return ( return (
<Page> <Page>
<SEO <SEO
title="Get Manifold Dollars" title="Get Mana"
description="Get Manifold Dollars" description="Buy mana to trade in your favorite markets on Manifold"
url="/add-funds" url="/add-funds"
/> />
<Col className="items-center"> <Col className="items-center">
<Col className="h-full rounded bg-white p-4 py-8 sm:p-8 sm:shadow-md"> <Col className="h-full rounded bg-white p-4 py-8 sm:p-8 sm:shadow-md">
<Title className="!mt-0" text="Get Manifold Dollars" /> <Title className="!mt-0" text="Get Mana" />
<img <img
className="mb-6 block -scale-x-100 self-center" className="mb-6 block -scale-x-100 self-center"
src="/stylized-crane-black.png" src="/stylized-crane-black.png"
@ -40,8 +40,8 @@ export default function AddFundsPage() {
/> />
<div className="mb-6 text-gray-500"> <div className="mb-6 text-gray-500">
Purchase Manifold Dollars to trade in your favorite markets. <br />{' '} Buy mana (M$) to trade in your favorite markets. <br /> (Not
(Not redeemable for cash.) redeemable for cash.)
</div> </div>
<div className="mb-2 text-sm text-gray-500">Amount</div> <div className="mb-2 text-sm text-gray-500">Amount</div>

View File

@ -92,7 +92,7 @@ export default function ChallengesListPage() {
tap the button above to create a new market & challenge in one. tap the button above to create a new market & challenge in one.
</p> </p>
<Tabs tabs={[...userTab, ...publicTab]} /> <Tabs className="mb-4" tabs={[...userTab, ...publicTab]} />
</Col> </Col>
</Page> </Page>
) )

View File

@ -34,20 +34,14 @@ export const getStaticProps = fromPropz(getStaticPropz)
export async function getStaticPropz(props: { export async function getStaticPropz(props: {
params: { username: string; contractSlug: string } params: { username: string; contractSlug: string }
}) { }) {
const { username, contractSlug } = props.params const { contractSlug } = props.params
const contract = (await getContractFromSlug(contractSlug)) || null const contract = (await getContractFromSlug(contractSlug)) || null
const contractId = contract?.id const contractId = contract?.id
const bets = contractId ? await listAllBets(contractId) : [] const bets = contractId ? await listAllBets(contractId) : []
return { return {
props: { props: { contract, bets },
contract,
username,
slug: contractSlug,
bets,
},
revalidate: 60, // regenerate after a minute revalidate: 60, // regenerate after a minute
} }
} }
@ -58,16 +52,9 @@ export async function getStaticPaths() {
export default function ContractEmbedPage(props: { export default function ContractEmbedPage(props: {
contract: Contract | null contract: Contract | null
username: string
bets: Bet[] bets: Bet[]
slug: string
}) { }) {
props = usePropz(props, getStaticPropz) ?? { props = usePropz(props, getStaticPropz) ?? { contract: null, bets: [] }
contract: null,
username: '',
bets: [],
slug: '',
}
const contract = useContractWithPreload(props.contract) const contract = useContractWithPreload(props.contract)
const { bets } = props const { bets } = props

View File

@ -1,7 +1,7 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import Link from 'next/link' import Link from 'next/link'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { toast, Toaster } from 'react-hot-toast' import { toast } from 'react-hot-toast'
import { Group, GROUP_CHAT_SLUG } from 'common/group' import { Group, GROUP_CHAT_SLUG } from 'common/group'
import { Contract, listContractsByGroupSlug } from 'web/lib/firebase/contracts' import { Contract, listContractsByGroupSlug } from 'web/lib/firebase/contracts'
@ -48,11 +48,11 @@ import { Spacer } from 'web/components/layout/spacer'
import { usePost } from 'web/hooks/use-post' import { usePost } from 'web/hooks/use-post'
import { useAdmin } from 'web/hooks/use-admin' import { useAdmin } from 'web/hooks/use-admin'
import { track } from '@amplitude/analytics-browser' import { track } from '@amplitude/analytics-browser'
import { GroupNavBar } from 'web/components/nav/group-nav-bar'
import { ArrowLeftIcon } from '@heroicons/react/solid' import { ArrowLeftIcon } from '@heroicons/react/solid'
import { GroupSidebar } from 'web/components/nav/group-sidebar'
import { SelectMarketsModal } from 'web/components/contract-select-modal' import { SelectMarketsModal } from 'web/components/contract-select-modal'
import { BETTORS } from 'common/user' import { BETTORS } from 'common/user'
import { Page } from 'web/components/page'
import { Tabs } from 'web/components/layout/tabs'
export const getStaticProps = fromPropz(getStaticPropz) export const getStaticProps = fromPropz(getStaticPropz)
export async function getStaticPropz(props: { params: { slugs: string[] } }) { export async function getStaticPropz(props: { params: { slugs: string[] } }) {
@ -140,10 +140,6 @@ export default function GroupPage(props: {
const user = useUser() const user = useUser()
const isAdmin = useAdmin() const isAdmin = useAdmin()
const memberIds = useMemberIds(group?.id ?? null) ?? props.memberIds const memberIds = useMemberIds(group?.id ?? null) ?? props.memberIds
// Note: Keep in sync with sidebarPages
const [sidebarIndex, setSidebarIndex] = useState(
['markets', 'leaderboards', 'about'].indexOf(page ?? 'markets')
)
useSaveReferral(user, { useSaveReferral(user, {
defaultReferrerUsername: creator.username, defaultReferrerUsername: creator.username,
@ -157,7 +153,7 @@ export default function GroupPage(props: {
const isMember = user && memberIds.includes(user.id) const isMember = user && memberIds.includes(user.id)
const maxLeaderboardSize = 50 const maxLeaderboardSize = 50
const leaderboardPage = ( const leaderboardTab = (
<Col> <Col>
<div className="mt-4 flex flex-col gap-8 px-4 md:flex-row"> <div className="mt-4 flex flex-col gap-8 px-4 md:flex-row">
<GroupLeaderboard <GroupLeaderboard
@ -176,7 +172,7 @@ export default function GroupPage(props: {
</Col> </Col>
) )
const aboutPage = ( const aboutTab = (
<Col> <Col>
{(group.aboutPostId != null || isCreator || isAdmin) && ( {(group.aboutPostId != null || isCreator || isAdmin) && (
<GroupAboutPost <GroupAboutPost
@ -196,10 +192,14 @@ export default function GroupPage(props: {
</Col> </Col>
) )
const questionsPage = ( const questionsTab = (
<> <>
{/* align the divs to the right */} <div className={'flex justify-end '}>
<div className={' flex justify-end px-2 pb-2 sm:hidden'}> <div
className={
'flex items-end justify-self-end px-2 md:absolute md:top-0 md:pb-2'
}
>
<div> <div>
<JoinOrAddQuestionsButtons <JoinOrAddQuestionsButtons
group={group} group={group}
@ -208,6 +208,7 @@ export default function GroupPage(props: {
/> />
</div> </div>
</div> </div>
</div>
<ContractSearch <ContractSearch
headerClassName="md:sticky" headerClassName="md:sticky"
user={user} user={user}
@ -215,92 +216,42 @@ export default function GroupPage(props: {
defaultFilter={suggestedFilter} defaultFilter={suggestedFilter}
additionalFilter={{ groupSlug: group.slug }} additionalFilter={{ groupSlug: group.slug }}
persistPrefix={`group-${group.slug}`} persistPrefix={`group-${group.slug}`}
includeProbSorts
/> />
</> </>
) )
const sidebarPages = [ const tabs = [
{ {
title: 'Markets', title: 'Markets',
content: questionsPage, content: questionsTab,
href: groupPath(group.slug, 'markets'),
key: 'markets',
}, },
{ {
title: 'Leaderboards', title: 'Leaderboards',
content: leaderboardPage, content: leaderboardTab,
href: groupPath(group.slug, 'leaderboards'),
key: 'leaderboards',
}, },
{ {
title: 'About', title: 'About',
content: aboutPage, content: aboutTab,
href: groupPath(group.slug, 'about'),
key: 'about',
}, },
] ]
const pageContent = sidebarPages[sidebarIndex].content
const onSidebarClick = (key: string) => {
const index = sidebarPages.findIndex((t) => t.key === key)
setSidebarIndex(index)
// Append the page to the URL, e.g. /group/mexifold/markets
router.replace(
{ query: { ...router.query, slugs: [group.slug, key] } },
undefined,
{ shallow: true }
)
}
const joinOrAddQuestionsButton = (
<JoinOrAddQuestionsButtons
group={group}
user={user}
isMember={!!isMember}
/>
)
return ( return (
<> <Page logoSubheading={group.name}>
<TopGroupNavBar
group={group}
currentPage={sidebarPages[sidebarIndex].key}
onClick={onSidebarClick}
/>
<div>
<div
className={
'mx-auto w-full pb-[58px] lg:grid lg:grid-cols-12 lg:gap-x-2 lg:pb-0 xl:max-w-7xl xl:gap-x-8'
}
>
<Toaster />
<GroupSidebar
groupName={group.name}
className="sticky top-0 hidden divide-gray-300 self-start pl-2 lg:col-span-2 lg:flex"
onClick={onSidebarClick}
joinOrAddQuestionsButton={joinOrAddQuestionsButton}
currentKey={sidebarPages[sidebarIndex].key}
/>
<SEO <SEO
title={group.name} title={group.name}
description={`Created by ${creator.name}. ${group.about}`} description={`Created by ${creator.name}. ${group.about}`}
url={groupPath(group.slug)} url={groupPath(group.slug)}
/> />
<main className={'px-2 pt-1 lg:col-span-8 lg:pt-6 xl:col-span-8'}> <TopGroupNavBar group={group} />
{pageContent} <div className={'relative p-2 pt-0 md:pt-2'}>
</main> <Tabs className={'mb-2'} tabs={tabs} />
</div> </div>
</div> </Page>
</>
) )
} }
export function TopGroupNavBar(props: { export function TopGroupNavBar(props: { group: Group }) {
group: Group
currentPage: string
onClick: (key: string) => void
}) {
return ( return (
<header className="sticky top-0 z-50 w-full border-b border-gray-200 md:hidden lg:col-span-12"> <header className="sticky top-0 z-50 w-full border-b border-gray-200 md:hidden lg:col-span-12">
<div className="flex items-center bg-white px-4"> <div className="flex items-center bg-white px-4">
@ -317,7 +268,6 @@ export function TopGroupNavBar(props: {
</h1> </h1>
</div> </div>
</div> </div>
<GroupNavBar currentPage={props.currentPage} onClick={props.onClick} />
</header> </header>
) )
} }
@ -330,11 +280,13 @@ function JoinOrAddQuestionsButtons(props: {
}) { }) {
const { group, user, isMember } = props const { group, user, isMember } = props
return user && isMember ? ( return user && isMember ? (
<Row className={'w-full self-start pt-4'}> <Row className={'mb-2 w-full self-start md:mt-2 '}>
<AddContractButton group={group} user={user} /> <AddContractButton group={group} user={user} />
</Row> </Row>
) : group.anyoneCanJoin ? ( ) : group.anyoneCanJoin ? (
<div className="mb-2 md:mb-0">
<JoinGroupButton group={group} user={user} /> <JoinGroupButton group={group} user={user} />
</div>
) : null ) : null
} }
@ -451,7 +403,7 @@ function GroupLeaderboard(props: {
return ( return (
<Leaderboard <Leaderboard
className="max-w-xl" className="max-w-xl"
users={topUsers.map((t) => t.user)} entries={topUsers.map((t) => t.user)}
title={title} title={title}
columns={[ columns={[
{ header, renderCell: (user) => formatMoney(scoresByUser[user.id]) }, { header, renderCell: (user) => formatMoney(scoresByUser[user.id]) },

View File

@ -99,6 +99,7 @@ export default function Groups(props: {
</div> </div>
<Tabs <Tabs
className="mb-4"
currentPageForAnalytics={'groups'} currentPageForAnalytics={'groups'}
tabs={[ tabs={[
...(user ...(user

View File

@ -81,7 +81,7 @@ export default function Leaderboards(_props: {
<Col className="mx-4 items-center gap-10 lg:flex-row"> <Col className="mx-4 items-center gap-10 lg:flex-row">
<Leaderboard <Leaderboard
title={`🏅 Top ${BETTORS}`} title={`🏅 Top ${BETTORS}`}
users={topTraders} entries={topTraders}
columns={[ columns={[
{ {
header: 'Total profit', header: 'Total profit',
@ -92,7 +92,7 @@ export default function Leaderboards(_props: {
<Leaderboard <Leaderboard
title="🏅 Top creators" title="🏅 Top creators"
users={topCreators} entries={topCreators}
columns={[ columns={[
{ {
header: 'Total bet', header: 'Total bet',
@ -106,7 +106,7 @@ export default function Leaderboards(_props: {
<Col className="mx-4 my-10 items-center gap-10 lg:mx-0 lg:w-1/2 lg:flex-row"> <Col className="mx-4 my-10 items-center gap-10 lg:mx-0 lg:w-1/2 lg:flex-row">
<Leaderboard <Leaderboard
title="🏅 Top followed" title="🏅 Top followed"
users={topFollowed} entries={topFollowed}
columns={[ columns={[
{ {
header: 'Total followers', header: 'Total followers',
@ -132,6 +132,7 @@ export default function Leaderboards(_props: {
/> />
<Title text={'Leaderboards'} className={'hidden md:block'} /> <Title text={'Leaderboards'} className={'hidden md:block'} />
<Tabs <Tabs
className="mb-4"
currentPageForAnalytics={'leaderboards'} currentPageForAnalytics={'leaderboards'}
defaultIndex={1} defaultIndex={1}
tabs={[ tabs={[

View File

@ -26,6 +26,7 @@ export default function Analytics() {
return ( return (
<Page> <Page>
<Tabs <Tabs
className="mb-4"
currentPageForAnalytics={'stats'} currentPageForAnalytics={'stats'}
tabs={[ tabs={[
{ {
@ -89,6 +90,7 @@ export function CustomAnalytics(props: Stats) {
<Spacer h={4} /> <Spacer h={4} />
<Tabs <Tabs
className="mb-4"
defaultIndex={1} defaultIndex={1}
tabs={[ tabs={[
{ {
@ -141,6 +143,7 @@ export function CustomAnalytics(props: Stats) {
period? period?
</p> </p>
<Tabs <Tabs
className="mb-4"
defaultIndex={1} defaultIndex={1}
tabs={[ tabs={[
{ {
@ -198,6 +201,7 @@ export function CustomAnalytics(props: Stats) {
<Spacer h={4} /> <Spacer h={4} />
<Tabs <Tabs
className="mb-4"
defaultIndex={2} defaultIndex={2}
tabs={[ tabs={[
{ {
@ -239,6 +243,7 @@ export function CustomAnalytics(props: Stats) {
<Title text="Daily activity" /> <Title text="Daily activity" />
<Tabs <Tabs
className="mb-4"
defaultIndex={0} defaultIndex={0}
tabs={[ tabs={[
{ {
@ -293,6 +298,7 @@ export function CustomAnalytics(props: Stats) {
<Spacer h={4} /> <Spacer h={4} />
<Tabs <Tabs
className="mb-4"
defaultIndex={1} defaultIndex={1}
tabs={[ tabs={[
{ {
@ -323,6 +329,7 @@ export function CustomAnalytics(props: Stats) {
<Title text="Ratio of Active Users" /> <Title text="Ratio of Active Users" />
<Tabs <Tabs
className="mb-4"
defaultIndex={1} defaultIndex={1}
tabs={[ tabs={[
{ {
@ -367,6 +374,7 @@ export function CustomAnalytics(props: Stats) {
Sum of bet amounts. (Divided by 100 to be more readable.) Sum of bet amounts. (Divided by 100 to be more readable.)
</p> </p>
<Tabs <Tabs
className="mb-4"
defaultIndex={1} defaultIndex={1}
tabs={[ tabs={[
{ {

View File

@ -83,14 +83,14 @@ const tourneys: Tourney[] = [
endTime: toDate('Sep 30, 2022'), endTime: toDate('Sep 30, 2022'),
groupId: 'fhksfIgqyWf7OxsV9nkM', groupId: 'fhksfIgqyWf7OxsV9nkM',
}, },
{ // {
title: 'Manifold F2P Tournament', // title: 'Manifold F2P Tournament',
blurb: // blurb:
'Who can amass the most mana starting from a free-to-play (F2P) account?', // 'Who can amass the most mana starting from a free-to-play (F2P) account?',
award: 'Poem', // award: 'Poem',
endTime: toDate('Sep 15, 2022'), // endTime: toDate('Sep 15, 2022'),
groupId: '6rrIja7tVW00lUVwtsYS', // groupId: '6rrIja7tVW00lUVwtsYS',
}, // },
// { // {
// title: 'Cause Exploration Prizes', // title: 'Cause Exploration Prizes',
// blurb: // blurb:

View File

@ -92,7 +92,7 @@ export function PostCommentInput(props: {
return ( return (
<CommentInput <CommentInput
replyToUser={replyToUser} replyTo={replyToUser}
parentCommentId={parentCommentId} parentCommentId={parentCommentId}
onSubmitComment={onSubmitComment} onSubmitComment={onSubmitComment}
/> />

175
yarn.lock
View File

@ -2476,10 +2476,10 @@
resolved "https://registry.yarnpkg.com/@mdx-js/util/-/util-1.6.22.tgz#219dfd89ae5b97a8801f015323ffa4b62f45718b" resolved "https://registry.yarnpkg.com/@mdx-js/util/-/util-1.6.22.tgz#219dfd89ae5b97a8801f015323ffa4b62f45718b"
integrity sha512-H1rQc1ZOHANWBvPcW+JpGwr+juXSxM8Q8YCkm3GhZd8REu1fHR3z99CErO1p9pkcfcxZnMdIZdIsXkOHY0NilA== integrity sha512-H1rQc1ZOHANWBvPcW+JpGwr+juXSxM8Q8YCkm3GhZd8REu1fHR3z99CErO1p9pkcfcxZnMdIZdIsXkOHY0NilA==
"@next/env@12.2.5": "@next/env@12.3.1":
version "12.2.5" version "12.3.1"
resolved "https://registry.yarnpkg.com/@next/env/-/env-12.2.5.tgz#d908c57b35262b94db3e431e869b72ac3e1ad3e3" resolved "https://registry.yarnpkg.com/@next/env/-/env-12.3.1.tgz#18266bd92de3b4aa4037b1927aa59e6f11879260"
integrity sha512-vLPLV3cpPGjUPT3PjgRj7e3nio9t6USkuew3JE/jMeon/9Mvp1WyR18v3iwnCuX7eUAm1HmAbJHHLAbcu/EJcw== integrity sha512-9P9THmRFVKGKt9DYqeC2aKIxm8rlvkK38V1P1sRE7qyoPBIs8l9oo79QoSdPtOWfzkbDAVUqvbQGgTMsb8BtJg==
"@next/eslint-plugin-next@12.1.6": "@next/eslint-plugin-next@12.1.6":
version "12.1.6" version "12.1.6"
@ -2488,70 +2488,70 @@
dependencies: dependencies:
glob "7.1.7" glob "7.1.7"
"@next/swc-android-arm-eabi@12.2.5": "@next/swc-android-arm-eabi@12.3.1":
version "12.2.5" version "12.3.1"
resolved "https://registry.yarnpkg.com/@next/swc-android-arm-eabi/-/swc-android-arm-eabi-12.2.5.tgz#903a5479ab4c2705d9c08d080907475f7bacf94d" resolved "https://registry.yarnpkg.com/@next/swc-android-arm-eabi/-/swc-android-arm-eabi-12.3.1.tgz#b15ce8ad376102a3b8c0f3c017dde050a22bb1a3"
integrity sha512-cPWClKxGhgn2dLWnspW+7psl3MoLQUcNqJqOHk2BhNcou9ARDtC0IjQkKe5qcn9qg7I7U83Gp1yh2aesZfZJMA== integrity sha512-i+BvKA8tB//srVPPQxIQN5lvfROcfv4OB23/L1nXznP+N/TyKL8lql3l7oo2LNhnH66zWhfoemg3Q4VJZSruzQ==
"@next/swc-android-arm64@12.2.5": "@next/swc-android-arm64@12.3.1":
version "12.2.5" version "12.3.1"
resolved "https://registry.yarnpkg.com/@next/swc-android-arm64/-/swc-android-arm64-12.2.5.tgz#2f9a98ec4166c7860510963b31bda1f57a77c792" resolved "https://registry.yarnpkg.com/@next/swc-android-arm64/-/swc-android-arm64-12.3.1.tgz#85d205f568a790a137cb3c3f720d961a2436ac9c"
integrity sha512-vMj0efliXmC5b7p+wfcQCX0AfU8IypjkzT64GiKJD9PgiA3IILNiGJr1fw2lyUDHkjeWx/5HMlMEpLnTsQslwg== integrity sha512-CmgU2ZNyBP0rkugOOqLnjl3+eRpXBzB/I2sjwcGZ7/Z6RcUJXK5Evz+N0ucOxqE4cZ3gkTeXtSzRrMK2mGYV8Q==
"@next/swc-darwin-arm64@12.2.5": "@next/swc-darwin-arm64@12.3.1":
version "12.2.5" version "12.3.1"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-12.2.5.tgz#31b1c3c659d54be546120c488a1e1bad21c24a1d" resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-12.3.1.tgz#b105457d6760a7916b27e46c97cb1a40547114ae"
integrity sha512-VOPWbO5EFr6snla/WcxUKtvzGVShfs302TEMOtzYyWni6f9zuOetijJvVh9CCTzInnXAZMtHyNhefijA4HMYLg== integrity sha512-hT/EBGNcu0ITiuWDYU9ur57Oa4LybD5DOQp4f22T6zLfpoBMfBibPtR8XktXmOyFHrL/6FC2p9ojdLZhWhvBHg==
"@next/swc-darwin-x64@12.2.5": "@next/swc-darwin-x64@12.3.1":
version "12.2.5" version "12.3.1"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-12.2.5.tgz#2e44dd82b2b7fef88238d1bc4d3bead5884cedfd" resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-12.3.1.tgz#6947b39082271378896b095b6696a7791c6e32b1"
integrity sha512-5o8bTCgAmtYOgauO/Xd27vW52G2/m3i5PX7MUYePquxXAnX73AAtqA3WgPXBRitEB60plSKZgOTkcpqrsh546A== integrity sha512-9S6EVueCVCyGf2vuiLiGEHZCJcPAxglyckTZcEwLdJwozLqN0gtS0Eq0bQlGS3dH49Py/rQYpZ3KVWZ9BUf/WA==
"@next/swc-freebsd-x64@12.2.5": "@next/swc-freebsd-x64@12.3.1":
version "12.2.5" version "12.3.1"
resolved "https://registry.yarnpkg.com/@next/swc-freebsd-x64/-/swc-freebsd-x64-12.2.5.tgz#e24e75d8c2581bfebc75e4f08f6ddbd116ce9dbd" resolved "https://registry.yarnpkg.com/@next/swc-freebsd-x64/-/swc-freebsd-x64-12.3.1.tgz#2b6c36a4d84aae8b0ea0e0da9bafc696ae27085a"
integrity sha512-yYUbyup1JnznMtEBRkK4LT56N0lfK5qNTzr6/DEyDw5TbFVwnuy2hhLBzwCBkScFVjpFdfiC6SQAX3FrAZzuuw== integrity sha512-qcuUQkaBZWqzM0F1N4AkAh88lLzzpfE6ImOcI1P6YeyJSsBmpBIV8o70zV+Wxpc26yV9vpzb+e5gCyxNjKJg5Q==
"@next/swc-linux-arm-gnueabihf@12.2.5": "@next/swc-linux-arm-gnueabihf@12.3.1":
version "12.2.5" version "12.3.1"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm-gnueabihf/-/swc-linux-arm-gnueabihf-12.2.5.tgz#46d8c514d834d2b5f67086013f0bd5e3081e10b9" resolved "https://registry.yarnpkg.com/@next/swc-linux-arm-gnueabihf/-/swc-linux-arm-gnueabihf-12.3.1.tgz#6e421c44285cfedac1f4631d5de330dd60b86298"
integrity sha512-2ZE2/G921Acks7UopJZVMgKLdm4vN4U0yuzvAMJ6KBavPzqESA2yHJlm85TV/K9gIjKhSk5BVtauIUntFRP8cg== integrity sha512-diL9MSYrEI5nY2wc/h/DBewEDUzr/DqBjIgHJ3RUNtETAOB3spMNHvJk2XKUDjnQuluLmFMloet9tpEqU2TT9w==
"@next/swc-linux-arm64-gnu@12.2.5": "@next/swc-linux-arm64-gnu@12.3.1":
version "12.2.5" version "12.3.1"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-12.2.5.tgz#91f725ac217d3a1f4f9f53b553615ba582fd3d9f" resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-12.3.1.tgz#8863f08a81f422f910af126159d2cbb9552ef717"
integrity sha512-/I6+PWVlz2wkTdWqhlSYYJ1pWWgUVva6SgX353oqTh8njNQp1SdFQuWDqk8LnM6ulheVfSsgkDzxrDaAQZnzjQ== integrity sha512-o/xB2nztoaC7jnXU3Q36vGgOolJpsGG8ETNjxM1VAPxRwM7FyGCPHOMk1XavG88QZSQf+1r+POBW0tLxQOJ9DQ==
"@next/swc-linux-arm64-musl@12.2.5": "@next/swc-linux-arm64-musl@12.3.1":
version "12.2.5" version "12.3.1"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-12.2.5.tgz#e627e8c867920995810250303cd9b8e963598383" resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-12.3.1.tgz#0038f07cf0b259d70ae0c80890d826dfc775d9f3"
integrity sha512-LPQRelfX6asXyVr59p5sTpx5l+0yh2Vjp/R8Wi4X9pnqcayqT4CUJLiHqCvZuLin3IsFdisJL0rKHMoaZLRfmg== integrity sha512-2WEasRxJzgAmP43glFNhADpe8zB7kJofhEAVNbDJZANp+H4+wq+/cW1CdDi8DqjkShPEA6/ejJw+xnEyDID2jg==
"@next/swc-linux-x64-gnu@12.2.5": "@next/swc-linux-x64-gnu@12.3.1":
version "12.2.5" version "12.3.1"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-12.2.5.tgz#83a5e224fbc4d119ef2e0f29d0d79c40cc43887e" resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-12.3.1.tgz#c66468f5e8181ffb096c537f0dbfb589baa6a9c1"
integrity sha512-0szyAo8jMCClkjNK0hknjhmAngUppoRekW6OAezbEYwHXN/VNtsXbfzgYOqjKWxEx3OoAzrT3jLwAF0HdX2MEw== integrity sha512-JWEaMyvNrXuM3dyy9Pp5cFPuSSvG82+yABqsWugjWlvfmnlnx9HOQZY23bFq3cNghy5V/t0iPb6cffzRWylgsA==
"@next/swc-linux-x64-musl@12.2.5": "@next/swc-linux-x64-musl@12.3.1":
version "12.2.5" version "12.3.1"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-12.2.5.tgz#be700d48471baac1ec2e9539396625584a317e95" resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-12.3.1.tgz#c6269f3e96ac0395bc722ad97ce410ea5101d305"
integrity sha512-zg/Y6oBar1yVnW6Il1I/08/2ukWtOG6s3acdJdEyIdsCzyQi4RLxbbhkD/EGQyhqBvd3QrC6ZXQEXighQUAZ0g== integrity sha512-xoEWQQ71waWc4BZcOjmatuvPUXKTv6MbIFzpm4LFeCHsg2iwai0ILmNXf81rJR+L1Wb9ifEke2sQpZSPNz1Iyg==
"@next/swc-win32-arm64-msvc@12.2.5": "@next/swc-win32-arm64-msvc@12.3.1":
version "12.2.5" version "12.3.1"
resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-12.2.5.tgz#a93e958133ad3310373fda33a79aa10af2a0aa97" resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-12.3.1.tgz#83c639ee969cee36ce247c3abd1d9df97b5ecade"
integrity sha512-3/90DRNSqeeSRMMEhj4gHHQlLhhKg5SCCoYfE3kBjGpE63EfnblYUqsszGGZ9ekpKL/R4/SGB40iCQr8tR5Jiw== integrity sha512-hswVFYQYIeGHE2JYaBVtvqmBQ1CppplQbZJS/JgrVI3x2CurNhEkmds/yqvDONfwfbttTtH4+q9Dzf/WVl3Opw==
"@next/swc-win32-ia32-msvc@12.2.5": "@next/swc-win32-ia32-msvc@12.3.1":
version "12.2.5" version "12.3.1"
resolved "https://registry.yarnpkg.com/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-12.2.5.tgz#4f5f7ba0a98ff89a883625d4af0125baed8b2e19" resolved "https://registry.yarnpkg.com/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-12.3.1.tgz#52995748b92aa8ad053440301bc2c0d9fbcf27c2"
integrity sha512-hGLc0ZRAwnaPL4ulwpp4D2RxmkHQLuI8CFOEEHdzZpS63/hMVzv81g8jzYA0UXbb9pus/iTc3VRbVbAM03SRrw== integrity sha512-Kny5JBehkTbKPmqulr5i+iKntO5YMP+bVM8Hf8UAmjSMVo3wehyLVc9IZkNmcbxi+vwETnQvJaT5ynYBkJ9dWA==
"@next/swc-win32-x64-msvc@12.2.5": "@next/swc-win32-x64-msvc@12.3.1":
version "12.2.5" version "12.3.1"
resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-12.2.5.tgz#20fed129b04a0d3f632c6d0de135345bb623b1e4" resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-12.3.1.tgz#27d71a95247a9eaee03d47adee7e3bd594514136"
integrity sha512-7h5/ahY7NeaO2xygqVrSG/Y8Vs4cdjxIjowTZ5W6CKoTKn7tmnuxlUc2h74x06FKmbhAd9agOjr/AOKyxYYm9Q== integrity sha512-W1ijvzzg+kPEX6LAc+50EYYSEo0FVu7dmTE+t+DM4iOLqgGHoW9uYSz9wCVdkXOEEMP9xhXfGpcSxsfDucyPkA==
"@nivo/annotations@0.74.0": "@nivo/annotations@0.74.0":
version "0.74.0" version "0.74.0"
@ -2933,10 +2933,10 @@
"@svgr/plugin-jsx" "^6.2.1" "@svgr/plugin-jsx" "^6.2.1"
"@svgr/plugin-svgo" "^6.2.0" "@svgr/plugin-svgo" "^6.2.0"
"@swc/helpers@0.4.3": "@swc/helpers@0.4.11":
version "0.4.3" version "0.4.11"
resolved "https://registry.yarnpkg.com/@swc/helpers/-/helpers-0.4.3.tgz#16593dfc248c53b699d4b5026040f88ddb497012" resolved "https://registry.yarnpkg.com/@swc/helpers/-/helpers-0.4.11.tgz#db23a376761b3d31c26502122f349a21b592c8de"
integrity sha512-6JrF+fdUK2zbGpJIlN7G3v966PQjyx/dPt1T9km2wj+EUBqgrxCk3uX4Kct16MIm9gGxfKRcfax2hVf5jvlTzA== integrity sha512-rEUrBSGIoSFuYxwBYtlUFMlE2CwGhmW+w9355/5oduSw8e5h2+Tj4UrAGNNgP9915++wj5vkQo0UuOBqOAq4nw==
dependencies: dependencies:
tslib "^2.4.0" tslib "^2.4.0"
@ -4545,6 +4545,11 @@ caniuse-lite@^1.0.30001230, caniuse-lite@^1.0.30001332:
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001341.tgz#59590c8ffa8b5939cf4161f00827b8873ad72498" resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001341.tgz#59590c8ffa8b5939cf4161f00827b8873ad72498"
integrity sha512-2SodVrFFtvGENGCv0ChVJIDQ0KPaS1cg7/qtfMaICgeMolDdo/Z2OD32F0Aq9yl6F4YFwGPBS5AaPqNYiW4PoA== integrity sha512-2SodVrFFtvGENGCv0ChVJIDQ0KPaS1cg7/qtfMaICgeMolDdo/Z2OD32F0Aq9yl6F4YFwGPBS5AaPqNYiW4PoA==
caniuse-lite@^1.0.30001406:
version "1.0.30001409"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001409.tgz#6135da9dcab34cd9761d9cdb12a68e6740c5e96e"
integrity sha512-V0mnJ5dwarmhYv8/MzhJ//aW68UpvnQBXv8lJ2QUsvn2pHcmAuNtu8hQEDz37XnA1iE+lRR9CIfGWWpgJ5QedQ==
ccount@^1.0.0, ccount@^1.0.3: ccount@^1.0.0, ccount@^1.0.3:
version "1.1.0" version "1.1.0"
resolved "https://registry.yarnpkg.com/ccount/-/ccount-1.1.0.tgz#246687debb6014735131be8abab2d93898f8d043" resolved "https://registry.yarnpkg.com/ccount/-/ccount-1.1.0.tgz#246687debb6014735131be8abab2d93898f8d043"
@ -8637,31 +8642,31 @@ next-sitemap@^2.5.14:
"@corex/deepmerge" "^2.6.148" "@corex/deepmerge" "^2.6.148"
minimist "^1.2.6" minimist "^1.2.6"
next@12.2.5: next@12.3.1:
version "12.2.5" version "12.3.1"
resolved "https://registry.yarnpkg.com/next/-/next-12.2.5.tgz#14fb5975e8841fad09553b8ef41fe1393602b717" resolved "https://registry.yarnpkg.com/next/-/next-12.3.1.tgz#127b825ad2207faf869b33393ec8c75fe61e50f1"
integrity sha512-tBdjqX5XC/oFs/6gxrZhjmiq90YWizUYU6qOWAfat7zJwrwapJ+BYgX2PmiacunXMaRpeVT4vz5MSPSLgNkrpA== integrity sha512-l7bvmSeIwX5lp07WtIiP9u2ytZMv7jIeB8iacR28PuUEFG5j0HGAPnMqyG5kbZNBG2H7tRsrQ4HCjuMOPnANZw==
dependencies: dependencies:
"@next/env" "12.2.5" "@next/env" "12.3.1"
"@swc/helpers" "0.4.3" "@swc/helpers" "0.4.11"
caniuse-lite "^1.0.30001332" caniuse-lite "^1.0.30001406"
postcss "8.4.14" postcss "8.4.14"
styled-jsx "5.0.4" styled-jsx "5.0.7"
use-sync-external-store "1.2.0" use-sync-external-store "1.2.0"
optionalDependencies: optionalDependencies:
"@next/swc-android-arm-eabi" "12.2.5" "@next/swc-android-arm-eabi" "12.3.1"
"@next/swc-android-arm64" "12.2.5" "@next/swc-android-arm64" "12.3.1"
"@next/swc-darwin-arm64" "12.2.5" "@next/swc-darwin-arm64" "12.3.1"
"@next/swc-darwin-x64" "12.2.5" "@next/swc-darwin-x64" "12.3.1"
"@next/swc-freebsd-x64" "12.2.5" "@next/swc-freebsd-x64" "12.3.1"
"@next/swc-linux-arm-gnueabihf" "12.2.5" "@next/swc-linux-arm-gnueabihf" "12.3.1"
"@next/swc-linux-arm64-gnu" "12.2.5" "@next/swc-linux-arm64-gnu" "12.3.1"
"@next/swc-linux-arm64-musl" "12.2.5" "@next/swc-linux-arm64-musl" "12.3.1"
"@next/swc-linux-x64-gnu" "12.2.5" "@next/swc-linux-x64-gnu" "12.3.1"
"@next/swc-linux-x64-musl" "12.2.5" "@next/swc-linux-x64-musl" "12.3.1"
"@next/swc-win32-arm64-msvc" "12.2.5" "@next/swc-win32-arm64-msvc" "12.3.1"
"@next/swc-win32-ia32-msvc" "12.2.5" "@next/swc-win32-ia32-msvc" "12.3.1"
"@next/swc-win32-x64-msvc" "12.2.5" "@next/swc-win32-x64-msvc" "12.3.1"
no-case@^3.0.4: no-case@^3.0.4:
version "3.0.4" version "3.0.4"
@ -11267,10 +11272,10 @@ style-to-object@0.3.0, style-to-object@^0.3.0:
dependencies: dependencies:
inline-style-parser "0.1.1" inline-style-parser "0.1.1"
styled-jsx@5.0.4: styled-jsx@5.0.7:
version "5.0.4" version "5.0.7"
resolved "https://registry.yarnpkg.com/styled-jsx/-/styled-jsx-5.0.4.tgz#5b1bd0b9ab44caae3dd1361295559706e044aa53" resolved "https://registry.yarnpkg.com/styled-jsx/-/styled-jsx-5.0.7.tgz#be44afc53771b983769ac654d355ca8d019dff48"
integrity sha512-sDFWLbg4zR+UkNzfk5lPilyIgtpddfxXEULxhujorr5jtePTUqiPDc5BC0v1NRqTr/WaFBGQQUoYToGlF4B2KQ== integrity sha512-b3sUzamS086YLRuvnaDigdAewz1/EFYlHpYBP5mZovKEdQQOIIYq8lApylub3HHZ6xFjV051kkGU7cudJmrXEA==
stylehacks@^5.1.0: stylehacks@^5.1.0:
version "5.1.0" version "5.1.0"