Merge branch 'main' into editor-tweet

This commit is contained in:
Austin Chen 2022-08-11 20:03:40 -07:00
commit fb75aa73ba
23 changed files with 318 additions and 197 deletions

View File

@ -25,6 +25,10 @@ export function isAdmin(email: string) {
return ENV_CONFIG.adminEmails.includes(email) return ENV_CONFIG.adminEmails.includes(email)
} }
export function isManifoldId(userId: string) {
return userId === 'IPTOzEqrpkWmEzh6hwvAyY9PqFb2'
}
export const DOMAIN = ENV_CONFIG.domain export const DOMAIN = ENV_CONFIG.domain
export const FIREBASE_CONFIG = ENV_CONFIG.firebaseConfig export const FIREBASE_CONFIG = ENV_CONFIG.firebaseConfig
export const PROJECT_ID = ENV_CONFIG.firebaseConfig.projectId export const PROJECT_ID = ENV_CONFIG.firebaseConfig.projectId

View File

@ -2,6 +2,7 @@ import { EnvConfig, PROD_CONFIG } from './prod'
export const DEV_CONFIG: EnvConfig = { export const DEV_CONFIG: EnvConfig = {
...PROD_CONFIG, ...PROD_CONFIG,
domain: 'dev.manifold.markets',
firebaseConfig: { firebaseConfig: {
apiKey: 'AIzaSyBoq3rzUa8Ekyo3ZaTnlycQYPRCA26VpOw', apiKey: 'AIzaSyBoq3rzUa8Ekyo3ZaTnlycQYPRCA26VpOw',
authDomain: 'dev-mantic-markets.firebaseapp.com', authDomain: 'dev-mantic-markets.firebaseapp.com',

View File

@ -528,6 +528,10 @@ $ curl https://manifold.markets/api/v0/bet -X POST -H 'Content-Type: application
"contractId":"{...}"}' "contractId":"{...}"}'
``` ```
### `POST /v0/bet/cancel/[id]`
Cancel the limit order of a bet with the specified id. If the bet was unfilled, it will be cancelled so that no other bets will match with it. This is action irreversable.
### `POST /v0/market` ### `POST /v0/market`
Creates a new market on behalf of the authorized user. Creates a new market on behalf of the authorized user.

View File

@ -18,7 +18,7 @@ import {
groupPayoutsByUser, groupPayoutsByUser,
Payout, Payout,
} from '../../common/payouts' } from '../../common/payouts'
import { isAdmin } from '../../common/envs/constants' import { isManifoldId } from '../../common/envs/constants'
import { removeUndefinedProps } from '../../common/util/object' import { removeUndefinedProps } from '../../common/util/object'
import { LiquidityProvision } from '../../common/liquidity-provision' import { LiquidityProvision } from '../../common/liquidity-provision'
import { APIError, newEndpoint, validate } from './api' import { APIError, newEndpoint, validate } from './api'
@ -82,7 +82,7 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => {
req.body req.body
) )
if (creatorId !== auth.uid && !isAdmin(auth.uid)) if (creatorId !== auth.uid && !isManifoldId(auth.uid))
throw new APIError(403, 'User is not creator of contract') throw new APIError(403, 'User is not creator of contract')
if (contract.resolution) throw new APIError(400, 'Contract already resolved') if (contract.resolution) throw new APIError(400, 'Contract already resolved')

View File

@ -1,26 +1,23 @@
import { MAX_ANSWER_LENGTH } from 'common/answer' import { MAX_ANSWER_LENGTH } from 'common/answer'
import { useState } from 'react'
import Textarea from 'react-expanding-textarea' import Textarea from 'react-expanding-textarea'
import { XIcon } from '@heroicons/react/solid' import { XIcon } from '@heroicons/react/solid'
import { Col } from '../layout/col' import { Col } from '../layout/col'
import { Row } from '../layout/row' import { Row } from '../layout/row'
export function MultipleChoiceAnswers(props: { export function MultipleChoiceAnswers(props: {
answers: string[]
setAnswers: (answers: string[]) => void setAnswers: (answers: string[]) => void
}) { }) {
const [answers, setInternalAnswers] = useState(['', '', '']) const { answers, setAnswers } = props
const setAnswer = (i: number, answer: string) => { const setAnswer = (i: number, answer: string) => {
const newAnswers = setElement(answers, i, answer) const newAnswers = setElement(answers, i, answer)
setInternalAnswers(newAnswers) setAnswers(newAnswers)
props.setAnswers(newAnswers)
} }
const removeAnswer = (i: number) => { const removeAnswer = (i: number) => {
const newAnswers = answers.slice(0, i).concat(answers.slice(i + 1)) const newAnswers = answers.slice(0, i).concat(answers.slice(i + 1))
setInternalAnswers(newAnswers) setAnswers(newAnswers)
props.setAnswers(newAnswers)
} }
const addAnswer = () => setAnswer(answers.length, '') const addAnswer = () => setAnswer(answers.length, '')
@ -40,10 +37,10 @@ export function MultipleChoiceAnswers(props: {
/> />
{answers.length > 2 && ( {answers.length > 2 && (
<button <button
className="btn btn-xs btn-outline ml-2" className="-mr-2 rounded p-2"
onClick={() => removeAnswer(i)} onClick={() => removeAnswer(i)}
> >
<XIcon className="h-4 w-4 flex-shrink-0" /> <XIcon className="h-5 w-5 flex-shrink-0" />
</button> </button>
)} )}
</Row> </Row>

View File

@ -22,7 +22,10 @@ export function ChoicesToggleGroup(props: {
} = props } = props
return ( return (
<RadioGroup <RadioGroup
className={clsx(className, 'flex flex-row flex-wrap items-center gap-3')} className={clsx(
className,
'flex flex-row flex-wrap items-center gap-2 sm:gap-3'
)}
value={currentChoice.toString()} value={currentChoice.toString()}
onChange={setChoice} onChange={setChoice}
> >

View File

@ -3,14 +3,17 @@ import algoliasearch from 'algoliasearch/lite'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
import { User } from 'common/user' import { User } from 'common/user'
import { Sort, useQueryAndSortParams } from '../hooks/use-sort-and-query-params' import {
QuerySortOptions,
Sort,
useQueryAndSortParams,
} from '../hooks/use-sort-and-query-params'
import { import {
ContractHighlightOptions, ContractHighlightOptions,
ContractsGrid, ContractsGrid,
} from './contract/contracts-grid' } from './contract/contracts-grid'
import { Row } from './layout/row' import { Row } from './layout/row'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { Spacer } from './layout/spacer'
import { ENV, IS_PRIVATE_MANIFOLD } from 'common/envs/constants' import { ENV, IS_PRIVATE_MANIFOLD } from 'common/envs/constants'
import { useFollows } from 'web/hooks/use-follows' import { useFollows } from 'web/hooks/use-follows'
import { track, trackCallback } from 'web/lib/service/analytics' import { track, trackCallback } from 'web/lib/service/analytics'
@ -21,6 +24,7 @@ import { PillButton } from './buttons/pill-button'
import { range, sortBy } from 'lodash' import { range, sortBy } from 'lodash'
import { DEFAULT_CATEGORY_GROUPS } from 'common/categories' import { DEFAULT_CATEGORY_GROUPS } from 'common/categories'
import { Col } from './layout/col' import { Col } from './layout/col'
import clsx from 'clsx'
const searchClient = algoliasearch( const searchClient = algoliasearch(
'GJQPAYENIF', 'GJQPAYENIF',
@ -45,12 +49,8 @@ export const DEFAULT_SORT = 'score'
type filter = 'personal' | 'open' | 'closed' | 'resolved' | 'all' type filter = 'personal' | 'open' | 'closed' | 'resolved' | 'all'
export function ContractSearch(props: { export function ContractSearch(props: {
user: User | null | undefined user?: User | null
querySortOptions?: { querySortOptions?: { defaultFilter?: filter } & QuerySortOptions
defaultSort: Sort
defaultFilter?: filter
shouldLoadFromStorage?: boolean
}
additionalFilter?: { additionalFilter?: {
creatorId?: string creatorId?: string
tag?: string tag?: string
@ -66,6 +66,7 @@ export function ContractSearch(props: {
hideGroupLink?: boolean hideGroupLink?: boolean
hideQuickBet?: boolean hideQuickBet?: boolean
} }
headerClassName?: string
}) { }) {
const { const {
user, user,
@ -77,6 +78,7 @@ export function ContractSearch(props: {
showPlaceHolder, showPlaceHolder,
cardHideOptions, cardHideOptions,
highlightOptions, highlightOptions,
headerClassName,
} = props } = props
const memberGroups = (useMemberGroups(user?.id) ?? []).filter( const memberGroups = (useMemberGroups(user?.id) ?? []).filter(
@ -99,11 +101,8 @@ export function ContractSearch(props: {
const follows = useFollows(user?.id) const follows = useFollows(user?.id)
const { shouldLoadFromStorage, defaultSort } = querySortOptions ?? {} const { query, setQuery, sort, setSort } =
const { query, setQuery, sort, setSort } = useQueryAndSortParams({ useQueryAndSortParams(querySortOptions)
defaultSort,
shouldLoadFromStorage,
})
const [filter, setFilter] = useState<filter>( const [filter, setFilter] = useState<filter>(
querySortOptions?.defaultFilter ?? 'open' querySortOptions?.defaultFilter ?? 'open'
@ -257,87 +256,90 @@ export function ContractSearch(props: {
} }
return ( return (
<Col> <Col className="h-full">
<Row className="gap-1 sm:gap-2"> <Col
<input className={clsx(
type="text" 'bg-base-200 sticky top-0 z-20 gap-3 pb-3',
value={query} headerClassName
onChange={(e) => updateQuery(e.target.value)}
onBlur={trackCallback('search', { query })}
placeholder={showPlaceHolder ? `Search ${filter} markets` : ''}
className="input input-bordered w-full"
/>
{!query && (
<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 && !query && ( >
<select <Row className="gap-1 sm:gap-2">
className="select select-bordered" <input
value={sort} type="text"
onChange={(e) => selectSort(e.target.value as Sort)} value={query}
> onChange={(e) => updateQuery(e.target.value)}
{sortOptions.map((option) => ( onBlur={trackCallback('search', { query })}
<option key={option.value} value={option.value}> placeholder={showPlaceHolder ? `Search ${filter} markets` : ''}
{option.label} className="input input-bordered w-full"
</option> />
))} {!query && (
</select> <select
)} className="select select-bordered"
</Row> value={filter}
onChange={(e) => selectFilter(e.target.value as filter)}
<Spacer h={3} />
{pillsEnabled && (
<Row className="scrollbar-hide items-start gap-2 overflow-x-auto">
<PillButton
key={'all'}
selected={pillFilter === undefined}
onSelect={selectPill(undefined)}
>
All
</PillButton>
<PillButton
key={'personal'}
selected={pillFilter === 'personal'}
onSelect={selectPill('personal')}
>
{user ? 'For you' : 'Featured'}
</PillButton>
{user && (
<PillButton
key={'your-bets'}
selected={pillFilter === 'your-bets'}
onSelect={selectPill('your-bets')}
> >
Your bets <option value="open">Open</option>
</PillButton> <option value="closed">Closed</option>
<option value="resolved">Resolved</option>
<option value="all">All</option>
</select>
)}
{!hideOrderSelector && !query && (
<select
className="select select-bordered"
value={sort}
onChange={(e) => selectSort(e.target.value as Sort)}
>
{sortOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
)} )}
{pillGroups.map(({ name, slug }) => {
return (
<PillButton
key={slug}
selected={pillFilter === slug}
onSelect={selectPill(slug)}
>
{name}
</PillButton>
)
})}
</Row> </Row>
)}
<Spacer h={3} /> {pillsEnabled && (
<Row className="scrollbar-hide items-start gap-2 overflow-x-auto">
<PillButton
key={'all'}
selected={pillFilter === undefined}
onSelect={selectPill(undefined)}
>
All
</PillButton>
<PillButton
key={'personal'}
selected={pillFilter === 'personal'}
onSelect={selectPill('personal')}
>
{user ? 'For you' : 'Featured'}
</PillButton>
{user && (
<PillButton
key={'your-bets'}
selected={pillFilter === 'your-bets'}
onSelect={selectPill('your-bets')}
>
Your bets
</PillButton>
)}
{pillGroups.map(({ name, slug }) => {
return (
<PillButton
key={slug}
selected={pillFilter === slug}
onSelect={selectPill(slug)}
>
{name}
</PillButton>
)
})}
</Row>
)}
</Col>
{filter === 'personal' && {filter === 'personal' &&
(follows ?? []).length === 0 && (follows ?? []).length === 0 &&

View File

@ -76,7 +76,8 @@ export function ContractCard(props: {
<Col className="relative flex-1 gap-3 pr-1"> <Col className="relative flex-1 gap-3 pr-1">
<div <div
className={clsx( className={clsx(
'peer absolute -left-6 -top-4 -bottom-4 right-0 z-10' 'peer absolute -left-6 -top-4 -bottom-4 z-10',
hideQuickBet ? '-right-20' : 'right-0'
)} )}
> >
{onClick ? ( {onClick ? (

View File

@ -13,7 +13,7 @@ import { TextEditor, useTextEditor } from 'web/components/editor'
import { Button } from '../button' import { Button } from '../button'
import { Spacer } from '../layout/spacer' import { Spacer } from '../layout/spacer'
import { Editor, Content as ContentType } from '@tiptap/react' import { Editor, Content as ContentType } from '@tiptap/react'
import { appendToEditor } from '../editor/utils' import { insertContent } from '../editor/utils'
export function ContractDescription(props: { export function ContractDescription(props: {
contract: Contract contract: Contract
@ -95,7 +95,8 @@ function RichEditContract(props: { contract: Contract; isAdmin?: boolean }) {
size="xs" size="xs"
onClick={() => { onClick={() => {
setEditing(true) setEditing(true)
appendToEditor(editor, `<p>${editTimestamp()}</p>`) editor?.commands.focus('end')
insertContent(editor, `<p>${editTimestamp()}</p>`)
}} }}
> >
Edit description Edit description
@ -127,7 +128,7 @@ function EditQuestion(props: {
function joinContent(oldContent: ContentType, newContent: string) { function joinContent(oldContent: ContentType, newContent: string) {
const editor = new Editor({ content: oldContent, extensions: exhibitExts }) const editor = new Editor({ content: oldContent, extensions: exhibitExts })
appendToEditor(editor, newContent) insertContent(editor, newContent)
return editor.getJSON() return editor.getJSON()
} }

View File

@ -33,7 +33,7 @@ import { Col } from 'web/components/layout/col'
import { ContractGroupsList } from 'web/components/groups/contract-groups-list' import { ContractGroupsList } from 'web/components/groups/contract-groups-list'
import { SiteLink } from 'web/components/site-link' import { SiteLink } from 'web/components/site-link'
import { groupPath } from 'web/lib/firebase/groups' import { groupPath } from 'web/lib/firebase/groups'
import { appendToEditor } from '../editor/utils' import { insertContent } from '../editor/utils'
export type ShowTime = 'resolve-date' | 'close-date' export type ShowTime = 'resolve-date' | 'close-date'
@ -149,7 +149,7 @@ export function ContractDetails(props: {
const groupInfo = ( const groupInfo = (
<Row> <Row>
<UserGroupIcon className="mx-1 inline h-5 w-5 shrink-0" /> <UserGroupIcon className="mx-1 inline h-5 w-5 shrink-0" />
<span className={'line-clamp-1'}> <span className="truncate">
{groupToDisplay ? groupToDisplay.name : 'No group'} {groupToDisplay ? groupToDisplay.name : 'No group'}
</span> </span>
</Row> </Row>
@ -283,7 +283,8 @@ function EditableCloseDate(props: {
const formattedCloseDate = dayjs(newCloseTime).format('YYYY-MM-DD h:mm a') const formattedCloseDate = dayjs(newCloseTime).format('YYYY-MM-DD h:mm a')
const editor = new Editor({ content, extensions: exhibitExts }) const editor = new Editor({ content, extensions: exhibitExts })
appendToEditor( editor.commands.focus('end')
insertContent(
editor, editor,
`<br><p>Close date updated to ${formattedCloseDate}</p>` `<br><p>Close date updated to ${formattedCloseDate}</p>`
) )

View File

@ -8,18 +8,17 @@ 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 { CommentTipMap } from 'web/hooks/use-tip-txns' import { CommentTipMap } from 'web/hooks/use-tip-txns'
import { LiquidityProvision } from 'common/liquidity-provision'
import { useComments } from 'web/hooks/use-comments' import { useComments } from 'web/hooks/use-comments'
import { useLiquidity } from 'web/hooks/use-liquidity'
export function ContractTabs(props: { export function ContractTabs(props: {
contract: Contract contract: Contract
user: User | null | undefined user: User | null | undefined
bets: Bet[] bets: Bet[]
liquidityProvisions: LiquidityProvision[]
comments: Comment[] comments: Comment[]
tips: CommentTipMap tips: CommentTipMap
}) { }) {
const { contract, user, bets, tips, liquidityProvisions } = props const { contract, user, bets, tips } = props
const { outcomeType } = contract const { outcomeType } = contract
const userBets = user && bets.filter((bet) => bet.userId === user.id) const userBets = user && bets.filter((bet) => bet.userId === user.id)
@ -27,6 +26,9 @@ export function ContractTabs(props: {
(bet) => !bet.isAnte && !bet.isRedemption && bet.amount !== 0 (bet) => !bet.isAnte && !bet.isRedemption && bet.amount !== 0
) )
const liquidityProvisions =
useLiquidity(contract.id)?.filter((l) => !l.isAnte && l.amount > 0) ?? []
// Load comments here, so the badge count will be correct // Load comments here, so the badge count will be correct
const updatedComments = useComments(contract.id) const updatedComments = useComments(contract.id)
const comments = updatedComments ?? props.comments const comments = updatedComments ?? props.comments

View File

@ -22,8 +22,14 @@ import { mentionSuggestion } from './editor/mention-suggestion'
import { DisplayMention } from './editor/mention' import { DisplayMention } from './editor/mention'
import Iframe from 'common/util/tiptap-iframe' import Iframe from 'common/util/tiptap-iframe'
import TiptapTweet from './editor/tiptap-tweet' import TiptapTweet from './editor/tiptap-tweet'
import { CodeIcon, PhotographIcon } from '@heroicons/react/solid'
import { EmbedModal } from './editor/embed-modal' import { EmbedModal } from './editor/embed-modal'
import {
CodeIcon,
PhotographIcon,
PresentationChartLineIcon,
} from '@heroicons/react/solid'
import { MarketModal } from './editor/market-modal'
import { insertContent } from './editor/utils'
const DisplayImage = Image.configure({ const DisplayImage = Image.configure({
HTMLAttributes: { HTMLAttributes: {
@ -103,7 +109,7 @@ export function useTextEditor(props: {
// If the pasted content is iframe code, directly inject it // If the pasted content is iframe code, directly inject it
const text = event.clipboardData?.getData('text/plain').trim() ?? '' const text = event.clipboardData?.getData('text/plain').trim() ?? ''
if (isValidIframe(text)) { if (isValidIframe(text)) {
editor.chain().insertContent(text).run() insertContent(editor, text)
return true // Prevent the code from getting pasted as text return true // Prevent the code from getting pasted as text
} }
@ -130,6 +136,7 @@ export function TextEditor(props: {
}) { }) {
const { editor, upload, children } = props const { editor, upload, children } = props
const [iframeOpen, setIframeOpen] = useState(false) const [iframeOpen, setIframeOpen] = useState(false)
const [marketOpen, setMarketOpen] = useState(false)
return ( return (
<> <>
@ -139,16 +146,15 @@ export function TextEditor(props: {
<EditorContent editor={editor} /> <EditorContent editor={editor} />
{/* Toolbar, with buttons for images and embeds */} {/* Toolbar, with buttons for images and embeds */}
<div className="flex h-9 items-center gap-5 pl-4 pr-1"> <div className="flex h-9 items-center gap-5 pl-4 pr-1">
<div className="flex items-center"> <div className="tooltip flex items-center" data-tip="Add image">
<FileUploadButton <FileUploadButton
onFiles={upload.mutate} onFiles={upload.mutate}
className="-m-2.5 flex h-10 w-10 items-center justify-center rounded-full text-gray-400 hover:text-gray-500" className="-m-2.5 flex h-10 w-10 items-center justify-center rounded-full text-gray-400 hover:text-gray-500"
> >
<PhotographIcon className="h-5 w-5" aria-hidden="true" /> <PhotographIcon className="h-5 w-5" aria-hidden="true" />
<span className="sr-only">Upload an image</span>
</FileUploadButton> </FileUploadButton>
</div> </div>
<div className="flex items-center"> <div className="tooltip flex items-center" data-tip="Add embed">
<button <button
type="button" type="button"
onClick={() => setIframeOpen(true)} onClick={() => setIframeOpen(true)}
@ -160,7 +166,23 @@ export function TextEditor(props: {
setOpen={setIframeOpen} setOpen={setIframeOpen}
/> />
<CodeIcon className="h-5 w-5" aria-hidden="true" /> <CodeIcon className="h-5 w-5" aria-hidden="true" />
<span className="sr-only">Embed an iframe</span> </button>
</div>
<div className="tooltip flex items-center" data-tip="Add market">
<button
type="button"
onClick={() => setMarketOpen(true)}
className="-m-2.5 flex h-10 w-10 items-center justify-center rounded-full text-gray-400 hover:text-gray-500"
>
<MarketModal
editor={editor}
open={marketOpen}
setOpen={setMarketOpen}
/>
<PresentationChartLineIcon
className="h-5 w-5"
aria-hidden="true"
/>
</button> </button>
</div> </div>
{/* Spacer that also focuses editor on click */} {/* Spacer that also focuses editor on click */}

View File

@ -0,0 +1,86 @@
import { Editor } from '@tiptap/react'
import { Contract } from 'common/contract'
import { useState } from 'react'
import { Button } from '../button'
import { ContractSearch } from '../contract-search'
import { Col } from '../layout/col'
import { Modal } from '../layout/modal'
import { Row } from '../layout/row'
import { LoadingIndicator } from '../loading-indicator'
import { embedCode } from '../share-embed-button'
import { insertContent } from './utils'
export function MarketModal(props: {
editor: Editor | null
open: boolean
setOpen: (open: boolean) => void
}) {
const { editor, open, setOpen } = props
const [contracts, setContracts] = useState<Contract[]>([])
const [loading, setLoading] = useState(false)
async function addContract(contract: Contract) {
if (contracts.map((c) => c.id).includes(contract.id)) {
setContracts(contracts.filter((c) => c.id !== contract.id))
} else setContracts([...contracts, contract])
}
async function doneAddingContracts() {
setLoading(true)
insertContent(editor, ...contracts.map(embedCode))
setLoading(false)
setOpen(false)
setContracts([])
}
return (
<Modal open={open} setOpen={setOpen} className={'sm:p-0'} size={'lg'}>
<Col className="h-[85vh] w-full gap-4 rounded-md bg-white">
<Row className="p-8 pb-0">
<div className={'text-xl text-indigo-700'}>Embed a market</div>
{!loading && (
<Row className="grow justify-end gap-4">
{contracts.length > 0 && (
<Button onClick={doneAddingContracts} color={'indigo'}>
Embed {contracts.length} question
{contracts.length > 1 && 's'}
</Button>
)}
<Button onClick={() => setContracts([])} color="gray">
Cancel
</Button>
</Row>
)}
</Row>
{loading && (
<div className="w-full justify-center">
<LoadingIndicator />
</div>
)}
<div className="overflow-y-scroll sm:px-8">
<ContractSearch
hideOrderSelector
onContractClick={addContract}
overrideGridClassName={
'flex grid grid-cols-1 sm:grid-cols-2 flex-col gap-3 p-1'
}
showPlaceHolder
cardHideOptions={{ hideGroupLink: true, hideQuickBet: true }}
querySortOptions={{ disableQueryString: true }}
highlightOptions={{
contractIds: contracts.map((c) => c.id),
highlightClassName:
'!bg-indigo-100 outline outline-2 outline-indigo-300',
}}
additionalFilter={{}} /* hide pills */
headerClassName="bg-white"
/>
</div>
</Col>
</Modal>
)
}

View File

@ -1,10 +1,13 @@
import { Editor, Content } from '@tiptap/react' import { Editor, Content } from '@tiptap/react'
export function appendToEditor(editor: Editor | null, content: Content) { export function insertContent(editor: Editor | null, ...contents: Content[]) {
editor if (!editor) {
?.chain() return
.focus('end') }
.createParagraphNear()
.insertContent(content) let e = editor.chain()
.run() for (const content of contents) {
e = e.createParagraphNear().insertContent(content)
}
e.run()
} }

View File

@ -0,0 +1,7 @@
import Confetti, { Props as ConfettiProps } from 'react-confetti'
import { useWindowSize } from 'web/hooks/use-window-size'
export function FullscreenConfetti(props: ConfettiProps) {
const { width, height } = useWindowSize()
return <Confetti {...props} width={width} height={height} />
}

View File

@ -21,6 +21,7 @@ import { Content, useTextEditor } from 'web/components/editor'
import { useUnseenPreferredNotifications } from 'web/hooks/use-notifications' import { useUnseenPreferredNotifications } from 'web/hooks/use-notifications'
import { ChevronDownIcon, UsersIcon } from '@heroicons/react/outline' import { ChevronDownIcon, UsersIcon } from '@heroicons/react/outline'
import { setNotificationsAsSeen } from 'web/pages/notifications' import { setNotificationsAsSeen } from 'web/pages/notifications'
import { usePrivateUser } from 'web/hooks/use-user'
export function GroupChat(props: { export function GroupChat(props: {
messages: Comment[] messages: Comment[]
@ -29,6 +30,9 @@ export function GroupChat(props: {
tips: CommentTipMap tips: CommentTipMap
}) { }) {
const { messages, user, group, tips } = props const { messages, user, group, tips } = props
const privateUser = usePrivateUser(user?.id)
const { editor, upload } = useTextEditor({ const { editor, upload } = useTextEditor({
simple: true, simple: true,
placeholder: 'Send a message', placeholder: 'Send a message',
@ -175,6 +179,15 @@ export function GroupChat(props: {
</div> </div>
</div> </div>
)} )}
{privateUser && (
<GroupChatNotificationsIcon
group={group}
privateUser={privateUser}
shouldSetAsSeen={true}
hidden={true}
/>
)}
</Col> </Col>
) )
} }
@ -248,6 +261,7 @@ export function GroupChatInBubble(props: {
group={group} group={group}
privateUser={privateUser} privateUser={privateUser}
shouldSetAsSeen={shouldShowChat} shouldSetAsSeen={shouldShowChat}
hidden={false}
/> />
)} )}
</button> </button>
@ -259,8 +273,9 @@ function GroupChatNotificationsIcon(props: {
group: Group group: Group
privateUser: PrivateUser privateUser: PrivateUser
shouldSetAsSeen: boolean shouldSetAsSeen: boolean
hidden: boolean
}) { }) {
const { privateUser, group, shouldSetAsSeen } = props const { privateUser, group, shouldSetAsSeen, hidden } = props
const preferredNotificationsForThisGroup = useUnseenPreferredNotifications( const preferredNotificationsForThisGroup = useUnseenPreferredNotifications(
privateUser, privateUser,
{ {
@ -282,7 +297,9 @@ function GroupChatNotificationsIcon(props: {
return ( return (
<div <div
className={ className={
preferredNotificationsForThisGroup.length > 0 && !shouldSetAsSeen !hidden &&
preferredNotificationsForThisGroup.length > 0 &&
!shouldSetAsSeen
? 'absolute right-4 top-4 h-3 w-3 rounded-full border-2 border-white bg-red-500' ? 'absolute right-4 top-4 h-3 w-3 rounded-full border-2 border-white bg-red-500'
: 'hidden' : 'hidden'
} }

View File

@ -28,7 +28,6 @@ export function ControlledTabs(props: TabProps & { activeIndex: number }) {
className, className,
currentPageForAnalytics, currentPageForAnalytics,
} = props } = props
const activeTab = tabs[activeIndex] as Tab | undefined // can be undefined in weird case
return ( return (
<> <>
<nav <nav
@ -64,7 +63,11 @@ export function ControlledTabs(props: TabProps & { activeIndex: number }) {
</a> </a>
))} ))}
</nav> </nav>
{activeTab?.content} {tabs.map((tab, i) => (
<div key={i} className={i === activeIndex ? 'block' : 'hidden'}>
{tab.content}
</div>
))}
</> </>
) )
} }

View File

@ -9,13 +9,11 @@ import { copyToClipboard } from 'web/lib/util/copy'
import { ToastClipboard } from 'web/components/toast-clipboard' import { ToastClipboard } from 'web/components/toast-clipboard'
import { track } from 'web/lib/service/analytics' import { track } from 'web/lib/service/analytics'
function copyEmbedCode(contract: Contract) { export function embedCode(contract: Contract) {
const title = contract.question const title = contract.question
const src = `https://${DOMAIN}/embed${contractPath(contract)}` const src = `https://${DOMAIN}/embed${contractPath(contract)}`
const embedCode = `<iframe width="560" height="405" src="${src}" title="${title}" frameborder="0"></iframe>` return `<iframe width="560" height="405" src="${src}" title="${title}" frameborder="0"></iframe>`
copyToClipboard(embedCode)
} }
export function ShareEmbedButton(props: { export function ShareEmbedButton(props: {
@ -29,7 +27,7 @@ export function ShareEmbedButton(props: {
as="div" as="div"
className="relative z-10 flex-shrink-0" className="relative z-10 flex-shrink-0"
onMouseUp={() => { onMouseUp={() => {
copyEmbedCode(contract) copyToClipboard(embedCode(contract))
track('copy embed code') track('copy embed code')
}} }}
> >

View File

@ -4,14 +4,8 @@ import { useEffect, useState } from 'react'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { LinkIcon } from '@heroicons/react/solid' import { LinkIcon } from '@heroicons/react/solid'
import { PencilIcon } from '@heroicons/react/outline' import { PencilIcon } from '@heroicons/react/outline'
import Confetti from 'react-confetti'
import { import { getPortfolioHistory, User } from 'web/lib/firebase/users'
follow,
getPortfolioHistory,
unfollow,
User,
} from 'web/lib/firebase/users'
import { CreatorContractsList } from './contract/contracts-grid' import { CreatorContractsList } from './contract/contracts-grid'
import { SEO } from './SEO' import { SEO } from './SEO'
import { Page } from './page' import { Page } from './page'
@ -24,15 +18,14 @@ import { Row } from './layout/row'
import { genHash } from 'common/util/random' import { genHash } from 'common/util/random'
import { QueryUncontrolledTabs } from './layout/tabs' import { QueryUncontrolledTabs } from './layout/tabs'
import { UserCommentsList } from './comments-list' import { UserCommentsList } from './comments-list'
import { useWindowSize } from 'web/hooks/use-window-size'
import { Comment, getUsersComments } from 'web/lib/firebase/comments' import { Comment, getUsersComments } from 'web/lib/firebase/comments'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
import { getContractFromId, listContracts } from 'web/lib/firebase/contracts' import { getContractFromId, listContracts } from 'web/lib/firebase/contracts'
import { LoadingIndicator } from './loading-indicator' import { LoadingIndicator } from './loading-indicator'
import { FullscreenConfetti } from 'web/components/fullscreen-confetti'
import { BetsList } from './bets-list' import { BetsList } from './bets-list'
import { FollowersButton, FollowingButton } from './following-button' import { FollowersButton, FollowingButton } from './following-button'
import { useFollows } from 'web/hooks/use-follows' import { UserFollowButton } from './follow-button'
import { FollowButton } from './follow-button'
import { PortfolioMetrics } from 'common/user' import { PortfolioMetrics } from 'common/user'
import { GroupsButton } from 'web/components/groups/groups-button' import { GroupsButton } from 'web/components/groups/groups-button'
import { PortfolioValueSection } from './portfolio/portfolio-value-section' import { PortfolioValueSection } from './portfolio/portfolio-value-section'
@ -88,7 +81,6 @@ export function UserPage(props: { user: User; currentUser?: User }) {
Dictionary<Contract> | undefined Dictionary<Contract> | undefined
>() >()
const [showConfetti, setShowConfetti] = useState(false) const [showConfetti, setShowConfetti] = useState(false)
const { width, height } = useWindowSize()
useEffect(() => { useEffect(() => {
const claimedMana = router.query['claimed-mana'] === 'yes' const claimedMana = router.query['claimed-mana'] === 'yes'
@ -120,19 +112,8 @@ export function UserPage(props: { user: User; currentUser?: User }) {
} }
}, [userBets, usersComments]) }, [userBets, usersComments])
const yourFollows = useFollows(currentUser?.id)
const isFollowing = yourFollows?.includes(user.id)
const profit = user.profitCached.allTime const profit = user.profitCached.allTime
const onFollow = () => {
if (!currentUser) return
follow(currentUser.id, user.id)
}
const onUnfollow = () => {
if (!currentUser) return
unfollow(currentUser.id, user.id)
}
return ( return (
<Page key={user.id}> <Page key={user.id}>
<SEO <SEO
@ -141,12 +122,7 @@ export function UserPage(props: { user: User; currentUser?: User }) {
url={`/${user.username}`} url={`/${user.username}`}
/> />
{showConfetti && ( {showConfetti && (
<Confetti <FullscreenConfetti recycle={false} numberOfPieces={300} />
width={width ? width : 500}
height={height ? height : 500}
recycle={false}
numberOfPieces={300}
/>
)} )}
{/* Banner image up top, with an circle avatar overlaid */} {/* Banner image up top, with an circle avatar overlaid */}
<div <div
@ -167,13 +143,7 @@ export function UserPage(props: { user: User; currentUser?: User }) {
{/* Top right buttons (e.g. edit, follow) */} {/* Top right buttons (e.g. edit, follow) */}
<div className="absolute right-0 top-0 mt-4 mr-4"> <div className="absolute right-0 top-0 mt-4 mr-4">
{!isCurrentUser && ( {!isCurrentUser && <UserFollowButton userId={user.id} />}
<FollowButton
isFollowing={isFollowing}
onFollow={onFollow}
onUnfollow={onUnfollow}
/>
)}
{isCurrentUser && ( {isCurrentUser && (
<SiteLink className="btn" href="/profile"> <SiteLink className="btn" href="/profile">
<PencilIcon className="h-5 w-5" />{' '} <PencilIcon className="h-5 w-5" />{' '}

View File

@ -25,12 +25,18 @@ export function getSavedSort() {
} }
} }
export function useQueryAndSortParams(options?: { export interface QuerySortOptions {
defaultSort?: Sort defaultSort?: Sort
shouldLoadFromStorage?: boolean shouldLoadFromStorage?: boolean
}) { /** Use normal react state instead of url query string */
const { defaultSort = DEFAULT_SORT, shouldLoadFromStorage = true } = disableQueryString?: boolean
options ?? {} }
export function useQueryAndSortParams({
defaultSort = DEFAULT_SORT,
shouldLoadFromStorage = true,
disableQueryString,
}: QuerySortOptions = {}) {
const router = useRouter() const router = useRouter()
const { s: sort, q: query } = router.query as { const { s: sort, q: query } = router.query as {
@ -68,7 +74,9 @@ export function useQueryAndSortParams(options?: {
const setQuery = (query: string | undefined) => { const setQuery = (query: string | undefined) => {
setQueryState(query) setQueryState(query)
pushQuery(query) if (!disableQueryString) {
pushQuery(query)
}
} }
useEffect(() => { useEffect(() => {
@ -86,10 +94,13 @@ export function useQueryAndSortParams(options?: {
} }
}) })
// use normal state if querydisableQueryString
const [sortState, setSortState] = useState(defaultSort)
return { return {
sort: sort ?? defaultSort, sort: disableQueryString ? sortState : sort ?? defaultSort,
query: queryState ?? '', query: queryState ?? '',
setSort, setSort: disableQueryString ? setSortState : setSort,
setQuery, setQuery,
} }
} }

View File

@ -25,8 +25,7 @@ import { Leaderboard } from 'web/components/leaderboard'
import { resolvedPayout } from 'common/calculate' import { resolvedPayout } from 'common/calculate'
import { formatMoney } from 'common/util/format' import { formatMoney } from 'common/util/format'
import { ContractTabs } from 'web/components/contract/contract-tabs' import { ContractTabs } from 'web/components/contract/contract-tabs'
import { useWindowSize } from 'web/hooks/use-window-size' import { FullscreenConfetti } from 'web/components/fullscreen-confetti'
import Confetti from 'react-confetti'
import { NumericBetPanel } from 'web/components/numeric-bet-panel' import { NumericBetPanel } from 'web/components/numeric-bet-panel'
import { NumericResolutionPanel } from 'web/components/numeric-resolution-panel' import { NumericResolutionPanel } from 'web/components/numeric-resolution-panel'
import { useIsIframe } from 'web/hooks/use-is-iframe' import { useIsIframe } from 'web/hooks/use-is-iframe'
@ -36,7 +35,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 { CommentTipMap, useTipTxns } from 'web/hooks/use-tip-txns' import { CommentTipMap, useTipTxns } from 'web/hooks/use-tip-txns'
import { useLiquidity } from 'web/hooks/use-liquidity'
import { useSaveReferral } from 'web/hooks/use-save-referral' import { useSaveReferral } from 'web/hooks/use-save-referral'
import { getOpenGraphProps } from 'web/components/contract/contract-card-preview' import { getOpenGraphProps } from 'web/components/contract/contract-card-preview'
import { User } from 'common/user' import { User } from 'common/user'
@ -161,15 +159,12 @@ export function ContractPageContent(
}) })
const bets = useBets(contract.id) ?? props.bets const bets = useBets(contract.id) ?? props.bets
const liquidityProvisions =
useLiquidity(contract.id)?.filter((l) => !l.isAnte && l.amount > 0) ?? []
// Sort for now to see if bug is fixed. // Sort for now to see if bug is fixed.
comments.sort((c1, c2) => c1.createdTime - c2.createdTime) comments.sort((c1, c2) => c1.createdTime - c2.createdTime)
const tips = useTipTxns({ contractId: contract.id }) const tips = useTipTxns({ contractId: contract.id })
const { width, height } = useWindowSize()
const [showConfetti, setShowConfetti] = useState(false) const [showConfetti, setShowConfetti] = useState(false)
useEffect(() => { useEffect(() => {
@ -196,12 +191,7 @@ export function ContractPageContent(
return ( return (
<Page rightSidebar={rightSidebar}> <Page rightSidebar={rightSidebar}>
{showConfetti && ( {showConfetti && (
<Confetti <FullscreenConfetti recycle={false} numberOfPieces={300} />
width={width ? width : 500}
height={height ? height : 500}
recycle={false}
numberOfPieces={300}
/>
)} )}
{ogCardProps && ( {ogCardProps && (
@ -267,7 +257,6 @@ export function ContractPageContent(
<ContractTabs <ContractTabs
contract={contract} contract={contract}
user={user} user={user}
liquidityProvisions={liquidityProvisions}
bets={bets} bets={bets}
tips={tips} tips={tips}
comments={comments} comments={comments}

View File

@ -4,6 +4,7 @@ import { sortBy } from 'lodash'
import { ContractsGrid } from 'web/components/contract/contracts-grid' import { ContractsGrid } from 'web/components/contract/contracts-grid'
import { useContracts } from 'web/hooks/use-contracts' import { useContracts } from 'web/hooks/use-contracts'
import { import {
QuerySortOptions,
Sort, Sort,
useQueryAndSortParams, useQueryAndSortParams,
} from 'web/hooks/use-sort-and-query-params' } from 'web/hooks/use-sort-and-query-params'
@ -11,10 +12,7 @@ import {
const MAX_CONTRACTS_RENDERED = 100 const MAX_CONTRACTS_RENDERED = 100
export default function ContractSearchFirestore(props: { export default function ContractSearchFirestore(props: {
querySortOptions?: { querySortOptions?: QuerySortOptions
defaultSort: Sort
shouldLoadFromStorage?: boolean
}
additionalFilter?: { additionalFilter?: {
creatorId?: string creatorId?: string
tag?: string tag?: string

View File

@ -120,7 +120,8 @@ export function NewContract(props: {
const [isLogScale, setIsLogScale] = useState<boolean>(!!params?.isLogScale) const [isLogScale, setIsLogScale] = useState<boolean>(!!params?.isLogScale)
const [initialValueString, setInitialValueString] = useState(initValue) const [initialValueString, setInitialValueString] = useState(initValue)
const [answers, setAnswers] = useState<string[]>([]) // for multiple choice // for multiple choice, init to 3 empty answers
const [answers, setAnswers] = useState(['', '', ''])
useEffect(() => { useEffect(() => {
if (groupId) if (groupId)
@ -285,7 +286,7 @@ export function NewContract(props: {
<Spacer h={6} /> <Spacer h={6} />
{outcomeType === 'MULTIPLE_CHOICE' && ( {outcomeType === 'MULTIPLE_CHOICE' && (
<MultipleChoiceAnswers setAnswers={setAnswers} /> <MultipleChoiceAnswers answers={answers} setAnswers={setAnswers} />
)} )}
{outcomeType === 'PSEUDO_NUMERIC' && ( {outcomeType === 'PSEUDO_NUMERIC' && (
@ -299,7 +300,7 @@ export function NewContract(props: {
<Row className="gap-2"> <Row className="gap-2">
<input <input
type="number" type="number"
className="input input-bordered" className="input input-bordered w-32"
placeholder="MIN" placeholder="MIN"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onChange={(e) => setMinString(e.target.value)} onChange={(e) => setMinString(e.target.value)}
@ -310,7 +311,7 @@ export function NewContract(props: {
/> />
<input <input
type="number" type="number"
className="input input-bordered" className="input input-bordered w-32"
placeholder="MAX" placeholder="MAX"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onChange={(e) => setMaxString(e.target.value)} onChange={(e) => setMaxString(e.target.value)}