diff --git a/common/envs/constants.ts b/common/envs/constants.ts index 7092d711..48f9bf63 100644 --- a/common/envs/constants.ts +++ b/common/envs/constants.ts @@ -25,6 +25,10 @@ export function isAdmin(email: string) { return ENV_CONFIG.adminEmails.includes(email) } +export function isManifoldId(userId: string) { + return userId === 'IPTOzEqrpkWmEzh6hwvAyY9PqFb2' +} + export const DOMAIN = ENV_CONFIG.domain export const FIREBASE_CONFIG = ENV_CONFIG.firebaseConfig export const PROJECT_ID = ENV_CONFIG.firebaseConfig.projectId diff --git a/common/envs/dev.ts b/common/envs/dev.ts index 3c062472..719de36e 100644 --- a/common/envs/dev.ts +++ b/common/envs/dev.ts @@ -2,6 +2,7 @@ import { EnvConfig, PROD_CONFIG } from './prod' export const DEV_CONFIG: EnvConfig = { ...PROD_CONFIG, + domain: 'dev.manifold.markets', firebaseConfig: { apiKey: 'AIzaSyBoq3rzUa8Ekyo3ZaTnlycQYPRCA26VpOw', authDomain: 'dev-mantic-markets.firebaseapp.com', diff --git a/docs/docs/api.md b/docs/docs/api.md index 48564cb3..e4936418 100644 --- a/docs/docs/api.md +++ b/docs/docs/api.md @@ -528,6 +528,10 @@ $ curl https://manifold.markets/api/v0/bet -X POST -H 'Content-Type: application "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` Creates a new market on behalf of the authorized user. diff --git a/functions/src/resolve-market.ts b/functions/src/resolve-market.ts index cc07d4be..7277f40b 100644 --- a/functions/src/resolve-market.ts +++ b/functions/src/resolve-market.ts @@ -18,7 +18,7 @@ import { groupPayoutsByUser, Payout, } from '../../common/payouts' -import { isAdmin } from '../../common/envs/constants' +import { isManifoldId } from '../../common/envs/constants' import { removeUndefinedProps } from '../../common/util/object' import { LiquidityProvision } from '../../common/liquidity-provision' import { APIError, newEndpoint, validate } from './api' @@ -82,7 +82,7 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => { 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') if (contract.resolution) throw new APIError(400, 'Contract already resolved') diff --git a/web/components/answers/multiple-choice-answers.tsx b/web/components/answers/multiple-choice-answers.tsx index 450c221a..69f54648 100644 --- a/web/components/answers/multiple-choice-answers.tsx +++ b/web/components/answers/multiple-choice-answers.tsx @@ -1,26 +1,23 @@ import { MAX_ANSWER_LENGTH } from 'common/answer' -import { useState } from 'react' import Textarea from 'react-expanding-textarea' import { XIcon } from '@heroicons/react/solid' - import { Col } from '../layout/col' import { Row } from '../layout/row' export function MultipleChoiceAnswers(props: { + answers: string[] setAnswers: (answers: string[]) => void }) { - const [answers, setInternalAnswers] = useState(['', '', '']) + const { answers, setAnswers } = props const setAnswer = (i: number, answer: string) => { const newAnswers = setElement(answers, i, answer) - setInternalAnswers(newAnswers) - props.setAnswers(newAnswers) + setAnswers(newAnswers) } const removeAnswer = (i: number) => { const newAnswers = answers.slice(0, i).concat(answers.slice(i + 1)) - setInternalAnswers(newAnswers) - props.setAnswers(newAnswers) + setAnswers(newAnswers) } const addAnswer = () => setAnswer(answers.length, '') @@ -40,10 +37,10 @@ export function MultipleChoiceAnswers(props: { /> {answers.length > 2 && ( )} diff --git a/web/components/choices-toggle-group.tsx b/web/components/choices-toggle-group.tsx index 61c4e4fd..1e918eda 100644 --- a/web/components/choices-toggle-group.tsx +++ b/web/components/choices-toggle-group.tsx @@ -22,7 +22,10 @@ export function ChoicesToggleGroup(props: { } = props return ( diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx index 30be1f6e..40a62923 100644 --- a/web/components/contract-search.tsx +++ b/web/components/contract-search.tsx @@ -3,14 +3,17 @@ import algoliasearch from 'algoliasearch/lite' import { Contract } from 'common/contract' 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 { ContractHighlightOptions, ContractsGrid, } from './contract/contracts-grid' import { Row } from './layout/row' import { useEffect, useMemo, useState } from 'react' -import { Spacer } from './layout/spacer' import { ENV, IS_PRIVATE_MANIFOLD } from 'common/envs/constants' import { useFollows } from 'web/hooks/use-follows' import { track, trackCallback } from 'web/lib/service/analytics' @@ -21,6 +24,7 @@ import { PillButton } from './buttons/pill-button' import { range, sortBy } from 'lodash' import { DEFAULT_CATEGORY_GROUPS } from 'common/categories' import { Col } from './layout/col' +import clsx from 'clsx' const searchClient = algoliasearch( 'GJQPAYENIF', @@ -45,12 +49,8 @@ export const DEFAULT_SORT = 'score' type filter = 'personal' | 'open' | 'closed' | 'resolved' | 'all' export function ContractSearch(props: { - user: User | null | undefined - querySortOptions?: { - defaultSort: Sort - defaultFilter?: filter - shouldLoadFromStorage?: boolean - } + user?: User | null + querySortOptions?: { defaultFilter?: filter } & QuerySortOptions additionalFilter?: { creatorId?: string tag?: string @@ -66,6 +66,7 @@ export function ContractSearch(props: { hideGroupLink?: boolean hideQuickBet?: boolean } + headerClassName?: string }) { const { user, @@ -77,6 +78,7 @@ export function ContractSearch(props: { showPlaceHolder, cardHideOptions, highlightOptions, + headerClassName, } = props const memberGroups = (useMemberGroups(user?.id) ?? []).filter( @@ -99,11 +101,8 @@ export function ContractSearch(props: { const follows = useFollows(user?.id) - const { shouldLoadFromStorage, defaultSort } = querySortOptions ?? {} - const { query, setQuery, sort, setSort } = useQueryAndSortParams({ - defaultSort, - shouldLoadFromStorage, - }) + const { query, setQuery, sort, setSort } = + useQueryAndSortParams(querySortOptions) const [filter, setFilter] = useState( querySortOptions?.defaultFilter ?? 'open' @@ -257,87 +256,90 @@ export function ContractSearch(props: { } return ( - - - updateQuery(e.target.value)} - onBlur={trackCallback('search', { query })} - placeholder={showPlaceHolder ? `Search ${filter} markets` : ''} - className="input input-bordered w-full" - /> - {!query && ( - + + selectSort(e.target.value as Sort)} - > - {sortOptions.map((option) => ( - - ))} - - )} - - - - - {pillsEnabled && ( - - - All - - - {user ? 'For you' : 'Featured'} - - - {user && ( - + + updateQuery(e.target.value)} + onBlur={trackCallback('search', { query })} + placeholder={showPlaceHolder ? `Search ${filter} markets` : ''} + className="input input-bordered w-full" + /> + {!query && ( + + )} + {!hideOrderSelector && !query && ( + )} - - {pillGroups.map(({ name, slug }) => { - return ( - - {name} - - ) - })} - )} - + {pillsEnabled && ( + + + All + + + {user ? 'For you' : 'Featured'} + + + {user && ( + + Your bets + + )} + + {pillGroups.map(({ name, slug }) => { + return ( + + {name} + + ) + })} + + )} + {filter === 'personal' && (follows ?? []).length === 0 && diff --git a/web/components/contract/contract-card.tsx b/web/components/contract/contract-card.tsx index 4ef90884..248c3863 100644 --- a/web/components/contract/contract-card.tsx +++ b/web/components/contract/contract-card.tsx @@ -76,7 +76,8 @@ export function ContractCard(props: {
{onClick ? ( diff --git a/web/components/contract/contract-description.tsx b/web/components/contract/contract-description.tsx index 4c9b77a2..9bf2114b 100644 --- a/web/components/contract/contract-description.tsx +++ b/web/components/contract/contract-description.tsx @@ -13,7 +13,7 @@ import { TextEditor, useTextEditor } from 'web/components/editor' import { Button } from '../button' import { Spacer } from '../layout/spacer' import { Editor, Content as ContentType } from '@tiptap/react' -import { appendToEditor } from '../editor/utils' +import { insertContent } from '../editor/utils' export function ContractDescription(props: { contract: Contract @@ -95,7 +95,8 @@ function RichEditContract(props: { contract: Contract; isAdmin?: boolean }) { size="xs" onClick={() => { setEditing(true) - appendToEditor(editor, `

${editTimestamp()}

`) + editor?.commands.focus('end') + insertContent(editor, `

${editTimestamp()}

`) }} > Edit description @@ -127,7 +128,7 @@ function EditQuestion(props: { function joinContent(oldContent: ContentType, newContent: string) { const editor = new Editor({ content: oldContent, extensions: exhibitExts }) - appendToEditor(editor, newContent) + insertContent(editor, newContent) return editor.getJSON() } diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx index 90b5f3d1..081b035d 100644 --- a/web/components/contract/contract-details.tsx +++ b/web/components/contract/contract-details.tsx @@ -33,7 +33,7 @@ import { Col } from 'web/components/layout/col' import { ContractGroupsList } from 'web/components/groups/contract-groups-list' import { SiteLink } from 'web/components/site-link' import { groupPath } from 'web/lib/firebase/groups' -import { appendToEditor } from '../editor/utils' +import { insertContent } from '../editor/utils' export type ShowTime = 'resolve-date' | 'close-date' @@ -149,7 +149,7 @@ export function ContractDetails(props: { const groupInfo = ( - + {groupToDisplay ? groupToDisplay.name : 'No group'} @@ -283,7 +283,8 @@ function EditableCloseDate(props: { const formattedCloseDate = dayjs(newCloseTime).format('YYYY-MM-DD h:mm a') const editor = new Editor({ content, extensions: exhibitExts }) - appendToEditor( + editor.commands.focus('end') + insertContent( editor, `

Close date updated to ${formattedCloseDate}

` ) diff --git a/web/components/contract/contract-tabs.tsx b/web/components/contract/contract-tabs.tsx index 5aee7899..eb455df0 100644 --- a/web/components/contract/contract-tabs.tsx +++ b/web/components/contract/contract-tabs.tsx @@ -8,18 +8,17 @@ import { Spacer } from '../layout/spacer' import { Tabs } from '../layout/tabs' import { Col } from '../layout/col' import { CommentTipMap } from 'web/hooks/use-tip-txns' -import { LiquidityProvision } from 'common/liquidity-provision' import { useComments } from 'web/hooks/use-comments' +import { useLiquidity } from 'web/hooks/use-liquidity' export function ContractTabs(props: { contract: Contract user: User | null | undefined bets: Bet[] - liquidityProvisions: LiquidityProvision[] comments: Comment[] tips: CommentTipMap }) { - const { contract, user, bets, tips, liquidityProvisions } = props + const { contract, user, bets, tips } = props const { outcomeType } = contract 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 ) + const liquidityProvisions = + useLiquidity(contract.id)?.filter((l) => !l.isAnte && l.amount > 0) ?? [] + // Load comments here, so the badge count will be correct const updatedComments = useComments(contract.id) const comments = updatedComments ?? props.comments diff --git a/web/components/editor.tsx b/web/components/editor.tsx index 0807faf8..74f608aa 100644 --- a/web/components/editor.tsx +++ b/web/components/editor.tsx @@ -22,8 +22,14 @@ import { mentionSuggestion } from './editor/mention-suggestion' import { DisplayMention } from './editor/mention' import Iframe from 'common/util/tiptap-iframe' import TiptapTweet from './editor/tiptap-tweet' -import { CodeIcon, PhotographIcon } from '@heroicons/react/solid' 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({ HTMLAttributes: { @@ -103,7 +109,7 @@ export function useTextEditor(props: { // If the pasted content is iframe code, directly inject it const text = event.clipboardData?.getData('text/plain').trim() ?? '' if (isValidIframe(text)) { - editor.chain().insertContent(text).run() + insertContent(editor, 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 [iframeOpen, setIframeOpen] = useState(false) + const [marketOpen, setMarketOpen] = useState(false) return ( <> @@ -139,16 +146,15 @@ export function TextEditor(props: { {/* Toolbar, with buttons for images and embeds */}
-
+
-
+
+
+
+
{/* Spacer that also focuses editor on click */} diff --git a/web/components/editor/market-modal.tsx b/web/components/editor/market-modal.tsx new file mode 100644 index 00000000..1c88afbc --- /dev/null +++ b/web/components/editor/market-modal.tsx @@ -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([]) + 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 ( + + + +
Embed a market
+ + {!loading && ( + + {contracts.length > 0 && ( + + )} + + + )} +
+ + {loading && ( +
+ +
+ )} + +
+ c.id), + highlightClassName: + '!bg-indigo-100 outline outline-2 outline-indigo-300', + }} + additionalFilter={{}} /* hide pills */ + headerClassName="bg-white" + /> +
+ +
+ ) +} diff --git a/web/components/editor/utils.ts b/web/components/editor/utils.ts index 74af38c5..50b94ce2 100644 --- a/web/components/editor/utils.ts +++ b/web/components/editor/utils.ts @@ -1,10 +1,13 @@ import { Editor, Content } from '@tiptap/react' -export function appendToEditor(editor: Editor | null, content: Content) { - editor - ?.chain() - .focus('end') - .createParagraphNear() - .insertContent(content) - .run() +export function insertContent(editor: Editor | null, ...contents: Content[]) { + if (!editor) { + return + } + + let e = editor.chain() + for (const content of contents) { + e = e.createParagraphNear().insertContent(content) + } + e.run() } diff --git a/web/components/fullscreen-confetti.tsx b/web/components/fullscreen-confetti.tsx new file mode 100644 index 00000000..390b3a9b --- /dev/null +++ b/web/components/fullscreen-confetti.tsx @@ -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 +} diff --git a/web/components/groups/group-chat.tsx b/web/components/groups/group-chat.tsx index 62d327f2..0f9e8955 100644 --- a/web/components/groups/group-chat.tsx +++ b/web/components/groups/group-chat.tsx @@ -21,6 +21,7 @@ import { Content, useTextEditor } from 'web/components/editor' import { useUnseenPreferredNotifications } from 'web/hooks/use-notifications' import { ChevronDownIcon, UsersIcon } from '@heroicons/react/outline' import { setNotificationsAsSeen } from 'web/pages/notifications' +import { usePrivateUser } from 'web/hooks/use-user' export function GroupChat(props: { messages: Comment[] @@ -29,6 +30,9 @@ export function GroupChat(props: { tips: CommentTipMap }) { const { messages, user, group, tips } = props + + const privateUser = usePrivateUser(user?.id) + const { editor, upload } = useTextEditor({ simple: true, placeholder: 'Send a message', @@ -175,6 +179,15 @@ export function GroupChat(props: {
)} + + {privateUser && ( +